diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py index ddef5c09686..a00b6b23dca 100644 --- a/odoo/addons/test_new_api/models/test_new_api.py +++ b/odoo/addons/test_new_api/models/test_new_api.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import datetime +import logging from odoo import models, fields, api, _, Command from odoo.exceptions import AccessError, ValidationError @@ -1088,8 +1088,8 @@ class ModelChildM2o(models.Model): @api.depends('parent_id.name') def _compute_sizes(self): for record in self: - record.size1 = len(self.parent_id.name) - record.size2 = len(self.parent_id.name) + record.size1 = len(record.parent_id.name) + record.size2 = len(record.parent_id.name) @api.depends('name') def _compute_cost(self): @@ -1390,3 +1390,124 @@ class TriggerRight(models.Model): def _compute_left_size(self): for record in self: record.left_size = len(record.left_ids) + + +class Precompute(models.Model): + _name = 'test_new_api.precompute' + _description = 'model with precomputed fields' + + name = fields.Char(required=True) + + # both fields are precomputed + lower = fields.Char(compute='_compute_names', store=True, precompute=True) + upper = fields.Char(compute='_compute_names', store=True, precompute=True) + + # precomputed that depends on precomputed fields + lowup = fields.Char(compute='_compute_lowup', store=True, precompute=True) + + # kind of precomputed related field traversing a many2one + partner_id = fields.Many2one('res.partner') + commercial_id = fields.Many2one('res.partner', compute='_compute_commercial_id', + store=True, precompute=True) + + # precomputed depending on one2many fields + line_ids = fields.One2many('test_new_api.precompute.line', 'parent_id') + size = fields.Integer(compute='_compute_size', store=True, precompute=True) + + @api.depends('name') + def _compute_names(self): + for record in self: + record.lower = (record.name or "").lower() + record.upper = (record.name or "").upper() + + @api.depends('lower', 'upper') + def _compute_lowup(self): + for record in self: + record.lowup = record.lower + record.upper + + @api.depends('partner_id.commercial_partner_id') + def _compute_commercial_id(self): + for record in self: + record.commercial_id = record.partner_id.commercial_partner_id + + @api.depends('line_ids.size') + def _compute_size(self): + for record in self: + record.size = sum(record.line_ids.mapped('size')) + + +class PrecomputeLine(models.Model): + _name = 'test_new_api.precompute.line' + _description = 'secondary model with precomputed fields' + + parent_id = fields.Many2one('test_new_api.precompute') + name = fields.Char(required=True) + size = fields.Integer(compute='_compute_size', store=True, precompute=True) + + @api.depends('name') + def _compute_size(self): + for line in self: + line.size = len(line.name or "") + + +class PrecomputeCombo(models.Model): + _name = 'test_new_api.precompute.combo' + _description = 'yet another model with precomputed fields' + + _logger = logging.getLogger('precompute_setter') + + name = fields.Char() + reader = fields.Char(compute='_compute_reader', precompute=True, store=True) + editer = fields.Char(compute='_compute_editer', precompute=True, store=True, readonly=False) + setter = fields.Char(compute='_compute_setter', precompute=True, inverse='_inverse_setter', store=True) + + @api.depends('name') + def _compute_reader(self): + for record in self: + record.reader = record.name + + @api.depends('name') + def _compute_editer(self): + for record in self: + record.editer = record.name + + @api.depends('name') + def _compute_setter(self): + for record in self: + record.setter = record.name + + def _inverse_setter(self): + self._logger.warning("Unexpected inverse of %s.setter", self._name, stack_info=True) + + +class PrecomputeEditable(models.Model): + _name = 'test_new_api.precompute.editable' + _description = 'yet another model with precomputed editable fields' + + foo = fields.Char() + bar = fields.Char(compute='_compute_bar', precompute=True, store=True, readonly=False) + baz = fields.Char(compute='_compute_baz', precompute=True, store=True, readonly=False) + baz2 = fields.Char(compute='_compute_baz2', precompute=True, store=True) + + @api.depends('foo') + def _compute_bar(self): + self.bar = "COMPUTED" + + @api.depends('bar') + def _compute_baz(self): + self.baz = "COMPUTED" + + @api.depends('baz') + def _compute_baz2(self): + # this field is a trick to get the value of baz if it ever is recomputed + # during the precomputation of bar + for record in self: + record.baz2 = record.baz + + +class PrecomputeRequired(models.Model): + _name = 'test_new_api.precompute.required' + _description = 'a model with precomputed required fields' + + partner_id = fields.Many2one('res.partner', required=True) + name = fields.Char(related='partner_id.name', precompute=True, store=True, required=True) diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv index 4c20f2a7413..9250f277377 100644 --- a/odoo/addons/test_new_api/security/ir.model.access.csv +++ b/odoo/addons/test_new_api/security/ir.model.access.csv @@ -79,3 +79,8 @@ access_test_new_api_model_constrained_unlinks,access_test_new_api_model_constrai access_test_new_api_trigger_left,access_test_new_api_trigger_left,model_test_new_api_trigger_left,,1,1,1,1 access_test_new_api_trigger_middle,access_test_new_api_trigger_middle,model_test_new_api_trigger_middle,,1,1,1,1 access_test_new_api_trigger_right,access_test_new_api_trigger_right,model_test_new_api_trigger_right,,1,1,1,1 +access_test_new_api_precompute,access_test_new_api_precompute,model_test_new_api_precompute,,1,0,0,0 +access_test_new_api_precompute_line,access_test_new_api_precompute_line,model_test_new_api_precompute_line,,1,0,0,0 +access_test_new_api_precompute_combo,access_test_new_api_precompute_combo,model_test_new_api_precompute_combo,,1,0,0,0 +access_test_new_api_precompute_editable,access_test_new_api_precompute_editable,model_test_new_api_precompute_editable,,1,0,0,0 +access_test_new_api_precompute_required,access_test_new_api_precompute_required,model_test_new_api_precompute_required,,1,0,0,0 diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 1223366053f..9f606aef959 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -1630,6 +1630,26 @@ class TestFields(TransactionCaseWithUserDemo): # check that this does not generate an infinite recursion new_disc._convert_to_write(new_disc._cache) + def test_40_new_convert_to_write(self): + new_disc = self.env['test_new_api.discussion'].new({ + 'name': "Stuff", + 'moderator': self.env.uid, + 'participants': [(6, 0, self.env.user.ids)], + }) + # Put the user groups in the cache of the new record + new_disc.participants.groups_id + + # Check that the groups in the cache are not returned by convert_to_write + # because no real change happened, the values are identical except that + # self.env.user.groups_id._ids = (Id1, Id2, ...) whereas + # new_disc.participants.groups_id._ids = (NewId(origin=Id1), NewId(origin=Id2), ...) + field = new_disc._fields.get("participants") + # make sure that there is no inverse field for discussions on res_users, + # as the test depends on it + self.assertFalse(new_disc.pool.field_inverses[field]) + convert = field.convert_to_write(new_disc["participants"], new_disc) + self.assertEqual(convert, [(6, 0, self.env.user.ids)]) + def test_40_new_inherited_fields(self): """ Test the behavior of new records with inherited fields. """ email = self.env['test_new_api.emailmessage'].new({'body': 'XXX'}) @@ -3411,3 +3431,179 @@ class TestWrongRelatedError(common.TransactionCase): ) with self.assertRaisesRegex(KeyError, errMsg): self.registry.setup_models(self.env.cr) + + +class TestPrecomputeModel(common.TransactionCase): + + def test_precompute_consistency(self): + Model = self.registry['test_new_api.precompute'] + self.assertEqual(Model.lower.compute, Model.upper.compute) + self.assertTrue(Model.lower.precompute) + self.assertTrue(Model.upper.precompute) + + # see what happens if not both are precompute + self.addCleanup(self.registry.reset_changes) + self.patch(Model.upper, 'precompute', False) + with self.assertLogs('odoo.modules.registry', level='WARNING'): + self.registry.setup_models(self.cr) + self.registry.field_computed + + def test_precompute_dependencies_base(self): + Model = self.registry['test_new_api.precompute'] + self.assertTrue(Model.lower.precompute) + self.assertTrue(Model.upper.precompute) + self.assertTrue(Model.lowup.precompute) + + # see what happens if precompute depends on non-precompute + self.addCleanup(self.registry.reset_changes) + self.patch(Model.lower, 'precompute', False) + self.patch(Model.upper, 'precompute', False) + with self.assertWarns(UserWarning): + self.registry.setup_models(self.cr) + self.registry.field_triggers + + def test_precompute_dependencies_many2one(self): + Model = self.registry['test_new_api.precompute'] + Partner = self.registry['res.partner'] + + # Model.commercial_id depends on partner_id.commercial_partner_id, and + # precomputation is valid when traversing many2one fields + self.assertTrue(Model.commercial_id.precompute) + self.assertFalse(Partner.commercial_partner_id.precompute) + + def test_precompute_dependencies_one2many(self): + Model = self.registry['test_new_api.precompute'] + Line = self.registry['test_new_api.precompute.line'] + self.assertTrue(Model.size.precompute) + self.assertTrue(Line.size.precompute) + + # see what happens if precompute depends on non-precompute + self.addCleanup(self.registry.reset_changes) + # ensure that Model.size.precompute is restored after setup_models() + self.patch(Model.size, 'precompute', True) + self.patch(Line.size, 'precompute', False) + with self.assertWarns(UserWarning): + self.registry.setup_models(self.cr) + self.registry.field_triggers + + +class TestPrecompute(common.TransactionCase): + + def test_precompute(self): + model = self.env['test_new_api.precompute'] + + # warmup + model.create({'name': 'Foo', 'line_ids': [Command.create({'name': 'bar'})]}) + + # the creation makes one insert query for the main record, and one for the line + with self.assertQueries([ + insert(model, 'name', 'lower', 'upper', 'lowup', 'commercial_id', 'size'), + insert(model.line_ids, 'parent_id', 'name', 'size'), + ]): + record = model.create({'name': 'Foo', 'line_ids': [Command.create({'name': 'bar'})]}) + + # check the values in the database + self.cr.execute(f'SELECT * FROM "{model._table}" WHERE id=%s', [record.id]) + [row] = self.cr.dictfetchall() + + self.assertEqual(row['name'], 'Foo') + self.assertEqual(row['lower'], 'foo') + self.assertEqual(row['upper'], 'FOO') + self.assertEqual(row['lowup'], 'fooFOO') + self.assertEqual(row['size'], 3) + + def test_precompute_combo(self): + model = self.env['test_new_api.precompute.combo'] + + # warmup + model.create({}) + QUERIES = [insert(model, 'name', 'reader', 'editer', 'setter')] + + # no value at all + with self.assertQueries(QUERIES): + record = model.create({'name': 'A'}) + + self.assertEqual(record.reader, 'A') + self.assertEqual(record.editer, 'A') + self.assertEqual(record.setter, 'A') + + # default value + with self.assertQueries(QUERIES), self.assertLogs('precompute_setter', level='WARNING'): + defaults = dict(default_reader='X', default_editer='Y', default_setter='Z') + record = model.with_context(**defaults).create({'name': 'A'}) + + self.assertEqual(record.reader, 'A') + self.assertEqual(record.editer, 'Y') + self.assertEqual(record.setter, 'Z') + + # explicit value + with self.assertQueries(QUERIES), self.assertLogs('precompute_setter', level='WARNING'): + record = model.create({'name': 'A', 'reader': 'X', 'editer': 'Y', 'setter': 'Z'}) + + self.assertEqual(record.reader, 'A') + self.assertEqual(record.editer, 'Y') + self.assertEqual(record.setter, 'Z') + + def test_precompute_editable(self): + model = self.env['test_new_api.precompute.editable'] + + # no value for bar, no value for baz + record = model.create({'foo': 'foo'}) + self.assertEqual(record.bar, 'COMPUTED') + self.assertEqual(record.baz, 'COMPUTED') + self.assertEqual(record.baz2, 'COMPUTED') + + # value for bar, no value for baz + record = model.create({'foo': 'foo', 'bar': 'bar'}) + self.assertEqual(record.bar, 'bar') + self.assertEqual(record.baz, 'COMPUTED') + self.assertEqual(record.baz2, 'COMPUTED') + + # no value for bar, value for baz: the computation of bar should not + # recompute baz in memory, in case a third field depends on it + record = model.create({'foo': 'foo', 'baz': 'baz'}) + self.assertEqual(record.bar, 'COMPUTED') + self.assertEqual(record.baz, 'baz') + self.assertEqual(record.baz2, 'baz') + + # value for bar, value for baz + record = model.create({'foo': 'foo', 'bar': 'bar', 'baz': 'baz'}) + self.assertEqual(record.bar, 'bar') + self.assertEqual(record.baz, 'baz') + self.assertEqual(record.baz2, 'baz') + + def test_precompute_required(self): + model = self.env['test_new_api.precompute.required'] + + field = type(model).name + self.assertTrue(field.related) + self.assertTrue(field.store) + self.assertTrue(field.required) + + partner = self.env['res.partner'].create({'name': 'Foo'}) + + # this will crash if field is not precomputed + record = model.create({'partner_id': partner.id}) + self.assertEqual(record.name, 'Foo') + + # check the queries being made + QUERIES = [insert(model, 'partner_id', 'name')] + with self.assertQueries(QUERIES): + record = model.create({'partner_id': partner.id}) + + def test_precompute_batch(self): + model = self.env['test_new_api.precompute.required'] + + partners = self.env['res.partner'].create([ + {'name': name} + for name in "Foo Bar Baz".split() + ]) + + # warmup + model.create({'partner_id': partners[0].id}) + model.flush() + model.invalidate_cache() + + # check the number of queries: 1 SELECT + 3 INSERT + with self.assertQueryCount(4): + model.create([{'partner_id': pid} for pid in partners.ids]) diff --git a/odoo/addons/test_new_api/tests/test_one2many.py b/odoo/addons/test_new_api/tests/test_one2many.py index 44e91961db0..4d323e16f9d 100644 --- a/odoo/addons/test_new_api/tests/test_one2many.py +++ b/odoo/addons/test_new_api/tests/test_one2many.py @@ -283,3 +283,72 @@ class One2manyCase(TransactionCase): order.write({ 'line_ids': [Command.link(line0.id), Command.link(line1.id)], }) + + def test_new_real_interactions(self): + """ Test and specify the interactions between new and real records. + Through m2o and o2m, with real/unreal records on both sides, the behavior + varies greatly. At least, the behavior will be clearly consistent and any + change will have to adapt the current test. + """ + ############## + # REAL - NEW # + ############## + parent = self.env['test_new_api.model_parent_m2o'].create({'name': 'parentB'}) + new_child = self.env['test_new_api.model_child_m2o'].new({'name': 'B', 'parent_id': parent.id}) + + # wanted behavior: when creating a new with a real parent id, the child + # isn't present in the parent childs until true creation + self.assertFalse(parent.child_ids) + self.assertEqual(new_child.parent_id, parent) + + # current (wanted?) behavior: when adding a new record to a real record + # o2m, the record is created, but not linked to the new in cache + # REAL.O2M += NEW RECORD + parent.child_ids += new_child + self.assertTrue(parent.child_ids) + self.assertNotEqual(parent.child_ids, new_child) + + ############# + # NEW - NEW # + ############# + # wanted behavior: linking new records to new records is totally fine + new_parent = self.env['test_new_api.model_parent_m2o'].new({ + "name": 'parentC3PO', + "child_ids": [(0, 0, {"name": "C3"})], + }) + self.assertEqual(new_parent, new_parent.child_ids.parent_id) + self.assertFalse(new_parent.id) + self.assertTrue(new_parent.child_ids) + self.assertFalse(new_parent.child_ids.ids) + + new_child = self.env['test_new_api.model_child_m2o'].new({ + 'name': 'PO', + }) + new_parent.child_ids += new_child + self.assertIn(new_child, new_parent.child_ids) + self.assertEqual(len(new_parent.child_ids), 2) + self.assertListEqual(new_parent.child_ids.mapped('name'), ['C3', 'PO']) + + new_child2 = self.env['test_new_api.model_child_m2o'].new({ + 'name': 'R2D2', + 'parent_id': new_parent.id, + }) + self.assertIn(new_child2, new_parent.child_ids) + self.assertEqual(len(new_parent.child_ids), 3) + self.assertListEqual(new_parent.child_ids.mapped('name'), ['C3', 'PO', 'R2D2']) + + ############################### + # NEW TO REAL CONVERSION TEST # + ############################### + + # A bit out of scope, but was interesting to check everything was + # working fine on the way. + name = type(new_parent).name + child_ids = type(new_parent).child_ids + parent = self.env['test_new_api.model_parent_m2o'].create({ + 'name': name.convert_to_write(new_parent.name, new_parent), + 'child_ids': child_ids.convert_to_write(new_parent.child_ids, new_parent), + }) + self.assertEqual(len(parent.child_ids), 3) + self.assertEqual(parent, parent.child_ids.parent_id) + self.assertEqual(parent.child_ids.mapped('name'), ['C3', 'PO', 'R2D2']) diff --git a/odoo/fields.py b/odoo/fields.py index 74e9d71cbc9..c0f76a53cba 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -187,6 +187,18 @@ class Field(MetaField('DummyField', (object,), {})): .. seealso:: :ref:`Advanced Fields/Compute fields ` + :param bool precompute: whether the field should be computed before record insertion + in database. Should be used to specify manually some fields as precompute=True + when the field can be computed before record insertion. + (e.g. avoid statistics fields based on search/read_group), many2one + linking to the previous record, ... (default: `False`) + + .. warning:: + + Precompute only happens if no explicit value and no default value is + provided to create(). This means that a default value disables the + precomputation, even if the field is specified as precompute=True. + :param bool compute_sudo: whether the field should be recomputed as superuser to bypass access rights (by default ``True`` for stored fields, ``False`` for non stored fields) @@ -236,6 +248,7 @@ class Field(MetaField('DummyField', (object,), {})): recursive = False # whether self depends on itself compute = None # compute(recs) computes field on recs compute_sudo = False # whether field should be recomputed as superuser + precompute = False # whether field has to be computed before creation inverse = None # inverse(recs) inverses field on recs search = None # search(recs, operator, value) searches on self related = None # sequence of field names, for related fields @@ -244,7 +257,7 @@ class Field(MetaField('DummyField', (object,), {})): string = None # field label help = None # field tooltip - invisible = False # whether the field is invisible + invisible = False # whether the field is invisible readonly = False # whether the field is readonly required = False # whether the field is required states = None # set readonly and required depending on state @@ -384,6 +397,13 @@ class Field(MetaField('DummyField', (object,), {})): attrs['compute_sudo'] = attrs.get('compute_sudo', attrs.get('related_sudo', True)) attrs['copy'] = attrs.get('copy', False) attrs['readonly'] = attrs.get('readonly', True) + if attrs.get('precompute'): + if not attrs.get('compute') and not attrs.get('related'): + warnings.warn(f"precompute attribute doesn't make any sense on non computed field {self}") + attrs['precompute'] = False + elif not attrs.get('store'): + warnings.warn(f"precompute attribute has no impact on non stored field {self}") + attrs['precompute'] = False if attrs.get('company_dependent'): # by default, company-dependent fields are not stored, not computed # in superuser mode and not copied @@ -701,6 +721,7 @@ class Field(MetaField('DummyField', (object,), {})): for dotnames in registry.field_depends[self]: field_seq = [] model_name = self.model_name + check_precompute = self.precompute for index, fname in enumerate(dotnames.split('.')): Model = registry[model_name] @@ -720,6 +741,12 @@ class Field(MetaField('DummyField', (object,), {})): self.recursive = True warnings.warn(f"Field {self} should be declared with recursive=True") + # precomputed fields can depend on non-precomputed ones, as long + # as they are reachable through at least one many2one field + if check_precompute and field.store and field.compute and not field.precompute: + warnings.warn(f"Field {self} cannot be precomputed as it depends on non-precomputed field {field}") + self.precompute = False + field_seq.append(field) # do not make self trigger itself: for instance, a one2many @@ -732,6 +759,9 @@ class Field(MetaField('DummyField', (object,), {})): for inv_field in Model.pool.field_inverses[field]: yield tuple(field_seq) + (inv_field,) + if check_precompute and field.type == 'many2one': + check_precompute = False + model_name = field.comodel_name ############################################################################ diff --git a/odoo/models.py b/odoo/models.py index 289e9daaf68..c94cb0deee5 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2840,17 +2840,17 @@ class BaseModel(metaclass=MetaModel): continue # don't update custom fields new = field.update_db(self, columns) if new and field.compute: - fields_to_compute.append(field.name) + fields_to_compute.append(field) if fields_to_compute: - @self.pool.post_init - def mark_fields_to_compute(): - recs = self.with_context(active_test=False).search([], order='id') - if not recs: - return - for field in fields_to_compute: - _logger.info("Storing computed values of %s.%s", recs._name, field) - self.env.add_to_compute(recs._fields[field], recs) + # mark existing records for computation now, so that computed + # required fields are flushed before the NOT NULL constraint is + # added to the database + cr.execute('SELECT id FROM "{}"'.format(self._table)) + records = self.browse(row[0] for row in cr.fetchall()) + for field in fields_to_compute: + _logger.info("Prepare computation of %s", field) + self.env.add_to_compute(field, records) if self._auto: self._add_sql_constraints() @@ -4000,28 +4000,14 @@ Fields: self = self.browse() self.check_access_rights('create') - bad_names = {'id', 'parent_path'} - if self._log_access: - # the superuser can set log_access fields while loading registry - if not(self.env.uid == SUPERUSER_ID and not self.pool.ready): - bad_names.update(LOG_ACCESS_COLUMNS) + vals_list = self._prepare_create_values(vals_list) # classify fields for each record data_list = [] inversed_fields = set() for vals in vals_list: - # add missing defaults - vals = self._add_missing_default_values(vals) - - # set magic fields - for name in bad_names: - vals.pop(name, None) - if self._log_access: - vals.setdefault('create_uid', self.env.uid) - vals.setdefault('create_date', self.env.cr.now()) - vals.setdefault('write_uid', self.env.uid) - vals.setdefault('write_date', self.env.cr.now()) + precomputed = vals.pop('__precomputed__', ()) # distribute fields into sets for various purposes data = {} @@ -4047,11 +4033,12 @@ Fields: stored[key] = val if field.inherited: inherited[field.related_field.model_name][key] = val - elif field.inverse: + elif field.inverse and field not in precomputed: inversed[key] = val inversed_fields.add(field) - # protect non-readonly computed fields against (re)computation - if field.compute and not field.readonly: + # protect editable computed fields and precomputed fields + # against (re)computation + if field.compute and (not field.readonly or field.precompute): protected.update(self.pool.field_computed.get(field, [field])) data_list.append(data) @@ -4118,6 +4105,88 @@ Fields: records._check_company() return records + def _prepare_create_values(self, vals_list): + """ Clean up and complete the given create values, and return a list of + new vals containing: + + * default values, + * discarded forbidden values (magic fields), + * precomputed fields. + + :param list vals_list: List of create values + :returns: new list of completed create values + :rtype: dict + """ + bad_names = ['id', 'parent_path'] + if self._log_access: + # the superuser can set log_access fields while loading registry + if not(self.env.uid == SUPERUSER_ID and not self.pool.ready): + bad_names.extend(LOG_ACCESS_COLUMNS) + + # also discard precomputed readonly fields (to force their computation) + bad_names.extend( + fname + for fname, field in self._fields.items() + if field.precompute and field.readonly + ) + + result_vals_list = [] + for vals in vals_list: + # add default values + vals = self._add_missing_default_values(vals) + + # add magic fields + for fname in bad_names: + vals.pop(fname, None) + if self._log_access: + vals.setdefault('create_uid', self.env.uid) + vals.setdefault('create_date', self.env.cr.now()) + vals.setdefault('write_uid', self.env.uid) + vals.setdefault('write_date', self.env.cr.now()) + + result_vals_list.append(vals) + + # add precomputed fields + self._add_precomputed_values(result_vals_list) + + return result_vals_list + + def _add_precomputed_values(self, vals_list): + """ Add missing precomputed fields to ``vals_list`` values. + Only applies for precompute=True fields. + + :param dict vals_list: list(dict) of create values + """ + precomputable = { + fname: field + for fname, field in self._fields.items() + if field.precompute + } + if not precomputable: + return + + # determine which vals must be completed + vals_list_todo = [ + vals + for vals in vals_list + if any(fname not in vals for fname in precomputable) + ] + if not vals_list_todo: + return + + # create new records for the vals that must be completed + records = self.browse().concat(*(self.new(vals) for vals in vals_list_todo)) + + for record, vals in zip(records, vals_list_todo): + vals['__precomputed__'] = precomputed = set() + for fname, field in precomputable.items(): + if fname not in vals: + # computed stored fields with a column + # have to be computed before create + # s.t. required and constraints can be applied on those fields. + vals[fname] = field.convert_to_write(record[fname], self) + precomputed.add(field) + @api.model def _create(self, data_list): """ Create records from the stored field values in ``data_list``. """ diff --git a/odoo/modules/registry.py b/odoo/modules/registry.py index 948cd7973d5..01f3b52cef8 100644 --- a/odoo/modules/registry.py +++ b/odoo/modules/registry.py @@ -321,6 +321,9 @@ class Registry(Mapping): if len({field.compute_sudo for field in fields}) > 1: _logger.warning("%s: inconsistent 'compute_sudo' for computed fields: %s", model_name, ", ".join(field.name for field in fields)) + if len({field.precompute for field in fields}) > 1: + _logger.warning("%s: inconsistent 'precompute' for computed fields: %s", + model_name, ", ".join(field.name for field in fields)) return computed @lazy_property