diff --git a/addons/web/models/models.py b/addons/web/models/models.py index cb56c4ac5a3..f8512d6275a 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -2,13 +2,17 @@ from typing import Dict, List import babel.dates -import pytz import base64 +import copy +import itertools import json +import pytz from odoo import _, _lt, api, fields, models +from odoo.fields import Command +from odoo.models import BaseModel, NewId from odoo.osv.expression import AND, TRUE_DOMAIN, normalize_domain -from odoo.tools import date_utils +from odoo.tools import date_utils, unique from odoo.tools.misc import OrderedSet, get_lang from odoo.exceptions import UserError from collections import defaultdict @@ -831,6 +835,208 @@ class Base(models.AbstractModel): return { 'values': field_range, } + def onchange2(self, values: Dict, field_names: List[str], fields_spec: Dict): + """ + Perform an onchange on the given fields, and return the result. + + :param values: dictionary mapping field names to values on the form view, + giving the current state of modification + :param field_names: names of the modified fields + :param fields_spec: dictionary specifying the fields in the view, + just like the one used by :meth:`web_read`; it is used to format + the resulting values + + When creating a record from scratch, the client should call this with an + empty list as ``field_names``. In that case, the method first adds + default values to ``values``, computes the remaining fields, applies + onchange methods to them, and return all the fields in ``fields_spec``. + + The result is a dictionary with two optional keys. The key ``"value"`` + is used to return field values that should be modified on the caller. + The corresponding value is a dict mapping field names to their value, + in the format of :meth:`web_read`, except for x2many fields, where the + value is a list of commands to be applied on the caller's field value. + + The key ``"warning"`` provides a warning message to the caller. The + corresponding value is a dictionary like:: + + { + "title": "Be careful!", # subject of message + "message": "Blah blah blah.", # full warning message + "type": "dialog", # how to display the warning + } + + """ + # this is for tests using `Form` + self.env.flush_all() + + env = self.env + first_call = not field_names + + if any(fname not in self._fields for fname in field_names): + return {} + + if first_call: + field_names = [fname for fname in values if fname != 'id'] + missing_names = [fname for fname in fields_spec if fname not in values] + defaults = self.default_get(missing_names) + for field_name in missing_names: + values[field_name] = defaults.get(field_name, False) + if field_name in defaults: + field_names.append(field_name) + + # prefetch x2many lines: this speeds up the initial snapshot by avoiding + # computing fields on new records as much as possible, as that can be + # costly and is not necessary at all + self.fetch(fields_spec.keys()) + for field_name, field_spec in fields_spec.items(): + field = self._fields[field_name] + if field.type not in ('one2many', 'many2many'): + continue + sub_fields_spec = field_spec.get('fields') or {} + if sub_fields_spec and values.get(field_name): + # retrieve all line ids in commands + line_ids = set(self[field_name].ids) + for cmd in values[field_name]: + if cmd[0] in (Command.UPDATE, Command.LINK): + line_ids.add(cmd[1]) + elif cmd[0] == Command.SET: + line_ids.update(cmd[2]) + # prefetch stored fields on lines + lines = self[field_name].browse(line_ids) + lines.fetch(sub_fields_spec.keys()) + # copy the cache of lines to their corresponding new records; + # this avoids computing computed stored fields on new_lines + new_lines = lines.browse(map(NewId, line_ids)) + cache = self.env.cache + for field_name in sub_fields_spec: + field = lines._fields[field_name] + cache.update_raw( + new_lines, field, map(copy.copy, cache.get_values(lines, field)), + ) + + # Isolate changed values, to handle inconsistent data sent from the + # client side: when a form view contains two one2many fields that + # overlap, the lines that appear in both fields may be sent with + # different data. Consider, for instance: + # + # foo_ids: [line with value=1, ...] + # bar_ids: [line with value=1, ...] + # + # If value=2 is set on 'line' in 'bar_ids', the client sends + # + # foo_ids: [line with value=1, ...] + # bar_ids: [line with value=2, ...] + # + # The idea is to put 'foo_ids' in cache first, so that the snapshot + # contains value=1 for line in 'foo_ids'. The snapshot is then updated + # with the value of `bar_ids`, which will contain value=2 on line. + # + # The issue also occurs with other fields. For instance, an onchange on + # a move line has a value for the field 'move_id' that contains the + # values of the move, among which the one2many that contains the line + # itself, with old values! + # + changed_values = {fname: values[fname] for fname in field_names} + # set changed values to null in initial_values; not setting them + # triggers default_get() on the new record when creating snapshot0 + initial_values = dict(values, **dict.fromkeys(field_names, False)) + + # do not force delegate fields to False + for parent_name in self._inherits.values(): + if not initial_values.get(parent_name, True): + initial_values.pop(parent_name) + + # create a new record, and update it with initial values + record = self.new(origin=self) + if self: + # fill in the cache of record with the values of self + cache_values = {fname: self[fname] for fname in fields_spec} + record._update_cache(cache_values, validate=False) + record._update_cache(initial_values) + + # make parent records match with the form values; this ensures that + # computed fields on parent records have all their dependencies at + # their expected value + for field_name in initial_values: + field = self._fields.get(field_name) + if field and field.inherited: + parent_name, field_name = field.related.split('.', 1) + record[parent_name]._update_cache({field_name: record[field_name]}) + + # make a snapshot based on the initial values of record + snapshot0 = RecordSnapshot(record, fields_spec, fetch=(not first_call)) + + # store changed values in cache; also trigger recomputations based on + # subfields (e.g., line.a has been modified, line.b is computed stored + # and depends on line.a, but line.b is not in the form view) + record._update_cache(changed_values, validate=False) + + # update snapshot0 with changed values + for field_name in field_names: + snapshot0.fetch(field_name) + + # Determine which field(s) should be triggered an onchange. On the first + # call, 'names' only contains fields with a default. If 'self' is a new + # line in a one2many field, 'names' also contains the one2many's inverse + # field, and that field may not be in nametree. + todo = list(unique(itertools.chain(field_names, fields_spec))) if first_call else list(field_names) + done = set() + + # mark fields to do as modified to trigger recomputations + protected = [self._fields[fname] for fname in field_names] + with self.env.protecting(protected, record): + record.modified(todo) + for field_name in todo: + field = self._fields[field_name] + if field.inherited: + # modifying an inherited field should modify the parent + # record accordingly; because we don't actually assign the + # modified field on the record, the modification on the + # parent record has to be done explicitly + parent = record[field.related.split('.')[0]] + parent[field_name] = record[field_name] + + result = {'warnings': OrderedSet()} + + # process names in order + while todo: + # apply field-specific onchange methods + for field_name in todo: + record._onchange_eval(field_name, "1", result) + done.add(field_name) + + if not env.context.get('recursive_onchanges', True): + break + + # determine which fields to process for the next pass + todo = [ + field_name + for field_name in fields_spec + if field_name not in done and snapshot0.has_changed(field_name) + ] + + # make the snapshot with the final values of record + snapshot1 = RecordSnapshot(record, fields_spec) + + # determine values that have changed by comparing snapshots + result['value'] = snapshot1.diff(snapshot0, force=first_call) + + # format warnings + warnings = result.pop('warnings') + if len(warnings) == 1: + title, message, type_ = warnings.pop() + if not type_: + type_ = 'dialog' + result['warning'] = dict(title=title, message=message, type=type_) + elif len(warnings) > 1: + # concatenate warning titles and messages + title = _("Warnings") + message = '\n\n'.join([warn_title + '\n\n' + warn_message for warn_title, warn_message, warn_type in warnings]) + result['warning'] = dict(title=title, message=message, type='dialog') + + return result + class ResCompany(models.Model): _inherit = 'res.company' @@ -867,3 +1073,98 @@ class ResCompany(models.Model): b64_val = self._get_asset_style_b64() if b64_val != asset_attachment.datas: asset_attachment.write({'datas': b64_val}) + + +class RecordSnapshot(dict): + """ A dict with the values of a record, following a prefix tree. """ + __slots__ = ['record', 'fields_spec'] + + def __init__(self, record: BaseModel, fields_spec: Dict, fetch=True): + # put record in dict to include it when comparing snapshots + super().__init__() + self.record = record + self.fields_spec = fields_spec + if fetch: + for name in fields_spec: + self.fetch(name) + + def __eq__(self, other: 'RecordSnapshot'): + return self.record == other.record and super().__eq__(other) + + def fetch(self, field_name): + """ Set the value of field ``name`` from the record's value. """ + if self.record._fields[field_name].type in ('one2many', 'many2many'): + # x2many fields are serialized as a dict of line snapshots + lines = self.record[field_name] + if 'context' in self.fields_spec[field_name]: + lines = lines.with_context(**self.fields_spec[field_name]['context']) + sub_fields_spec = self.fields_spec[field_name].get('fields') or {} + self[field_name] = {line.id: RecordSnapshot(line, sub_fields_spec) for line in lines} + else: + self[field_name] = self.record[field_name] + + def has_changed(self, field_name) -> bool: + """ Return whether a field on the record has changed. """ + if field_name not in self: + return True + if self.record._fields[field_name].type not in ('one2many', 'many2many'): + return self[field_name] != self.record[field_name] + return self[field_name].keys() != set(self.record[field_name]._ids) or any( + line_snapshot.has_changed(subname) + for line_snapshot in self[field_name].values() + for subname in self.fields_spec[field_name].get('fields') or {} + ) + + def diff(self, other: 'RecordSnapshot', force=False): + """ Return the values in ``self`` that differ from ``other``. """ + + # determine fields to return + simple_fields_spec = {} + x2many_fields_spec = {} + for field_name, field_spec in self.fields_spec.items(): + if field_name == 'id': + continue + if not force and other.get(field_name) == self[field_name]: + continue + field = self.record._fields[field_name] + if field.type in ('one2many', 'many2many'): + x2many_fields_spec[field_name] = field_spec + else: + simple_fields_spec[field_name] = field_spec + + # use web_read() for simple fields + [result] = self.record.web_read(simple_fields_spec) + + # discard the NewId from the dict + result.pop('id') + + # for x2many fields: serialize value as commands + for field_name, field_spec in x2many_fields_spec.items(): + result[field_name] = commands = [] + # commands for removed lines + remove = Command.delete if field.type == 'one2many' else Command.unlink + for id_, line_snapshot in (other.get(field_name) or {}).items(): + if id_ not in self[field_name]: + commands.append(remove(id_.origin or id_.ref or 0)) + # commands for modified or extra lines + for id_, line_snapshot in self[field_name].items(): + if id_ in other.get(field_name, ()): + # existing line: check diff + line_diff = line_snapshot.diff(other[field_name][id_]) + if line_diff: + commands.append(Command.update(id_.origin or id_.ref or 0, line_diff)) + elif not id_.origin: + # new line: send diff from scratch + line_diff = line_snapshot.diff({}) + commands.append((Command.CREATE, id_.origin or id_.ref or 0, line_diff)) + else: + # link line: send data to client and possible update + line = line_snapshot.record._origin + base_snapshot = RecordSnapshot(line, field_spec.get('fields') or {}) + line_data = base_snapshot.diff({}) + commands.append((Command.LINK, line.id, line_data)) + line_diff = line_snapshot.diff(base_snapshot) + if line_diff: + commands.append(Command.update(id_.origin, line_diff)) + + return result diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index 8f25f86be98..012249d6578 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -2810,6 +2810,39 @@ class Model(models.AbstractModel): process(etree.fromstring(view_info['arch']), view_info, '') return result + @api.model + def _get_fields_spec(self, view_info=None): + """ Return the fields specification from a view description; if not + given, the result of ``self.get_view()`` is used. + """ + def fill_spec(node, model, fields_spec): + if node.tag == 'field': + field_name = node.attrib['name'] + field_spec = fields_spec.setdefault(field_name, {}) + field = model._fields.get(field_name) + if field is not None: + sub_fields_spec = {} + if field.type == 'many2one': + sub_fields_spec.setdefault('display_name', {}) + if field.relational: + comodel = model.env[field.comodel_name] + for child in node: + fill_spec(child, comodel, sub_fields_spec) + if field.type == 'one2many': + sub_fields_spec.pop(field.inverse_name, None) + if sub_fields_spec: + field_spec.setdefault('fields', {}).update(sub_fields_spec) + else: + for child in node: + fill_spec(child, model, fields_spec) + + if view_info is None: + view_info = self.get_view() + + result = {} + fill_spec(etree.fromstring(view_info['arch']), self, result) + return result + class NameManager: """ An object that manages all the named elements in a view. """ 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 7afe6062c18..f45448434c0 100644 --- a/odoo/addons/test_new_api/models/test_new_api.py +++ b/odoo/addons/test_new_api/models/test_new_api.py @@ -267,6 +267,11 @@ class Multi(models.Model): for line in self.lines: line.partner = self.partner + @api.onchange('tags') + def _onchange_tags(self): + for line in self.lines: + line.tags |= self.tags + class MultiLine(models.Model): _name = 'test_new_api.multi.line' @@ -289,6 +294,16 @@ class MultiTag(models.Model): _description = 'Test New API Multi Tag' name = fields.Char() + display_name = fields.Char(compute='_compute_display_name') + + @api.depends('name') + @api.depends_context('special_tag') + def _compute_display_name(self): + for record in self: + name = record.name + if name and self.env.context.get('special_tag'): + name += "!" + record.display_name = name or "" class Edition(models.Model): diff --git a/odoo/addons/test_new_api/tests/__init__.py b/odoo/addons/test_new_api/tests/__init__.py index 2f39b0a808d..cc971ccfd18 100644 --- a/odoo/addons/test_new_api/tests/__init__.py +++ b/odoo/addons/test_new_api/tests/__init__.py @@ -2,6 +2,7 @@ from . import test_new_fields from . import test_onchange +from . import test_onchange2 from . import test_attributes from . import test_one2many from . import test_many2many diff --git a/odoo/addons/test_new_api/tests/test_onchange2.py b/odoo/addons/test_new_api/tests/test_onchange2.py new file mode 100644 index 00000000000..f4783176dc9 --- /dev/null +++ b/odoo/addons/test_new_api/tests/test_onchange2.py @@ -0,0 +1,1160 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest import skip +from unittest.mock import patch + +from odoo.addons.base.tests.common import SavepointCaseWithUserDemo +from odoo.tests import common, Form +from odoo import Command +from odoo.tools import submap + + +def strip_prefix(prefix, names): + size = len(prefix) + return [name[size:] for name in names if name.startswith(prefix)] + + +class TestOnchange2(SavepointCaseWithUserDemo): + + def setUp(self): + super().setUp() + self.Discussion = self.env['test_new_api.discussion'] + self.Message = self.env['test_new_api.message'] + self.EmailMessage = self.env['test_new_api.emailmessage'] + + def test_default_get(self): + """ checking values returned by default_get() """ + fields = ['name', 'categories', 'participants', 'messages'] + values = self.Discussion.default_get(fields) + self.assertEqual(values, {}) + + user = self.env.user + fields_spec = self.env['test_new_api.message']._get_fields_spec() + values = self.env['test_new_api.message'].onchange2({}, [], fields_spec)['value'] + self.assertEqual(values['discussion'], False) + self.assertEqual(values['body'], False) + self.assertEqual(values['author'], {'id': user.id, 'display_name': user.display_name}) + self.assertEqual(values['name'], f'[] {user.name}') + self.assertEqual(values['size'], 0) + + def test_get_field(self): + """ checking that accessing an unknown attribute does nothing special """ + with self.assertRaises(AttributeError): + self.Discussion.not_really_a_method() + + def test_onchange(self): + """ test the effect of onchange() """ + discussion = self.env.ref('test_new_api.discussion_0') + BODY = "What a beautiful day!" + USER = self.env.user + + fields_spec = self.Message._get_fields_spec() + self.assertEqual( + submap(fields_spec, ('author', 'body', 'discussion')), + { + 'author': {'fields': {'display_name': {}}}, + 'body': {}, + 'discussion': {'fields': {'display_name': {}}}, + } + ) + + # changing 'discussion' should recompute 'name' + values = { + 'discussion': discussion.id, + 'name': f"[] {USER.name}", + 'body': False, + 'author': USER.id, + 'size': 0, + } + self.env.invalidate_all() + result = self.Message.onchange2(values, ['discussion'], fields_spec) + self.assertEqual(result['value'], { + 'name': f"[{discussion.name}] {USER.name}", + }) + + # changing 'body' should recompute 'size' + values = { + 'discussion': discussion.id, + 'name': f"[{discussion.name}] {USER.name}", + 'body': BODY, + 'author': USER.id, + 'size': 0, + } + self.env.invalidate_all() + result = self.Message.onchange2(values, ['body'], fields_spec) + self.assertEqual(result['value'], { + 'size': len(BODY), + }) + + # changing 'body' should not recompute 'name', even if 'discussion' and + # 'name' are not consistent with each other + values = { + 'discussion': discussion.id, + 'name': False, + 'body': BODY, + 'author': USER.id, + 'size': 0, + } + self.env.invalidate_all() + result = self.Message.onchange2(values, ['body'], fields_spec) + self.assertNotIn('name', result['value']) + + def test_onchange_many2one(self): + Category = self.env['test_new_api.category'] + + fields_spec = Category._get_fields_spec() + self.assertEqual(fields_spec, { + 'name': {}, + 'parent': {'fields': {'display_name': {}}}, + 'root_categ': {'fields': {'display_name': {}}}, + 'dummy': {}, + 'color': {}, + }) + + root = Category.create(dict(name='root')) + + # set 'parent' to root, and check that 'root_categ' is computed as expected + values = { + 'name': 'test', + 'parent': root.id, + 'root_categ': False, + } + self.env.invalidate_all() + result = Category.onchange2(values, ['parent'], fields_spec) + self.assertEqual(result['value'], { + 'root_categ': {'id': root.id, 'display_name': root.name}, + }) + + # set 'parent' to False, and check that 'root_categ' is computed as expected + values = { + 'name': 'test', + 'parent': False, + 'root_categ': root.id, + } + self.env.invalidate_all() + result = Category.onchange2(values, ['parent'], fields_spec) + self.assertEqual(result['value'], { + 'root_categ': False, + }) + + def test_onchange_one2many(self): + """ test the effect of onchange() on one2many fields """ + USER = self.env.user + + # create an independent message + message1 = self.Message.create({'body': "ABC"}) + message2 = self.Message.create({'body': "ABC"}) + self.assertEqual(message1.name, "[%s] %s" % ('', USER.name)) + + fields_spec = self.Discussion._get_fields_spec() + self.assertEqual( + submap(fields_spec, ('name', 'messages')), + { + 'name': {}, + 'messages': {'fields': { + 'author': {'fields': {'display_name': {}}}, + 'body': {}, + 'name': {}, + 'size': {}, + 'important': {}, + }}, + } + ) + + # modify discussion name + values = { + 'name': "Foo", + 'categories': [], + 'moderator': False, + 'participants': [], + 'messages': [ + Command.link(message1.id), + Command.link(message2.id), + Command.update(message2.id, {'body': "XYZ"}), + (Command.CREATE, "virtual3", { + 'name': f"[] {USER.name}", + 'body': "ABC", + 'author': USER.id, + 'size': 3, + 'important': False, + }), + ], + } + self.env.invalidate_all() + result = self.Discussion.onchange2(values, ['name'], fields_spec) + self.assertIn('messages', result['value']) + self.assertEqual(result['value']['messages'], [ + Command.update(message1.id, {'name': f"[Foo] {USER.name}"}), + Command.update(message2.id, {'name': f"[Foo] {USER.name}"}), + Command.update("virtual3", {'name': f"[Foo] {USER.name}"}), + ]) + + # ensure onchange changing one2many without subfield works + one_level_fields_spec = {field_name: {} for field_name in fields_spec} + values = dict(values, name='{generate_dummy_message}') + result = self.Discussion.with_context(generate_dummy_message=True).onchange2(values, ['name'], one_level_fields_spec) + self.assertEqual(result['value']['messages'], [ + Command.create({}), + ]) + + def test_onchange_one2many_reference(self): + """ test the effect of onchange() on one2many fields with line references """ + BODY = "What a beautiful day!" + USER = self.env.user + + fields_spec = self.Discussion._get_fields_spec() + self.assertEqual( + submap(fields_spec, ('name', 'messages')), + { + 'name': {}, + 'messages': {'fields': { + 'author': {'fields': {'display_name': {}}}, + 'body': {}, + 'name': {}, + 'size': {}, + 'important': {}, + }}, + } + ) + + # modify discussion name, and check that the reference of the new line + # is returned + values = { + 'name': "Foo", + 'categories': [], + 'moderator': False, + 'participants': [], + 'messages': [ + (Command.CREATE, 'virtual1', { + 'name': f"[] {USER.name}", + 'body': BODY, + 'author': USER.id, + 'size': len(BODY), + 'important': False, + }), + ], + } + self.env.invalidate_all() + result = self.Discussion.onchange2(values, ['name'], fields_spec) + self.assertIn('messages', result['value']) + self.assertItemsEqual(result['value']['messages'], [ + (Command.UPDATE, 'virtual1', {'name': f"[Foo] {USER.name}"}), + ]) + + def test_onchange_one2many_multi(self): + """ test the effect of multiple onchange methods on one2many fields """ + partner1 = self.env['res.partner'].create({'name': 'A partner'}) + multi = self.env['test_new_api.multi'].create({'partner': partner1.id}) + line1 = multi.lines.create({'multi': multi.id}) + + self.assertEqual(multi.partner, partner1) + self.assertEqual(multi.name, partner1.name) + self.assertEqual(multi.lines, line1) + self.assertEqual(line1.partner, partner1) + self.assertEqual(line1.name, False) + + fields_spec = multi._get_fields_spec() + self.assertEqual(fields_spec, { + 'name': {}, + 'partner': {'fields': {'display_name': {}}}, + 'lines': { + 'fields': { + 'name': {}, + 'partner': {'fields': {'display_name': {}}}, + 'tags': {'fields': {'name': {}}}, + }, + }, + }) + + # modify 'partner' + # -> set 'partner' on all lines + # -> recompute 'name' + # -> set 'name' on all lines + partner2 = self.env['res.partner'].create({'name': 'A second partner'}) + values = { + 'name': partner1.name, + 'partner': partner2.id, # this one just changed + 'lines': [ + (Command.CREATE, 'virtual2', {'name': False, 'partner': False, 'tags': [Command.clear()]}), + ], + } + self.env.invalidate_all() + + result = multi.onchange2(values, ['partner'], fields_spec) + self.assertEqual(result['value'], { + 'name': partner2.name, + 'lines': [ + Command.update(line1.id, { + 'name': partner2.name, + 'partner': {'id': partner2.id, 'display_name': partner2.name}, + }), + Command.update('virtual2', { + 'name': partner2.name, + 'partner': {'id': partner2.id, 'display_name': partner2.name}, + }), + ], + }) + + # do it again, but this time with a new tag on the second line + values = { + 'name': partner1.name, + 'partner': partner2.id, # this one just changed + 'lines': [ + (Command.CREATE, 'virtual2', { + 'name': False, + 'partner': False, + 'tags': [Command.create({'name': 'Tag'})], + }), + ], + } + self.env.invalidate_all() + result = multi.onchange2(values, ['partner'], fields_spec) + expected_value = { + 'name': partner2.name, + 'lines': [ + Command.update(line1.id, { + 'name': partner2.name, + 'partner': {'id': partner2.id, 'display_name': partner2.name}, + }), + Command.update('virtual2', { + 'name': partner2.name, + 'partner': {'id': partner2.id, 'display_name': partner2.name}, + }), + ], + } + self.assertEqual(result['value'], expected_value) + + # ensure ID is not returned when asked and a many2many record is set to be created + self.env.invalidate_all() + + fields_spec = multi._get_fields_spec() + fields_spec['lines']['fields']['tags']['fields']['id'] = {} + result = multi.onchange2(values, ['partner'], fields_spec) + self.assertEqual(result['value'], expected_value) + + # ensure inverse of one2many field is not returned + self.env.invalidate_all() + + fields_spec = multi._get_fields_spec() + fields_spec['lines']['fields']['multi'] = {} + result = multi.onchange2(values, ['partner'], fields_spec) + self.assertEqual(result['value'], expected_value) + + def test_fields_specific(self): + """ test the effect of field-specific onchange method """ + discussion = self.env.ref('test_new_api.discussion_0') + demo = self.user_demo + + fields_spec = self.Discussion._get_fields_spec() + self.assertEqual(fields_spec.get('moderator'), {'fields': {'display_name': {}}}) + self.assertEqual(fields_spec.get('participants'), {'fields': {'display_name': {}}}) + + # first remove demo user from participants + discussion.participants -= demo + self.assertNotIn(demo, discussion.participants) + + # check that demo_user is added to participants when set as moderator + values = { + 'moderator': demo.id, + } + self.env.invalidate_all() + result = discussion.onchange2(values, ['moderator'], fields_spec) + + self.assertIn('participants', result['value']) + self.assertItemsEqual( + result['value']['participants'], + [(Command.LINK, demo.id, {'display_name': demo.display_name})], + ) + + def test_onchange_default(self): + """ test the effect of a conditional user-default on a field """ + Foo = self.env['test_new_api.foo'] + fields_spec = Foo._get_fields_spec() + self.assertTrue(type(Foo).value1.change_default) + self.assertIn('value1', Foo._onchange_methods) + + # create a user-defined default based on 'value1' + self.env['ir.default'].set('test_new_api.foo', 'value2', 666, condition='value1=42') + + # setting 'value1' to 42 should trigger the change of 'value2' + self.env.invalidate_all() + values = {'name': 'X', 'value1': 42, 'value2': False} + result = Foo.onchange2(values, ['value1'], fields_spec) + self.assertEqual(result['value'], {'value2': 666}) + + # setting 'value1' to 24 should not trigger the change of 'value2' + self.env.invalidate_all() + values = {'name': 'X', 'value1': 24, 'value2': False} + result = Foo.onchange2(values, ['value1'], fields_spec) + self.assertEqual(result['value'], {}) + + def test_onchange_one2many_first(self): + partner = self.env['res.partner'].create({ + 'name': 'X', + 'country_id': self.env.ref('base.be').id, + }) + with common.Form(self.env['test_new_api.multi']) as form: + form.partner = partner + self.assertEqual(form.partner, partner) + self.assertEqual(form.name, partner.name) + with form.lines.new() as line: + # the first onchange() must have computed partner + self.assertEqual(line.partner, partner) + + def test_onchange_one2many_value(self): + """ test the value of the one2many field inside the onchange """ + discussion = self.env.ref('test_new_api.discussion_0') + demo = self.user_demo + + fields_spec = self.Discussion._get_fields_spec() + self.assertEqual(fields_spec, { + 'important_emails': {'fields': { + 'author': {'fields': {'display_name': {}}}, + 'body': {}, + 'email_to': {}, + 'important': {}, + 'name': {}, + 'size': {}, + }}, + 'message_concat': {}, + 'messages': {'fields': { + 'author': {'fields': {'display_name': {}}}, + 'body': {}, + 'important': {}, + 'name': {}, + 'size': {}, + }}, + 'moderator': {'fields': {'display_name': {}}}, + 'name': {}, + 'participants': {'fields': {'display_name': {}}}, + }) + + self.assertEqual(len(discussion.messages), 3) + messages = [Command.link(msg.id) for msg in discussion.messages] + messages[0] = (1, messages[0][1], {'body': 'test onchange'}) + lines = ["%s:%s" % (m.name, m.body) for m in discussion.messages] + lines[0] = "%s:%s" % (discussion.messages[0].name, 'test onchange') + values = { + 'name': discussion.name, + 'moderator': demo.id, + 'categories': [Command.link(cat.id) for cat in discussion.categories], + 'messages': messages, + 'participants': [Command.link(usr.id) for usr in discussion.participants], + 'message_concat': False, + } + result = discussion.onchange2(values, ['messages'], fields_spec) + self.assertIn('message_concat', result['value']) + self.assertEqual(result['value']['message_concat'], "\n".join(lines)) + + def test_onchange_one2many_with_domain_on_related_field(self): + """ test the value of the one2many field when defined with a domain on a related field""" + discussion = self.env.ref('test_new_api.discussion_0') + demo = self.user_demo + + # mimic UI behaviour, so we get subfields + # (we need at least subfield: 'important_emails.important') + view_info = self.Discussion.get_view(self.env.ref('test_new_api.discussion_form').id, 'form') + fields_spec = self.Discussion._get_fields_spec(view_info=view_info) + self.assertEqual(fields_spec, { + 'name': {}, + 'moderator': {'fields': {'display_name': {}}}, + 'messages': {'fields': { + 'name': {}, + 'body': {}, + 'important': {}, + 'author': {'fields': {'display_name': {}}}, + 'size': {} + }}, + 'important_emails': {'fields': { + 'name': {}, + 'body': {}, + 'important': {}, + 'email_to': {}, + 'author': {'fields': {'display_name': {}}}, + 'size': {}}, + }, + 'participants': {'fields': {'display_name': {}}}, + 'message_concat': {}, + }) + + BODY = "What a beautiful day!" + USER = self.env.user + + # create standalone email + email = self.EmailMessage.create({ + 'discussion': discussion.id, + 'name': f"[] {USER.name}", + 'body': BODY, + 'author': USER.id, + 'important': False, + 'email_to': demo.email, + }) + + # check if server-side cache is working correctly + self.env.invalidate_all() + self.assertIn(email, discussion.emails) + self.assertNotIn(email, discussion.important_emails) + email.important = True + self.assertIn(email, discussion.important_emails) + + # check that when trigger an onchange, we don't reset important emails + # (force `invalidate` as but appear in onchange only when we get a cache + # miss) + self.env.invalidate_all() + self.assertEqual(len(discussion.messages), 4) + values = { + 'name': "Foo Bar", + 'moderator': demo.id, + 'important_messages': [Command.set(discussion.important_messages.ids)], + 'important_emails': [Command.set(discussion.important_emails.ids)], + } + self.env.invalidate_all() + result = discussion.onchange2(values, ['name'], fields_spec) + + self.assertEqual( + result['value']['important_emails'], + [Command.update(email.id, { + 'name': f'[Foo Bar] {USER.name}', + })], + ) + + def test_onchange_related(self): + user = self.env.user + + values = { + 'message': 1, + 'message_name': False, + 'message_currency': 2, + } + fields_spec = { + 'message': {'fields': {'display_name': {}}}, + 'message_name': {}, + 'message_currency': {'fields': {'display_name': {}}}, + } + + expected = { + 'message_name': 'Hey dude!', + 'message_currency': {'id': user.id, 'display_name': user.display_name}, + } + + self.env.invalidate_all() + Message = self.env['test_new_api.related'] + result = Message.onchange2(values, ['message'], fields_spec) + + self.assertEqual(result['value'], expected) + + self.env.invalidate_all() + Message = self.env(user=self.user_demo.id)['test_new_api.related'] + result = Message.onchange2(values, ['message'], fields_spec) + + self.assertEqual(result['value'], expected) + + 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') + fields_spec = self.Message._get_fields_spec() + self.assertEqual(fields_spec, { + 'discussion': {'fields': {'display_name': {}}}, + 'name': {}, + 'author': {'fields': {'display_name': {}}}, + 'size': {}, + 'attributes': {}, + 'body': {}, + }) + + 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.invalidate_all() + self.Message.onchange2(values, ['discussion'], fields_spec) + + self.assertFalse(called[0], "discussion.messages has been read") + + def test_onchange_one2many_many2one_in_form(self): + order = self.env['test_new_api.monetary_order'].create({ + 'currency_id': self.env.ref('base.USD').id, + }) + + # this call to onchange() is made when creating a new line in field + # order.line_ids; check what happens when the line's form view contains + # the inverse many2one field + values = {'order_id': {'id': order.id, 'currency_id': order.currency_id.id}} + fields_spec = { + 'order_id': {}, + 'subtotal': {}, + } + result = self.env['test_new_api.monetary_order_line'].onchange2(values, [], fields_spec) + + self.assertEqual(result['value']['order_id'], order.id) + + def test_onchange_inherited(self): + """ Setting an inherited field should assign the field on the parent record. """ + foo, bar = self.env['test_new_api.multi.tag'].create([{'name': 'Foo'}, {'name': 'Bar'}]) + view = self.env['ir.ui.view'].create({ + 'name': 'Payment form view', + 'model': 'test_new_api.payment', + 'arch': """ +
+ """, + }) + + # both fields 'tag_id' and 'tag_name' are inherited through 'move_id'; + # assigning 'tag_id' should modify 'move_id.tag_id' accordingly, which + # should in turn recompute `move.tag_name` and `tag_name` + form = Form(self.env['test_new_api.payment'], view) + self.assertEqual(form.tag_name, False) + form.tag_id = foo + self.assertEqual(form.tag_name, 'Foo') + self.assertEqual(form.tag_string, '') + form.tag_repeat = 2 + self.assertEqual(form.tag_name, 'Foo') + self.assertEqual(form.tag_string, 'FooFoo') + + payment = form.save() + self.assertEqual(payment.tag_id, foo) + self.assertEqual(payment.tag_name, 'Foo') + self.assertEqual(payment.tag_repeat, 2) + self.assertEqual(payment.tag_string, 'FooFoo') + + with Form(payment, view) as form: + form.tag_id = bar + self.assertEqual(form.tag_name, 'Bar') + self.assertEqual(form.tag_string, 'BarBar') + form.tag_repeat = 3 + self.assertEqual(form.tag_name, 'Bar') + self.assertEqual(form.tag_string, 'BarBarBar') + + self.assertEqual(payment.tag_id, bar) + self.assertEqual(payment.tag_name, 'Bar') + self.assertEqual(payment.tag_repeat, 3) + self.assertEqual(payment.tag_string, 'BarBarBar') + + def test_display_name(self): + self.env['ir.ui.view'].create({ + 'name': 'test_new_api.multi.tag form view', + 'model': 'test_new_api.multi.tag', + 'arch': """ + + """, + }) + + form = common.Form(self.env['test_new_api.multi.tag']) + self.assertEqual(form.name, False) + self.assertEqual(form.display_name, "") + + record = form.save() + self.assertEqual(record.name, False) + self.assertEqual(record.display_name, "") + self.assertEqual(record.name_get(), [(record.id, "")]) + + def test_reading_one2many_and_inverse_is_not_supported(self): + # when reading a relation one2many, sometime the relation itself defines + # the many2one toward the parent model. Adding this to the fields_spec + # is not supported + USER = self.env.user + + fields_spec = { + 'name': {}, + 'messages': {'fields': { + 'name': {}, + 'author': {'fields': {'display_name': {}}}, + # add the inverse of field 'messages' into its subfields + 'discussion': {'fields': {'display_name': {}}}, + }}, + } + + # modify discussion name with a special value that adds a new message + values = { + 'name': "{generate_dummy_message}", + } + + result = self.Discussion.with_context(generate_dummy_message=True).onchange2(values, ['name'], fields_spec) + self.assertEqual(result['value']['messages'], [ + Command.create({ + 'name': f'[{{generate_dummy_message}}] {USER.name}', + 'author': {'id': 1, 'display_name': f'{USER.name}'}, + 'discussion': False, # this value is False because the main record does not exist yet + }), + ]) + + def test_reading_many2one_extra_fields(self): + Category = self.env['test_new_api.category'] + root = Category.create(dict(name='root')) + + fields_spec = { + 'name': {}, + 'parent': {'fields': {'display_name': {}, 'color': {}}}, + 'root_categ': {'fields': {'display_name': {}, 'color': {}}}, + } + + # set 'parent' to root, and check that 'root_categ' is computed as expected + values = { + 'name': 'test', + 'parent': root.id, + 'root_categ': False, + } + self.env.invalidate_all() + result = Category.onchange2(values, ['parent'], fields_spec) + self.assertEqual(result['value'], { + 'root_categ': {'id': root.id, 'display_name': root.name, 'color': root.color}, + }) + + @skip("read() on many2one fields currently use name_get() instead of field 'display_name'") + def test_one2many_field_with_context_many2one(self): + """ test many2one fields with a context on their one2many container field """ + multi = self.env['test_new_api.multi'].create({}) + line = multi.lines.create({'multi': multi.id}) + + self.assertFalse(multi.partner) + self.assertFalse(multi.name) + self.assertEqual(multi.lines, line) + self.assertFalse(line.partner) + self.assertFalse(line.name) + + fields_spec = multi._get_fields_spec() + self.assertEqual(fields_spec, { + 'name': {}, + 'partner': {'fields': {'display_name': {}}}, + 'lines': { + 'fields': { + 'name': {}, + 'partner': {'fields': {'display_name': {}}}, + 'tags': {'fields': {'name': {}}}, + }, + }, + }) + # add a context on field 'lines' to change the display_name of subfield 'partner' + fields_spec['lines']['context'] = {'show_email': True} + + # create a partner (for a change) + partner = self.env['res.partner'].create({ + 'name': 'A partner', + 'email': 'foo@example.com', + }) + display_name = partner.name_get()[0][1] + display_name_email = partner.with_context(show_email=True).name_get()[0][1] + self.assertNotEqual(display_name_email, display_name) + + # modify 'partner' + # -> set 'partner' on all lines + # -> recompute 'name' + # -> set 'name' on all lines + values = { + 'partner': partner.id, # this one just changed + } + self.env.invalidate_all() + + result = multi.onchange2(values, ['partner'], fields_spec) + self.assertEqual(result['value'], { + 'name': partner.name, + 'lines': [ + Command.update(line.id, { + 'name': partner.name, + 'partner': {'id': partner.id, 'display_name': display_name_email}, + }), + ], + }) + + def test_one2many_field_with_context_many2many(self): + """ test relational fields with a context on their one2many container field """ + partner = self.env['res.partner'].create({'name': 'A partner'}) + multi = self.env['test_new_api.multi'].create({'partner': partner.id}) + line = multi.lines.create({'multi': multi.id}) + + self.assertEqual(multi.partner, partner) + self.assertEqual(multi.name, partner.name) + self.assertEqual(multi.lines, line) + self.assertEqual(line.partner, partner) + self.assertEqual(line.name, False) + + fields_spec = { + 'name': {}, + 'partner': {'fields': {'display_name': {}}}, + 'lines': { + # this context should change the 'display_name' of 'tags' + 'context': {'special_tag': True}, + 'fields': { + 'name': {}, + 'partner': {'fields': {'display_name': {}}}, + 'tags': {'fields': {'display_name': {}}}, + }, + }, + 'tags': {'fields': {'display_name': {}}}, + } + + # set field 'tags': this should modify 'tags' on all lines + tag = self.env['test_new_api.multi.tag'].create({'name': 'tag'}) + self.assertEqual(tag.display_name, 'tag') + self.assertEqual(tag.with_context(special_tag=True).display_name, 'tag!') + + values = { + 'tags': [Command.link(tag.id)], + } + + self.env.invalidate_all() + result = multi.onchange2(values, ['tags'], fields_spec) + self.assertEqual(result['value'], { + 'lines': [ + Command.update(line.id, { + 'tags': [(Command.LINK, tag.id, {'display_name': 'tag!'})], + }), + ], + }) + + +class TestComputeOnchange2(common.TransactionCase): + + def test_create(self): + model = self.env['test_new_api.compute.onchange'] + + # compute 'bar' (readonly) and 'baz' (editable) + record = model.create({'active': True}) + self.assertEqual(record.bar, "r") + self.assertEqual(record.baz, "z") + + # compute 'bar' and 'baz' + record = model.create({'active': True, 'foo': "foo"}) + self.assertEqual(record.bar, "foor") + self.assertEqual(record.baz, "fooz") + + # compute 'bar' but not 'baz' + record = model.create({'active': True, 'foo': "foo", 'bar': "bar", 'baz': "baz"}) + self.assertEqual(record.bar, "foor") + self.assertEqual(record.baz, "baz") + + # compute 'bar' and 'baz', but do not change its value + record = model.create({'active': False, 'foo': "foo"}) + self.assertEqual(record.bar, "foor") + self.assertEqual(record.baz, False) + + # compute 'bar' but not 'baz' + record = model.create({'active': False, 'foo': "foo", 'bar': "bar", 'baz': "baz"}) + self.assertEqual(record.bar, "foor") + self.assertEqual(record.baz, "baz") + + def test_copy(self): + Model = self.env['test_new_api.compute.onchange'] + + # create tags + tag_foo, tag_bar = self.env['test_new_api.multi.tag'].create([ + {'name': 'foo1'}, + {'name': 'bar1'}, + ]) + + # compute 'bar' (readonly), 'baz', 'line_ids' and 'tag_ids' (editable) + record = Model.create({'active': True, 'foo': "foo1"}) + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "foo1z") + self.assertEqual(record.line_ids.mapped('foo'), ['foo1']) + self.assertEqual(record.tag_ids, tag_foo) + + # manually update 'baz' and 'lines' to test copy attribute + record.write({ + 'baz': "baz1", + 'line_ids': [Command.create({'foo': 'bar'})], + 'tag_ids': [Command.link(tag_bar.id)], + }) + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "baz1") + self.assertEqual(record.line_ids.mapped('foo'), ['foo1', 'bar']) + self.assertEqual(record.tag_ids, tag_foo + tag_bar) + + # copy the record, and check results + copied = record.copy() + self.assertEqual(copied.foo, "foo1 (copy)") # copied and modified + self.assertEqual(copied.bar, "foo1 (copy)r") # computed + self.assertEqual(copied.baz, "baz1") # copied + self.assertEqual(record.line_ids.mapped('foo'), ['foo1', 'bar']) # copied + self.assertEqual(record.tag_ids, tag_foo + tag_bar) # copied + + def test_write(self): + model = self.env['test_new_api.compute.onchange'] + record = model.create({'active': True, 'foo': "foo"}) + self.assertEqual(record.bar, "foor") + self.assertEqual(record.baz, "fooz") + + # recompute 'bar' (readonly) and 'baz' (editable) + record.write({'foo': "foo1"}) + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "foo1z") + + # recompute 'bar' but not 'baz' + record.write({'foo': "foo2", 'bar': "bar2", 'baz': "baz2"}) + self.assertEqual(record.bar, "foo2r") + self.assertEqual(record.baz, "baz2") + + # recompute 'bar' and 'baz', but do not change its value + record.write({'active': False, 'foo': "foo3"}) + self.assertEqual(record.bar, "foo3r") + self.assertEqual(record.baz, "baz2") + + # recompute 'bar' but not 'baz' + record.write({'active': False, 'foo': "foo4", 'bar': "bar4", 'baz': "baz4"}) + self.assertEqual(record.bar, "foo4r") + self.assertEqual(record.baz, "baz4") + + def test_set(self): + model = self.env['test_new_api.compute.onchange'] + record = model.create({'active': True, 'foo': "foo"}) + self.assertEqual(record.bar, "foor") + self.assertEqual(record.baz, "fooz") + + # recompute 'bar' (readonly) and 'baz' (editable) + record.foo = "foo1" + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "foo1z") + + # do not recompute 'baz' + record.baz = "baz2" + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "baz2") + + # recompute 'baz', but do not change its value + record.active = False + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "baz2") + + # recompute 'baz', but do not change its value + record.foo = "foo3" + self.assertEqual(record.bar, "foo3r") + self.assertEqual(record.baz, "baz2") + + # do not recompute 'baz' + record.baz = "baz4" + self.assertEqual(record.bar, "foo3r") + self.assertEqual(record.baz, "baz4") + + def test_set_new(self): + model = self.env['test_new_api.compute.onchange'] + record = model.new({'active': True}) + self.assertEqual(record.bar, "r") + self.assertEqual(record.baz, "z") + + # recompute 'bar' (readonly) and 'baz' (editable) + record.foo = "foo1" + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "foo1z") + + # do not recompute 'baz' + record.baz = "baz2" + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "baz2") + + # recompute 'baz', but do not change its value + record.active = False + self.assertEqual(record.bar, "foo1r") + self.assertEqual(record.baz, "baz2") + + # recompute 'baz', but do not change its value + record.foo = "foo3" + self.assertEqual(record.bar, "foo3r") + self.assertEqual(record.baz, "baz2") + + # do not recompute 'baz' + record.baz = "baz4" + self.assertEqual(record.bar, "foo3r") + self.assertEqual(record.baz, "baz4") + + def test_onchange(self): + # check computations of 'bar' (readonly) and 'baz' (editable) + form = common.Form(self.env['test_new_api.compute.onchange']) + self.assertEqual(form.bar, "r") + self.assertEqual(form.baz, False) + form.active = True + self.assertEqual(form.bar, "r") + self.assertEqual(form.baz, "z") + form.foo = "foo1" + self.assertEqual(form.bar, "foo1r") + self.assertEqual(form.baz, "foo1z") + form.baz = "baz2" + self.assertEqual(form.bar, "foo1r") + self.assertEqual(form.baz, "baz2") + form.active = False + self.assertEqual(form.bar, "foo1r") + self.assertEqual(form.baz, "baz2") + form.foo = "foo3" + self.assertEqual(form.bar, "foo3r") + self.assertEqual(form.baz, "baz2") + form.active = True + self.assertEqual(form.bar, "foo3r") + self.assertEqual(form.baz, "foo3z") + + with form.line_ids.new() as line: + # check computation of 'bar' (readonly) + self.assertEqual(line.foo, False) + self.assertEqual(line.bar, "r") + line.foo = "foo" + self.assertEqual(line.foo, "foo") + self.assertEqual(line.bar, "foor") + + record = form.save() + self.assertEqual(record.bar, "foo3r") + self.assertEqual(record.baz, "foo3z") + + form = common.Form(record) + self.assertEqual(form.bar, "foo3r") + self.assertEqual(form.baz, "foo3z") + form.foo = "foo4" + self.assertEqual(form.bar, "foo4r") + self.assertEqual(form.baz, "foo4z") + form.baz = "baz5" + self.assertEqual(form.bar, "foo4r") + self.assertEqual(form.baz, "baz5") + form.active = False + self.assertEqual(form.bar, "foo4r") + self.assertEqual(form.baz, "baz5") + form.foo = "foo6" + self.assertEqual(form.bar, "foo6r") + self.assertEqual(form.baz, "baz5") + + def test_onchange_default(self): + form = common.Form(self.env['test_new_api.compute.onchange'].with_context( + default_active=True, default_foo="foo", default_baz="baz", + )) + # 'baz' is computed editable, so when given a default value it should + # 'not be recomputed, even if a dependency also has a default value + self.assertEqual(form.foo, "foo") + self.assertEqual(form.bar, "foor") + self.assertEqual(form.baz, "baz") + + def test_onchange_once(self): + """ Modifies `foo` field which will trigger an onchange method and + checks it was triggered only one time. """ + form = Form(self.env['test_new_api.compute.onchange'].with_context(default_foo="oof")) + record = form.save() + self.assertEqual(record.foo, "oof") + self.assertEqual(record.count, 1, "value onchange must be called only one time") + + def test_onchange_one2many(self): + record = self.env['test_new_api.model_parent_m2o'].create({ + 'name': 'Family', + 'child_ids': [ + Command.create({'name': 'W', 'cost': 10}), + Command.create({'name': 'X', 'cost': 10}), + Command.create({'name': 'Y'}), + Command.create({'name': 'Z'}), + ], + }) + self.env.flush_all() + self.assertEqual(record.child_ids.mapped('name'), list('WXYZ')) + self.assertEqual(record.cost, 22) + + # modifying a line should not recompute the cost on other lines + with common.Form(record) as form: + with form.child_ids.edit(1) as line: + line.name = 'XXX' + self.assertEqual(form.cost, 15) + + with form.child_ids.edit(1) as line: + line.cost = 20 + self.assertEqual(form.cost, 32) + + with form.child_ids.edit(2) as line: + line.cost = 30 + self.assertEqual(form.cost, 61) + + def test_onchange_editable_compute_one2many(self): + # create a record with a computed editable field ('edit') on lines + record = self.env['test_new_api.compute_editable'].create({ + 'line_ids': [Command.create({'value': 7})], + }) + self.env.flush_all() + line = record.line_ids + self.assertRecordValues(line, [{'value': 7, 'edit': 7, 'count': 0}]) + + # retrieve the onchange spec for calling 'onchange' + fields_spec = record._get_fields_spec() + + # The onchange on 'line_ids' should increment 'count' and keep the value + # of 'edit' (this field should not be recomputed), whatever the order of + # the fields in the dictionary. This ensures that the value set by the + # user on a computed editable field on a line is not lost. + line_ids = [ + Command.update(line.id, {'value': 8, 'edit': 9, 'count': 0}), + (Command.CREATE, 'virtual2', {'value': 8, 'edit': 9, 'count': 0}), + ] + result = record.onchange2({'line_ids': line_ids}, ['line_ids'], fields_spec) + expected = {'value': { + 'line_ids': [ + Command.update(line.id, {'count': 8}), + Command.update('virtual2', {'count': 8}), + ], + }} + self.assertEqual(result, expected) + + # change dict order in lines, and try again + line_ids = [ + (op, id_, dict(reversed(list(vals.items())))) + for op, id_, vals in line_ids + ] + result = record.onchange2({'line_ids': line_ids}, ['line_ids'], fields_spec) + self.assertEqual(result, expected) + + def test_computed_editable_one2many_domain(self): + """ Test a computed, editable one2many field with a domain. """ + record = self.env['test_new_api.one2many'].create({'name': 'foo'}) + self.assertRecordValues(record.line_ids, [ + {'name': 'foo', 'count': 1}, + ]) + + # trigger recomputation by changing name + record.name = 'bar' + self.assertRecordValues(record.line_ids, [ + {'name': 'foo', 'count': 1}, + {'name': 'bar', 'count': 1}, + ]) + + # manually adding a line should not trigger recomputation + record.line_ids.create({'name': 'baz', 'container_id': record.id}) + self.assertRecordValues(record.line_ids, [ + {'name': 'foo', 'count': 1}, + {'name': 'bar', 'count': 1}, + {'name': 'baz', 'count': 1}, + ]) + + # changing the field in the domain should not trigger recomputation... + record.line_ids[-1].count = 2 + self.assertRecordValues(record.line_ids, [ + {'name': 'foo', 'count': 1}, + {'name': 'bar', 'count': 1}, + {'name': 'baz', 'count': 2}, + ]) + + # ...and may show cache inconsistencies + record.line_ids[-1].count = 0 + self.assertRecordValues(record.line_ids, [ + {'name': 'foo', 'count': 1}, + {'name': 'bar', 'count': 1}, + {'name': 'baz', 'count': 0}, + ]) + self.env.flush_all() + self.env.invalidate_all() + self.assertRecordValues(record.line_ids, [ + {'name': 'foo', 'count': 1}, + {'name': 'bar', 'count': 1}, + ]) diff --git a/odoo/fields.py b/odoo/fields.py index d7dbac69743..0416886464a 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -2982,7 +2982,7 @@ class Many2one(_Relational): # Should not happen, unless the foreign key is missing. return False else: - return value.id + return value._origin.id def convert_to_write(self, value, record): if type(value) in IdType: