',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({value: { turtles: [
+ [5],
+ [1, 1, {turtle_foo: "from onchange", partner_ids: [[5]]}],
+ ]}});
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ form.$('span[name="delete"]').first().click();
+ assert.strictEqual(form.$('.o_data_row').text(), 'from onchange',
+ 'onchange has been properly applied');
+ form.destroy();
+ });
+
QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) {
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
index 492dfd4224c..72061f326e0 100644
--- a/addons/web/static/tests/views/basic_model_tests.js
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -74,7 +74,6 @@ QUnit.module('Views', {
mockRPC: function (route, args) {
assert.deepEqual(args.kwargs.context, {
active_field: 2,
- bin_size: true,
someKey: 'some value',
}, "should have sent the correct context");
return this._super.apply(this, arguments);
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 1f5e57b2202..9f2a9b68698 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -199,7 +199,7 @@ QUnit.module('Views', {
});
QUnit.test('only necessary fields are fetched with correct context', function (assert) {
- assert.expect(2);
+ assert.expect(1);
var form = createView({
View: FormView,
@@ -214,8 +214,6 @@ QUnit.module('Views', {
// field, not sure why. Maybe this test should be modified.
assert.deepEqual(args.args[1], ["foo", "display_name"],
"should only fetch requested fields");
- assert.deepEqual(args.kwargs.context, {bin_size: true},
- "bin_size should always be in the context");
return this._super(route, args);
}
});
@@ -483,6 +481,37 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('autofocus on second notebook page', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '
',
+ res_id: 1,
+ });
+
+ assert.notOk(form.$('.o_notebook .nav li:first()').hasClass('active'),
+ 'first tab should not active');
+ assert.ok(form.$('.o_notebook .nav li:nth(1)').hasClass('active'),
+ 'second tab should be active');
+
+ form.destroy();
+ });
+
QUnit.test('invisible attrs on group are re-evaluated on field change', function (assert) {
assert.expect(2);
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 9c8361e63ae..9e6d6e9e30f 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -493,6 +493,58 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('quick create several records in a row', function (assert) {
+ assert.expect(6);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '
' +
+ '' +
+ '' +
+ '
' +
+ '',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1,
+ "first column should contain one record");
+
+ // click to add an element, fill the input and press ENTER
+ kanban.$('.o_kanban_header .o_kanban_quick_add i').first().click();
+
+ assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 1,
+ "the quick create should be open");
+
+ kanban.$('.o_kanban_quick_create input')
+ .val('new partner 1')
+ .trigger($.Event('keypress', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2,
+ "first column should now contain two records");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 1,
+ "the quick create should still be open");
+
+ // create a second element in a row
+ kanban.$('.o_kanban_quick_create input')
+ .val('new partner 2')
+ .trigger($.Event('keypress', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+
+ assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 3,
+ "first column should now contain three records");
+ assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 1,
+ "the quick create should still be open");
+
+ kanban.destroy();
+ });
+
QUnit.test('quick create fail in grouped', function (assert) {
assert.expect(7);
@@ -524,7 +576,6 @@ QUnit.module('Views', {
return this._super.apply(this, arguments);
},
});
- kanban.renderButtons();
assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2,
"there should be 2 records in first column");
@@ -556,6 +607,48 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('quick create record in empty grouped kanban', function (assert) {
+ assert.expect(3);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '
' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ var result = [
+ {__domain: [['product_id', '=', 3]], product_id_count: 0},
+ {__domain: [['product_id', '=', 5]], product_id_count: 0},
+ ];
+ return $.when(result);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 2,
+ "there should be 2 columns");
+ assert.strictEqual(kanban.$('.o_kanban_record').length, 0,
+ "both columns should be empty");
+
+ kanban.$buttons.find('.o-kanban-button-new').click();
+
+ assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_quick_create').length, 1,
+ "should have opened the quick create in the first column");
+
+ kanban.destroy();
+ });
+
QUnit.test('many2many_tags in kanban views', function (assert) {
assert.expect(12);
diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js
index 7f82df2cb28..fb1ce3e12b4 100644
--- a/addons/web_editor/static/src/js/editor/rte.summernote.js
+++ b/addons/web_editor/static/src/js/editor/rte.summernote.js
@@ -388,9 +388,12 @@ eventHandler.modules.imageDialog.showImageDialog = function ($editable) {
if (r.sc.tagName && r.sc.childNodes.length) {
r.sc = r.sc.childNodes[r.so];
}
+ var media = $(r.sc).parents().addBack().filter(function (i, el) {
+ return dom.isImg(el);
+ })[0];
core.bus.trigger('media_dialog_demand', {
$editable: $editable,
- media: dom.isImg(r.sc) ? r.sc : null,
+ media: media,
});
return new $.Deferred().reject();
};
diff --git a/addons/web_tour/static/src/js/tour_manager.js b/addons/web_tour/static/src/js/tour_manager.js
index 4b7dde7696b..1b076783fbd 100644
--- a/addons/web_tour/static/src/js/tour_manager.js
+++ b/addons/web_tour/static/src/js/tour_manager.js
@@ -172,18 +172,18 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
/**
* Registers a tour described by the following arguments *in order*
*
- * @param [string] name - tour's name
- * @param [Object] - options (optional), available options are:
- * @param [boolean] - true if this is only for tests
- * @param [boolean]
+ * @param {string} name - tour's name
+ * @param {Object} [options] - options (optional), available options are:
+ * @param {boolean} [options.test=false] - true if this is only for tests
+ * @param {boolean} [options.skip_enabled=false]
* true to add a link in its tips to consume the whole tour
- * @param [string]
+ * @param {string} [options.url]
* the url to load when manually running the tour
- * @param [boolean]
+ * @param {boolean} [options.rainbowMan=true]
* whether or not the rainbowman must be shown at the end of the tour
- * @param [Deferred]
+ * @param {Deferred} [options.wait_for]
* indicates when the tour can be started
- * @param [Object] steps - steps' descriptions, each step being an object
+ * @param {Object[]} steps - steps' descriptions, each step being an object
* containing a tip description
*/
register: function() {
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index c8c80c433c1..40d3c32fc87 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -66,17 +66,17 @@ class Website(Home):
@http.route('/', type='http', auth="public", website=True)
def index(self, **kw):
homepage = request.website.homepage_id
- if homepage and homepage.url != '/':
+ if homepage and (homepage.sudo().is_visible or request.env.user.has_group('base.group_user')) and homepage.url != '/':
return request.env['ir.http'].reroute(homepage.url)
website_page = request.env['ir.http']._serve_page()
if website_page:
return website_page
else:
- top_menu = request.website.sudo().menu_id
- first_menu = top_menu and top_menu.child_id and top_menu.child_id[0]
- if first_menu and first_menu.url != '/' and (not (first_menu.url.startswith(('/?', '/#')))):
- return request.redirect(first_menu.url)
+ top_menu = request.website.menu_id
+ first_menu = top_menu and top_menu.child_id and top_menu.child_id.filtered(lambda menu: menu.is_visible)
+ if first_menu and first_menu[0].url != '/' and (not (first_menu[0].url.startswith(('/?', '/#')))):
+ return request.redirect(first_menu[0].url)
raise request.not_found()
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 65c514487a2..84813d7eca5 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -62,7 +62,7 @@ class Http(models.AbstractModel):
if not request.session.uid:
env = api.Environment(request.cr, SUPERUSER_ID, request.context)
website = env['website'].get_current_website()
- if website:
+ if website and website.user_id:
request.uid = website.user_id.id
if not request.uid:
super(Http, cls)._auth_method_public()
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index a4848313a9c..d6deb298eb2 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -63,7 +63,7 @@ class Website(models.Model):
google_management_client_id = fields.Char('Google Client ID')
google_management_client_secret = fields.Char('Google Client Secret')
- user_id = fields.Many2one('res.users', string='Public User', default=lambda self: self.env.ref('base.public_user').id)
+ user_id = fields.Many2one('res.users', string='Public User', required=True, default=lambda self: self.env.ref('base.public_user').id)
cdn_activated = fields.Boolean('Activate CDN for assets')
cdn_url = fields.Char('CDN Base URL', default='')
cdn_filters = fields.Text('CDN Filters', default=lambda s: '\n'.join(DEFAULT_CDN_FILTERS), help="URL matching those filters will be rewritten using the CDN Base URL")
@@ -791,7 +791,7 @@ class Menu(models.Model):
_parent_store = True
_parent_order = 'sequence'
- _order = "sequence"
+ _order = "sequence, id"
def _default_sequence(self):
menu = self.search([], limit=1, order="sequence DESC")
@@ -807,6 +807,14 @@ class Menu(models.Model):
child_id = fields.One2many('website.menu', 'parent_id', string='Child Menus')
parent_left = fields.Integer('Parent Left', index=True)
parent_right = fields.Integer('Parent Rigth', index=True)
+ is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible')
+
+ @api.one
+ def _compute_visible(self):
+ visible = True
+ if self.page_id and not self.page_id.sudo().is_visible and not self.user_has_groups('base.group_user'):
+ visible = False
+ self.is_visible = visible
@api.model
def clean_url(self):
@@ -815,7 +823,7 @@ class Menu(models.Model):
url = self.page_id.sudo().url
else:
url = self.url
- if not self.url.startswith('/'):
+ if url and not self.url.startswith('/'):
if '@' in self.url:
if not self.url.startswith('mailto'):
url = 'mailto:%s' % self.url
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index c5241720c3f..abae2c3fdfc 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -67,14 +67,14 @@
diff --git a/addons/website_event_track/controllers/main.py b/addons/website_event_track/controllers/main.py
index f2ae337deda..819176096b2 100644
--- a/addons/website_event_track/controllers/main.py
+++ b/addons/website_event_track/controllers/main.py
@@ -67,11 +67,13 @@ class WebsiteEventTrackController(http.Controller):
@http.route(['''/event/
/agenda'''], type='http', auth="public", website=True, sitemap=False)
def event_agenda(self, event, tag=None, **post):
+ local_tz = pytz.timezone(event.date_tz or 'UTC')
days_tracks = collections.defaultdict(lambda: [])
for track in event.track_ids.sorted(lambda track: (track.date or '', bool(track.location_id))):
if not track.date:
continue
- days_tracks[track.date[:10]].append(track)
+ date = fields.Datetime.from_string(track.date).replace(tzinfo=pytz.utc).astimezone(local_tz)
+ days_tracks[str(date)[:10]].append(track)
days = {}
tracks_by_days = {}
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
index 88bbb95648c..d54978e290c 100644
--- a/addons/website_sale/static/src/js/website_sale.js
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -215,7 +215,7 @@ odoo.define('website_sale.website_sale', function (require) {
return false;
});
- $('.oe_website_sale .a-submit, #comment .a-submit').off('click').on('click', function (event) {
+ $('.oe_website_sale, #comment').off('click', '.a-submit').on('click', '.a-submit', function (event) {
if (!event.isDefaultPrevented() && !$(this).is(".disabled")) {
event.preventDefault();
$(this).closest('form').submit();
diff --git a/addons/website_sale_options/static/src/js/website_sale.js b/addons/website_sale_options/static/src/js/website_sale.js
index b766a71200e..da7025d65bb 100644
--- a/addons/website_sale_options/static/src/js/website_sale.js
+++ b/addons/website_sale_options/static/src/js/website_sale.js
@@ -35,7 +35,7 @@ $('.oe_website_sale #add_to_cart, .oe_website_sale #products_grid .a-submit')
$(this).remove();
});
- $modal.on('click', '.a-submit', function () {
+ $modal.on('click', '.a-submit', function (ev) {
var $a = $(this);
$form.ajaxSubmit({
url: '/shop/cart/update_option',
@@ -50,6 +50,7 @@ $('.oe_website_sale #add_to_cart, .oe_website_sale #products_grid .a-submit')
}
});
$modal.modal('hide');
+ ev.preventDefault();
});
$modal.on('click', '.css_attribute_color input', function (event) {
diff --git a/doc/_extensions/autojsdoc/ext/directives.py b/doc/_extensions/autojsdoc/ext/directives.py
index 98b3f9e8322..d127e24725b 100644
--- a/doc/_extensions/autojsdoc/ext/directives.py
+++ b/doc/_extensions/autojsdoc/ext/directives.py
@@ -565,6 +565,7 @@ def extract_subtypes(parent_name, doc):
'name': typename,
'doc': param.doc,
'_members': [
+ # TODO: add default value
(sub.name, jsdoc.PropertyDoc(dict(sub.to_dict(), sourcemodule=doc['sourcemodule'])))
for sub in subs
],
diff --git a/doc/_extensions/autojsdoc/parser/jsdoc.py b/doc/_extensions/autojsdoc/parser/jsdoc.py
index a34a2aa9b83..55ec998c94f 100644
--- a/doc/_extensions/autojsdoc/parser/jsdoc.py
+++ b/doc/_extensions/autojsdoc/parser/jsdoc.py
@@ -122,8 +122,14 @@ class FunctionDoc(CommentDoc):
@property
def params(self):
tag_texts = self.get_as_list('param')
- if self.get('guessed_params') is None:
- return [ParamDoc(text) for text in tag_texts]
+ # turns out guessed_params is *almost* (?) always set to a list,
+ # if empty list of guessed params fall back to @params
+ if not self['guessed_params']:
+ # only get "primary" params (no "." in name)
+ return [
+ p for p in map(ParamDoc, tag_texts)
+ if '.' not in p.name
+ ]
else:
param_dict = {}
for text in tag_texts:
diff --git a/doc/cla/corporate/meta-it.md b/doc/cla/corporate/meta-it.md
index 01b33f35c8b..f321433af3f 100644
--- a/doc/cla/corporate/meta-it.md
+++ b/doc/cla/corporate/meta-it.md
@@ -12,4 +12,4 @@ Robin Lucbenet robin@meta-it.fr https://github.com/n1b0r
List of contributors:
-Guillaume Masson guillaume.masson@meta-it.fr
+Guillaume Masson guillaume.masson@meta-it.fr https://github.com/metaminux
diff --git a/doc/cla/individual/rcastro-tyc.md b/doc/cla/individual/rcastro-tyc.md
new file mode 100644
index 00000000000..ea6de8af17b
--- /dev/null
+++ b/doc/cla/individual/rcastro-tyc.md
@@ -0,0 +1,11 @@
+Costa Rica, 2018-01-11
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Randall Castro randall_castro@me.com https://github.com/rcastro-tyc
diff --git a/doc/reference/testing.rst b/doc/reference/testing.rst
index a54440293cc..ada14166202 100644
--- a/doc/reference/testing.rst
+++ b/doc/reference/testing.rst
@@ -75,6 +75,10 @@ in each method::
# other tests...
+.. note::
+
+ Test methods must start with ``test_``
+
.. autoclass:: odoo.tests.common.Form
:members:
diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py
index 776a098e453..d857c14593b 100644
--- a/odoo/addons/base/models/ir_actions.py
+++ b/odoo/addons/base/models/ir_actions.py
@@ -442,7 +442,12 @@ class IrActionsServer(models.Model):
for exp in action.fields_lines:
res[exp.col1.name] = exp.eval_value(eval_context=eval_context)[exp.id]
- self.env[action.model_id.model].browse(self._context.get('active_id')).write(res)
+ if self._context.get('onchange_self'):
+ record_cached = self._context['onchange_self']
+ for field, new_value in res.items():
+ record_cached[field] = new_value
+ else:
+ self.env[action.model_id.model].browse(self._context.get('active_id')).write(res)
@api.model
def run_action_object_create(self, action, eval_context=None):
@@ -537,6 +542,8 @@ class IrActionsServer(models.Model):
elif hasattr(self, 'run_action_%s' % action.state):
active_id = self._context.get('active_id')
+ if not active_id and self._context.get('onchange_self'):
+ active_id = self._context['onchange_self']._origin.id
active_ids = self._context.get('active_ids', [active_id] if active_id else [])
for active_id in active_ids:
# run context dedicated to a particular active_id
diff --git a/odoo/addons/base/models/ir_cron.py b/odoo/addons/base/models/ir_cron.py
index 375d9a966da..58dc0f06fb7 100644
--- a/odoo/addons/base/models/ir_cron.py
+++ b/odoo/addons/base/models/ir_cron.py
@@ -215,7 +215,7 @@ class ir_cron(models.Model):
_logger.debug("Job `%s` already executed by another process/thread. skipping it", job['cron_name'])
continue
# Got the lock on the job row, run its code
- _logger.debug('Starting job `%s`.', job['cron_name'])
+ _logger.info('Starting job `%s`.', job['cron_name'])
job_cr = db.cursor()
try:
registry = odoo.registry(db_name)
diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py
index 70094f476d1..c11d1a2e340 100644
--- a/odoo/addons/base/models/ir_ui_view.py
+++ b/odoo/addons/base/models/ir_ui_view.py
@@ -247,7 +247,7 @@ actual arch.
if fullpath:
arch_fs = get_view_arch_from_file(fullpath, view.xml_id)
# replace %(xml_id)s, %(xml_id)d, %%(xml_id)s, %%(xml_id)d by the res_id
- arch_fs = arch_fs and resolve_external_ids(arch_fs, view.xml_id)
+ arch_fs = arch_fs and resolve_external_ids(arch_fs, view.xml_id).replace('%%', '%')
else:
_logger.warning("View %s: Full path [%s] cannot be found.", view.xml_id, view.arch_fs)
arch_fs = False
diff --git a/odoo/api.py b/odoo/api.py
index 143f51bc31c..e9657b798c0 100644
--- a/odoo/api.py
+++ b/odoo/api.py
@@ -1004,11 +1004,14 @@ class Cache(object):
def get_records(self, model, field):
""" Return the records of ``model`` that have a value for ``field``. """
- browse = model.browse
- ids = [record_id
- for record_id, field_record_cache in self._data[field].items()
- if field.cache_key(browse(record_id)) in field_record_cache]
- return browse(ids)
+ key = field.cache_key(model)
+ # optimization: do not field.cache_key(record) for each record in cache
+ ids = [
+ record_id
+ for record_id, field_record_cache in self._data[field].items()
+ if key in field_record_cache
+ ]
+ return model.browse(ids)
def invalidate(self, spec=None):
""" Invalidate the cache, partially or totally depending on ``spec``. """
diff --git a/odoo/fields.py b/odoo/fields.py
index b6b13e97c8c..519fd5c124e 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -763,6 +763,9 @@ class Field(MetaField('DummyField', (object,), {})):
cache, the full cache key being ``(self, record.id, key)``.
"""
env = record.env
+ # IMPORTANT: odoo.api.Cache.get_records() depends on the fact that the
+ # result does not depend on record.id. If you ever make the following
+ # dependent on record.id, don't forget to fix the other method!
return env if self.context_dependent else (env.cr, env.uid)
def null(self, record):
diff --git a/odoo/service/db.py b/odoo/service/db.py
index ec15383bd4b..ce868f693dd 100644
--- a/odoo/service/db.py
+++ b/odoo/service/db.py
@@ -231,9 +231,13 @@ def dump_db(db_name, stream, backup_format='zip'):
@check_db_management_enabled
def exp_restore(db_name, data, copy=False):
+ def chunks(d, n=8192):
+ for i in range(0, len(d), n):
+ yield d[i:i+n]
data_file = tempfile.NamedTemporaryFile(delete=False)
try:
- data_file.write(base64.b64decode(data))
+ for chunk in chunks(data):
+ data_file.write(base64.b64decode(chunk))
data_file.close()
restore_db(db_name, data_file.name, copy=copy)
finally: