diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index 45af7c4fe4efa..117d3be6d6326 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -535,7 +535,7 @@ def _onchange_relation_table(self): # check whether other fields use the same table others = self.search([('ttype', '=', 'many2many'), ('relation_table', '=', self.relation_table), - ('id', 'not in', self._origin.ids)]) + ('id', 'not in', self.ids)]) if others: for other in others: if (other.model, other.relation) == (self.relation, self.model): diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 0cf10f1ad558c..fb5f0906a320c 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -266,10 +266,8 @@ def _compute_partner_share(self): @api.depends('vat') def _compute_same_vat_partner_id(self): for partner in self: - partner_id = partner.id - if isinstance(partner_id, models.NewId): - # deal with onchange(), which is always called on a single record - partner_id = self._origin.id + # use _origin to deal with onchange() + partner_id = partner._origin.id domain = [('vat', '=', partner.vat)] if partner_id: domain += [('id', '!=', partner_id), '!', ('id', 'child_of', partner_id)] @@ -379,7 +377,7 @@ def onchange_parent_id(self): if not self.parent_id: return result = {} - partner = getattr(self, '_origin', self) + partner = self._origin if partner.parent_id and partner.parent_id != self.parent_id: result['warning'] = { 'title': _('Warning'), diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index 256e89cbb7d8e..f55b99bfdebac 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -962,15 +962,15 @@ def create(self, vals_list): if 'groups_id' in values: # complete 'groups_id' with implied groups user = self.new(values) + gs = user.groups_id._origin group_public = self.env.ref('base.group_public', raise_if_not_found=False) group_portal = self.env.ref('base.group_portal', raise_if_not_found=False) - if group_public and group_public in user.groups_id: - gs = self.env.ref('base.group_public') | self.env.ref('base.group_public').trans_implied_ids - elif group_portal and group_portal in user.groups_id: - gs = self.env.ref('base.group_portal') | self.env.ref('base.group_portal').trans_implied_ids - else: - gs = user.groups_id | user.groups_id.trans_implied_ids - values['groups_id'] = type(self).groups_id.convert_to_write(gs, user.groups_id) + if group_public and group_public in gs: + gs = group_public + elif group_portal and group_portal in gs: + gs = group_portal + gs = gs | gs.trans_implied_ids + values['groups_id'] = type(self).groups_id.convert_to_write(gs, user) return super(UsersImplied, self).create(vals_list) @api.multi diff --git a/odoo/addons/test_new_api/models.py b/odoo/addons/test_new_api/models.py index e6ad747097d63..31e8e9eeeb102 100644 --- a/odoo/addons/test_new_api/models.py +++ b/odoo/addons/test_new_api/models.py @@ -214,6 +214,7 @@ class Multi(models.Model): name = fields.Char(related='partner.name', readonly=True) partner = fields.Many2one('res.partner') lines = fields.One2many('test_new_api.multi.line', 'multi') + partners = fields.One2many(related='partner.child_ids') @api.onchange('name') def _onchange_name(self): 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 de9bc11dcafca..c83704dbbe3cc 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -956,10 +956,154 @@ def test_40_new_defaults(self): discussion = discussion.with_context(default_categories=[(4, cat1.id)]) # no value gives the default value new_disc = discussion.new({'name': "Foo"}) - self.assertEqual(new_disc.categories, cat1) + self.assertEqual(new_disc.categories._origin, cat1) # value is combined with default value new_disc = discussion.new({'name': "Foo", 'categories': [(4, cat2.id)]}) - self.assertEqual(new_disc.categories, cat1 + cat2) + self.assertEqual(new_disc.categories._origin, cat1 + cat2) + + def test_40_new_fields(self): + """ Test new records with relational fields. """ + # create a new discussion with all kinds of relational fields + msg0 = self.env['test_new_api.message'].create({'body': "XXX"}) + msg1 = self.env['test_new_api.message'].create({'body': "WWW"}) + cat0 = self.env['test_new_api.category'].create({'name': 'AAA'}) + cat1 = self.env['test_new_api.category'].create({'name': 'DDD'}) + new_disc = self.env['test_new_api.discussion'].new({ + 'name': "Stuff", + 'moderator': self.env.uid, + 'messages': [ + (4, msg0.id), + (4, msg1.id), (1, msg1.id, {'body': "YYY"}), + (0, 0, {'body': "ZZZ"}) + ], + 'categories': [ + (4, cat0.id), + (4, cat1.id), (1, cat1.id, {'name': "BBB"}), + (0, 0, {'name': "CCC"}) + ], + }) + self.assertFalse(new_disc.id) + + # many2one field values are actual records + self.assertEqual(new_disc.moderator.id, self.env.uid) + + # x2many fields values are new records + new_msg0, new_msg1, new_msg2 = new_disc.messages + self.assertFalse(new_msg0.id) + self.assertFalse(new_msg1.id) + self.assertFalse(new_msg2.id) + + new_cat0, new_cat1, new_cat2 = new_disc.categories + self.assertFalse(new_cat0.id) + self.assertFalse(new_cat1.id) + self.assertFalse(new_cat2.id) + + # the x2many has its inverse field set + self.assertEqual(new_msg0.discussion, new_disc) + self.assertEqual(new_msg1.discussion, new_disc) + self.assertEqual(new_msg2.discussion, new_disc) + + self.assertFalse(msg0.discussion) + self.assertFalse(msg1.discussion) + + self.assertEqual(new_cat0.discussions, new_disc) # add other discussions + self.assertEqual(new_cat1.discussions, new_disc) + self.assertEqual(new_cat2.discussions, new_disc) + + self.assertNotIn(new_disc, cat0.discussions) + self.assertNotIn(new_disc, cat1.discussions) + + # new lines are connected to their origin + self.assertEqual(new_msg0._origin, msg0) + self.assertEqual(new_msg1._origin, msg1) + self.assertFalse(new_msg2._origin) + + self.assertEqual(new_cat0._origin, cat0) + self.assertEqual(new_cat1._origin, cat1) + self.assertFalse(new_cat2._origin) + + # the field values are either specific, or the same as the origin + self.assertEqual(new_msg0.body, "XXX") + self.assertEqual(new_msg1.body, "YYY") + self.assertEqual(new_msg2.body, "ZZZ") + + self.assertEqual(msg0.body, "XXX") + self.assertEqual(msg1.body, "WWW") + + self.assertEqual(new_cat0.name, "AAA") + self.assertEqual(new_cat1.name, "BBB") + self.assertEqual(new_cat2.name, "CCC") + + self.assertEqual(cat0.name, "AAA") + self.assertEqual(cat1.name, "DDD") + + # special case for many2one fields that define _inherits + new_email = self.env['test_new_api.emailmessage'].new({'body': "XXX"}) + self.assertFalse(new_email.id) + self.assertTrue(new_email.message) + self.assertFalse(new_email.message.id) + self.assertEqual(new_email.body, "XXX") + + new_email = self.env['test_new_api.emailmessage'].new({'message': msg0.id}) + self.assertFalse(new_email.id) + self.assertFalse(new_email._origin) + self.assertFalse(new_email.message.id) + self.assertEqual(new_email.message._origin, msg0) + self.assertEqual(new_email.body, "XXX") + + def test_40_new_ref_origin(self): + """ Test the behavior of new records with ref/origin. """ + Discussion = self.env['test_new_api.discussion'] + new = Discussion.new + + # new records with identical/different refs + xs = new() + new(ref='a') + new(ref='b') + new(ref='b') + self.assertEqual([x == y for x in xs for y in xs], [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 1, + 0, 0, 1, 1, + ]) + for x in xs: + self.assertFalse(x._origin) + + # new records with identical/different origins + a, b = Discussion.create([{'name': "A"}, {'name': "B"}]) + xs = new() + new(origin=a) + new(origin=b) + new(origin=b) + self.assertEqual([x == y for x in xs for y in xs], [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 1, + 0, 0, 1, 1, + ]) + self.assertFalse(xs[0]._origin) + self.assertEqual(xs[1]._origin, a) + self.assertEqual(xs[2]._origin, b) + self.assertEqual(xs[3]._origin, b) + self.assertEqual(xs._origin, a + b + b) + self.assertEqual(xs._origin._origin, a + b + b) + + # new records with refs and origins + x1 = new(ref='a') + x2 = new(origin=b) + self.assertNotEqual(x1, x2) + + # new discussion based on existing discussion + disc = self.env.ref('test_new_api.discussion_0') + new_disc = disc.new(origin=disc) + self.assertFalse(new_disc.id) + self.assertEqual(new_disc._origin, disc) + self.assertEqual(new_disc.name, disc.name) + # many2one field + self.assertEqual(new_disc.moderator, disc.moderator) + # one2many field + self.assertTrue(new_disc.messages) + self.assertNotEqual(new_disc.messages, disc.messages) + self.assertEqual(new_disc.messages._origin, disc.messages) + # many2many field + self.assertTrue(new_disc.participants) + self.assertNotEqual(new_disc.participants, disc.participants) + self.assertEqual(new_disc.participants._origin, disc.participants) @mute_logger('odoo.addons.base.models.ir_model') def test_41_new_related(self): @@ -1005,6 +1149,16 @@ def test_42_new_related(self): self.assertNotEqual(message.sudo().env, message.env) self.assertEqual(message.discussion_name, discussion.name) + def test_43_new_related(self): + """ test the behavior of one2many related fields """ + partner = self.env['res.partner'].create({ + 'name': 'Foo', + 'child_ids': [(0, 0, {'name': 'Bar'})], + }) + multi = self.env['test_new_api.multi'].new() + multi.partner = partner + self.assertEqual(multi.partners.mapped('name'), ['Bar']) + def test_50_defaults(self): """ test default values. """ fields = ['discussion', 'body', 'author', 'size'] diff --git a/odoo/addons/test_performance/tests/test_performance.py b/odoo/addons/test_performance/tests/test_performance.py index 7ea5fbcdadcab..c05a62cc4d924 100644 --- a/odoo/addons/test_performance/tests/test_performance.py +++ b/odoo/addons/test_performance/tests/test_performance.py @@ -355,14 +355,26 @@ def test_several_prefetch(self): ) records = self.env['test_performance.base'].search([]) self.assertEqual(len(records), 1280) + # should only cause 2 queries thanks to prefetching with self.assertQueryCount(__system__=2, demo=2): records.mapped('value') + records.invalidate_cache(['value']) + with self.assertQueryCount(__system__=2, demo=2): + records.mapped('value') + records.invalidate_cache(['value']) + with self.assertQueryCount(__system__=2, demo=2): + new_recs = records.browse(records.new(origin=record).id for record in records) + new_recs.mapped('value') + + records.invalidate_cache(['value']) with self.assertQueryCount(__system__=2, demo=2): with self.env.do_in_onchange(): records.mapped('value') + + # clean up after each pass self.env.cr.execute( 'delete from test_performance_base where id not in %s', (tuple(initial_records.ids),) diff --git a/odoo/fields.py b/odoo/fields.py index 214d1812b2176..1267e33a217f0 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -23,8 +23,8 @@ import psycopg2 from .sql_db import LazyCursor -from .tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar,\ - ustr, OrderedSet, pycompat, sql, date_utils, unique +from .tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar, \ + ustr, OrderedSet, pycompat, sql, date_utils, unique, IterableGenerator from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT from .tools.translate import html_translate, _ @@ -1019,11 +1019,17 @@ def __set__(self, record, value): # determine dependent fields spec = self.modified_draft(record) - # set value in cache, inverse field, and mark record as dirty + # set value in cache record.env.cache.set(record, self, value) - if env.in_onchange: - for invf in record._field_inverses[self]: - invf._update(record[self.name], record) + + if not record.id or env.in_onchange: + # set inverse fields on new records in the comodel + if self.relational: + inv_recs = record[self.name].filtered(lambda r: not r.id) + if inv_recs: + for invf in record._field_inverses[self]: + invf._update(inv_recs, record) + # mark field as dirty env.dirty[record].add(self.name) # determine more dependent fields, and invalidate them @@ -1125,6 +1131,18 @@ def determine_draft_value(self, record): self.compute_value(record) return + origin = record._origin + if origin: + # retrieve value from original record + value = self.convert_to_cache(origin[self.name], record) + return record.env.cache.set(record, self, value) + + if self.type == 'many2one' and self.delegate: + # special case: parent records are new as well + parent = record.env[self.comodel_name].new() + value = self.convert_to_cache(parent, record) + return record.env.cache.set(record, self, value) + null = self.convert_to_cache(False, record, validate=False) record.env.cache.set_special(record, self, lambda: null) @@ -1157,14 +1175,14 @@ def determine_domain(self, records, operator, value): # Notification when fields are modified # - def modified_draft(self, records): + def modified_draft(self, record): """ Same as :meth:`modified`, but in draft mode. """ - env = records.env + env = record.env # invalidate the fields on the records in cache that depend on - # ``records``, except fields currently being computed + # ``record``, except fields currently being computed spec = [] - for field, path in records._field_triggers[self]: + for field, path in record._field_triggers[self]: if not field.compute: # Note: do not invalidate non-computed fields. Such fields may # require invalidation in general (like *2many fields with @@ -1172,16 +1190,12 @@ def modified_draft(self, records): # we would simply lose their values during an onchange! continue - target = env[field.model_name] - protected = env.protected(field) - if path == 'id' and field.model_name == records._name: - target = records - protected - elif path and env.in_onchange: - target = (env.cache.get_records(target, field) - protected).filtered( - lambda rec: rec if path == 'id' else rec._mapped_cache(path) & records - ) + if path == 'id' and field.model_name == record._name: + target = record else: - target = env.cache.get_records(target, field) - protected + target = env.cache.get_records(env[field.model_name], field) + target = target.filtered(lambda t: not t.id) + target -= env.protected(field) if target: spec.append((field, target._ids)) @@ -2173,22 +2187,28 @@ def convert_to_column(self, value, record, values=None, validate=True): def convert_to_cache(self, value, record, validate=True): # cache format: tuple(ids) if type(value) in IdType: - return (value,) + ids = (value,) elif isinstance(value, BaseModel): - if not validate or (value._name == self.comodel_name and len(value) <= 1): - return value._ids - raise ValueError("Wrong value for %s: %r" % (self, value)) + if validate and (value._name != self.comodel_name or len(value) > 1): + raise ValueError("Wrong value for %s: %r" % (self, value)) + ids = value._ids elif isinstance(value, tuple): # value is either a pair (id, name), or a tuple of ids - return value[:1] + ids = value[:1] elif isinstance(value, dict): - return record.env[self.comodel_name].new(value)._ids + ids = record.env[self.comodel_name].new(value)._ids else: - return () + ids = () + + if self.delegate and record and not record.id: + # the parent record of a new record is a new record + ids = tuple(it and NewId(it) for it in ids) + + return ids def convert_to_record(self, value, record): # use registry to avoid creating a recordset for the model - prefetch_ids = PrefetchValueIds(record, self) + prefetch_ids = IterableGenerator(prefetch_value_ids, record, self) return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids) def convert_to_read(self, value, record, use_name_get=True): @@ -2274,45 +2294,56 @@ def _update(self, records, value): def convert_to_cache(self, value, record, validate=True): # cache format: tuple(ids) if isinstance(value, BaseModel): - if not validate or (value._name == self.comodel_name): - return value._ids + if validate and value._name != self.comodel_name: + raise ValueError("Wrong value for %s: %s" % (self, value)) + ids = value._ids + if record and not record.id: + # x2many field value of new record is new records + ids = tuple(it and NewId(it) for it in ids) + return ids + elif isinstance(value, (list, tuple)): # value is a list/tuple of commands, dicts or record ids comodel = record.env[self.comodel_name] + # if record is new, the field's value is new records + if record and not record.id: + browse = lambda it: comodel.browse([it and NewId(it)]) + else: + browse = comodel.browse # determine the value ids ids = OrderedSet(record[self.name]._ids) # modify ids with the commands for command in value: if isinstance(command, (tuple, list)): if command[0] == 0: - ids.add(comodel.new(command[2], command[1]).id) + ids.add(comodel.new(command[2], ref=command[1]).id) elif command[0] == 1: - comodel.browse(command[1]).update(command[2]) - ids.add(command[1]) - elif command[0] == 2: - # note: the record will be deleted by write() - ids.discard(command[1]) - elif command[0] == 3: - ids.discard(command[1]) + line = browse(command[1]) + line.update(command[2]) + ids.add(line.id) + elif command[0] in (2, 3): + ids.discard(browse(command[1]).id) elif command[0] == 4: - ids.add(command[1]) + ids.add(browse(command[1]).id) elif command[0] == 5: ids.clear() elif command[0] == 6: - ids = OrderedSet(command[2]) + ids = OrderedSet(browse(it).id for it in command[2]) elif isinstance(command, dict): ids.add(comodel.new(command).id) else: - ids.add(command) + ids.add(browse(command).id) # return result as a tuple return tuple(ids) + elif not value: return () + raise ValueError("Wrong value for %s: %s" % (self, value)) def convert_to_record(self, value, record): # use registry to avoid creating a recordset for the model - prefetch_ids = PrefetchValueIds(record, self) + prefetch_ids = IterableGenerator(prefetch_value_ids, record, self) return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids) def convert_to_read(self, value, record, use_name_get=True): @@ -2322,16 +2353,23 @@ def convert_to_write(self, value, record): # make result with new and existing records result = [(6, 0, [])] for record in value: - if not record.id: - values = {name: record[name] for name in record._cache} - values = record._convert_to_write(values) + origin = record._origin + if not origin: + values = record._convert_to_write({ + name: record[name] + for name in record._cache + }) result.append((0, 0, values)) - elif record._is_dirty(): - values = {name: record[name] for name in record._get_dirty()} - values = record._convert_to_write(values) - result.append((1, record.id, values)) else: - result[0][2].append(record.id) + result[0][2].append(origin.id) + if record != origin: + values = record._convert_to_write({ + name: record[name] + for name in record._cache + if record[name] != origin[name] + }) + if values: + result.append((1, origin.id, values)) return result def convert_to_onchange(self, value, record, names): @@ -2350,12 +2388,12 @@ def convert_to_onchange(self, value, record, names): result = [(5,)] for record in value: - if not record.id: + if not record.id and not record._origin: result.append((0, record.id.ref or 0, vals[record])) elif vals[record]: - result.append((1, record.id, vals[record])) + result.append((1, record._origin.id, vals[record])) else: - result.append((4, record.id)) + result.append((4, record._origin.id)) return result def convert_to_export(self, value, record): @@ -2369,9 +2407,8 @@ def _compute_related(self, records): super(_RelationalMulti, self)._compute_related(records) if self.related_sudo: # determine which records in the relation are actually accessible - target = records[self.name] - target_ids = set(target.search([('id', 'in', target.ids)]).ids) - accessible = lambda target: target.id in target_ids + line_ids = set(records[self.name]._filter_access_rules('read')._ids) + accessible = lambda line: line.id in line_ids # filter values to keep the accessible records only for record in records: record[self.name] = record[self.name].filtered(accessible) @@ -2873,27 +2910,16 @@ def __set__(self, record, value): raise TypeError("field 'id' cannot be assigned") -class PrefetchValueIds(object): - """ An iterable on the ids of the cached values of a relational field for - the prefetch set of a record. +def prefetch_value_ids(record, field): + """ Return an iterator over the ids of the cached values of a relational + field for the prefetch set of a record. """ - __slots__ = ('_record', '_field') - - def __init__(self, record, field): - self._record = record - self._field = field - - def __iter__(self): - record = self._record - records = record.browse(record._prefetch_ids) - return unique( - id_ - for ids in record.env.cache.get_values(records, self._field, ()) - for id_ in ids - ) + records = record.browse(record._prefetch_ids) + ids_seq = record.env.cache.get_values(records, field, ()) + return unique(id_ for ids in ids_seq for id_ in ids) # imported here to avoid dependency cycle issues from odoo import SUPERUSER_ID from .exceptions import AccessError, MissingError, UserError -from .models import check_pg_name, BaseModel, IdType +from .models import check_pg_name, BaseModel, NewId, IdType diff --git a/odoo/models.py b/odoo/models.py index a02e21ec66369..c08019216bbf1 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -52,7 +52,8 @@ from .exceptions import AccessError, MissingError, ValidationError, UserError from .osv.query import Query from .tools import frozendict, lazy_classproperty, lazy_property, ormcache, \ - Collector, LastOrderedSet, OrderedSet, groupby + Collector, LastOrderedSet, OrderedSet, IterableGenerator, \ + groupby from .tools.config import config from .tools.func import frame_codeinfo from .tools.misc import CountingStream, clean_context, DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT @@ -172,15 +173,41 @@ def _get_addon_name(self, full_name): class NewId(object): - """ Pseudo-ids for new records, encapsulating an optional reference. """ - __slots__ = ['ref'] + """ Pseudo-ids for new records, encapsulating an optional origin id (actual + record id) and an optional reference (any value). + """ + __slots__ = ['origin', 'ref'] - def __init__(self, ref=None): + def __init__(self, origin=None, ref=None): + self.origin = origin self.ref = ref def __bool__(self): return False - __nonzero__ = __bool__ + + def __eq__(self, other): + return isinstance(other, NewId) and ( + (self.origin and other.origin and self.origin == other.origin) + or (self.ref and other.ref and self.ref == other.ref) + ) + + def __hash__(self): + return hash(self.origin or self.ref or id(self)) + + def __repr__(self): + return ( + "" % self.origin if self.origin else + "" % self.ref if self.ref else + "" % id(self) + ) + + +def origin_ids(ids): + """ Return an iterator over the origin ids corresponding to ``ids``. + Actual ids are returned as is, and ids without origin are not returned. + """ + return ((id_ or id_.origin) for id_ in ids if (id_ or id_.origin)) + IdType = (int, str, NewId) @@ -2517,6 +2544,7 @@ def _inherits_check(self): _logger.warning('Field definition for _inherits reference "%s" in "%s" must be marked as "required" with ondelete="cascade" or "restrict", forcing it to required + cascade.', field_name, self._name) field.required = True field.ondelete = "cascade" + field.delegate = True # reflect fields with delegate=True in dictionary self._inherits for field in self._fields.values(): @@ -3110,14 +3138,21 @@ def _filter_access_rules(self, operation): if not where_clause: return self - valid_ids = [] + # detemine ids in database that satisfy ir.rules + valid_ids = set() query = "SELECT {}.id FROM {} WHERE {}.id IN %s AND {}".format( self._table, ",".join(tables), self._table, " AND ".join(where_clause), ) for sub_ids in self._cr.split_for_in_conditions(self.ids): self._cr.execute(query, [sub_ids] + where_params) - valid_ids.extend(row[0] for row in self._cr.fetchall()) - return self.browse(valid_ids) + valid_ids.update(row[0] for row in self._cr.fetchall()) + + # return new ids without origin and ids with origin in valid_ids + return self.browse([ + it + for it in self._ids + if not (it or it.origin) or (it or it.origin) in valid_ids + ]) @api.multi def unlink(self): @@ -4720,10 +4755,8 @@ def browse(self, ids=None): @property def ids(self): - """ List of actual record ids in this recordset (ignores placeholder - ids for records to create) - """ - return [it for it in self._ids if it] + """ Return the list of actual record ids corresponding to ``self``. """ + return list(origin_ids(self._ids)) # backward-compatibility with former browse records _cr = property(lambda self: self.env.cr) @@ -4951,31 +4984,43 @@ def update(self, values): # @api.model - def new(self, values={}, ref=None): - """ new([values]) -> record + def new(self, values={}, origin=None, ref=None): + """ new([values], [origin], [ref]) -> record Return a new record instance attached to the current environment and initialized with the provided ``value``. The record is *not* created in database, it only exists in memory. - One can pass a reference value to identify the record among other new + One can pass an ``origin`` record, which is the actual record behind the + result. It is retrieved as ``record._origin``. Two new records with the + same origin record are considered equal. + + One can also pass a ``ref`` value to identify the record among other new records. The reference is encapsulated in the ``id`` of the record. """ - record = self.browse([NewId(ref)]) + if origin is not None: + origin = origin.id + record = self.browse([NewId(origin, ref)]) record._cache.update(record._convert_to_cache(values, update=True)) - if record.env.in_onchange: - # The cache update does not set inverse fields, so do it manually. - # This is useful for computing a function field on secondary - # records, if that field depends on the main record. - for name in values: - field = self._fields.get(name) - if field: + # set inverse fields on new records in the comodel + for name in values: + field = self._fields.get(name) + if field and field.relational: + inv_recs = record[name].filtered(lambda r: not r.id) + if inv_recs: for invf in self._field_inverses[field]: - invf._update(record[name], record) + invf._update(inv_recs, record) return record + @property + def _origin(self): + """ Return the actual records corresponding to ``self``. """ + ids = tuple(origin_ids(self._ids)) + prefetch_ids = IterableGenerator(origin_ids, self._prefetch_ids) + return self._browse(self.env, ids, prefetch_ids) + # # Dirty flags, to mark record fields modified (in draft mode) # @@ -5430,10 +5475,11 @@ def __init__(self, record, tree): # put record in dict to include it when comparing snapshots super(Snapshot, self).__init__({'': record, '': tree}) for name, subnames in tree.items(): + field = record._fields[name] # x2many fields are serialized as a list of line snapshots self[name] = ( [Snapshot(line, subnames) for line in record[name]] - if subnames else record[name] + if field.type in ('one2many', 'many2many') else record[name] ) def diff(self, other): @@ -5445,14 +5491,15 @@ def diff(self, other): for name, subnames in self[''].items(): if (name == 'id') or (other.get(name) == self[name]): continue - if not subnames: - field = record._fields[name] + field = record._fields[name] + if field.type not in ('one2many', 'many2many'): result[name] = field.convert_to_onchange(self[name], record, {}) else: # x2many fields: serialize value as commands result[name] = commands = [(5,)] for line_snapshot in self[name]: line = line_snapshot[''] + line = line._origin or line if not line.id: # new line: send diff from scratch line_diff = line_snapshot.diff({}) @@ -5475,21 +5522,21 @@ def diff(self, other): # prefetch x2many lines without data (for the initial snapshot) for name, subnames in nametree.items(): if subnames and values.get(name): - # retrieve all ids in commands, and read the expected fields - line_ids = [] + # retrieve all ids in commands + line_ids = set() for cmd in values[name]: if cmd[0] in (1, 4): - line_ids.append(cmd[1]) + line_ids.add(cmd[1]) elif cmd[0] == 6: - line_ids.extend(cmd[2]) - lines = self.browse()[name].browse(line_ids) - lines.read(list(subnames), load='_classic_write') + line_ids.update(cmd[2]) + # build corresponding new lines, and prefetch fields + new_lines = self[name].browse(NewId(id_) for id_ in line_ids) + for subname in subnames: + new_lines.mapped(subname) # create a new record with values, and attach ``self`` to it with env.do_in_onchange(): - record = self.new(values) - # attach ``self`` with a different context (for cache consistency) - record._origin = self.with_context(__onchange=True) + record = self.new(values, origin=self) # make a snapshot based on the initial values of record with env.do_in_onchange(): diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py index 3d34c608fd49d..e828443ae80da 100644 --- a/odoo/tools/misc.py +++ b/odoo/tools/misc.py @@ -1045,12 +1045,28 @@ def add(self, elem): def discard(self, elem): self._map.pop(elem, None) + class LastOrderedSet(OrderedSet): """ A set collection that remembers the elements last insertion order. """ def add(self, elem): OrderedSet.discard(self, elem) OrderedSet.add(self, elem) + +class IterableGenerator: + """ An iterable object based on a generator function, which is called each + time the object is iterated over. + """ + __slots__ = ['func', 'args'] + + def __init__(self, func, *args): + self.func = func + self.args = args + + def __iter__(self): + return self.func(*self.args) + + def groupby(iterable, key=None): """ Return a collection of pairs ``(key, elements)`` from ``iterable``. The ``key`` is a function computing a key value for each element. This