[ADD] fields.py: New x2many command helper

We provide a new helper class to help redact x2many commands for create
and write methods. To ensure best compatibility with the xmlrpc layer we
do not change the protocole, the commands are still 3-elements tuples
where the first element is still an integer in between 0 and 6. The
helper class provide the cannonical constants and static methods to ease
working with the commands. The new helper class is also available in QWeb.

Developers are encouraged to transition their code so it uses this new
helper class.

Task: 2366606
This commit is contained in:
Julien Castiaux
2020-11-30 10:16:09 +00:00
committed by Raphael Collet
parent 90a7072f93
commit eded14b4c4
7 changed files with 229 additions and 118 deletions
+4
View File
@@ -220,6 +220,10 @@ Relational Fields
.. autoclass:: Many2many()
.. autoclass:: Command()
:members:
:undoc-members:
Pseudo-relational fields
''''''''''''''''''''''''
+1
View File
@@ -125,6 +125,7 @@ from . import models
from . import fields
from . import api
from odoo.tools.translate import _, _lt
from odoo.fields import Command
#----------------------------------------------------------
# Other imports, which may require stuff from above
+3 -1
View File
@@ -6,7 +6,7 @@ from werkzeug.wrappers import Response
from odoo.http import Controller, dispatch_rpc, request, route
from odoo.service import wsgi_server
from odoo.fields import Date, Datetime
from odoo.fields import Date, Datetime, Command
from odoo.tools import lazy
@@ -34,6 +34,8 @@ class OdooMarshaller(xmlrpc.client.Marshaller):
return self.dispatch[type(v)](self, v, write)
dispatch[lazy] = dump_lazy
dispatch[Command] = dispatch[int]
# monkey-patch xmlrpc.client's marshaller
xmlrpc.client.Marshaller = OdooMarshaller
+3 -1
View File
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo import api, fields, models, tools, SUPERUSER_ID, _, Command
from odoo.exceptions import MissingError, UserError, ValidationError, AccessError
from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval, test_python_expr
@@ -76,6 +76,7 @@ class IrActions(models.Model):
'timezone': timezone,
'b64encode': base64.b64encode,
'b64decode': base64.b64decode,
'Command': Command,
}
@api.model
@@ -376,6 +377,7 @@ class IrActionsServer(models.Model):
# - time, datetime, dateutil, timezone: useful Python libraries
# - log: log(message, level='info'): logging function to record debug information in ir.logging table
# - UserError: Warning Exception to use with raise
# - Command: x2Many commands namespace
# To return an action, assign: action = {...}\n\n\n\n"""
@api.model
+179 -60
View File
@@ -11,6 +11,7 @@ import itertools
import logging
import base64
import binascii
import enum
import pytz
import psycopg2
@@ -1015,10 +1016,11 @@ class Field(MetaField('DummyField', (object,), {})):
defaults = record.default_get([self.name])
if self.name in defaults:
# The null value above is necessary to convert x2many field
# values. For instance, converting [(4, id)] accesses the
# field's current value, then adds the given id. Without an
# initial value, the conversion ends up here to determine
# the field's value, and generates an infinite recursion.
# values. For instance, converting [(Command.LINK, id)]
# accesses the field's current value, then adds the given
# id. Without an initial value, the conversion ends up here
# to determine the field's value, and generates an infinite
# recursion.
value = self.convert_to_cache(defaults[self.name], record)
env.cache.set(record, self, value)
@@ -2804,6 +2806,123 @@ class Many2oneReference(Integer):
return model_ids
class Command(enum.IntEnum):
"""
:class:`~odoo.fields.One2many` and :class:`~odoo.fields.Many2many` fields
expect a special command to manipulate the relation they implement.
Internally, each command is a 3-elements tuple where the first element is a
mandatory integer that identifies the command, the second element is either
the related record id to apply the command on (commands update, delete,
unlink and link) either 0 (commands create, clear and set), the third
element is either the ``values`` to write on the record (commands create
and update) either the new ``ids`` list of related records (command set),
either 0 (commands delete, unlink, link, and clear).
Via Python, we encourage developers craft new commands via the various
functions of this namespace. We also encourage developers to use the
command identifier constant names when comparing the 1st element of
existing commands.
Via RPC, it is impossible nor to use the functions nor the command constant
names. It is required to instead write the literal 3-elements tuple where
the first element is the integer identifier of the command.
"""
CREATE = 0
UPDATE = 1
DELETE = 2
UNLINK = 3
LINK = 4
CLEAR = 5
SET = 6
@classmethod
def create(cls, values: dict):
"""
Create new records in the comodel using ``values``, link the created
records to ``self``.
In case of a :class:`~odoo.fields.Many2many` relation, one unique
new record is created in the comodel such that all records in `self`
are linked to the new record.
In case of a :class:`~odoo.fields.One2many` relation, one new record
is created in the comodel for every record in ``self`` such that every
record in ``self`` is linked to exactly one of the new records.
Return the command triple :samp:`(CREATE, 0, {values})`
"""
return (cls.CREATE, 0, values)
@classmethod
def update(cls, id: int, values: dict):
"""
Write ``values`` on the related record.
Return the command triple :samp:`(UPDATE, {id}, {values})`
"""
return (cls.UPDATE, id, values)
@classmethod
def delete(cls, id: int):
"""
Remove the related record from the database and remove its relation
with ``self``.
In case of a :class:`~odoo.fields.Many2many` relation, removing the
record from the database may be prevented if it is still linked to
other records.
Return the command triple :samp:`(DELETE, {id}, 0)`
"""
return (cls.DELETE, id, 0)
@classmethod
def unlink(cls, id: int):
"""
Remove the relation between ``self`` and the related record.
In case of a :class:`~odoo.fields.One2many` relation, the given record
is deleted from the database if the inverse field is set as
``ondelete='cascade'``. Otherwise, the value of the inverse field is
set to False and the record is kept.
Return the command triple :samp:`(UNLINK, {id}, 0)`
"""
return (cls.UNLINK, id, 0)
@classmethod
def link(cls, id: int):
"""
Add a relation between ``self`` and the related record.
Return the command triple :samp:`(LINK, {id}, 0)`
"""
return (cls.LINK, id, 0)
@classmethod
def clear(cls):
"""
Remove all records from the relation with ``self``. It behaves like
executing the `unlink` command on every record.
Return the command triple :samp:`(CLEAR, 0, 0)`
"""
return (cls.CLEAR, 0, 0)
@classmethod
def set(cls, ids: list):
"""
Replace the current relations of ``self`` by the given ones. It behaves
like executing the ``unlink`` command on every removed relation then
executing the ``link`` command on every new relation.
Return the command triple :samp:`(SET, 0, {ids})`
"""
return (cls.SET, 0, ids)
class _RelationalMulti(_Relational):
""" Abstract class for relational fields *2many. """
@@ -2862,22 +2981,22 @@ class _RelationalMulti(_Relational):
# modify ids with the commands
for command in value:
if isinstance(command, (tuple, list)):
if command[0] == 0:
if command[0] == Command.CREATE:
ids.add(comodel.new(command[2], ref=command[1]).id)
elif command[0] == 1:
elif command[0] == Command.UPDATE:
line = browse(command[1])
if validate:
line.update(command[2])
else:
line._update_cache(command[2], validate=False)
ids.add(line.id)
elif command[0] in (2, 3):
elif command[0] in (Command.DELETE, Command.UNLINK):
ids.discard(browse(command[1]).id)
elif command[0] == 4:
elif command[0] == Command.LINK:
ids.add(browse(command[1]).id)
elif command[0] == 5:
elif command[0] == Command.CLEAR:
ids.clear()
elif command[0] == 6:
elif command[0] == Command.SET:
ids = OrderedSet(browse(it).id for it in command[2])
elif isinstance(command, dict):
ids.add(comodel.new(command).id)
@@ -2927,7 +3046,7 @@ class _RelationalMulti(_Relational):
if isinstance(value, BaseModel) and value._name == self.comodel_name:
# make result with new and existing records
inv_names = {field.name for field in record._field_inverses[self]}
result = [(6, 0, [])]
result = [Command.set([])]
for record in value:
origin = record._origin
if not origin:
@@ -2936,7 +3055,7 @@ class _RelationalMulti(_Relational):
for name in record._cache
if name not in inv_names
})
result.append((0, 0, values))
result.append(Command.create(values))
else:
result[0][2].append(origin.id)
if record != origin:
@@ -2946,11 +3065,11 @@ class _RelationalMulti(_Relational):
if name not in inv_names and record[name] != origin[name]
})
if values:
result.append((1, origin.id, values))
result.append(Command.update(origin.id, values))
return result
if value is False or value is None:
return [(5,)]
return [Command.clear()]
if isinstance(value, list):
return value
@@ -2992,13 +3111,13 @@ class _RelationalMulti(_Relational):
for idx, (recs, value) in enumerate(records_commands_list):
if isinstance(value, tuple):
value = [(6, 0, value)]
value = [Command.set(value)]
elif isinstance(value, BaseModel) and value._name == self.comodel_name:
value = [(6, 0, value._ids)]
value = [Command.set(value._ids)]
elif value is False or value is None:
value = [(5,)]
value = [Command.clear()]
elif isinstance(value, list) and value and not isinstance(value[0], (tuple, list)):
value = [(6, 0, tuple(value))]
value = [Command.set(tuple(value))]
if not isinstance(value, list):
raise ValueError("Wrong value for %s: %s" % (self, value))
records_commands_list[idx] = (recs, value)
@@ -3151,22 +3270,22 @@ class One2many(_RelationalMulti):
for recs, commands in records_commands_list:
for command in (commands or ()):
if command[0] == 0:
if command[0] == Command.CREATE:
for record in recs:
to_create.append(dict(command[2], **{inverse: record.id}))
allow_full_delete = False
elif command[0] == 1:
elif command[0] == Command.UPDATE:
comodel.browse(command[1]).write(command[2])
elif command[0] == 2:
elif command[0] == Command.DELETE:
to_delete.append(command[1])
elif command[0] == 3:
elif command[0] == Command.UNLINK:
unlink(comodel.browse(command[1]))
elif command[0] == 4:
elif command[0] == Command.LINK:
to_inverse.setdefault(recs[-1], set()).add(command[1])
allow_full_delete = False
elif command[0] in (5, 6) :
elif command[0] in (Command.CLEAR, Command.SET):
# do not try to delete anything in creation mode if nothing has been created before
line_ids = command[2] if command[0] == 6 else []
line_ids = command[2] if command[0] == Command.SET else []
if not allow_full_delete and not line_ids:
continue
flush()
@@ -3192,21 +3311,21 @@ class One2many(_RelationalMulti):
for recs, commands in records_commands_list:
for command in (commands or ()):
if command[0] == 0:
if command[0] == Command.CREATE:
for record in recs:
link(record, comodel.new(command[2], ref=command[1]))
elif command[0] == 1:
elif command[0] == Command.UPDATE:
comodel.browse(command[1]).write(command[2])
elif command[0] == 2:
elif command[0] == Command.DELETE:
unlink(comodel.browse(command[1]))
elif command[0] == 3:
elif command[0] == Command.UNLINK:
unlink(comodel.browse(command[1]))
elif command[0] == 4:
elif command[0] == Command.LINK:
link(recs[-1], comodel.browse(command[1]))
elif command[0] in (5, 6):
elif command[0] in (Command.CLEAR, Command.SET):
# assign the given lines to the last record only
cache.update(recs, self, [()] * len(recs))
lines = comodel.browse(command[2] if command[0] == 6 else [])
lines = comodel.browse(command[2] if command[0] == Command.SET else [])
cache.set(recs[-1], self, lines._ids)
return records
@@ -3238,22 +3357,22 @@ class One2many(_RelationalMulti):
for recs, commands in records_commands_list:
for command in commands:
if command[0] == 0:
if command[0] == Command.CREATE:
for record in recs:
line = comodel.new(command[2], ref=command[1])
line[inverse] = record
elif command[0] == 1:
elif command[0] == Command.UPDATE:
browse([command[1]]).update(command[2])
elif command[0] == 2:
elif command[0] == Command.DELETE:
browse([command[1]])[inverse] = False
elif command[0] == 3:
elif command[0] == Command.UNLINK:
browse([command[1]])[inverse] = False
elif command[0] == 4:
elif command[0] == Command.LINK:
browse([command[1]])[inverse] = recs[-1]
elif command[0] in (5, 6):
elif command[0] in (Command.CLEAR, Command.SET):
# assign the given lines to the last record only
cache.update(recs, self, [()] * len(recs))
lines = comodel.browse(command[2] if command[0] == 6 else [])
lines = comodel.browse(command[2] if command[0] == Command.SET else [])
cache.set(recs[-1], self, lines._ids)
else:
@@ -3267,21 +3386,21 @@ class One2many(_RelationalMulti):
for recs, commands in records_commands_list:
for command in commands:
if command[0] == 0:
if command[0] == Command.CREATE:
for record in recs:
link(record, comodel.new(command[2], ref=command[1]))
elif command[0] == 1:
elif command[0] == Command.UPDATE:
browse([command[1]]).update(command[2])
elif command[0] == 2:
elif command[0] == Command.DELETE:
unlink(browse([command[1]]))
elif command[0] == 3:
elif command[0] == Command.UNLINK:
unlink(browse([command[1]]))
elif command[0] == 4:
elif command[0] == Command.LINK:
link(recs[-1], browse([command[1]]))
elif command[0] in (5, 6):
elif command[0] in (Command.CLEAR, Command.SET):
# assign the given lines to the last record only
cache.update(recs, self, [()] * len(recs))
lines = comodel.browse(command[2] if command[0] == 6 else [])
lines = comodel.browse(command[2] if command[0] == Command.SET else [])
cache.set(recs[-1], self, lines._ids)
return records
@@ -3531,20 +3650,20 @@ class Many2many(_RelationalMulti):
for command in (commands or ()):
if not isinstance(command, (list, tuple)) or not command:
continue
if command[0] == 0:
if command[0] == Command.CREATE:
to_create.append((recs._ids, command[2]))
elif command[0] == 1:
elif command[0] == Command.UPDATE:
comodel.browse(command[1]).write(command[2])
elif command[0] == 2:
elif command[0] == Command.DELETE:
to_delete.append(command[1])
elif command[0] == 3:
elif command[0] == Command.UNLINK:
relation_remove(recs._ids, command[1])
elif command[0] == 4:
elif command[0] == Command.LINK:
relation_add(recs._ids, command[1])
elif command[0] in (5, 6):
elif command[0] in (Command.CLEAR, Command.SET):
# new lines must no longer be linked to records
to_create = [(set(ids) - set(recs._ids), vals) for (ids, vals) in to_create]
relation_set(recs._ids, command[2] if command[0] == 6 else ())
relation_set(recs._ids, command[2] if command[0] == Command.SET else ())
if to_create:
# create lines in batch, and link them
@@ -3648,28 +3767,28 @@ class Many2many(_RelationalMulti):
for command in commands:
if not isinstance(command, (list, tuple)) or not command:
continue
if command[0] == 0:
if command[0] == Command.CREATE:
line_id = comodel.new(command[2], ref=command[1]).id
for line_ids in new_relation.values():
line_ids.add(line_id)
elif command[0] == 1:
elif command[0] == Command.UPDATE:
line_id = new(command[1])
comodel.browse([line_id]).update(command[2])
elif command[0] == 2:
elif command[0] == Command.DELETE:
line_id = new(command[1])
for line_ids in new_relation.values():
line_ids.discard(line_id)
elif command[0] == 3:
elif command[0] == Command.UNLINK:
line_id = new(command[1])
for line_ids in new_relation.values():
line_ids.discard(line_id)
elif command[0] == 4:
elif command[0] == Command.LINK:
line_id = new(command[1])
for line_ids in new_relation.values():
line_ids.add(line_id)
elif command[0] in (5, 6):
elif command[0] in (Command.CLEAR, Command.SET):
# new lines must no longer be linked to records
line_ids = command[2] if command[0] == 6 else ()
line_ids = command[2] if command[0] == Command.SET else ()
line_ids = set(new(line_id) for line_id in line_ids)
for id_ in recs._ids:
new_relation[id_] = set(line_ids)
+30 -48
View File
@@ -1300,10 +1300,11 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
# convert default values to the right format
#
# we explicitly avoid using _convert_to_write() for x2many fields,
# because the latter leaves values like [(4, 2), (4, 3)], which are not
# supported by the web client as default values; stepping through the
# cache allows to normalize such a list to [(6, 0, [2, 3])], which is
# properly supported by the web client
# because the latter leaves values like [(Command.LINK, 2),
# (Command.LINK, 3)], which are not supported by the web client as
# default values; stepping through the cache allows to normalize
# such a list to [(Command.SET, 0, [2, 3])], which is properly
# supported by the web client
for fname, value in defaults.items():
if fname in self._fields:
field = self._fields[fname]
@@ -1844,10 +1845,10 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
for name, value in defaults.items():
if self._fields[name].type == 'many2many' and value and isinstance(value[0], int):
# convert a list of ids into a list of commands
defaults[name] = [(6, 0, value)]
defaults[name] = [Command.set(value)]
elif self._fields[name].type == 'one2many' and value and isinstance(value[0], dict):
# convert a list of dicts into a list of commands
defaults[name] = [(0, 0, x) for x in value]
defaults[name] = [Command.create(x) for x in value]
defaults.update(values)
return defaults
@@ -3529,37 +3530,18 @@ Fields:
:const:`odoo.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT`
* .. _openerp/models/relationals/format:
:class:`~odoo.fields.One2many` and
:class:`~odoo.fields.Many2many` use a special "commands" format to
manipulate the set of records stored in/associated with the field.
This format is a list of triplets executed sequentially, where each
triplet is a command to execute on the set of records. Not all
commands apply in all situations. Possible commands are:
``(0, 0, values)``
adds a new record created from the provided ``value`` dict.
``(1, id, values)``
updates an existing record of id ``id`` with the values in
``values``. Can not be used in :meth:`~.create`.
``(2, id, 0)``
removes the record of id ``id`` from the set, then deletes it
(from the database). Can not be used in :meth:`~.create`.
``(3, id, 0)``
removes the record of id ``id`` from the set, but does not
delete it. Can not be used in
:meth:`~.create`.
``(4, id, 0)``
adds an existing record of id ``id`` to the set.
``(5, 0, 0)``
removes all records from the set, equivalent to using the
command ``3`` on every record explicitly. Can not be used in
:meth:`~.create`.
``(6, 0, ids)``
replaces all existing records in the set by the ``ids`` list,
equivalent to using the command ``5`` followed by a command
``4`` for each ``id`` in ``ids``.
"""
The expected value of a :class:`~odoo.fields.One2many` or
:class:`~odoo.fields.Many2many` relational field is a list of
:class:`~odoo.fields.Command` that manipulate the relation the
implement. There are a total of 7 commands:
:meth:`~odoo.fields.Command.create`,
:meth:`~odoo.fields.Command.update`,
:meth:`~odoo.fields.Command.delete`,
:meth:`~odoo.fields.Command.unlink`,
:meth:`~odoo.fields.Command.link`,
:meth:`~odoo.fields.Command.clear`, and
:meth:`~odoo.fields.Command.set`.
"""
if not self:
return True
@@ -4534,11 +4516,11 @@ Fields:
# duplicate following the order of the ids because we'll rely on
# it later for copying translations in copy_translation()!
lines = [rec.copy_data()[0] for rec in self[name].sorted(key='id')]
# the lines are duplicated using the wrong (old) parent, but then
# are reassigned to the correct one thanks to the (0, 0, ...)
default[name] = [(0, 0, line) for line in lines if line]
# the lines are duplicated using the wrong (old) parent, but then are
# reassigned to the correct one thanks to the (Command.CREATE, 0, ...)
default[name] = [Command.create(line) for line in lines if line]
elif field.type == 'many2many':
default[name] = [(6, 0, self[name].ids)]
default[name] = [Command.set(self[name].ids)]
else:
default[name] = field.convert_to_write(self[name], self)
@@ -6047,7 +6029,7 @@ Fields:
result[name] = field.convert_to_onchange(self[name], record, {})
else:
# x2many fields: serialize value as commands
result[name] = commands = [(5,)]
result[name] = commands = [Command.clear()]
# The purpose of the following line is to enable the prefetching.
# In the loop below, line._prefetch_ids actually depends on the
# value of record[name] in cache (see prefetch_ids on x2many
@@ -6062,7 +6044,7 @@ Fields:
if not line.id:
# new line: send diff from scratch
line_diff = line_snapshot.diff({})
commands.append((0, line.id.ref or 0, line_diff))
commands.append((Command.CREATE, line.id.ref or 0, line_diff))
else:
# existing line: check diff from database
# (requires a clean record cache!)
@@ -6071,9 +6053,9 @@ Fields:
# send all fields because the web client
# might need them to evaluate modifiers
line_diff = line_snapshot.diff({})
commands.append((1, line.id, line_diff))
commands.append(Command.update(line.id, line_diff))
else:
commands.append((4, line.id))
commands.append(Command.link(line.id))
return result
nametree = PrefixTree(self.browse(), field_onchange)
@@ -6093,9 +6075,9 @@ Fields:
# retrieve all line ids in commands
line_ids = set()
for cmd in values[name]:
if cmd[0] in (1, 4):
if cmd[0] in (Command.UPDATE, Command.LINK):
line_ids.add(cmd[1])
elif cmd[0] == 6:
elif cmd[0] == Command.SET:
line_ids.update(cmd[2])
# prefetch stored fields on lines
lines = self[name].browse(line_ids)
@@ -6507,4 +6489,4 @@ def lazy_name_get(self):
# keep those imports here to avoid dependency cycle errors
from .osv import expression
from .fields import Field, Datetime
from .fields import Field, Datetime, Command
+9 -8
View File
@@ -53,6 +53,7 @@ class RecordDictWrapper(dict):
def _get_idref(self, env, model_str, idref):
idref2 = dict(idref,
Command=odoo.fields.Command,
time=time,
DateTime=datetime,
datetime=datetime,
@@ -301,10 +302,10 @@ form: module.record_id""" % (xml_id,)
for group in g_names:
if group.startswith('-'):
group_id = self.id_get(group[1:])
groups_value.append((3, group_id))
groups_value.append(odoo.Command.unlink(group_id))
else:
group_id = self.id_get(group)
groups_value.append((4, group_id))
groups_value.append(odoo.Command.link(group_id))
res['groups_id'] = groups_value
if rec.get('paperformat'):
pf_name = rec.get('paperformat')
@@ -405,10 +406,10 @@ form: module.record_id""" % (xml_id,)
for group in g_names:
if group.startswith('-'):
group_id = self.id_get(group[1:])
groups_value.append((3, group_id))
groups_value.append(odoo.Command.unlink(group_id))
else:
group_id = self.id_get(group)
groups_value.append((4, group_id))
groups_value.append(odoo.Command.link(group_id))
res['groups_id'] = groups_value
if rec.get('target'):
@@ -467,10 +468,10 @@ form: module.record_id""" % (xml_id,)
for group in rec.get('groups', '').split(','):
if group.startswith('-'):
group_id = self.id_get(group[1:])
groups.append((3, group_id))
groups.append(odoo.Command.unlink(group_id))
elif group:
group_id = self.id_get(group)
groups.append((4, group_id))
groups.append(odoo.Command.link(group_id))
if groups:
values['groups_id'] = groups
@@ -551,7 +552,7 @@ form: module.record_id""" % (xml_id,)
_fields = env[rec_model]._fields
# if the current field is many2many
if (f_name in _fields) and _fields[f_name].type == 'many2many':
f_val = [(6, 0, [x[f_use] for x in s])]
f_val = [odoo.Command.set([x[f_use] for x in s])]
elif len(s):
# otherwise (we are probably in a many2one field),
# take the first element of the search
@@ -637,7 +638,7 @@ form: module.record_id""" % (xml_id,)
groups = el.attrib.pop('groups', None)
if groups:
grp_lst = [("ref('%s')" % x) for x in groups.split(',')]
record.append(Field(name="groups_id", eval="[(6, 0, ["+', '.join(grp_lst)+"])]"))
record.append(Field(name="groups_id", eval="[Command.set(["+', '.join(grp_lst)+"])]"))
if el.get('primary') == 'True':
# Pseudo clone mode, we'll set the t-name to the full canonical xmlid
el.append(