diff --git a/issue12473.diff b/issue12473.diff new file mode 100644 index 0000000..1da260f --- /dev/null +++ b/issue12473.diff @@ -0,0 +1,544 @@ +diff --git a/proteus/proteus/__init__.py b/proteus/proteus/__init__.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_cHJvdGV1cy9wcm90ZXVzL19faW5pdF9fLnB5..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_cHJvdGV1cy9wcm90ZXVzL19faW5pdF9fLnB5 100644 +--- a/proteus/proteus/__init__.py ++++ b/proteus/proteus/__init__.py +@@ -1152,9 +1152,8 @@ + values.update(self._on_change_args(on_change)) + if values: + context = self._context +- changes = getattr(self._proxy, 'on_change')(values, names, context) +- for change in changes: +- self._set_on_change(change) ++ change = getattr(self._proxy, 'on_change')(values, names, context) ++ self._set_on_change(change) + + values = {} + fieldnames = set(names) +diff --git a/sao/src/model.js b/sao/src/model.js +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_c2FvL3NyYy9tb2RlbC5qcw==..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_c2FvL3NyYy9tb2RlbC5qcw== 100644 +--- a/sao/src/model.js ++++ b/sao/src/model.js +@@ -1071,5 +1071,5 @@ + [values], this.get_context(), false)); + } + } else { +- changes = this.model.execute( ++ changes = [this.model.execute( + 'on_change', +@@ -1075,5 +1075,5 @@ + 'on_change', +- [values, fieldnames], this.get_context(), false); ++ [values, fieldnames], this.get_context(), false)]; + } + } catch (e) { + return; +@@ -1129,9 +1129,9 @@ + delete this._values[fieldname + '.']; + } + } +- var result; ++ var changed; + fieldnames = Object.keys(fieldnames); + if (fieldnames.length) { + try { + if ((fieldnames.length == 1) || + (values.id === undefined)) { +@@ -1133,7 +1133,7 @@ + fieldnames = Object.keys(fieldnames); + if (fieldnames.length) { + try { + if ((fieldnames.length == 1) || + (values.id === undefined)) { +- result = {}; ++ changed = {}; + for (const fieldname of fieldnames) { +@@ -1139,6 +1139,8 @@ + for (const fieldname of fieldnames) { +- result[fieldname] = this.model.execute( +- 'on_change_with_' + fieldname, +- [values], this.get_context(), false); ++ changed = jQuery.extend( ++ changed, ++ this.model.execute( ++ 'on_change_with_' + fieldname, ++ [values], this.get_context(), false)); + } + } else { +@@ -1143,9 +1145,9 @@ + } + } else { +- result = this.model.execute( ++ changed = this.model.execute( + 'on_change_with', + [values, fieldnames], this.get_context(), false); + } + } catch (e) { + return; + } +@@ -1146,10 +1148,10 @@ + 'on_change_with', + [values, fieldnames], this.get_context(), false); + } + } catch (e) { + return; + } +- this.set_on_change(result); ++ this.set_on_change(changed); + } + if (!jQuery.isEmptyObject(later)) { + values = {}; +@@ -1164,5 +1166,5 @@ + try { + if ((fieldnames.length == 1) || + (values.id === undefined)) { +- result = {}; ++ changed = {}; + for (const fieldname of fieldnames) { +@@ -1168,6 +1170,8 @@ + for (const fieldname of fieldnames) { +- result[fieldname] = this.model.execute( +- 'on_change_with_' + fieldname, +- [values], this.get_context(), false); ++ changed = jQuery.extend( ++ changed, ++ this.model.execute( ++ 'on_change_with_' + fieldname, ++ [values], this.get_context(), false)); + } + } else { +@@ -1172,9 +1176,9 @@ + } + } else { +- result = this.model.execute( ++ changed = this.model.execute( + 'on_change_with', + [values, fieldnames], this.get_context(), false); + } + } catch (e) { + return; + } +@@ -1175,10 +1179,10 @@ + 'on_change_with', + [values, fieldnames], this.get_context(), false); + } + } catch (e) { + return; + } +- this.set_on_change(result); ++ this.set_on_change(changed); + } + }, + set_on_change: function(values) { +diff --git a/tryton/tryton/gui/window/view_form/model/record.py b/tryton/tryton/gui/window/view_form/model/record.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uL3RyeXRvbi9ndWkvd2luZG93L3ZpZXdfZm9ybS9tb2RlbC9yZWNvcmQucHk=..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uL3RyeXRvbi9ndWkvd2luZG93L3ZpZXdfZm9ybS9tb2RlbC9yZWNvcmQucHk= 100644 +--- a/tryton/tryton/gui/window/view_form/model/record.py ++++ b/tryton/tryton/gui/window/view_form/model/record.py +@@ -577,9 +577,9 @@ + 'on_change_' + fieldname, + values, context=self.get_context())) + else: +- changes = RPCExecute( +- 'model', self.model_name, 'on_change', +- values, fieldnames, context=self.get_context()) ++ changes = [RPCExecute( ++ 'model', self.model_name, 'on_change', ++ values, fieldnames, context=self.get_context())] + except RPCException: + pass + else: +@@ -621,5 +621,5 @@ + if fieldnames: + try: + if len(fieldnames) == 1 or 'id' not in values: +- result = {} ++ changed = {} + for fieldname in fieldnames: +@@ -625,6 +625,6 @@ + for fieldname in fieldnames: +- result[fieldname] = RPCExecute( +- 'model', self.model_name, +- 'on_change_with_' + fieldname, +- values, context=self.get_context()) ++ changed.update(RPCExecute( ++ 'model', self.model_name, ++ 'on_change_with_' + fieldname, ++ values, context=self.get_context())) + else: +@@ -630,6 +630,6 @@ + else: +- result = RPCExecute( ++ changed = RPCExecute( + 'model', self.model_name, 'on_change_with', + values, list(fieldnames), context=self.get_context()) + except RPCException: + return +@@ -632,8 +632,8 @@ + 'model', self.model_name, 'on_change_with', + values, list(fieldnames), context=self.get_context()) + except RPCException: + return +- self.set_on_change(result) ++ self.set_on_change(changed) + if later: + values = {} + for fieldname in later: +@@ -642,5 +642,5 @@ + values.update(self._get_on_change_args(on_change_with)) + try: + if len(later) == 1 or 'id' not in values: +- result = {} ++ changed = {} + for fieldname in fieldnames: +@@ -646,6 +646,6 @@ + for fieldname in fieldnames: +- result[fieldname] = RPCExecute( +- 'model', self.model_name, +- 'on_change_with_' + fieldname, +- values, context=self.get_context()) ++ changed.update(RPCExecute( ++ 'model', self.model_name, ++ 'on_change_with_' + fieldname, ++ values, context=self.get_context())) + else: +@@ -651,6 +651,6 @@ + else: +- result = RPCExecute( ++ changed = RPCExecute( + 'model', self.model_name, 'on_change_with', + values, list(later), context=self.get_context()) + except RPCException: + return +@@ -653,8 +653,8 @@ + 'model', self.model_name, 'on_change_with', + values, list(later), context=self.get_context()) + except RPCException: + return +- self.set_on_change(result) ++ self.set_on_change(changed) + + def autocomplete_with(self, field_name): + for fieldname, fieldinfo in self.group.fields.items(): +diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC9DSEFOR0VMT0c=..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC9DSEFOR0VMT0c= 100644 +--- a/trytond/CHANGELOG ++++ b/trytond/CHANGELOG +@@ -1,3 +1,5 @@ ++* Support add/update/remove/delete for on_change_with of xxx2Many ++* Add decorator on RPC + * Add a canonicalize function for domains + * Enforce record rules when reading only non SQL fields (#12428) + * Support PYSON comparison of timedelta +diff --git a/trytond/doc/ref/rpc.rst b/trytond/doc/ref/rpc.rst +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC9kb2MvcmVmL3JwYy5yc3Q=..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC9kb2MvcmVmL3JwYy5yc3Q= 100644 +--- a/trytond/doc/ref/rpc.rst ++++ b/trytond/doc/ref/rpc.rst +@@ -4,7 +4,7 @@ + RPC + === + +-.. class:: RPC([readonly[, instantiate[, result[, check_access[, unique[, fresh_session[, cache]]]]]]]) ++.. class:: RPC([readonly[, instantiate[, [decorator, result[, check_access[, unique[, fresh_session[, cache]]]]]]]]) + + Define the behavior of Remote Procedure Call. + +@@ -18,6 +18,10 @@ + + The position or the slice of the argument to be instanciated + ++.. attribute:: RPC.decorator ++ ++ The function to decorate the called procedure with ++ + .. attribute:: RPC.result + + The function to transform the result +diff --git a/trytond/trytond/model/fields/field.py b/trytond/trytond/model/fields/field.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC90cnl0b25kL21vZGVsL2ZpZWxkcy9maWVsZC5weQ==..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC90cnl0b25kL21vZGVsL2ZpZWxkcy9maWVsZC5weQ== 100644 +--- a/trytond/trytond/model/fields/field.py ++++ b/trytond/trytond/model/fields/field.py +@@ -1,7 +1,7 @@ + # This file is part of Tryton. The COPYRIGHT file at the top level of + # this repository contains the full copyright notices and license terms. + import warnings +-from functools import partial, wraps ++from functools import wraps + + import sql + from sql import ( +@@ -195,18 +195,15 @@ + return record._changed_values + + +-def on_change_with_result(field, value): +- from ..modelstorage import ModelStorage +- if field._type in {'many2one', 'one2one', 'reference'}: +- if isinstance(value, ModelStorage): +- if field._type == 'reference': +- value = str(value) +- else: +- value = value.id +- elif field._type in {'one2many', 'many2many'}: +- if isinstance(value, (list, tuple)): +- value = [int(r) for r in value] +- return value ++def on_change_with_result(fieldname): ++ def decorator(func): ++ @wraps(func) ++ def wrapper(self, *args, **kwargs): ++ value = func(self, *args, **kwargs) ++ setattr(self, fieldname, value) ++ return self._changed_values ++ return wrapper ++ return decorator + + + def domain_method(func): +@@ -476,9 +473,9 @@ + return [self.sql_column(table)] + + def set_rpc(self, model): +- for attribute, result in ( +- ('on_change', on_change_result), +- ('on_change_with', partial(on_change_with_result, self)), ++ for attribute, decorator, result in ( ++ ('on_change', None, on_change_result), ++ ('on_change_with', on_change_with_result(self.name), None), + ): + if not getattr(self, attribute): + continue +@@ -486,7 +483,8 @@ + assert hasattr(model, func_name), \ + 'Missing %s on model %s' % (func_name, model.__name__) + model.__rpc__.setdefault( +- func_name, RPC(instantiate=0, result=result)) ++ func_name, ++ RPC(instantiate=0, decorator=decorator, result=result)) + + def definition(self, model, language): + pool = Pool() +diff --git a/trytond/trytond/model/fields/function.py b/trytond/trytond/model/fields/function.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC90cnl0b25kL21vZGVsL2ZpZWxkcy9mdW5jdGlvbi5weQ==..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC90cnl0b25kL21vZGVsL2ZpZWxkcy9mdW5jdGlvbi5weQ== 100644 +--- a/trytond/trytond/model/fields/function.py ++++ b/trytond/trytond/model/fields/function.py +@@ -9,7 +9,7 @@ + from trytond.tools import is_instance_method + from trytond.transaction import Transaction, without_check_access + +-from .field import Field, domain_method, on_change_with_result ++from .field import Field, domain_method + + + def getter_context(func): +@@ -124,5 +124,16 @@ + + def call(name): + if not instance_method: +- return on_change_with_result(self, method(records, name)) ++ values = method(records, name) ++ if isinstance(name, str): ++ return convert_dict(values) ++ else: ++ return {k: convert_dict(v, k) for k, v in values.items()} ++ else: ++ return {r.id: convert(method(r, name)) for r in records} ++ ++ def convert(value, name=None): ++ from ..model import Model as BaseModel ++ if name: ++ field = Model._fields[name]._field + else: +@@ -128,7 +139,22 @@ + else: +- return { +- r.id: on_change_with_result(self, method(r, name)) +- for r in records} ++ field = self._field ++ if field._type in {'many2one', 'one2one', 'reference'}: ++ if isinstance(value, BaseModel): ++ if field._type == 'reference': ++ value = str(value) ++ else: ++ value = int(value) ++ elif field._type in {'one2many', 'many2many'}: ++ if value: ++ value = [int(r) for r in value] ++ return value ++ ++ def convert_dict(values, name=None): ++ # Keep the same class ++ values = values.copy() ++ values.update((k, convert(v, name)) for k, v in values.items()) ++ return values ++ + if isinstance(name, list): + names = name + if multiple: +diff --git a/trytond/trytond/model/modelview.py b/trytond/trytond/model/modelview.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC90cnl0b25kL21vZGVsL21vZGVsdmlldy5weQ==..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC90cnl0b25kL21vZGVsL21vZGVsdmlldy5weQ== 100644 +--- a/trytond/trytond/model/modelview.py ++++ b/trytond/trytond/model/modelview.py +@@ -68,8 +68,9 @@ + super(ModelView, cls).__setup__() + cls.__rpc__['fields_view_get'] = RPC(cache=dict(days=1)) + cls.__rpc__['view_toolbar_get'] = RPC(cache=dict(days=1)) +- cls.__rpc__['on_change'] = RPC(instantiate=0) +- cls.__rpc__['on_change_with'] = RPC(instantiate=0) ++ cls.__rpc__['on_change'] = RPC(instantiate=0, result=on_change_result) ++ cls.__rpc__['on_change_with'] = RPC( ++ instantiate=0, result=on_change_result) + cls.__rpc__['on_change_notify'] = RPC(instantiate=0) + cls._buttons = {} + +@@ -758,8 +759,16 @@ + return func + return decorator + ++ @on_change ++ def on_change_with(self, fieldnames): ++ for fieldname in fieldnames: ++ method_name = 'on_change_with_%s' % fieldname ++ value = getattr(self, method_name)() ++ setattr(self, fieldname, value) ++ ++ @on_change + def on_change(self, fieldnames): + for fieldname in sorted(fieldnames): + method = getattr(self, 'on_change_%s' % fieldname, None) + if method: + method() +@@ -761,34 +770,8 @@ + def on_change(self, fieldnames): + for fieldname in sorted(fieldnames): + method = getattr(self, 'on_change_%s' % fieldname, None) + if method: + method() +- # XXX remove backward compatibility +- return [self._changed_values] +- +- def on_change_with(self, fieldnames): +- from .modelstorage import ModelStorage +- changes = {} +- for fieldname in fieldnames: +- field = self._fields[fieldname] +- method_name = 'on_change_with_%s' % fieldname +- value = getattr(self, method_name)() +- setattr(self, fieldname, value) +- if field._type in {'many2one', 'one2one', 'reference'}: +- if isinstance(value, ModelStorage): +- if value.id and value.id >= 0: +- changes[f'%{fieldname}.'] = { +- 'rec_name': value.rec_name, +- } +- if field._type == 'reference': +- value = str(value) +- else: +- value = value.id +- elif field._type in {'one2many', 'many2many'}: +- if isinstance(value, (list, tuple)): +- value = [int(r) for r in value] +- changes[fieldname] = value +- return changes + + def on_change_notify(self): + """Return a list of type and message couples. +@@ -802,8 +785,7 @@ + By default, the value of a field is its internal representation except: + - for Many2One and One2One field: the id. + - for Reference field: the string model,id +- - for Many2Many: the list of ids +- - for One2Many: a dictionary composed of three keys: ++ - for One2Many and Many2Many: a dictionary composed of three keys: + - add: a list of tuple, the first element is the index where + the new line is added, the second element is + `_default_values` +@@ -822,7 +804,7 @@ + # Always test key presence in case value is None + if (fname in init_values + and value == init_values[fname] +- and field._type != 'one2many'): ++ and field._type not in {'one2many', 'many2many'}): + continue + if field._type in ('many2one', 'one2one', 'reference'): + if value: +diff --git a/trytond/trytond/protocols/dispatcher.py b/trytond/trytond/protocols/dispatcher.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC90cnl0b25kL3Byb3RvY29scy9kaXNwYXRjaGVyLnB5..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC90cnl0b25kL3Byb3RvY29scy9kaXNwYXRjaGVyLnB5 100644 +--- a/trytond/trytond/protocols/dispatcher.py ++++ b/trytond/trytond/protocols/dispatcher.py +@@ -190,7 +190,7 @@ + c_args, c_kwargs, transaction.context, transaction.timestamp \ + = rpc.convert(obj, *args, **kwargs) + transaction.context['_request'] = request.context +- meth = getattr(obj, method) ++ meth = rpc.decorate(getattr(obj, method)) + if (rpc.instantiate is None + or not is_instance_method(obj, method)): + result = rpc.result(meth(*c_args, **c_kwargs)) +diff --git a/trytond/trytond/rpc.py b/trytond/trytond/rpc.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC90cnl0b25kL3JwYy5weQ==..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC90cnl0b25kL3JwYy5weQ== 100644 +--- a/trytond/trytond/rpc.py ++++ b/trytond/trytond/rpc.py +@@ -12,9 +12,10 @@ + + readonly: The transaction mode + instantiate: The position or the slice of the arguments to be instanciated ++ decorator: A function to decorate the procedure with + result: The function to transform the result + check_access: If access right must be checked + fresh_session: If a fresh session is required + unique: Check instances are unique + ''' + +@@ -15,9 +16,10 @@ + result: The function to transform the result + check_access: If access right must be checked + fresh_session: If a fresh session is required + unique: Check instances are unique + ''' + +- __slots__ = ('readonly', 'instantiate', 'result', 'check_access', +- 'fresh_session', 'unique', 'cache') ++ __slots__ = ( ++ 'readonly', 'instantiate', 'decorator', 'result', ++ 'check_access', 'fresh_session', 'unique', 'cache') + +@@ -23,5 +25,6 @@ + +- def __init__(self, readonly=True, instantiate=None, result=None, ++ def __init__( ++ self, readonly=True, instantiate=None, decorator=None, result=None, + check_access=True, fresh_session=False, unique=True, cache=None): + self.readonly = readonly + self.instantiate = instantiate +@@ -25,6 +28,7 @@ + check_access=True, fresh_session=False, unique=True, cache=None): + self.readonly = readonly + self.instantiate = instantiate ++ self.decorator = decorator + if result is None: + def result(r): + return r +@@ -83,6 +87,11 @@ + context['_check_access'] = True + return args, kwargs, context, timestamp + ++ def decorate(self, func): ++ if self.decorator: ++ func = self.decorator(func) ++ return func ++ + + class RPCCache: + __slots__ = ('duration',) +diff --git a/trytond/trytond/tests/test_field_function.py b/trytond/trytond/tests/test_field_function.py +index bfad1dc7382cb9fd2c17bef77da699ed36869d3e_dHJ5dG9uZC90cnl0b25kL3Rlc3RzL3Rlc3RfZmllbGRfZnVuY3Rpb24ucHk=..bf7072adc76db4e62ec3fbde7efde17e7bbd630d_dHJ5dG9uZC90cnl0b25kL3Rlc3RzL3Rlc3RfZmllbGRfZnVuY3Rpb24ucHk= 100644 +--- a/trytond/trytond/tests/test_field_function.py ++++ b/trytond/trytond/tests/test_field_function.py +@@ -103,7 +103,7 @@ + + record = Model() + record.save() +- with patch.object(Model, 'get_function1') as getter: ++ with patch.object(Model, 'get_function1', autospec=True) as getter: + getter.return_value = 'test' + + Model.read([record.id], ['function1', 'function2']) diff --git a/series b/series index c7d351d..dcb91ed 100644 --- a/series +++ b/series @@ -23,3 +23,5 @@ statement_of_account.diff # [account] Cumulate balance of previous fiscal years issue11731.diff # [currency] currency test don't pass when Currency Rates Source Not Ready (forex) counterpart_party_payment_clearing.diff # [account_payment_clearing] Add the possiblity to have a party in the counterpart move when reconcile on a payment. + +issue12473.diff # [trytond] Support add/update/remove/delete on on_change_with for one2many