' +
+ '
' +
'
' +
'Click to define a target' +
'
' +
@@ -46,9 +47,12 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as
assert.strictEqual(args.args[1].invoiced_target, 123,
"new value is correctly saved");
}
+ if (args.method === 'read') { // Read happens after the write
+ assert.deepEqual(args.args[1], ['invoiced_target', 'display_name'],
+ 'the read (after write) should ask for invoiced_target');
+ }
return this._super.apply(this, arguments);
},
-
});
assert.strictEqual(kanban.$('.o_kanban_view .sales_team_target_definition').length, 1,
@@ -64,6 +68,54 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as
kanban.$('.o_kanban_primary_bottom:last input').val('123');
kanban.$('.o_kanban_primary_bottom:last input').blur();
+ assert.strictEqual(kanban.$('.o_kanban_record').text(), "123Click to define a target",
+ 'The kanban record should display the updated target value');
+
+ kanban.destroy();
+});
+
+QUnit.test('edit target supports push Enter', function (assert) {
+ assert.expect(3);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'crm.team',
+ data: this.data,
+ arch: '
' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ 'Click to define a target' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].invoiced_target, 123,
+ "new value is correctly saved");
+ }
+ if (args.method === 'read') { // Read happens after the write
+ assert.deepEqual(args.args[1], ['invoiced_target', 'display_name'],
+ 'the read (after write) should ask for invoiced_target');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ kanban.$('a.sales_team_target_definition').click();
+
+ kanban.$('.o_kanban_primary_bottom:last input').focus();
+ kanban.$('.o_kanban_primary_bottom:last input').val('123');
+ kanban.$('.o_kanban_primary_bottom:last input').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER, keyCode: $.ui.keyCode.ENTER}));
+
+ assert.strictEqual(kanban.$('.o_kanban_record').text(), "123Click to define a target",
+ 'The kanban record should display the updated target value');
+
kanban.destroy();
});
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index c42c193999c..409741aa0a5 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -268,7 +268,8 @@ class StockMove(models.Model):
# Update the standard price with the price of the last used candidate, if any.
if new_standard_price and move.product_id.cost_method == 'fifo':
- move.product_id.sudo().standard_price = new_standard_price
+ move.product_id.sudo().with_context(force_company=move.company_id.id) \
+ .standard_price = new_standard_price
# If there's still quantity to value but we're out of candidates, we fall in the
# negative stock use case. We chose to value the out move at the price of the
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index c078069b074..b51fa49ed9a 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -2096,6 +2096,18 @@ var FieldProgressBar = AbstractField.extend({
this.$('.o_progressbar_value').focus().select();
}
},
+ /**
+ * The progress bar has more than one field/value to deal with
+ * i.e. max_value
+ *
+ * @override
+ * @private
+ */
+ _reset: function () {
+ this._super.apply(this, arguments);
+ var new_max_value = this.recordData[this.nodeOptions.max_value];
+ this.max_value = new_max_value !== undefined ? new_max_value : this.max_value;
+ },
isSet: function () {
return true;
},
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 2bb98932c68..b6fbc0cd924 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -468,6 +468,7 @@ var FieldMany2One = AbstractField.extend({
initial_ids: ids ? _.map(ids, function (x) { return x[0]; }) : undefined,
initial_view: view,
disable_multiple_selection: true,
+ no_create: !self.can_create,
on_selected: function (records) {
self.reinitialize(records[0]);
self.activate();
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 9b6e5a5d192..2dc154d6eae 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -141,6 +141,7 @@ var AbstractView = Class.extend({
modelName: params.modelName,
res_id: params.currentId,
res_ids: params.ids,
+ orderedBy: params.context ? params.context.orderedBy : [],
};
if (params.modelName) {
this.loadParams.modelName = params.modelName;
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index d236673b0d5..69fbe44b14b 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -3365,7 +3365,7 @@ var BasicModel = AbstractModel.extend({
model: params.modelName,
offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0),
openGroupByDefault: params.openGroupByDefault,
- orderedBy: params.orderedBy || (params.context && params.context.orderedBy) || [],
+ orderedBy: params.orderedBy || [],
orderedResIDs: params.orderedResIDs,
parentID: params.parentID,
rawContext: params.rawContext,
@@ -4279,7 +4279,7 @@ var BasicModel = AbstractModel.extend({
if (orderData1 > orderData2) {
return order.asc ? 1 : -1;
}
- return compareRecords(record1ID, record2ID, level + 1);
+ return compareRecords(resId1, resId2, level + 1);
};
utils.stableSort(list.res_ids, compareRecords);
}
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index c3cb0c3c404..9e94db14cb7 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -4888,6 +4888,48 @@ QUnit.module('basic_fields', {
form.destroy();
});
+
+ QUnit.module('FieldProgressBar');
+
+ QUnit.test('Field ProgressBar: max_value should update', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records = this.data.partner.records.slice(0,1);
+ this.data.partner.records[0].qux = 2;
+
+ this.data.partner.onchanges = {
+ display_name: function (obj) {
+ obj.int_field = 999;
+ obj.qux = 5;
+ }
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '
',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_progressbar_value').text(), '10 / 2',
+ 'The initial value of the progress bar should be correct');
+
+ // trigger the onchange
+ form.$('.o_input[name=display_name]').val('new name').trigger('input');
+
+ assert.strictEqual(form.$('.o_progressbar_value').text(), '999 / 5',
+ 'The value of the progress bar should be correct after the update');
+
+ form.destroy();
+ });
});
});
});
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index ca6cd680269..61aaa29a664 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -2733,6 +2733,48 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('x2many default_order multiple fields', function (assert) {
+ assert.expect(7);
+
+ this.data.partner.records = [
+ {int_field: 10, id: 1, display_name: "record1"},
+ {int_field: 12, id: 2, display_name: "record2"},
+ {int_field: 11, id: 3, display_name: "record3"},
+ {int_field: 12, id: 4, display_name: "record4"},
+ {int_field: 10, id: 5, display_name: "record5"},
+ {int_field: 10, id: 6, display_name: "record6"},
+ {int_field: 11, id: 7, display_name: "record7"},
+ ];
+
+ this.data.partner.records[0].p = [1, 7, 4, 5, 2, 6, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '
',
+ res_id: 1,
+ });
+
+ var $recordList = form.$('.o_field_x2many_list .o_data_row');
+ var expectedOrderId = ['1', '5', '6', '3', '7', '2', '4'];
+
+ _.each($recordList, function(record, index) {
+ var $record = $(record);
+ assert.strictEqual($record.find('.o_data_cell').eq(0).text(), expectedOrderId[index],
+ 'The record should be the right place. Index: ' + index);
+ });
+
+ form.destroy();
+ });
+
QUnit.test('many2many list add *many* records, remove, re-add', function (assert) {
assert.expect(5);
@@ -2873,6 +2915,41 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('one2many from a model that has been sorted', function (assert) {
+ assert.expect(1);
+
+ /* On a standard list view, sort your records by a field
+ * Click on a record which contains a x2m with multiple records in it
+ * The x2m shouldn't take the orderedBy of the parent record (the one on the form)
+ */
+
+ this.data.partner.records[0].turtles = [3, 2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'
',
+ res_id: 1,
+ context: {
+ orderedBy: [{
+ name: 'foo',
+ asc: false,
+ }]
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_one2many[name=turtles] tbody').text().trim(), "kawablip",
+ 'The o2m should not have been sorted.');
+
+ form.destroy();
+ });
+
QUnit.test('O2M List with pager, decoration and default_order: add and cancel adding', function (assert) {
assert.expect(3);
diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js
index 66e5859a1bf..a6cf176468b 100644
--- a/addons/web/static/tests/views/view_dialogs_tests.js
+++ b/addons/web/static/tests/views/view_dialogs_tests.js
@@ -476,6 +476,66 @@ QUnit.module('Views', {
testUtils.unpatch(ListController);
parent.destroy();
});
+
+ QUnit.test('propagate can_create onto the search popup o2m', function (assert) {
+ assert.expect(3);
+
+ this.data.instrument.records = [
+ {id: 1, name: 'Tromblon1'},
+ {id: 2, name: 'Tromblon2'},
+ {id: 3, name: 'Tromblon3'},
+ {id: 4, name: 'Tromblon4'},
+ {id: 5, name: 'Tromblon5'},
+ {id: 6, name: 'Tromblon6'},
+ {id: 7, name: 'Tromblon7'},
+ {id: 8, name: 'Tromblon8'},
+ ];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '
',
+ res_id: 1,
+ archs: {
+ 'instrument,false,list': '
'+
+ ''+
+ '',
+ 'instrument,false,search': '
'+
+ ''+
+ '',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+
+ mockRPC: function(route, args) {
+ if (args.method === 'get_formview_id') {
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$('.o_field_widget[name="instrument"] .o_input').click();
+
+ assert.notOk($('.ui-autocomplete a:contains(Create and Edit)').length,
+ 'Create and edit not present in dropdown');
+
+ $('.ui-autocomplete a:contains(Search More)').trigger('mouseenter').trigger('click');
+
+ var $modal = $('.modal-dialog.modal-lg');
+
+ assert.strictEqual($modal.length, 1, 'Modal present');
+
+ assert.strictEqual($modal.find('.modal-footer button').text(), "Cancel",
+ 'Only the cancel button is present in modal');
+
+ form.destroy();
+ });
});
});
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
index f7de11fe485..6fb01886d20 100644
--- a/addons/website_sale/models/sale_order.py
+++ b/addons/website_sale/models/sale_order.py
@@ -54,7 +54,7 @@ class SaleOrder(models.Model):
abandoned_delay = float(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_abandoned_delay', '1.0'))
abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay))
for order in self:
- domain = order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line
+ domain = order.date_order and order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line
order.is_abandoned_cart = bool(domain)
def _search_abandoned_cart(self, operator, value):
diff --git a/doc/cla/corporate/camptocamp.md b/doc/cla/corporate/camptocamp.md
index 206fad26646..e4206b66474 100644
--- a/doc/cla/corporate/camptocamp.md
+++ b/doc/cla/corporate/camptocamp.md
@@ -34,3 +34,4 @@ Damien Crier damien.crier@camptocamp.com https://github.com/damdam-s
Simone Orsi simahawk@gmail.com https://github.com/simahawk
Patrick Tombez patrick.tombez@camptocamp.com https://github.com/p-tombez
Julien Coux julien.coux@camptocamp.com https://github.com/jcoux
+Alexandre Saunier alexandre.saunier@camptocamp.com https://github.com/asaunier
diff --git a/doc/setup/install.rst b/doc/setup/install.rst
index c1f65abafd1..add4f22962b 100644
--- a/doc/setup/install.rst
+++ b/doc/setup/install.rst
@@ -217,10 +217,13 @@ command is available and configured properly, run the following commands :
$ sudo systemctl start postgresql
In order to print PDF reports, you must install wkhtmltopdf_ yourself:
-the version of wkhtmltopdf_ available in debian repositories does not support
-headers and footers so it can not be installed automatically.
-The recommended version is 0.12.1 and is available on `the wkhtmltopdf download page`_,
-in the archive section.
+the version of wkhtmltopdf_ available in Debian repositories does
+not support headers and footers so it is not used as a direct dependency.
+The recommended version is 0.12.5 and is available on
+`the wkhtmltopdf download page`_, in the archive section. Previously
+recommended version 0.12.1 is a good alternative.
+More details on the various versions and their respective quirks can be
+found in our `wiki
`_.
Repository
^^^^^^^^^^
@@ -606,7 +609,7 @@ official Odoo `docker image `_ page.
http://www.enterprisedb.com/products-services-training/pgdownload
.. _Quilt: http://en.wikipedia.org/wiki/Quilt_(software)
.. _saas: https://www.odoo.com/page/start
-.. _the wkhtmltopdf download page: https://github.com/wkhtmltopdf/wkhtmltopdf/releases/tag/0.12.1
+.. _the wkhtmltopdf download page: https://github.com/wkhtmltopdf/wkhtmltopdf/releases/tag/0.12.5
.. _UAC: http://en.wikipedia.org/wiki/User_Account_Control
.. _wkhtmltopdf: http://wkhtmltopdf.org
.. _pip: https://pip.pypa.io
diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py
index 4ec7c98cf3a..e3865a476ff 100644
--- a/odoo/addons/base/models/ir_translation.py
+++ b/odoo/addons/base/models/ir_translation.py
@@ -438,15 +438,15 @@ class IrTranslation(models.Model):
if not callable(field.translate):
return
- trans = self.env['ir.translation']
- outdated = trans
- discarded = trans
+ Translation = self.env['ir.translation']
+ outdated = Translation
+ discarded = Translation
for record in records:
# get field value and terms to translate
value = record[field.name]
terms = set(field.get_trans_terms(value))
- record_trans = trans.search([
+ translations = Translation.search([
('type', '=', 'model'),
('name', '=', "%s,%s" % (field.model_name, field.name)),
('res_id', '=', record.id),
@@ -454,25 +454,32 @@ class IrTranslation(models.Model):
if not terms:
# discard all translations for that field
- discarded += record_trans
+ discarded += translations
continue
- # remap existing translations on terms when possible
- trans_src = record_trans.mapped('src')
- for trans in record_trans:
- if trans.src == trans.value:
- discarded += trans
- elif trans.src not in terms:
- matches = get_close_matches(trans.src, terms, 1, 0.9)
- if matches:
- if matches[0] in trans_src:
- # there is already a translation for this term; discard this one
- discarded += trans
- else:
- trans.write({'src': matches[0], 'state': trans.state})
- trans_src.append(matches[0]) # avoid reuse of term
- else:
- outdated += trans
+ # remap existing translations on terms when possible; each term
+ # should be translated at most once per language
+ done = set() # {(src, lang), ...}
+ translations_to_match = []
+
+ for translation in translations:
+ if translation.src == translation.value:
+ discarded += translation
+ elif translation.src in terms:
+ done.add((translation.src, translation.lang))
+ else:
+ translations_to_match.append(translation)
+
+ for translation in translations_to_match:
+ matches = get_close_matches(translation.src, terms, 1, 0.9)
+ src = matches[0] if matches else None
+ if not src:
+ outdated += translation
+ elif (src, translation.lang) in done:
+ discarded += translation
+ else:
+ translation.write({'src': src, 'state': translation.state})
+ done.add((src, translation.lang))
# process outdated and discarded translations
outdated.write({'state': 'to_translate'})
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index b3268f359f9..84d740ded24 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -448,6 +448,8 @@ class Users(models.Model):
@api.model
def check_credentials(self, password):
""" Override this method to plug additional authentication methods"""
+ if not password:
+ raise AccessDenied()
user = self.sudo().search([('id', '=', self._uid), ('password', '=', password)])
if not user:
raise AccessDenied()
diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py
index e8a76b12c86..399508e9fcd 100644
--- a/odoo/addons/base/tests/test_translate.py
+++ b/odoo/addons/base/tests/test_translate.py
@@ -310,30 +310,36 @@ class TestTranslation(TransactionCase):
class TestXMLTranslation(TransactionCase):
def setUp(self):
super(TestXMLTranslation, self).setUp()
- self.env['ir.translation'].load_module_terms(['base'], ['fr_FR'])
+ self.env['ir.translation'].load_module_terms(['base'], ['fr_FR', 'nl_NL'])
+
+ def create_view(self, archf, terms, **kwargs):
+ view = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'model': 'res.partner',
+ 'arch': archf % terms,
+ })
+ for lang, trans_terms in kwargs.items():
+ for src, val in pycompat.izip(terms, trans_terms):
+ self.env['ir.translation'].create({
+ 'type': 'model',
+ 'name': 'ir.ui.view,arch_db',
+ 'lang': lang,
+ 'res_id': view.id,
+ 'src': src,
+ 'value': val,
+ 'state': 'translated',
+ })
+ return view
def test_copy(self):
""" Create a simple view, fill in translations, and copy it. """
- env_en = self.env(context={})
- env_fr = self.env(context={'lang': 'fr_FR'})
-
archf = ''
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
- view0 = self.env['ir.ui.view'].create({
- 'name': 'test',
- 'model': 'res.partner',
- 'arch': archf % terms_en,
- })
- for src, value in list(pycompat.izip(terms_en, terms_fr)):
- self.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'ir.ui.view,arch_db',
- 'lang': 'fr_FR',
- 'res_id': view0.id,
- 'src': src,
- 'value': value,
- })
+ view0 = self.create_view(archf, terms_en, fr_FR=terms_fr)
+
+ env_en = self.env(context={})
+ env_fr = self.env(context={'lang': 'fr_FR'})
# check translated field
self.assertEqual(view0.with_env(env_en).arch_db, archf % terms_en)
@@ -360,17 +366,29 @@ class TestXMLTranslation(TransactionCase):
archf = ''
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = (' Couteau', 'Fourchette ', ' Cuiller ')
- view0 = self.env['ir.ui.view'].create({
- 'name': 'test',
- 'model': 'res.partner',
- 'arch': archf % terms_en,
- })
- for src, value in list(pycompat.izip(terms_en, terms_fr)):
- self.env['ir.translation'].create({
- 'type': 'model',
- 'name': 'ir.ui.view,arch_db',
- 'lang': 'fr_FR',
- 'res_id': view0.id,
- 'src': src,
- 'value': value,
- })
+ self.create_view(archf, terms_en, fr_FR=terms_fr)
+
+ def test_sync(self):
+ """ Check translations after minor change in source terms. """
+ archf = ''
+ terms_en = ('Bread and cheeze',)
+ terms_fr = ('Pain et fromage',)
+ terms_nl = ('Brood and kaas',)
+ view = self.create_view(archf, terms_en, fr_FR=terms_fr, nl_NL=terms_nl)
+
+ env_en = self.env(context={})
+ env_fr = self.env(context={'lang': 'fr_FR'})
+ env_nl = self.env(context={'lang': 'nl_NL'})
+
+ self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
+ self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
+
+ # modify source term in view (fixed type in 'cheeze')
+ terms_en = ('Bread and cheese',)
+ view.write({'arch_db': archf % terms_en})
+
+ # check whether translations have been synchronized
+ self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
+ self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
diff --git a/odoo/addons/test_new_api/tests/test_onchange.py b/odoo/addons/test_new_api/tests/test_onchange.py
index 1bfdee908a7..03cad7f4009 100644
--- a/odoo/addons/test_new_api/tests/test_onchange.py
+++ b/odoo/addons/test_new_api/tests/test_onchange.py
@@ -1,4 +1,8 @@
# -*- coding: utf-8 -*-
+try:
+ from unittest.mock import patch
+except ImportError:
+ from mock import patch
from odoo.tests import common
@@ -465,3 +469,32 @@ class TestOnChange(common.TransactionCase):
result = Message.onchange(value, ['message', 'message_name', 'message_currency'], field_onchange)
self.assertEqual(result['value'], onchange_result)
+
+ def test_onchange_many2one_one2many(self):
+ """ Setting a many2one field should not read the inverse one2many. """
+ discussion = self.env.ref('test_new_api.discussion_0')
+ field_onchange = self.Message._onchange_spec()
+ self.assertEqual(field_onchange.get('discussion'), '1')
+
+ values = {
+ 'discussion': discussion.id,
+ 'name': "[%s] %s" % ('', self.env.user.name),
+ 'body': False,
+ 'author': self.env.uid,
+ 'size': 0,
+ }
+
+ called = [False]
+ orig_read = type(discussion).read
+
+ def mock_read(self, fields=None, load='_classic_read'):
+ if discussion in self and 'messages' in (fields or ()):
+ called[0] = True
+ return orig_read(self, fields, load)
+
+ # changing 'discussion' on message should not read 'messages' on discussion
+ with patch.object(type(discussion), 'read', mock_read, create=True):
+ self.env.cache.invalidate()
+ self.Message.onchange(values, 'discussion', field_onchange)
+
+ self.assertFalse(called[0], "discussion.messages has been read")
diff --git a/odoo/api.py b/odoo/api.py
index d1b1872e7f5..075d9ac3cab 100644
--- a/odoo/api.py
+++ b/odoo/api.py
@@ -983,6 +983,12 @@ class Cache(object):
value = self._data[field][record.id].get(key, SpecialValue(None))
return default if isinstance(value, SpecialValue) else value
+ def get_special(self, record, field, default=None):
+ """ Return the special value of ``field`` for ``record``. """
+ key = field.cache_key(record)
+ value = self._data[field][record.id].get(key)
+ return value.get if isinstance(value, SpecialValue) else default
+
def set_special(self, record, field, getter):
""" Set the value of ``field`` for ``record`` to return ``getter()``. """
key = field.cache_key(record)
diff --git a/odoo/fields.py b/odoo/fields.py
index 39fe7825e30..d8efade6691 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -2054,6 +2054,26 @@ class Many2one(_Relational):
return super(Many2one, self).convert_to_onchange(value, record, names)
+class _RelationalMultiUpdate(object):
+ """ A getter to update the value of an x2many field, without reading its
+ value until necessary.
+ """
+ __slots__ = ['record', 'field', 'value']
+
+ def __init__(self, record, field, value):
+ self.record = record
+ self.field = field
+ self.value = value
+
+ def __call__(self):
+ # determine the current field's value, and update it in cache only
+ record, field, value = self.record, self.field, self.value
+ cache = record.env.cache
+ cache.remove(record, field)
+ val = field.convert_to_cache(record[field.name] | value, record, validate=False)
+ cache.set(record, field, val)
+ return val
+
class _RelationalMulti(_Relational):
""" Abstract class for relational fields *2many. """
@@ -2065,21 +2085,16 @@ class _RelationalMulti(_Relational):
""" Update the cached value of ``self`` for ``records`` with ``value``. """
cache = records.env.cache
for record in records:
- if cache.contains(record, self):
+ special = cache.get_special(record, self)
+ if isinstance(special, _RelationalMultiUpdate):
+ # include 'value' in the existing _RelationalMultiUpdate; this
+ # avoids reading the field's value (which may be large)
+ special.value |= value
+ elif cache.contains(record, self):
val = self.convert_to_cache(record[self.name] | value, record, validate=False)
cache.set(record, self, val)
else:
- cache.set_special(record, self, self._update_getter(record, value))
-
- def _update_getter(self, record, value):
- def getter():
- # determine the current field's value, and update it in cache only
- cache = record.env.cache
- cache.remove(record, self)
- val = self.convert_to_cache(record[self.name] | value, record, validate=False)
- cache.set(record, self, val)
- return val
- return getter
+ cache.set_special(record, self, _RelationalMultiUpdate(record, self, value))
def convert_to_cache(self, value, record, validate=True):
# cache format: tuple(ids)