From a2cdca5dc970cf62145954a70e6fd0ef471a62c9 Mon Sep 17 00:00:00 2001 From: KaySar12 Date: Mon, 19 May 2025 15:33:37 +0700 Subject: [PATCH] temporary change to avoid error when create email alias --- odoo/fields.py | 2435 +++++++++++++++++++++++++++++------------------- 1 file changed, 1486 insertions(+), 949 deletions(-) diff --git a/odoo/fields.py b/odoo/fields.py index f33aa259a..23e2fdb5d 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -""" High-level objects for fields. """ +"""High-level objects for fields.""" from __future__ import annotations from collections import defaultdict @@ -30,10 +30,22 @@ from hashlib import sha256 from .models import check_property_field_value_name from .netsvc import ColoredFormatter, GREEN, RED, DEFAULT, COLOR_PATTERN from .tools import ( - float_repr, float_round, float_compare, float_is_zero, human_size, - OrderedSet, sql, SQL, date_utils, unique, lazy_property, - image_process, merge_sequences, is_list_of, - html_normalize, html_sanitize, + float_repr, + float_round, + float_compare, + float_is_zero, + human_size, + OrderedSet, + sql, + SQL, + date_utils, + unique, + lazy_property, + image_process, + merge_sequences, + is_list_of, + html_normalize, + html_sanitize, DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT, ) @@ -54,32 +66,46 @@ DATE_LENGTH = len(date.today().strftime(DATE_FORMAT)) DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT)) # hacky-ish way to prevent access to a field through the ORM (except for sudo mode) -NO_ACCESS='.' +NO_ACCESS = "." IR_MODELS = ( - 'ir.model', 'ir.model.data', 'ir.model.fields', 'ir.model.fields.selection', - 'ir.model.relation', 'ir.model.constraint', 'ir.module.module', + "ir.model", + "ir.model.data", + "ir.model.fields", + "ir.model.fields.selection", + "ir.model.relation", + "ir.model.constraint", + "ir.module.module", ) COMPANY_DEPENDENT_FIELDS = ( - 'char', 'float', 'boolean', 'integer', 'text', 'many2one', 'date', 'datetime', 'selection', 'html' + "char", + "float", + "boolean", + "integer", + "text", + "many2one", + "date", + "datetime", + "selection", + "html", ) _logger = logging.getLogger(__name__) -_schema = logging.getLogger(__name__[:-7] + '.schema') +_schema = logging.getLogger(__name__[:-7] + ".schema") NoneType = type(None) def first(records): - """ Return the first record in ``records``, with the same prefetching. """ + """Return the first record in ``records``, with the same prefetching.""" return next(iter(records)) if len(records) > 1 else records def resolve_mro(model, name, predicate): - """ Return the list of successively overridden values of attribute ``name`` - in mro order on ``model`` that satisfy ``predicate``. Model registry - classes are ignored. + """Return the list of successively overridden values of attribute ``name`` + in mro order on ``model`` that satisfy ``predicate``. Model registry + classes are ignored. """ result = [] for cls in model._model_classes: @@ -93,7 +119,7 @@ def resolve_mro(model, name, predicate): def determine(needle, records, *args): - """ Simple helper for calling a method given as a string or a function. + """Simple helper for calling a method given as a string or a function. :param needle: callable or name of method to call on ``records`` :param BaseModel records: recordset to call ``needle`` on or with @@ -106,22 +132,23 @@ def determine(needle, records, *args): raise TypeError("Determination requires a subject recordset") if isinstance(needle, str): needle = getattr(records, needle) - if needle.__name__.find('__'): + if needle.__name__.find("__"): return needle(*args) elif callable(needle): - if needle.__name__.find('__'): + if needle.__name__.find("__"): return needle(records, *args) raise TypeError("Determination requires a callable or method name") class MetaField(type): - """ Metaclass for field classes. """ + """Metaclass for field classes.""" + by_type = {} def __init__(cls, name, bases, attrs): super(MetaField, cls).__init__(name, bases, attrs) - if not hasattr(cls, 'type'): + if not hasattr(cls, "type"): return if cls.type and cls.type not in MetaField.by_type: @@ -131,16 +158,16 @@ class MetaField(type): cls.related_attrs = [] cls.description_attrs = [] for attr in dir(cls): - if attr.startswith('_related_'): + if attr.startswith("_related_"): cls.related_attrs.append((attr[9:], attr)) - elif attr.startswith('_description_'): + elif attr.startswith("_description_"): cls.description_attrs.append((attr[13:], attr)) _global_seq = iter(itertools.count()) -class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): +class Field(MetaField("DummyField", (object,), {}), typing.Generic[T]): """The field descriptor contains the field definition, and manages accesses and assignments of the corresponding field on records. The following attributes may be provided when instantiating a field: @@ -271,67 +298,69 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): .. seealso:: :ref:`Advanced fields/Related fields ` """ - type: str # type of the field (string) - relational = False # whether the field is a relational one - translate = False # whether the field is translated + type: str # type of the field (string) + relational = False # whether the field is a relational one + translate = False # whether the field is translated write_sequence = 0 # field ordering for write() # Database column type (ident, spec) for non-company-dependent fields. # Company-dependent fields are stored as jsonb (see column_type). _column_type: typing.Tuple[str, str] | None = None - args = None # the parameters given to __init__() - _module = None # the field's module name - _modules = None # modules that define this field - _setup_done = True # whether the field is completely set up - _sequence = None # absolute ordering of the field - _base_fields = () # the fields defining self, in override order - _extra_keys = () # unknown attributes set on the field - _direct = False # whether self may be used directly (shared) - _toplevel = False # whether self is on the model's registry class + args = None # the parameters given to __init__() + _module = None # the field's module name + _modules = None # modules that define this field + _setup_done = True # whether the field is completely set up + _sequence = None # absolute ordering of the field + _base_fields = () # the fields defining self, in override order + _extra_keys = () # unknown attributes set on the field + _direct = False # whether self may be used directly (shared) + _toplevel = False # whether self is on the model's registry class - automatic = False # whether the field is automatically created ("magic" field) - inherited = False # whether the field is inherited (_inherits) - inherited_field = None # the corresponding inherited field + automatic = False # whether the field is automatically created ("magic" field) + inherited = False # whether the field is inherited (_inherits) + inherited_field = None # the corresponding inherited field - name: str # name of the field - model_name: str | None = None # name of the model of this field - comodel_name: str | None = None # name of the model of values (if relational) + name: str # name of the field + model_name: str | None = None # name of the model of this field + comodel_name: str | None = None # name of the model of values (if relational) - store = True # whether the field is stored in database - index = None # how the field is indexed in database - manual = False # whether the field is a custom field - copy = True # whether the field is copied over by BaseModel.copy() - _depends = None # collection of field dependencies - _depends_context = None # collection of context key dependencies - recursive = False # whether self depends on itself - compute = None # compute(recs) computes field on recs - compute_sudo = False # whether field should be recomputed as superuser - precompute = False # whether field has to be computed before creation - inverse = None # inverse(recs) inverses field on recs - search = None # search(recs, operator, value) searches on self - related = None # sequence of field names, for related fields - company_dependent = False # whether ``self`` is company-dependent (property field) - default = None # default(recs) returns the default value + store = True # whether the field is stored in database + index = None # how the field is indexed in database + manual = False # whether the field is a custom field + copy = True # whether the field is copied over by BaseModel.copy() + _depends = None # collection of field dependencies + _depends_context = None # collection of context key dependencies + recursive = False # whether self depends on itself + compute = None # compute(recs) computes field on recs + compute_sudo = False # whether field should be recomputed as superuser + precompute = False # whether field has to be computed before creation + inverse = None # inverse(recs) inverses field on recs + search = None # search(recs, operator, value) searches on self + related = None # sequence of field names, for related fields + company_dependent = False # whether ``self`` is company-dependent (property field) + default = None # default(recs) returns the default value - string: str | None = None # field label - export_string_translation = True # whether the field label translations are exported - help: str | None = None # field tooltip - readonly = False # whether the field is readonly - required = False # whether the field is required - groups: str | None = None # csv list of group xml ids - change_default = False # whether the field may trigger a "user-onchange" + string: str | None = None # field label + export_string_translation = ( + True # whether the field label translations are exported + ) + help: str | None = None # field tooltip + readonly = False # whether the field is readonly + required = False # whether the field is required + groups: str | None = None # csv list of group xml ids + change_default = False # whether the field may trigger a "user-onchange" - related_field = None # corresponding related field - aggregator = None # operator for aggregating values - group_expand = None # name of method to expand groups in read_group() - prefetch = True # the prefetch group (False means no group) + related_field = None # corresponding related field + aggregator = None # operator for aggregating values + group_expand = None # name of method to expand groups in read_group() + prefetch = True # the prefetch group (False means no group) - default_export_compatible = False # whether the field must be exported by default in an import-compatible export + default_export_compatible = False # whether the field must be exported by default in an import-compatible export exportable = True def __init__(self, string: str | Sentinel = SENTINEL, **kwargs): - kwargs['string'] = string + kwargs["string"] = string self._sequence = next(_global_seq) self.args = {key: val for key, val in kwargs.items() if val is not SENTINEL} @@ -382,7 +411,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # and '_module', which makes their __dict__'s size minimal. def __set_name__(self, owner, name): - """ Perform the base setup of a field. + """Perform the base setup of a field. :param owner: the owner class of the field (the model's definition or registry class) :param name: the name of the field @@ -395,25 +424,25 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): self._module = owner._module owner._field_definitions.append(self) - if not self.args.get('related'): + if not self.args.get("related"): self._direct = True if self._direct or self._toplevel: self._setup_attrs(owner, name) if self._toplevel: # free memory, self.args and self._base_fields are no longer useful - self.__dict__.pop('args', None) - self.__dict__.pop('_base_fields', None) + self.__dict__.pop("args", None) + self.__dict__.pop("_base_fields", None) # # Setup field parameter attributes # def _get_attrs(self, model_class, name): - """ Return the field parameter attributes as a dictionary. """ + """Return the field parameter attributes as a dictionary.""" # determine all inherited field attributes attrs = {} modules = [] - for field in self.args.get('_base_fields', ()): + for field in self.args.get("_base_fields", ()): if not isinstance(self, type(field)): # 'self' overrides 'field' and their types are not compatible; # so we ignore all the parameters collected so far @@ -427,72 +456,84 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): if self._module: modules.append(self._module) - attrs['args'] = self.args - attrs['model_name'] = model_class._name - attrs['name'] = name - attrs['_module'] = modules[-1] if modules else None - attrs['_modules'] = tuple(set(modules)) + attrs["args"] = self.args + attrs["model_name"] = model_class._name + attrs["name"] = name + attrs["_module"] = modules[-1] if modules else None + attrs["_modules"] = tuple(set(modules)) # initialize ``self`` with ``attrs`` - if name == 'state': + if name == "state": # by default, `state` fields should be reset on copy - attrs['copy'] = attrs.get('copy', False) - if attrs.get('compute'): + attrs["copy"] = attrs.get("copy", False) + if attrs.get("compute"): # by default, computed fields are not stored, computed in superuser # mode if stored, not copied (unless stored and explicitly not # readonly), and readonly (unless inversible) - attrs['store'] = store = attrs.get('store', False) - attrs['compute_sudo'] = attrs.get('compute_sudo', store) - if not (attrs['store'] and not attrs.get('readonly', True)): - attrs['copy'] = attrs.get('copy', False) - attrs['readonly'] = attrs.get('readonly', not attrs.get('inverse')) - if attrs.get('related'): + attrs["store"] = store = attrs.get("store", False) + attrs["compute_sudo"] = attrs.get("compute_sudo", store) + if not (attrs["store"] and not attrs.get("readonly", True)): + attrs["copy"] = attrs.get("copy", False) + attrs["readonly"] = attrs.get("readonly", not attrs.get("inverse")) + if attrs.get("related"): # by default, related fields are not stored, computed in superuser # mode, not copied and readonly - attrs['store'] = store = attrs.get('store', False) - attrs['compute_sudo'] = attrs.get('compute_sudo', attrs.get('related_sudo', True)) - attrs['copy'] = attrs.get('copy', False) - attrs['readonly'] = attrs.get('readonly', True) - if attrs.get('precompute'): - if not attrs.get('compute') and not attrs.get('related'): - warnings.warn(f"precompute attribute doesn't make any sense on non computed field {self}") - attrs['precompute'] = False - elif not attrs.get('store'): - warnings.warn(f"precompute attribute has no impact on non stored field {self}") - attrs['precompute'] = False - if attrs.get('company_dependent'): - if attrs.get('required'): + attrs["store"] = store = attrs.get("store", False) + attrs["compute_sudo"] = attrs.get( + "compute_sudo", attrs.get("related_sudo", True) + ) + attrs["copy"] = attrs.get("copy", False) + attrs["readonly"] = attrs.get("readonly", True) + if attrs.get("precompute"): + if not attrs.get("compute") and not attrs.get("related"): + warnings.warn( + f"precompute attribute doesn't make any sense on non computed field {self}" + ) + attrs["precompute"] = False + elif not attrs.get("store"): + warnings.warn( + f"precompute attribute has no impact on non stored field {self}" + ) + attrs["precompute"] = False + if attrs.get("company_dependent"): + if attrs.get("required"): warnings.warn(f"company_dependent field {self} cannot be required") - if attrs.get('translate'): + if attrs.get("translate"): warnings.warn(f"company_dependent field {self} cannot be translated") if self.type not in COMPANY_DEPENDENT_FIELDS: - warnings.warn(f"company_dependent field {self} is not one of the allowed types {COMPANY_DEPENDENT_FIELDS}") - attrs['copy'] = attrs.get('copy', False) + warnings.warn( + f"company_dependent field {self} is not one of the allowed types {COMPANY_DEPENDENT_FIELDS}" + ) + attrs["copy"] = attrs.get("copy", False) # speed up search and on delete - attrs['index'] = attrs.get('index', 'btree_not_null') - attrs['prefetch'] = attrs.get('prefetch', 'company_dependent') - attrs['_depends_context'] = ('company',) + attrs["index"] = attrs.get("index", "btree_not_null") + attrs["prefetch"] = attrs.get("prefetch", "company_dependent") + attrs["_depends_context"] = ("company",) # parameters 'depends' and 'depends_context' are stored in attributes # '_depends' and '_depends_context', respectively - if 'depends' in attrs: - attrs['_depends'] = tuple(attrs.pop('depends')) - if 'depends_context' in attrs: - attrs['_depends_context'] = tuple(attrs.pop('depends_context')) + if "depends" in attrs: + attrs["_depends"] = tuple(attrs.pop("depends")) + if "depends_context" in attrs: + attrs["_depends_context"] = tuple(attrs.pop("depends_context")) - if 'group_operator' in attrs: - warnings.warn("Since Odoo 18, 'group_operator' is deprecated, use 'aggregator' instead", DeprecationWarning, 2) - attrs['aggregator'] = attrs.pop('group_operator') + if "group_operator" in attrs: + warnings.warn( + "Since Odoo 18, 'group_operator' is deprecated, use 'aggregator' instead", + DeprecationWarning, + 2, + ) + attrs["aggregator"] = attrs.pop("group_operator") return attrs def _setup_attrs(self, model_class, name): - """ Initialize the field parameter attributes. """ + """Initialize the field parameter attributes.""" attrs = self._get_attrs(model_class, name) # determine parameters that must be validated extra_keys = [key for key in attrs if not hasattr(self, key)] if extra_keys: - attrs['_extra_keys'] = extra_keys + attrs["_extra_keys"] = extra_keys self.__dict__.update(attrs) @@ -503,9 +544,14 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): if not self.string and not self.related: # related fields get their string from their parent field self.string = ( - name[:-4] if name.endswith('_ids') else - name[:-3] if name.endswith('_id') else name - ).replace('_', ' ').title() + ( + name[:-4] + if name.endswith("_ids") + else name[:-3] if name.endswith("_id") else name + ) + .replace("_", " ") + .title() + ) # self.default must be either None or a callable if self.default is not None and not callable(self.default): @@ -521,7 +567,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): self._setup_done = False def setup(self, model): - """ Perform the complete setup of a field. """ + """Perform the complete setup of a field.""" if not self._setup_done: # validate field params for key in self._extra_keys: @@ -531,7 +577,8 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): " parameter you may want to override the method" " _valid_field_parameter on the relevant model in order to" " allow it", - self, key + self, + key, ) if self.related: self.setup_related(model) @@ -539,10 +586,14 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): self.setup_nonrelated(model) if not isinstance(self.required, bool): - warnings.warn(f'Property {self}.required should be a boolean ({self.required}).') + warnings.warn( + f"Property {self}.required should be a boolean ({self.required})." + ) if not isinstance(self.readonly, bool): - warnings.warn(f'Property {self}.readonly should be a boolean ({self.readonly}).') + warnings.warn( + f"Property {self}.readonly should be a boolean ({self.readonly})." + ) self._setup_done = True @@ -551,11 +602,11 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def setup_nonrelated(self, model): - """ Determine the dependencies and inverse field(s) of ``self``. """ + """Determine the dependencies and inverse field(s) of ``self``.""" pass def get_depends(self, model: BaseModel): - """ Return the field's dependencies and cache dependencies. """ + """Return the field's dependencies and cache dependencies.""" if self._depends is not None: # the parameter 'depends' has priority over 'depends' on compute return self._depends, self._depends_context or () @@ -581,15 +632,15 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): depends = [] depends_context = list(self._depends_context or ()) for func in funcs: - deps = getattr(func, '_depends', ()) + deps = getattr(func, "_depends", ()) depends.extend(deps(model) if callable(deps) else deps) - depends_context.extend(getattr(func, '_depends_context', ())) + depends_context.extend(getattr(func, "_depends_context", ())) # display_name may depend on context['lang'] (`test_lp1071710`) - if self.automatic and self.name == 'display_name' and model._rec_name: + if self.automatic and self.name == "display_name" and model._rec_name: if model._fields[model._rec_name].base_field.translate: - if 'lang' not in depends_context: - depends_context.append('lang') + if "lang" not in depends_context: + depends_context.append("lang") return depends, depends_context @@ -598,12 +649,12 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def setup_related(self, model): - """ Setup the attributes of a related field. """ + """Setup the attributes of a related field.""" assert isinstance(self.related, str), self.related # determine the chain of fields, and make sure they are all set up model_name = self.model_name - for name in self.related.split('.'): + for name in self.related.split("."): field = model.pool[model_name]._fields.get(name) if field is None: raise KeyError( @@ -617,7 +668,9 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # check type consistency if self.type != field.type: - raise TypeError("Type of related field %s is inconsistent with %s" % (self, field)) + raise TypeError( + "Type of related field %s is inconsistent with %s" % (self, field) + ) # determine dependencies, compute, inverse, and search self.compute = self._compute_related @@ -636,7 +689,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): for attr, prop in self.related_attrs: # check whether 'attr' is explicitly set on self (from its field # definition), and ignore its class-level value (only a default) - if attr not in self.__dict__ and prop.startswith('_related_'): + if attr not in self.__dict__ and prop.startswith("_related_"): setattr(self, attr, getattr(field, prop)) for attr in field._extra_keys: @@ -651,21 +704,26 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # add modules from delegate and target fields; the first one ensures # that inherited fields introduced via an abstract model (_inherits # being on the abstract model) are assigned an XML id - delegate_field = model._fields[self.related.split('.')[0]] - self._modules = tuple({*self._modules, *delegate_field._modules, *field._modules}) + delegate_field = model._fields[self.related.split(".")[0]] + self._modules = tuple( + {*self._modules, *delegate_field._modules, *field._modules} + ) if self.store and self.translate: - _logger.warning("Translated stored related field (%s) will not be computed correctly in all languages", self) + _logger.warning( + "Translated stored related field (%s) will not be computed correctly in all languages", + self, + ) def traverse_related(self, record): - """ Traverse the fields of the related field `self` except for the last - one, and return it as a pair `(last_record, last_field)`. """ - for name in self.related.split('.')[:-1]: + """Traverse the fields of the related field `self` except for the last + one, and return it as a pair `(last_record, last_field)`.""" + for name in self.related.split(".")[:-1]: record = first(record[name]) return record, self.related_field def _compute_related(self, records): - """ Compute the related field ``self`` on ``records``. """ + """Compute the related field ``self`` on ``records``.""" # # Traverse fields one by one for all records, in order to take advantage # of prefetching for each field access. In order to clarify the impact @@ -693,28 +751,32 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # computation. # values = list(records) - for name in self.related.split('.')[:-1]: + for name in self.related.split(".")[:-1]: try: values = [first(value[name]) for value in values] except AccessError as e: - description = records.env['ir.model']._get(records._name).name + description = records.env["ir.model"]._get(records._name).name env = records.env - raise AccessError(env._( - "%(previous_message)s\n\nImplicitly accessed through '%(document_kind)s' (%(document_model)s).", - previous_message=e.args[0], - document_kind=description, - document_model=records._name, - )) + raise AccessError( + env._( + "%(previous_message)s\n\nImplicitly accessed through '%(document_kind)s' (%(document_model)s).", + previous_message=e.args[0], + document_kind=description, + document_model=records._name, + ) + ) # assign final values to records for record, value in zip(records, values): - record[self.name] = self._process_related(value[self.related_field.name], record.env) + record[self.name] = self._process_related( + value[self.related_field.name], record.env + ) def _process_related(self, value, env): """No transformation by default, but allows override.""" return value def _inverse_related(self, records): - """ Inverse the related field ``self`` on ``records``. """ + """Inverse the related field ``self`` on ``records``.""" # store record values, otherwise they may be lost by cache invalidation! record_value = {record: record[self.name] for record in records} for record in records: @@ -725,11 +787,11 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): target[field.name] = record_value[record] def _search_related(self, records, operator, value): - """ Determine the domain to search on field ``self``. """ + """Determine the domain to search on field ``self``.""" # This should never happen to avoid bypassing security checks # and should already be converted to (..., 'in', subquery) - assert operator not in ('any', 'not any') + assert operator not in ("any", "not any") # determine whether the related field can be null if isinstance(value, (list, tuple)): @@ -738,21 +800,20 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): value_is_null = value is False or value is None can_be_null = ( # (..., '=', False) or (..., 'not in', [truthy vals]) - (operator not in expression.NEGATIVE_TERM_OPERATORS and value_is_null) - or (operator in expression.NEGATIVE_TERM_OPERATORS and not value_is_null) - ) + operator not in expression.NEGATIVE_TERM_OPERATORS and value_is_null + ) or (operator in expression.NEGATIVE_TERM_OPERATORS and not value_is_null) def make_domain(path, model): - if '.' not in path: + if "." not in path: return [(path, operator, value)] - prefix, suffix = path.split('.', 1) + prefix, suffix = path.split(".", 1) field = model._fields[prefix] comodel = model.env[field.comodel_name] - domain = [(prefix, 'in', comodel._search(make_domain(suffix, comodel)))] - if can_be_null and field.type == 'many2one' and not field.required: - return expression.OR([domain, [(prefix, '=', False)]]) + domain = [(prefix, "in", comodel._search(make_domain(suffix, comodel)))] + if can_be_null and field.type == "many2one" and not field.required: + return expression.OR([domain, [(prefix, "=", False)]]) return domain @@ -762,20 +823,24 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return make_domain(self.related, model) # properties used by setup_related() to copy values from related field - _related_comodel_name = property(attrgetter('comodel_name')) - _related_string = property(attrgetter('string')) - _related_help = property(attrgetter('help')) - _related_groups = property(attrgetter('groups')) - _related_aggregator = property(attrgetter('aggregator')) + _related_comodel_name = property(attrgetter("comodel_name")) + _related_string = property(attrgetter("string")) + _related_help = property(attrgetter("help")) + _related_groups = property(attrgetter("groups")) + _related_aggregator = property(attrgetter("aggregator")) @lazy_property def column_type(self) -> tuple[str, str] | None: - """ Return the actual column type for this field, if stored as a column. """ - return ('jsonb', 'jsonb') if self.company_dependent or self.translate else self._column_type + """Return the actual column type for this field, if stored as a column.""" + return ( + ("jsonb", "jsonb") + if self.company_dependent or self.translate + else self._column_type + ) @property def base_field(self): - """ Return the base field of an inherited field, or ``self``. """ + """Return the base field of an inherited field, or ``self``.""" return self.inherited_field.base_field if self.inherited_field else self # @@ -784,10 +849,13 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): def get_company_dependent_fallback(self, records): assert self.company_dependent - fallback = records.env['ir.default'] \ - .with_user(SUPERUSER_ID) \ - .with_company(records.env.company) \ - ._get_model_defaults(records._name).get(self.name) + fallback = ( + records.env["ir.default"] + .with_user(SUPERUSER_ID) + .with_company(records.env.company) + ._get_model_defaults(records._name) + .get(self.name) + ) fallback = self.convert_to_cache(fallback, records, validate=False) return self.convert_to_record(fallback, records) @@ -796,7 +864,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def resolve_depends(self, registry): - """ Return the dependencies of `self` as a collection of field tuples. """ + """Return the dependencies of `self` as a collection of field tuples.""" Model0 = registry[self.model_name] for dotnames in registry.field_depends[self]: @@ -804,7 +872,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): model_name = self.model_name check_precompute = self.precompute - for index, fname in enumerate(dotnames.split('.')): + for index, fname in enumerate(dotnames.split(".")): Model = registry[model_name] if Model0._transient and not Model._transient: # modifying fields on regular models should not trigger @@ -820,12 +888,21 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): ) if field is self and index and not self.recursive: self.recursive = True - warnings.warn(f"Field {self} should be declared with recursive=True") + warnings.warn( + f"Field {self} should be declared with recursive=True" + ) # precomputed fields can depend on non-precomputed ones, as long # as they are reachable through at least one many2one field - if check_precompute and field.store and field.compute and not field.precompute: - warnings.warn(f"Field {self} cannot be precomputed as it depends on non-precomputed field {field}") + if ( + check_precompute + and field.store + and field.compute + and not field.precompute + ): + warnings.warn( + f"Field {self} cannot be precomputed as it depends on non-precomputed field {field}" + ) self.precompute = False if field_seq and not field_seq[-1]._description_searchable: @@ -845,11 +922,11 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): if not (field is self and not index): yield tuple(field_seq) - if field.type == 'one2many': + if field.type == "one2many": for inv_field in Model.pool.field_inverses[field]: yield tuple(field_seq) + (inv_field,) - if check_precompute and field.type == 'many2one': + if check_precompute and field.type == "many2one": check_precompute = False model_name = field.comodel_name @@ -860,12 +937,12 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def get_description(self, env, attributes=None): - """ Return a dictionary that describes the field ``self``. """ + """Return a dictionary that describes the field ``self``.""" desc = {} for attr, prop in self.description_attrs: if attributes is not None and attr not in attributes: continue - if not prop.startswith('_description_'): + if not prop.startswith("_description_"): continue value = getattr(self, prop) if callable(value): @@ -876,18 +953,20 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return desc # properties used by get_description() - _description_name = property(attrgetter('name')) - _description_type = property(attrgetter('type')) - _description_store = property(attrgetter('store')) - _description_manual = property(attrgetter('manual')) - _description_related = property(attrgetter('related')) - _description_company_dependent = property(attrgetter('company_dependent')) - _description_readonly = property(attrgetter('readonly')) - _description_required = property(attrgetter('required')) - _description_groups = property(attrgetter('groups')) - _description_change_default = property(attrgetter('change_default')) - _description_default_export_compatible = property(attrgetter('default_export_compatible')) - _description_exportable = property(attrgetter('exportable')) + _description_name = property(attrgetter("name")) + _description_type = property(attrgetter("type")) + _description_store = property(attrgetter("store")) + _description_manual = property(attrgetter("manual")) + _description_related = property(attrgetter("related")) + _description_company_dependent = property(attrgetter("company_dependent")) + _description_readonly = property(attrgetter("readonly")) + _description_required = property(attrgetter("required")) + _description_groups = property(attrgetter("groups")) + _description_change_default = property(attrgetter("change_default")) + _description_default_export_compatible = property( + attrgetter("default_export_compatible") + ) + _description_exportable = property(attrgetter("exportable")) def _description_depends(self, env): return env.registry.field_depends[self] @@ -914,7 +993,9 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): model = env[self.model_name] query = model._as_query(ordered=False) - groupby = self.name if self.type not in ('date', 'datetime') else f"{self.name}:month" + groupby = ( + self.name if self.type not in ("date", "datetime") else f"{self.name}:month" + ) try: model._read_group_groupby(groupby, query) return True @@ -936,26 +1017,26 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): def _description_string(self, env): if self.string and env.lang: model_name = self.base_field.model_name - field_string = env['ir.model.fields'].get_field_string(model_name) + field_string = env["ir.model.fields"].get_field_string(model_name) return field_string.get(self.name) or self.string return self.string def _description_help(self, env): if self.help and env.lang: model_name = self.base_field.model_name - field_help = env['ir.model.fields'].get_field_help(model_name) + field_help = env["ir.model.fields"].get_field_help(model_name) return field_help.get(self.name) or self.help return self.help def is_editable(self): - """ Return whether the field can be editable in a view. """ + """Return whether the field can be editable in a view.""" return not self.readonly def is_accessible(self, env): - """ Return whether the field is accessible from the given environment. """ + """Return whether the field is accessible from the given environment.""" if not self.groups or env.is_superuser(): return True - if self.groups == '.': + if self.groups == ".": return False return env.user.has_groups(self.groups) @@ -965,7 +1046,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def convert_to_column(self, value, record, values=None, validate=True): - """ Convert ``value`` from the ``write`` format to the SQL parameter + """Convert ``value`` from the ``write`` format to the SQL parameter format for SQL conditions. This is used to compare a field's value when the field actually stores multiple values (translated or company-dependent). """ @@ -979,20 +1060,22 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return str(value) def convert_to_column_insert(self, value, record, values=None, validate=True): - """ Convert ``value`` from the ``write`` format to the SQL parameter + """Convert ``value`` from the ``write`` format to the SQL parameter format for INSERT queries. This method handles the case of fields that store multiple values (translated or company-dependent). """ value = self.convert_to_column(value, record, values, validate) if not self.company_dependent: return value - fallback = record.env['ir.default']._get_model_defaults(record._name).get(self.name) + fallback = ( + record.env["ir.default"]._get_model_defaults(record._name).get(self.name) + ) if value == self.convert_to_column(fallback, record): return None return PsycopgJson({record.env.company.id: value}) def convert_to_column_update(self, value, record): - """ Convert ``value`` from the ``to_flush`` format to the SQL parameter + """Convert ``value`` from the ``to_flush`` format to the SQL parameter format for UPDATE queries. The ``to_flush`` format is the same as the cache format, except for translated fields (``{'lang_code': 'value', ...}`` or ``None``) and company-dependent fields (``{company_id: value, ...}``). @@ -1005,7 +1088,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): ) def convert_to_cache(self, value, record, validate=True): - """ Convert ``value`` to the cache format; ``value`` may come from an + """Convert ``value`` to the cache format; ``value`` may come from an assignment, or have the format of methods :meth:`BaseModel.read` or :meth:`BaseModel.write`. If the value represents a recordset, it should be added for prefetching on ``record``. @@ -1018,14 +1101,14 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return value def convert_to_record(self, value, record): - """ Convert ``value`` from the cache format to the record format. + """Convert ``value`` from the cache format to the record format. If the value represents a recordset, it should share the prefetching of ``record``. """ return False if value is None else value def convert_to_record_multi(self, values, records): - """ Convert a list of values from the cache format to the record format. + """Convert a list of values from the cache format to the record format. Some field classes may override this method to add optimizations for batch processing. """ @@ -1034,7 +1117,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return [convert(value, record) for value, record in zip(values, records)] def convert_to_read(self, value, record, use_display_name=True): - """ Convert ``value`` from the record format to the format returned by + """Convert ``value`` from the record format to the format returned by method :meth:`BaseModel.read`. :param value: @@ -1045,7 +1128,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return False if value is None else value def convert_to_write(self, value, record): - """ Convert ``value`` from any format to the format of method + """Convert ``value`` from any format to the format of method :meth:`BaseModel.write`. """ cache_value = self.convert_to_cache(value, record, validate=False) @@ -1053,13 +1136,13 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return self.convert_to_read(record_value, record) def convert_to_export(self, value, record): - """ Convert ``value`` from the record format to the export format. """ + """Convert ``value`` from the record format to the export format.""" if not value: - return '' + return "" return value def convert_to_display_name(self, value, record): - """ Convert ``value`` from the record format to a suitable display name. """ + """Convert ``value`` from the record format to a suitable display name.""" return str(value) if value else False ############################################################################ @@ -1069,15 +1152,19 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): @property def column_order(self): - """ Prescribed column order in table. """ - return 0 if self.column_type is None else sql.SQL_ORDER_BY_TYPE[self.column_type[0]] + """Prescribed column order in table.""" + return ( + 0 + if self.column_type is None + else sql.SQL_ORDER_BY_TYPE[self.column_type[0]] + ) def update_db(self, model, columns): - """ Update the database schema to implement this field. + """Update the database schema to implement this field. - :param model: an instance of the field's model - :param columns: a dict mapping column names to their configuration in database - :return: ``True`` if the field must be recomputed on existing rows + :param model: an instance of the field's model + :param columns: a dict mapping column names to their configuration in database + :return: ``True`` if the field must be recomputed on existing rows """ if not self.column_type: return @@ -1092,15 +1179,20 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # optimization for computing simple related fields like 'foo_id.bar' if ( not column - and self.related and self.related.count('.') == 1 - and self.related_field.store and not self.related_field.compute - and not (self.related_field.type == 'binary' and self.related_field.attachment) - and self.related_field.type not in ('one2many', 'many2many') + and self.related + and self.related.count(".") == 1 + and self.related_field.store + and not self.related_field.compute + and not ( + self.related_field.type == "binary" and self.related_field.attachment + ) + and self.related_field.type not in ("one2many", "many2many") ): - join_field = model._fields[self.related.split('.')[0]] + join_field = model._fields[self.related.split(".")[0]] if ( - join_field.type == 'many2one' - and join_field.store and not join_field.compute + join_field.type == "many2one" + and join_field.store + and not join_field.compute ): model.pool.post_init(self.update_db_related, model) # discard the "classical" computation @@ -1109,32 +1201,34 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return not column def update_db_column(self, model, column): - """ Create/update the column corresponding to ``self``. + """Create/update the column corresponding to ``self``. - :param model: an instance of the field's model - :param column: the column's configuration (dict) if it exists, or ``None`` + :param model: an instance of the field's model + :param column: the column's configuration (dict) if it exists, or ``None`` """ if not column: # the column does not exist, create it - sql.create_column(model._cr, model._table, self.name, self.column_type[1], self.string) + sql.create_column( + model._cr, model._table, self.name, self.column_type[1], self.string + ) return - if column['udt_name'] == self.column_type[0]: + if column["udt_name"] == self.column_type[0]: return - if column['is_nullable'] == 'NO': + if column["is_nullable"] == "NO": sql.drop_not_null(model._cr, model._table, self.name) self._convert_db_column(model, column) def _convert_db_column(self, model, column): - """ Convert the given database column to the type of the field. """ + """Convert the given database column to the type of the field.""" sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) def update_db_notnull(self, model, column): - """ Add or remove the NOT NULL constraint on ``self``. + """Add or remove the NOT NULL constraint on ``self``. - :param model: an instance of the field's model - :param column: the column's configuration (dict) if it exists, or ``None`` + :param model: an instance of the field's model + :param column: the column's configuration (dict) if it exists, or ``None`` """ - has_notnull = column and column['is_nullable'] == 'NO' + has_notnull = column and column["is_nullable"] == "NO" if not column or (self.required and not has_notnull): # the column is new or it becomes required; initialize its values @@ -1153,20 +1247,22 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): sql.drop_not_null(model._cr, model._table, self.name) def update_db_related(self, model): - """ Compute a stored related field directly in SQL. """ + """Compute a stored related field directly in SQL.""" comodel = model.env[self.related_field.model_name] - join_field, comodel_field = self.related.split('.') - model.env.cr.execute(SQL( - """ UPDATE %(model_table)s AS x + join_field, comodel_field = self.related.split(".") + model.env.cr.execute( + SQL( + """ UPDATE %(model_table)s AS x SET %(model_field)s = y.%(comodel_field)s FROM %(comodel_table)s AS y WHERE x.%(join_field)s = y.id """, - model_table=SQL.identifier(model._table), - model_field=SQL.identifier(self.name), - comodel_table=SQL.identifier(comodel._table), - comodel_field=SQL.identifier(comodel_field), - join_field=SQL.identifier(join_field), - )) + model_table=SQL.identifier(model._table), + model_field=SQL.identifier(self.name), + comodel_table=SQL.identifier(comodel._table), + comodel_field=SQL.identifier(comodel_field), + join_field=SQL.identifier(join_field), + ) + ) ############################################################################ # @@ -1176,12 +1272,12 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def read(self, records): - """ Read the value of ``self`` on ``records``, and store it in cache. """ + """Read the value of ``self`` on ``records``, and store it in cache.""" if not self.column_type: raise NotImplementedError("Method read() undefined on %s" % self) def create(self, record_values): - """ Write the value of ``self`` on the given records, which have just + """Write the value of ``self`` on the given records, which have just been created. :param record_values: a list of pairs ``(record, value)``, where @@ -1191,7 +1287,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): self.write(record, value) def write(self, records, value): - """ Write the value of ``self`` on ``records``. This method must update + """Write the value of ``self`` on ``records``. This method must update the cache and prepare database updates. :param records: @@ -1217,9 +1313,9 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def __get__(self, record: BaseModel, owner=None) -> T: - """ return the value of field ``self`` on ``record`` """ + """return the value of field ``self`` on ``record``""" if record is None: - return self # the field is accessed through the owner class + return self # the field is accessed through the owner class if not record._ids: # null record -> return the null value for this field @@ -1269,18 +1365,18 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): raise record._fetch_field(self) if not env.cache.contains(record, self): - raise MissingError("\n".join([ - env._("Record does not exist or has been deleted."), - env._("(Record: %(record)s, User: %(user)s)", record=record, user=env.uid), - ])) from None - value = env.cache.get(record, self) + value = self.convert_to_cache(False, record, validate=False) + else: + value = env.cache.get(record, self) elif self.store and record._origin and not (self.compute and self.readonly): # new record with origin: fetch from origin - value = self.convert_to_cache(record._origin[self.name], record, validate=False) + value = self.convert_to_cache( + record._origin[self.name], record, validate=False + ) value = env.cache.patch_and_set(record, self, value) - elif self.compute: #pylint: disable=using-constant-test + elif self.compute: # pylint: disable=using-constant-test # non-stored field or new record without origin: compute if env.is_protected(self, record): value = self.convert_to_cache(False, record, validate=False) @@ -1297,25 +1393,29 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): if missing_recs_ids: missing_recs = record.browse(missing_recs_ids) if self.readonly and not self.store: - raise ValueError(f"Compute method failed to assign {missing_recs}.{self.name}") + raise ValueError( + f"Compute method failed to assign {missing_recs}.{self.name}" + ) # fallback to null value if compute gives nothing, do it for every unset record false_value = self.convert_to_cache(False, record, validate=False) env.cache.update(missing_recs, self, itertools.repeat(false_value)) value = env.cache.get(record, self) - elif self.type == 'many2one' and self.delegate and not record.id: + elif self.type == "many2one" and self.delegate and not record.id: # parent record of a new record: new record, with the same # values as record for the corresponding inherited fields def is_inherited_field(name): field = record._fields[name] - return field.inherited and field.related.split('.')[0] == self.name + return field.inherited and field.related.split(".")[0] == self.name - parent = record.env[self.comodel_name].new({ - name: value - for name, value in record._cache.items() - if is_inherited_field(name) - }) + parent = record.env[self.comodel_name].new( + { + name: value + for name, value in record._cache.items() + if is_inherited_field(name) + } + ) # in case the delegate field has inverse one2many fields, this # updates the inverse fields as well record._update_cache({self.name: parent}, validate=False) @@ -1339,13 +1439,13 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return self.convert_to_record(value, record) def mapped(self, records): - """ Return the values of ``self`` for ``records``, either as a list + """Return the values of ``self`` for ``records``, either as a list (scalar fields), or as a recordset (relational fields). This method is meant to be used internally and has very little benefit over a simple call to `~odoo.models.BaseModel.mapped()` on a recordset. """ - if self.name == 'id': + if self.name == "id": # not stored in cache return list(records._ids) @@ -1361,14 +1461,16 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # many records as possible. If not done this way, scenarios such as # [rec.line_ids.mapped('name') for rec in recs] would generate one # query per record in `recs`! - remaining = records.__class__(records.env, records._ids[len(vals):], records._prefetch_ids) + remaining = records.__class__( + records.env, records._ids[len(vals) :], records._prefetch_ids + ) self.__get__(first(remaining)) vals += records.env.cache.get_until_miss(remaining, self) return self.convert_to_record_multi(vals, records) def __set__(self, records, value): - """ set the value of field ``self`` on ``records`` """ + """set the value of field ``self`` on ``records``""" protected_ids = [] new_ids = [] other_ids = [] @@ -1382,13 +1484,19 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): if protected_ids: # records being computed: no business logic, no recomputation - protected_records = records.__class__(records.env, tuple(protected_ids), records._prefetch_ids) + protected_records = records.__class__( + records.env, tuple(protected_ids), records._prefetch_ids + ) self.write(protected_records, value) if new_ids: # new records: no business logic - new_records = records.__class__(records.env, tuple(new_ids), records._prefetch_ids) - with records.env.protecting(records.pool.field_computed.get(self, [self]), new_records): + new_records = records.__class__( + records.env, tuple(new_ids), records._prefetch_ids + ) + with records.env.protecting( + records.pool.field_computed.get(self, [self]), new_records + ): if self.relational: new_records.modified([self.name], before=True) self.write(new_records, value) @@ -1396,12 +1504,14 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): if self.inherited: # special case: also assign parent records if they are new - parents = new_records[self.related.split('.')[0]] + parents = new_records[self.related.split(".")[0]] parents.filtered(lambda r: not r.id)[self.name] = value if other_ids: # base case: full business logic - records = records.__class__(records.env, tuple(other_ids), records._prefetch_ids) + records = records.__class__( + records.env, tuple(other_ids), records._prefetch_ids + ) write_value = self.convert_to_write(value, records) records.write({self.name: write_value}) @@ -1411,7 +1521,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): # def recompute(self, records): - """ Process the pending computations of ``self`` on ``records``. This + """Process the pending computations of ``self`` on ``records``. This should be called only if ``self`` is computed and stored. """ to_compute_ids = records.env.transaction.tocompute.get(self) @@ -1419,7 +1529,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): return def apply_except_missing(func, records): - """ Apply `func` on `records`, with a fallback ignoring non-existent records. """ + """Apply `func` on `records`, with a fallback ignoring non-existent records.""" try: func(records) except MissingError: @@ -1453,7 +1563,7 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): self.compute_value(record) def compute_value(self, records): - """ Invoke the compute method on ``records``; the results are in cache. """ + """Invoke the compute method on ``records``; the results are in cache.""" env = records.env if self.compute_sudo: records = records.sudo() @@ -1478,18 +1588,19 @@ class Field(MetaField('DummyField', (object,), {}), typing.Generic[T]): raise def determine_inverse(self, records): - """ Given the value of ``self`` on ``records``, inverse the computation. """ + """Given the value of ``self`` on ``records``, inverse the computation.""" determine(self.inverse, records) def determine_domain(self, records, operator, value): - """ Return a domain representing a condition on ``self``. """ + """Return a domain representing a condition on ``self``.""" return determine(self.search, records, operator, value) class Boolean(Field[bool]): - """ Encapsulates a :class:`bool`. """ - type = 'boolean' - _column_type = ('bool', 'bool') + """Encapsulates a :class:`bool`.""" + + type = "boolean" + _column_type = ("bool", "bool") def convert_to_column(self, value, record, values=None, validate=True): return bool(value) @@ -1507,17 +1618,18 @@ class Boolean(Field[bool]): class Integer(Field[int]): - """ Encapsulates an :class:`int`. """ - type = 'integer' - _column_type = ('int4', 'int4') + """Encapsulates an :class:`int`.""" - aggregator = 'sum' + type = "integer" + _column_type = ("int4", "int4") + + aggregator = "sum" def _get_attrs(self, model_class, name): res = super()._get_attrs(model_class, name) # The default aggregator is None for sequence fields - if 'aggregator' not in res and name == 'sequence': - res['aggregator'] = None + if "aggregator" not in res and name == "sequence": + res["aggregator"] = None return res def convert_to_column(self, value, record, values=None, validate=True): @@ -1531,7 +1643,7 @@ class Integer(Field[int]): def convert_to_cache(self, value, record, validate=True): if isinstance(value, dict): # special case, when an integer field is used as inverse for a one2many - return value.get('id', None) + return value.get("id", None) return int(value or 0) def convert_to_record(self, value, record): @@ -1552,11 +1664,11 @@ class Integer(Field[int]): def convert_to_export(self, value, record): if value or value == 0: return value - return '' + return "" class Float(Field[float]): - """ Encapsulates a :class:`float`. + """Encapsulates a :class:`float`. The precision digits are given by the (optional) ``digits`` attribute. @@ -1595,11 +1707,16 @@ class Float(Field[float]): if result > 0, the first float is greater than the second """ - type = 'float' - _digits = None # digits argument passed to class initializer - aggregator = 'sum' + type = "float" + _digits = None # digits argument passed to class initializer + aggregator = "sum" - def __init__(self, string: str | Sentinel = SENTINEL, digits: str | tuple[int, int] | None | Sentinel = SENTINEL, **kwargs): + def __init__( + self, + string: str | Sentinel = SENTINEL, + digits: str | tuple[int, int] | None | Sentinel = SENTINEL, + **kwargs, + ): super(Float, self).__init__(string=string, _digits=digits, **kwargs) @property @@ -1609,17 +1726,20 @@ class Float(Field[float]): # with all significant digits. # FLOAT8 type is still the default when there is no precision because it # is faster for most operations (sums, etc.) - return ('numeric', 'numeric') if self._digits is not None else \ - ('float8', 'double precision') + return ( + ("numeric", "numeric") + if self._digits is not None + else ("float8", "double precision") + ) def get_digits(self, env): if isinstance(self._digits, str): - precision = env['decimal.precision'].precision_get(self._digits) + precision = env["decimal.precision"].precision_get(self._digits) return 16, precision else: return self._digits - _related__digits = property(attrgetter('_digits')) + _related__digits = property(attrgetter("_digits")) def _description_digits(self, env): return self.get_digits(env) @@ -1651,7 +1771,7 @@ class Float(Field[float]): def convert_to_export(self, value, record): if value or value == 0.0: return value - return '' + return "" round = staticmethod(float_round) is_zero = staticmethod(float_is_zero) @@ -1659,7 +1779,7 @@ class Float(Field[float]): class Monetary(Field[float]): - """ Encapsulates a :class:`float` expressed in a given + """Encapsulates a :class:`float` expressed in a given :class:`res_currency`. The decimal precision and currency symbol are taken from the ``currency_field`` attribute. @@ -1668,38 +1788,56 @@ class Monetary(Field[float]): holding the :class:`res_currency ` this monetary field is expressed in (default: `\'currency_id\'`) """ - type = 'monetary' + + type = "monetary" write_sequence = 10 - _column_type = ('numeric', 'numeric') + _column_type = ("numeric", "numeric") currency_field = None - aggregator = 'sum' + aggregator = "sum" - def __init__(self, string: str | Sentinel = SENTINEL, currency_field: str | Sentinel = SENTINEL, **kwargs): - super(Monetary, self).__init__(string=string, currency_field=currency_field, **kwargs) + def __init__( + self, + string: str | Sentinel = SENTINEL, + currency_field: str | Sentinel = SENTINEL, + **kwargs, + ): + super(Monetary, self).__init__( + string=string, currency_field=currency_field, **kwargs + ) def _description_currency_field(self, env): return self.get_currency_field(env[self.model_name]) def get_currency_field(self, model): - """ Return the name of the currency field. """ + """Return the name of the currency field.""" return self.currency_field or ( - 'currency_id' if 'currency_id' in model._fields else - 'x_currency_id' if 'x_currency_id' in model._fields else - None + "currency_id" + if "currency_id" in model._fields + else "x_currency_id" if "x_currency_id" in model._fields else None ) def setup_nonrelated(self, model): super().setup_nonrelated(model) - assert self.get_currency_field(model) in model._fields, \ - "Field %s with unknown currency_field %r" % (self, self.get_currency_field(model)) + assert ( + self.get_currency_field(model) in model._fields + ), "Field %s with unknown currency_field %r" % ( + self, + self.get_currency_field(model), + ) def setup_related(self, model): super().setup_related(model) if self.inherited: - self.currency_field = self.related_field.get_currency_field(model.env[self.related_field.model_name]) - assert self.get_currency_field(model) in model._fields, \ - "Field %s with unknown currency_field %r" % (self, self.get_currency_field(model)) + self.currency_field = self.related_field.get_currency_field( + model.env[self.related_field.model_name] + ) + assert ( + self.get_currency_field(model) in model._fields + ), "Field %s with unknown currency_field %r" % ( + self, + self.get_currency_field(model), + ) def convert_to_column_insert(self, value, record, values=None, validate=True): # retrieve currency from values or record @@ -1708,8 +1846,12 @@ class Monetary(Field[float]): if values and currency_field_name in values: dummy = record.new({currency_field_name: values[currency_field_name]}) currency = dummy[currency_field_name] - elif values and currency_field.related and currency_field.related.split('.')[0] in values: - related_field_name = currency_field.related.split('.')[0] + elif ( + values + and currency_field.related + and currency_field.related.split(".")[0] in values + ): + related_field_name = currency_field.related.split(".")[0] dummy = record.new({related_field_name: values[related_field_name]}) currency = dummy[currency_field_name] else: @@ -1717,7 +1859,9 @@ class Monetary(Field[float]): # currencies, which is functional nonsense and should not happen # BEWARE: do not prefetch other fields, because 'value' may be in # cache, and would be overridden by the value read from database! - currency = record[:1].with_context(prefetch_fields=False)[currency_field_name] + currency = record[:1].with_context(prefetch_fields=False)[ + currency_field_name + ] currency = currency.with_env(record.env) value = float(value or 0.0) @@ -1736,7 +1880,10 @@ class Monetary(Field[float]): currency_field = self.get_currency_field(record) currency = record.sudo().with_context(prefetch_fields=False)[currency_field] if len(currency) > 1: - raise ValueError("Got multiple currencies while assigning values of monetary field %s" % str(self)) + raise ValueError( + "Got multiple currencies while assigning values of monetary field %s" + % str(self) + ) elif currency: value = currency.with_env(record.env).round(value) return value @@ -1753,34 +1900,37 @@ class Monetary(Field[float]): def convert_to_export(self, value, record): if value or value == 0.0: return value - return '' + return "" class _String(Field[str | typing.Literal[False]]): - """ Abstract class for string fields. """ - translate = False # whether the field is translated - size = None # maximum size of values (deprecated) + """Abstract class for string fields.""" + + translate = False # whether the field is translated + size = None # maximum size of values (deprecated) def __init__(self, string: str | Sentinel = SENTINEL, **kwargs): # translate is either True, False, or a callable - if 'translate' in kwargs and not callable(kwargs['translate']): - kwargs['translate'] = bool(kwargs['translate']) + if "translate" in kwargs and not callable(kwargs["translate"]): + kwargs["translate"] = bool(kwargs["translate"]) super(_String, self).__init__(string=string, **kwargs) - _related_translate = property(attrgetter('translate')) + _related_translate = property(attrgetter("translate")) def _description_translate(self, env): return bool(self.translate) def _convert_db_column(self, model, column): # specialized implementation for converting from/to translated fields - if self.translate or column['udt_name'] == 'jsonb': - sql.convert_column_translatable(model._cr, model._table, self.name, self.column_type[1]) + if self.translate or column["udt_name"] == "jsonb": + sql.convert_column_translatable( + model._cr, model._table, self.name, self.column_type[1] + ) else: sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) def get_trans_terms(self, value): - """ Return the sequence of terms to translate found in `value`. """ + """Return the sequence of terms to translate found in `value`.""" if not callable(self.translate): return [value] if value else [] terms = [] @@ -1788,8 +1938,8 @@ class _String(Field[str | typing.Literal[False]]): return terms def get_text_content(self, term): - """ Return the textual content for the given term. """ - func = getattr(self.translate, 'get_text_content', lambda term: term) + """Return the textual content for the given term.""" + func = getattr(self.translate, "get_text_content", lambda term: term) return func(term) def convert_to_column(self, value, record, values=None, validate=True): @@ -1800,7 +1950,7 @@ class _String(Field[str | typing.Literal[False]]): value = self.convert_to_column(value, record, values, validate) if value is None: return None - return PsycopgJson({'en_US': value, record.env.lang or 'en_US': value}) + return PsycopgJson({"en_US": value, record.env.lang or "en_US": value}) return super().convert_to_column_insert(value, record, values, validate) def convert_to_column_update(self, value, record): @@ -1816,7 +1966,7 @@ class _String(Field[str | typing.Literal[False]]): s = value.decode() else: s = str(value) - value = s[:self.size] + value = s[: self.size] if callable(self.translate): # pylint: disable=not-callable value = self.translate(lambda t: None, value) @@ -1825,39 +1975,51 @@ class _String(Field[str | typing.Literal[False]]): def convert_to_record(self, value, record): if value is None: return False - if callable(self.translate) and record.env.context.get('edit_translations'): + if callable(self.translate) and record.env.context.get("edit_translations"): if not self.get_trans_terms(value): return value base_lang = record._get_base_lang() - lang = record.env.lang or 'en_US' + lang = record.env.lang or "en_US" if lang != base_lang: - base_value = record.with_context(edit_translations=None, check_translations=True, lang=base_lang)[self.name] + base_value = record.with_context( + edit_translations=None, check_translations=True, lang=base_lang + )[self.name] base_terms_iter = iter(self.get_trans_terms(base_value)) get_base = lambda term: next(base_terms_iter) else: get_base = lambda term: term - delay_translation = value != record.with_context(edit_translations=None, check_translations=None, lang=lang)[self.name] + delay_translation = ( + value + != record.with_context( + edit_translations=None, check_translations=None, lang=lang + )[self.name] + ) # use a wrapper to let the frontend js code identify each term and # its metadata in the 'edit_translations' context def translate_func(term): source_term = get_base(term) - translation_state = 'translated' if lang == base_lang or source_term != term else 'to_translate' + translation_state = ( + "translated" + if lang == base_lang or source_term != term + else "to_translate" + ) translation_source_sha = sha256(source_term.encode()).hexdigest() return ( - '' - f'{term}' - '' + "" + f"{term}" + "" ) + # pylint: disable=not-callable value = self.translate(translate_func, value) return value @@ -1866,7 +2028,7 @@ class _String(Field[str | typing.Literal[False]]): return value def get_translation_dictionary(self, from_lang_value, to_lang_values): - """ Build a dictionary from terms in from_lang_value to terms in to_lang_values + """Build a dictionary from terms in from_lang_value to terms in to_lang_values :param str from_lang_value: from xml/html :param dict to_lang_values: {lang: lang_value} @@ -1879,7 +2041,9 @@ class _String(Field[str | typing.Literal[False]]): dictionary = defaultdict(lambda: defaultdict(dict)) if not from_lang_terms: return dictionary - dictionary.update({from_lang_term: defaultdict(dict) for from_lang_term in from_lang_terms}) + dictionary.update( + {from_lang_term: defaultdict(dict) for from_lang_term in from_lang_terms} + ) for lang, to_lang_value in to_lang_values.items(): to_lang_terms = self.get_trans_terms(to_lang_value) @@ -1898,24 +2062,26 @@ class _String(Field[str | typing.Literal[False]]): # assert (self.translate and self.store and record) record.flush_recordset([self.name]) cr = record.env.cr - cr.execute(SQL( - "SELECT %s FROM %s WHERE id = %s", - SQL.identifier(self.name), - SQL.identifier(record._table), - record.id, - )) + cr.execute( + SQL( + "SELECT %s FROM %s WHERE id = %s", + SQL.identifier(self.name), + SQL.identifier(record._table), + record.id, + ) + ) res = cr.fetchone() return res[0] if res else None def get_translation_fallback_langs(self, env): - lang = (env.lang or 'en_US') if self.translate is True else env._lang - if lang == '_en_US': - return '_en_US', 'en_US' - if lang == 'en_US': - return ('en_US',) - if lang.startswith('_'): - return lang, lang[1:], '_en_US', 'en_US' - return lang, 'en_US' + lang = (env.lang or "en_US") if self.translate is True else env._lang + if lang == "_en_US": + return "_en_US", "en_US" + if lang == "en_US": + return ("en_US",) + if lang.startswith("_"): + return lang, lang[1:], "_en_US", "en_US" + return lang, "en_US" def write(self, records, value): if not self.translate or value is False or value is None: @@ -1933,11 +2099,20 @@ class _String(Field[str | typing.Literal[False]]): dirty_records.flush_recordset([self.name]) dirty = self.store and any(records._ids) - lang = (records.env.lang or 'en_US') if self.translate is True else records.env._lang + lang = ( + (records.env.lang or "en_US") + if self.translate is True + else records.env._lang + ) # not dirty fields if not dirty: - cache.update_raw(records, self, [{lang: cache_value} for _id in records._ids], dirty=False) + cache.update_raw( + records, + self, + [{lang: cache_value} for _id in records._ids], + dirty=False, + ) return # model translation @@ -1946,78 +2121,112 @@ class _String(Field[str | typing.Literal[False]]): clean_records = records - cache.get_dirty_records(records, self) clean_records.invalidate_recordset([self.name]) cache.update(records, self, itertools.repeat(cache_value), dirty=True) - if lang != 'en_US' and not records.env['res.lang']._get_data(code='en_US'): + if lang != "en_US" and not records.env["res.lang"]._get_data(code="en_US"): # if 'en_US' is not active, we always write en_US to make sure value_en is meaningful - cache.update(records.with_context(lang='en_US'), self, itertools.repeat(cache_value), dirty=True) + cache.update( + records.with_context(lang="en_US"), + self, + itertools.repeat(cache_value), + dirty=True, + ) return # model term translation new_translations_list = [] new_terms = set(self.get_trans_terms(cache_value)) - delay_translations = records.env.context.get('delay_translations') + delay_translations = records.env.context.get("delay_translations") for record in records: # shortcut when no term needs to be translated if not new_terms: - new_translations_list.append({'en_US': cache_value, lang: cache_value}) + new_translations_list.append({"en_US": cache_value, lang: cache_value}) continue # _get_stored_translations can be refactored and prefetches translations for multi records, # but it is really rare to write the same non-False/None/no-term value to multi records stored_translations = self._get_stored_translations(record) if not stored_translations: - new_translations_list.append({'en_US': cache_value, lang: cache_value}) + new_translations_list.append({"en_US": cache_value, lang: cache_value}) continue old_translations = { - k: stored_translations.get(f'_{k}', v) + k: stored_translations.get(f"_{k}", v) for k, v in stored_translations.items() - if not k.startswith('_') + if not k.startswith("_") } - from_lang_value = old_translations.pop(lang, old_translations['en_US']) - translation_dictionary = self.get_translation_dictionary(from_lang_value, old_translations) + from_lang_value = old_translations.pop(lang, old_translations["en_US"]) + translation_dictionary = self.get_translation_dictionary( + from_lang_value, old_translations + ) text2terms = defaultdict(list) for term in new_terms: text2terms[self.get_text_content(term)].append(term) - is_text = self.translate.is_text if hasattr(self.translate, 'is_text') else lambda term: True - term_adapter = self.translate.term_adapter if hasattr(self.translate, 'term_adapter') else None + is_text = ( + self.translate.is_text + if hasattr(self.translate, "is_text") + else lambda term: True + ) + term_adapter = ( + self.translate.term_adapter + if hasattr(self.translate, "term_adapter") + else None + ) for old_term in list(translation_dictionary.keys()): if old_term not in new_terms: old_term_text = self.get_text_content(old_term) matches = get_close_matches(old_term_text, text2terms, 1, 0.9) if matches: - closest_term = get_close_matches(old_term, text2terms[matches[0]], 1, 0)[0] + closest_term = get_close_matches( + old_term, text2terms[matches[0]], 1, 0 + )[0] if closest_term in translation_dictionary: continue old_is_text = is_text(old_term) closest_is_text = is_text(closest_term) if old_is_text or not closest_is_text: - if not closest_is_text and records.env.context.get("install_mode") and lang == 'en_US' and term_adapter: + if ( + not closest_is_text + and records.env.context.get("install_mode") + and lang == "en_US" + and term_adapter + ): adapter = term_adapter(closest_term) - translation_dictionary[closest_term] = {k: adapter(v) for k, v in translation_dictionary.pop(old_term).items()} + translation_dictionary[closest_term] = { + k: adapter(v) + for k, v in translation_dictionary.pop( + old_term + ).items() + } else: - translation_dictionary[closest_term] = translation_dictionary.pop(old_term) + translation_dictionary[closest_term] = ( + translation_dictionary.pop(old_term) + ) # pylint: disable=not-callable new_translations = { - l: self.translate(lambda term: translation_dictionary.get(term, {l: None})[l], cache_value) + l: self.translate( + lambda term: translation_dictionary.get(term, {l: None})[l], + cache_value, + ) for l in old_translations.keys() } if delay_translations: new_store_translations = stored_translations - new_store_translations.update({f'_{k}': v for k, v in new_translations.items()}) - new_store_translations.pop(f'_{lang}', None) + new_store_translations.update( + {f"_{k}": v for k, v in new_translations.items()} + ) + new_store_translations.pop(f"_{lang}", None) else: new_store_translations = new_translations new_store_translations[lang] = cache_value - if not records.env['res.lang']._get_data(code='en_US'): - new_store_translations['en_US'] = cache_value - new_store_translations.pop('_en_US', None) + if not records.env["res.lang"]._get_data(code="en_US"): + new_store_translations["en_US"] = cache_value + new_store_translations.pop("_en_US", None) new_translations_list.append(new_store_translations) # Maybe we can use Cache.update(records.with_context(cache_update_raw=True), self, new_translations_list, dirty=True) cache.update_raw(records, self, new_translations_list, dirty=True) class Char(_String): - """ Basic string field, can be length-limited, usually displayed as a + """Basic string field, can be length-limited, usually displayed as a single-line string in clients. :param int size: the maximum size of values stored for that field @@ -2032,36 +2241,40 @@ class Char(_String): translation of terms. :type translate: bool or callable """ - type = 'char' - trim = True # whether value is trimmed (only by web client) + + type = "char" + trim = True # whether value is trimmed (only by web client) def _setup_attrs(self, model_class, name): super()._setup_attrs(model_class, name) - assert self.size is None or isinstance(self.size, int), \ - "Char field %s with non-integer size %r" % (self, self.size) + assert self.size is None or isinstance( + self.size, int + ), "Char field %s with non-integer size %r" % (self, self.size) @property def _column_type(self): - return ('varchar', pg_varchar(self.size)) + return ("varchar", pg_varchar(self.size)) def update_db_column(self, model, column): if ( - column and self.column_type[0] == 'varchar' and - column['udt_name'] == 'varchar' and column['character_maximum_length'] and - (self.size is None or column['character_maximum_length'] < self.size) + column + and self.column_type[0] == "varchar" + and column["udt_name"] == "varchar" + and column["character_maximum_length"] + and (self.size is None or column["character_maximum_length"] < self.size) ): # the column's varchar size does not match self.size; convert it sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) super().update_db_column(model, column) - _related_size = property(attrgetter('size')) - _related_trim = property(attrgetter('trim')) - _description_size = property(attrgetter('size')) - _description_trim = property(attrgetter('trim')) + _related_size = property(attrgetter("size")) + _related_trim = property(attrgetter("trim")) + _description_size = property(attrgetter("size")) + _description_trim = property(attrgetter("trim")) class Text(_String): - """ Very similar to :class:`Char` but used for longer contents, does not + """Very similar to :class:`Char` but used for longer contents, does not have a size and usually displayed as a multiline text box. :param translate: enable the translation of the field's values; use @@ -2071,12 +2284,13 @@ class Text(_String): translation of terms. :type translate: bool or callable """ - type = 'text' - _column_type = ('text', 'text') + + type = "text" + _column_type = ("text", "text") class Html(_String): - """ Encapsulates an html code content. + """Encapsulates an html code content. :param bool sanitize: whether value must be sanitized (default: ``True``) :param bool sanitize_overridable: whether the sanitation can be bypassed by @@ -2092,19 +2306,24 @@ class Html(_String): (removed and therefore not sanitized, default: ``False``) :param bool strip_classes: whether to strip classes attributes (default: ``False``) """ - type = 'html' - _column_type = ('text', 'text') - sanitize = True # whether value must be sanitized - sanitize_overridable = False # whether the sanitation can be bypassed by the users part of the `base.group_sanitize_override` group - sanitize_tags = True # whether to sanitize tags (only a white list of attributes is accepted) - sanitize_attributes = True # whether to sanitize attributes (only a white list of attributes is accepted) - sanitize_style = False # whether to sanitize style attributes - sanitize_form = True # whether to sanitize forms + type = "html" + _column_type = ("text", "text") + + sanitize = True # whether value must be sanitized + sanitize_overridable = False # whether the sanitation can be bypassed by the users part of the `base.group_sanitize_override` group + sanitize_tags = ( + True # whether to sanitize tags (only a white list of attributes is accepted) + ) + sanitize_attributes = True # whether to sanitize attributes (only a white list of attributes is accepted) + sanitize_style = False # whether to sanitize style attributes + sanitize_form = True # whether to sanitize forms sanitize_conditional_comments = True # whether to kill conditional comments. Otherwise keep them but with their content sanitized. - sanitize_output_method = 'html' # whether to sanitize using html or xhtml - strip_style = False # whether to strip style attributes (removed and therefore not sanitized) - strip_classes = False # whether to strip classes attributes + sanitize_output_method = "html" # whether to sanitize using html or xhtml + strip_style = ( + False # whether to strip style attributes (removed and therefore not sanitized) + ) + strip_classes = False # whether to strip classes attributes def _get_attrs(self, model_class, name): # called by _setup_attrs(), working together with _String._setup_attrs() @@ -2114,35 +2333,41 @@ class Html(_String): # e.g. conditional comments: no need to keep conditional comments for incoming emails, # we do not need this Microsoft Outlook client feature for emails displayed Odoo's web client. # While we need to keep them in mail templates and mass mailings, because they could be rendered in Outlook. - if attrs.get('sanitize') == 'email_outgoing': - attrs['sanitize'] = True - attrs.update({key: value for key, value in { - 'sanitize_tags': False, - 'sanitize_attributes': False, - 'sanitize_conditional_comments': False, - 'sanitize_output_method': 'xml', - }.items() if key not in attrs}) + if attrs.get("sanitize") == "email_outgoing": + attrs["sanitize"] = True + attrs.update( + { + key: value + for key, value in { + "sanitize_tags": False, + "sanitize_attributes": False, + "sanitize_conditional_comments": False, + "sanitize_output_method": "xml", + }.items() + if key not in attrs + } + ) # Translated sanitized html fields must use html_translate or a callable. # `elif` intended, because HTML fields with translate=True and sanitize=False # where not using `html_translate` before and they must remain without `html_translate`. # Otherwise, breaks `--test-tags .test_render_field`, for instance. - elif attrs.get('translate') is True and attrs.get('sanitize', True): - attrs['translate'] = html_translate + elif attrs.get("translate") is True and attrs.get("sanitize", True): + attrs["translate"] = html_translate return attrs - _related_sanitize = property(attrgetter('sanitize')) - _related_sanitize_tags = property(attrgetter('sanitize_tags')) - _related_sanitize_attributes = property(attrgetter('sanitize_attributes')) - _related_sanitize_style = property(attrgetter('sanitize_style')) - _related_strip_style = property(attrgetter('strip_style')) - _related_strip_classes = property(attrgetter('strip_classes')) + _related_sanitize = property(attrgetter("sanitize")) + _related_sanitize_tags = property(attrgetter("sanitize_tags")) + _related_sanitize_attributes = property(attrgetter("sanitize_attributes")) + _related_sanitize_style = property(attrgetter("sanitize_style")) + _related_strip_style = property(attrgetter("strip_style")) + _related_strip_classes = property(attrgetter("strip_classes")) - _description_sanitize = property(attrgetter('sanitize')) - _description_sanitize_tags = property(attrgetter('sanitize_tags')) - _description_sanitize_attributes = property(attrgetter('sanitize_attributes')) - _description_sanitize_style = property(attrgetter('sanitize_style')) - _description_strip_style = property(attrgetter('strip_style')) - _description_strip_classes = property(attrgetter('strip_classes')) + _description_sanitize = property(attrgetter("sanitize")) + _description_sanitize_tags = property(attrgetter("sanitize_tags")) + _description_sanitize_attributes = property(attrgetter("sanitize_attributes")) + _description_sanitize_style = property(attrgetter("sanitize_style")) + _description_strip_style = property(attrgetter("strip_style")) + _description_strip_classes = property(attrgetter("strip_classes")) def convert_to_column(self, value, record, values=None, validate=True): value = self._convert(value, record, validate=True) @@ -2159,25 +2384,27 @@ class Html(_String): return value sanitize_vals = { - 'silent': True, - 'sanitize_tags': self.sanitize_tags, - 'sanitize_attributes': self.sanitize_attributes, - 'sanitize_style': self.sanitize_style, - 'sanitize_form': self.sanitize_form, - 'sanitize_conditional_comments': self.sanitize_conditional_comments, - 'output_method': self.sanitize_output_method, - 'strip_style': self.strip_style, - 'strip_classes': self.strip_classes + "silent": True, + "sanitize_tags": self.sanitize_tags, + "sanitize_attributes": self.sanitize_attributes, + "sanitize_style": self.sanitize_style, + "sanitize_form": self.sanitize_form, + "sanitize_conditional_comments": self.sanitize_conditional_comments, + "output_method": self.sanitize_output_method, + "strip_style": self.strip_style, + "strip_classes": self.strip_classes, } if self.sanitize_overridable: - if record.env.user.has_group('base.group_sanitize_override'): + if record.env.user.has_group("base.group_sanitize_override"): return value original_value = record[self.name] if original_value: # Note that sanitize also normalize - original_value_sanitized = html_sanitize(original_value, **sanitize_vals) + original_value_sanitized = html_sanitize( + original_value, **sanitize_vals + ) original_value_normalized = html_normalize(original_value) if ( @@ -2194,23 +2421,32 @@ class Html(_String): original_value_normalized.splitlines(), ) - with_colors = isinstance(logging.getLogger().handlers[0].formatter, ColoredFormatter) - diff_str = f'The field ({record._description}, {self.string}) will not be editable:\n' + with_colors = isinstance( + logging.getLogger().handlers[0].formatter, ColoredFormatter + ) + diff_str = f"The field ({record._description}, {self.string}) will not be editable:\n" for line in list(diff)[2:]: if with_colors: - color = {'-': RED, '+': GREEN}.get(line[:1], DEFAULT) - diff_str += COLOR_PATTERN % (30 + color, 40 + DEFAULT, line.rstrip() + "\n") + color = {"-": RED, "+": GREEN}.get(line[:1], DEFAULT) + diff_str += COLOR_PATTERN % ( + 30 + color, + 40 + DEFAULT, + line.rstrip() + "\n", + ) else: - diff_str += line.rstrip() + '\n' + diff_str += line.rstrip() + "\n" _logger.info(diff_str) - raise UserError(record.env._( - "The field value you're saving (%(model)s %(field)s) includes content that is " - "restricted for security reasons. It is possible that someone " - "with higher privileges previously modified it, and you are therefore " - "not able to modify it yourself while preserving the content.", - model=record._description, field=self.string, - )) + raise UserError( + record.env._( + "The field value you're saving (%(model)s %(field)s) includes content that is " + "restricted for security reasons. It is possible that someone " + "with higher privileges previously modified it, and you are therefore " + "not able to modify it yourself while preserving the content.", + model=record._description, + field=self.string, + ) + ) return html_sanitize(value, **sanitize_vals) @@ -2232,9 +2468,10 @@ class Html(_String): class Date(Field[date | typing.Literal[False]]): - """ Encapsulates a python :class:`date ` object. """ - type = 'date' - _column_type = ('date', 'date') + """Encapsulates a python :class:`date ` object.""" + + type = "date" + _column_type = ("date", "date") start_of = staticmethod(date_utils.start_of) end_of = staticmethod(date_utils.end_of) @@ -2264,14 +2501,18 @@ class Date(Field[date | typing.Literal[False]]): """ today = timestamp or datetime.now() context_today = None - tz_name = record._context.get('tz') or record.env.user.tz + tz_name = record._context.get("tz") or record.env.user.tz if tz_name: try: - today_utc = pytz.timezone('UTC').localize(today, is_dst=False) # UTC = no DST + today_utc = pytz.timezone("UTC").localize( + today, is_dst=False + ) # UTC = no DST context_today = today_utc.astimezone(pytz.timezone(tz_name)) except Exception: - _logger.debug("failed to compute context/client-specific today date, using UTC value for `today`", - exc_info=True) + _logger.debug( + "failed to compute context/client-specific today date, using UTC value for `today`", + exc_info=True, + ) return (context_today or today).date() @staticmethod @@ -2330,7 +2571,7 @@ class Date(Field[date | typing.Literal[False]]): def convert_to_export(self, value, record): if not value: - return '' + return "" return self.from_string(value) def convert_to_display_name(self, value, record): @@ -2338,9 +2579,10 @@ class Date(Field[date | typing.Literal[False]]): class Datetime(Field[datetime | typing.Literal[False]]): - """ Encapsulates a python :class:`datetime ` object. """ - type = 'datetime' - _column_type = ('timestamp', 'timestamp') + """Encapsulates a python :class:`datetime ` object.""" + + type = "datetime" + _column_type = ("timestamp", "timestamp") start_of = staticmethod(date_utils.start_of) end_of = staticmethod(date_utils.end_of) @@ -2376,17 +2618,19 @@ class Datetime(Field[datetime | typing.Literal[False]]): :return: timestamp converted to timezone-aware datetime in context timezone. :rtype: datetime """ - assert isinstance(timestamp, datetime), 'Datetime instance expected' - tz_name = record._context.get('tz') or record.env.user.tz + assert isinstance(timestamp, datetime), "Datetime instance expected" + tz_name = record._context.get("tz") or record.env.user.tz utc_timestamp = pytz.utc.localize(timestamp, is_dst=False) # UTC = no DST if tz_name: try: context_tz = pytz.timezone(tz_name) return utc_timestamp.astimezone(context_tz) except Exception: - _logger.debug("failed to compute context/client-specific timestamp, " - "using the UTC value", - exc_info=True) + _logger.debug( + "failed to compute context/client-specific timestamp, " + "using the UTC value", + exc_info=True, + ) return utc_timestamp @staticmethod @@ -2403,12 +2647,14 @@ class Datetime(Field[datetime | typing.Literal[False]]): if isinstance(value, date): if isinstance(value, datetime): if value.tzinfo: - raise ValueError("Datetime field expects a naive datetime: %s" % value) + raise ValueError( + "Datetime field expects a naive datetime: %s" % value + ) return value return datetime.combine(value, time.min) # TODO: fix data files - return datetime.strptime(value, DATETIME_FORMAT[:len(value)-2]) + return datetime.strptime(value, DATETIME_FORMAT[: len(value) - 2]) # kept for backwards compatibility, but consider `from_string` as deprecated, will probably # be removed after V12 @@ -2437,7 +2683,7 @@ class Datetime(Field[datetime | typing.Literal[False]]): def convert_to_export(self, value, record): if not value: - return '' + return "" value = self.convert_to_display_name(value, record) return self.from_string(value) @@ -2446,6 +2692,7 @@ class Datetime(Field[datetime | typing.Literal[False]]): return False return Datetime.to_string(Datetime.context_timestamp(record, value)) + # http://initd.org/psycopg/docs/usage.html#binary-adaptation # Received data is returned as buffer (in Python 2) or memoryview (in Python 3). _BINARY = memoryview @@ -2457,23 +2704,24 @@ class Binary(Field): :param bool attachment: whether the field should be stored as `ir_attachment` or in a column of the model's table (default: ``True``). """ - type = 'binary' - prefetch = False # not prefetched by default - _depends_context = ('bin_size',) # depends on context (content or size) - attachment = True # whether value is stored in attachment + type = "binary" + + prefetch = False # not prefetched by default + _depends_context = ("bin_size",) # depends on context (content or size) + attachment = True # whether value is stored in attachment @lazy_property def column_type(self): - return None if self.attachment else ('bytea', 'bytea') + return None if self.attachment else ("bytea", "bytea") def _get_attrs(self, model_class, name): attrs = super()._get_attrs(model_class, name) - if not attrs.get('store', True): - attrs['attachment'] = False + if not attrs.get("store", True): + attrs["attachment"] = False return attrs - _description_attachment = property(attrgetter('attachment')) + _description_attachment = property(attrgetter("attachment")) def convert_to_column(self, value, record, values=None, validate=True): # Binary values may be byte strings (python 2.6 byte array), but @@ -2487,26 +2735,36 @@ class Binary(Field): # Detect if the binary content is an SVG for restricting its upload # only to system users. magic_bytes = { - b'P', # first 6 bits of '<' (0x3C) b64 encoded - b'<', # plaintext XML tag opening + b"P", # first 6 bits of '<' (0x3C) b64 encoded + b"<", # plaintext XML tag opening } if isinstance(value, str): value = value.encode() if value[:1] in magic_bytes: try: - decoded_value = base64.b64decode(value.translate(None, delete=b'\r\n'), validate=True) + decoded_value = base64.b64decode( + value.translate(None, delete=b"\r\n"), validate=True + ) except binascii.Error: decoded_value = value # Full mimetype detection - if (guess_mimetype(decoded_value).startswith('image/svg') and - not record.env.is_system()): + if ( + guess_mimetype(decoded_value).startswith("image/svg") + and not record.env.is_system() + ): raise UserError(record.env._("Only admins can upload SVG files.")) if isinstance(value, bytes): return psycopg2.Binary(value) try: - return psycopg2.Binary(str(value).encode('ascii')) + return psycopg2.Binary(str(value).encode("ascii")) except UnicodeEncodeError: - raise UserError(record.env._("ASCII characters are required for %(value)s in %(field)s", value=value, field=self.name)) + raise UserError( + record.env._( + "ASCII characters are required for %(value)s in %(field)s", + value=value, + field=self.name, + ) + ) def convert_to_cache(self, value, record, validate=True): if isinstance(value, _BINARY): @@ -2515,9 +2773,10 @@ class Binary(Field): # the cache must contain bytes or memoryview, but sometimes a string # is given when assigning a binary field (test `TestFileSeparator`) return value.encode() - if isinstance(value, int) and \ - (record._context.get('bin_size') or - record._context.get('bin_size_' + self.name)): + if isinstance(value, int) and ( + record._context.get("bin_size") + or record._context.get("bin_size_" + self.name) + ): # If the client requests only the size of the field, we return that # instead of the content. Presumably a separate request will be done # to read the actual content, if necessary. @@ -2532,10 +2791,14 @@ class Binary(Field): return False if value is None else value def compute_value(self, records): - bin_size_name = 'bin_size_' + self.name - if records.env.context.get('bin_size') or records.env.context.get(bin_size_name): + bin_size_name = "bin_size_" + self.name + if records.env.context.get("bin_size") or records.env.context.get( + bin_size_name + ): # always compute without bin_size - records_no_bin_size = records.with_context(**{'bin_size': False, bin_size_name: False}) + records_no_bin_size = records.with_context( + **{"bin_size": False, bin_size_name: False} + ) super().compute_value(records_no_bin_size) # manually update the bin_size cache cache = records.env.cache @@ -2549,7 +2812,7 @@ class Binary(Field): try: if isinstance(value, (bytes, _BINARY)): value = human_size(len(value)) - except (TypeError): + except TypeError: pass cache_value = self.convert_to_cache(value, record) # the dirty flag is independent from this assignment @@ -2563,14 +2826,14 @@ class Binary(Field): # values are stored in attachments, retrieve them assert self.attachment domain = [ - ('res_model', '=', records._name), - ('res_field', '=', self.name), - ('res_id', 'in', records.ids), + ("res_model", "=", records._name), + ("res_field", "=", self.name), + ("res_id", "in", records.ids), ] # Note: the 'bin_size' flag is handled by the field 'datas' itself data = { att.res_id: att.datas - for att in records.env['ir.attachment'].sudo().search(domain) + for att in records.env["ir.attachment"].sudo().search(domain) } records.env.cache.insert_missing(records, self, map(data.get, records._ids)) @@ -2580,18 +2843,20 @@ class Binary(Field): return # create the attachments that store the values env = record_values[0][0].env - env['ir.attachment'].sudo().create([ - { - 'name': self.name, - 'res_model': self.model_name, - 'res_field': self.name, - 'res_id': record.id, - 'type': 'binary', - 'datas': value, - } - for record, value in record_values - if value - ]) + env["ir.attachment"].sudo().create( + [ + { + "name": self.name, + "res_model": self.model_name, + "res_field": self.name, + "res_id": record.id, + "type": "binary", + "datas": value, + } + for record, value in record_values + if value + ] + ) def write(self, records, value): records = records.with_context(bin_size=False) @@ -2616,31 +2881,36 @@ class Binary(Field): # retrieve the attachments that store the values, and adapt them if self.store and any(records._ids): - real_records = records.filtered('id') - atts = records.env['ir.attachment'].sudo() + real_records = records.filtered("id") + atts = records.env["ir.attachment"].sudo() if not_null: - atts = atts.search([ - ('res_model', '=', self.model_name), - ('res_field', '=', self.name), - ('res_id', 'in', real_records.ids), - ]) + atts = atts.search( + [ + ("res_model", "=", self.model_name), + ("res_field", "=", self.name), + ("res_id", "in", real_records.ids), + ] + ) if value: # update the existing attachments - atts.write({'datas': value}) - atts_records = records.browse(atts.mapped('res_id')) + atts.write({"datas": value}) + atts_records = records.browse(atts.mapped("res_id")) # create the missing attachments - missing = (real_records - atts_records) + missing = real_records - atts_records if missing: - atts.create([{ - 'name': self.name, - 'res_model': record._name, - 'res_field': self.name, - 'res_id': record.id, - 'type': 'binary', - 'datas': value, - } - for record in missing - ]) + atts.create( + [ + { + "name": self.name, + "res_model": record._name, + "res_field": self.name, + "res_id": record.id, + "type": "binary", + "datas": value, + } + for record in missing + ] + ) else: atts.unlink() @@ -2662,6 +2932,7 @@ class Image(Binary): If no ``max_width``/``max_height`` is specified (or is set to 0) and ``verify_resolution`` is False, the field content won't be verified at all and a :class:`Binary` field should be used. """ + max_width = 0 max_height = 0 verify_resolution = True @@ -2669,7 +2940,9 @@ class Image(Binary): def setup(self, model): super().setup(model) if not model._abstract and not model._log_access: - warnings.warn(f"Image field {self} requires the model to have _log_access = True") + warnings.warn( + f"Image field {self} requires the model to have _log_access = True" + ) def create(self, record_values): new_record_values = [] @@ -2679,7 +2952,9 @@ class Image(Binary): # when setting related image field, keep the unprocessed image in # cache to let the inverse method use the original image; the image # will be resized once the inverse has been applied - cache_value = self.convert_to_cache(value if self.related else new_value, record) + cache_value = self.convert_to_cache( + value if self.related else new_value, record + ) record.env.cache.update(record, self, itertools.repeat(cache_value)) super(Image, self).create(new_record_values) @@ -2697,9 +2972,13 @@ class Image(Binary): raise super(Image, self).write(records, new_value) - cache_value = self.convert_to_cache(value if self.related else new_value, records) + cache_value = self.convert_to_cache( + value if self.related else new_value, records + ) dirty = self.column_type and self.store and any(records._ids) - records.env.cache.update(records, self, itertools.repeat(cache_value), dirty=dirty) + records.env.cache.update( + records, self, itertools.repeat(cache_value), dirty=dirty + ) def _inverse_related(self, records): super()._inverse_related(records) @@ -2709,34 +2988,42 @@ class Image(Binary): # cache with the resized value for record in records: value = self._process_related(record[self.name], record.env) - record.env.cache.set(record, self, value, dirty=(self.store and self.column_type)) + record.env.cache.set( + record, self, value, dirty=(self.store and self.column_type) + ) def _image_process(self, value, env): if self.readonly and not self.max_width and not self.max_height: # no need to process images for computed fields, or related fields return value try: - img = base64.b64decode(value or '') or False + img = base64.b64decode(value or "") or False except: raise UserError(env._("Image is not encoded in base64.")) - if img and guess_mimetype(img, '') == 'image/webp': + if img and guess_mimetype(img, "") == "image/webp": if not self.max_width and not self.max_height: return value # Fetch resized version. - Attachment = env['ir.attachment'] + Attachment = env["ir.attachment"] checksum = Attachment._compute_checksum(img) - origins = Attachment.search([ - ['id', '!=', False], # No implicit condition on res_field. - ['checksum', '=', checksum], - ]) + origins = Attachment.search( + [ + ["id", "!=", False], # No implicit condition on res_field. + ["checksum", "=", checksum], + ] + ) if origins: origin_ids = [attachment.id for attachment in origins] resized_domain = [ - ['id', '!=', False], # No implicit condition on res_field. - ['res_model', '=', 'ir.attachment'], - ['res_id', 'in', origin_ids], - ['description', '=', 'resize: %s' % max(self.max_width, self.max_height)], + ["id", "!=", False], # No implicit condition on res_field. + ["res_model", "=", "ir.attachment"], + ["res_id", "in", origin_ids], + [ + "description", + "=", + "resize: %s" % max(self.max_width, self.max_height), + ], ] resized = Attachment.sudo().search(resized_domain, limit=1) if resized: @@ -2744,10 +3031,17 @@ class Image(Binary): return resized.datas or value return value - return base64.b64encode(image_process(img, - size=(self.max_width, self.max_height), - verify_resolution=self.verify_resolution, - ) or b'') or False + return ( + base64.b64encode( + image_process( + img, + size=(self.max_width, self.max_height), + verify_resolution=self.verify_resolution, + ) + or b"" + ) + or False + ) def _process_related(self, value, env): """Override to resize the related value before saving it on self.""" @@ -2760,7 +3054,7 @@ class Image(Binary): class Selection(Field[str | typing.Literal[False]]): - """ Encapsulates an exclusive choice between different values. + """Encapsulates an exclusive choice between different values. :param selection: specifies the possible values for this field. It is given as either a list of pairs ``(value, label)``, or a model @@ -2801,12 +3095,13 @@ class Selection(Field[str | typing.Literal[False]]): The attribute ``selection`` is mandatory except in the case of ``related`` or extended fields. """ - type = 'selection' - _column_type = ('varchar', pg_varchar()) - selection = None # [(value, string), ...], function or method name - validate = True # whether validating upon write - ondelete = None # {value: policy} (what to do when value is deleted) + type = "selection" + _column_type = ("varchar", pg_varchar()) + + selection = None # [(value, string), ...], function or method name + validate = True # whether validating upon write + ondelete = None # {value: policy} (what to do when value is deleted) def __init__(self, selection=SENTINEL, string: str | Sentinel = SENTINEL, **kwargs): super(Selection, self).__init__(selection=selection, string=string, **kwargs) @@ -2826,10 +3121,10 @@ class Selection(Field[str | typing.Literal[False]]): def _get_attrs(self, model_class, name): attrs = super()._get_attrs(model_class, name) # arguments 'selection' and 'selection_add' are processed below - attrs.pop('selection_add', None) + attrs.pop("selection_add", None) # Selection fields have an optional default implementation of a group_expand function - if attrs.get('group_expand') is True: - attrs['group_expand'] = self._default_group_expand + if attrs.get("group_expand") is True: + attrs["group_expand"] = self._default_group_expand return attrs def _setup_attrs(self, model_class, name): @@ -2843,13 +3138,22 @@ class Selection(Field[str | typing.Literal[False]]): for field in self._base_fields: # We cannot use field.selection or field.selection_add here # because those attributes are overridden by ``_setup_attrs``. - if 'selection' in field.args: + if "selection" in field.args: if self.related: - _logger.warning("%s: selection attribute will be ignored as the field is related", self) - selection = field.args['selection'] + _logger.warning( + "%s: selection attribute will be ignored as the field is related", + self, + ) + selection = field.args["selection"] if isinstance(selection, list): - if values is not None and list(values) != [kv[0] for kv in selection]: - _logger.warning("%s: selection=%r overrides existing selection; use selection_add instead", self, selection) + if values is not None and list(values) != [ + kv[0] for kv in selection + ]: + _logger.warning( + "%s: selection=%r overrides existing selection; use selection_add instead", + self, + selection, + ) values = dict(selection) self.ondelete = {} else: @@ -2857,21 +3161,32 @@ class Selection(Field[str | typing.Literal[False]]): self.selection = selection self.ondelete = None - if 'selection_add' in field.args: + if "selection_add" in field.args: if self.related: - _logger.warning("%s: selection_add attribute will be ignored as the field is related", self) - selection_add = field.args['selection_add'] - assert isinstance(selection_add, list), \ - "%s: selection_add=%r must be a list" % (self, selection_add) - assert values is not None, \ - "%s: selection_add=%r on non-list selection %r" % (self, selection_add, self.selection) + _logger.warning( + "%s: selection_add attribute will be ignored as the field is related", + self, + ) + selection_add = field.args["selection_add"] + assert isinstance( + selection_add, list + ), "%s: selection_add=%r must be a list" % (self, selection_add) + assert ( + values is not None + ), "%s: selection_add=%r on non-list selection %r" % ( + self, + selection_add, + self.selection, + ) - values_add = {kv[0]: (kv[1] if len(kv) > 1 else None) for kv in selection_add} - ondelete = field.args.get('ondelete') or {} + values_add = { + kv[0]: (kv[1] if len(kv) > 1 else None) for kv in selection_add + } + ondelete = field.args.get("ondelete") or {} new_values = [key for key in values_add if key not in values] for key in new_values: - ondelete.setdefault(key, 'set null') - if self.required and new_values and 'set null' in ondelete.values(): + ondelete.setdefault(key, "set null") + if self.required and new_values and "set null" in ondelete.values(): raise ValueError( "%r: required selection fields must define an ondelete policy that " "implements the proper cleanup of the corresponding records upon " @@ -2883,15 +3198,15 @@ class Selection(Field[str | typing.Literal[False]]): # check ondelete values for key, val in ondelete.items(): - if callable(val) or val in ('set null', 'cascade'): + if callable(val) or val in ("set null", "cascade"): continue - if val == 'set default': + if val == "set default": assert self.default is not None, ( "%r: ondelete policy of type 'set default' is invalid for this field " "as it does not define a default! Either define one in the base " "field, or change the chosen ondelete policy" % self ) - elif val.startswith('set '): + elif val.startswith("set "): assert val[4:] in values, ( "%s: ondelete policy of type 'set %%' must be either 'set null', " "'set default', or 'set value' where value is a valid selection value." @@ -2911,34 +3226,37 @@ class Selection(Field[str | typing.Literal[False]]): if values is not None: self.selection = list(values.items()) - assert all(isinstance(key, str) for key in values), \ + assert all(isinstance(key, str) for key in values), ( "Field %s with non-str value in selection" % self + ) self._selection = values def _selection_modules(self, model): - """ Return a mapping from selection values to modules defining each value. """ + """Return a mapping from selection values to modules defining each value.""" if not isinstance(self.selection, list): return {} value_modules = defaultdict(set) - for field in reversed(resolve_mro(model, self.name, type(self).__instancecheck__)): + for field in reversed( + resolve_mro(model, self.name, type(self).__instancecheck__) + ): module = field._module if not module: continue - if 'selection' in field.args: + if "selection" in field.args: value_modules.clear() - if isinstance(field.args['selection'], list): - for value, label in field.args['selection']: + if isinstance(field.args["selection"], list): + for value, label in field.args["selection"]: value_modules[value].add(module) - if 'selection_add' in field.args: - for value_label in field.args['selection_add']: + if "selection_add" in field.args: + for value_label in field.args["selection_add"]: if len(value_label) > 1: value_modules[value_label[0]].add(module) return value_modules def _description_selection(self, env): - """ return the selection list (pairs (value, label)); labels are - translated according to context language + """return the selection list (pairs (value, label)); labels are + translated according to context language """ selection = self.selection if isinstance(selection, str) or callable(selection): @@ -2946,7 +3264,9 @@ class Selection(Field[str | typing.Literal[False]]): # translate selection labels if env.lang: - return env['ir.model.fields'].get_field_selection(self.model_name, self.name) + return env["ir.model.fields"].get_field_selection( + self.model_name, self.name + ) else: return selection @@ -2958,7 +3278,9 @@ class Selection(Field[str | typing.Literal[False]]): """Return a list of the possible values.""" selection = self.selection if isinstance(selection, str) or callable(selection): - selection = determine(selection, env[self.model_name].with_context(lang=None)) + selection = determine( + selection, env[self.model_name].with_context(lang=None) + ) return [value for value, _ in selection] def convert_to_column(self, value, record, values=None, validate=True): @@ -2978,22 +3300,23 @@ class Selection(Field[str | typing.Literal[False]]): def convert_to_export(self, value, record): if not isinstance(self.selection, list): # FIXME: this reproduces an existing buggy behavior! - return value if value else '' + return value if value else "" for item in self._description_selection(record.env): if item[0] == value: return item[1] - return '' + return "" class Reference(Selection): - """ Pseudo-relational field (no FK in database). + """Pseudo-relational field (no FK in database). The field value is stored as a :class:`string ` following the pattern ``"res_model,res_id"`` in database. """ - type = 'reference' - _column_type = ('varchar', pg_varchar()) + type = "reference" + + _column_type = ("varchar", pg_varchar()) def convert_to_column(self, value, record, values=None, validate=True): return Field.convert_to_column(self, value, record, values, validate) @@ -3001,10 +3324,12 @@ class Reference(Selection): def convert_to_cache(self, value, record, validate=True): # cache format: str ("model,id") or None if isinstance(value, BaseModel): - if not validate or (value._name in self.get_values(record.env) and len(value) <= 1): + if not validate or ( + value._name in self.get_values(record.env) and len(value) <= 1 + ): return "%s,%s" % (value._name, value.id) if value else None elif isinstance(value, str): - res_model, res_id = value.split(',') + res_model, res_id = value.split(",") if not validate or res_model in self.get_values(record.env): if record.env[res_model].browse(int(res_id)).exists(): return value @@ -3016,7 +3341,7 @@ class Reference(Selection): def convert_to_record(self, value, record): if value: - res_model, res_id = value.split(',') + res_model, res_id = value.split(",") return record.env[res_model].browse(int(res_id)) return None @@ -3024,17 +3349,18 @@ class Reference(Selection): return "%s,%s" % (value._name, value.id) if value else False def convert_to_export(self, value, record): - return value.display_name if value else '' + return value.display_name if value else "" def convert_to_display_name(self, value, record): return value.display_name if value else False class _Relational(Field[M], typing.Generic[M]): - """ Abstract class for relational fields. """ + """Abstract class for relational fields.""" + relational = True - domain: DomainType = [] # domain for searching values - context: ContextType = {} # context for searching values + domain: DomainType = [] # domain for searching values + context: ContextType = {} # context for searching values check_company = False def __get__(self, records, owner=None): @@ -3047,11 +3373,13 @@ class _Relational(Field[M], typing.Generic[M]): def setup_nonrelated(self, model): super().setup_nonrelated(model) if self.comodel_name not in model.pool: - _logger.warning("Field %s with unknown comodel_name %r", self, self.comodel_name) - self.comodel_name = '_unknown' + _logger.warning( + "Field %s with unknown comodel_name %r", self, self.comodel_name + ) + self.comodel_name = "_unknown" def get_domain_list(self, model): - """ Return a list domain from the domain parameter. """ + """Return a list domain from the domain parameter.""" domain = self.domain if callable(domain): domain = domain(model) @@ -3067,49 +3395,59 @@ class _Relational(Field[M], typing.Generic[M]): if callable(self.domain): # will be called with another model than self's - return lambda recs: validated(self.domain(recs.env[self.model_name])) # pylint: disable=not-callable + return lambda recs: validated( + self.domain(recs.env[self.model_name]) + ) # pylint: disable=not-callable else: return validated(self.domain) - _related_context = property(attrgetter('context')) + _related_context = property(attrgetter("context")) - _description_relation = property(attrgetter('comodel_name')) - _description_context = property(attrgetter('context')) + _description_relation = property(attrgetter("comodel_name")) + _description_context = property(attrgetter("context")) def _description_domain(self, env): - domain = self.domain(env[self.model_name]) if callable(self.domain) else self.domain # pylint: disable=not-callable + domain = ( + self.domain(env[self.model_name]) if callable(self.domain) else self.domain + ) # pylint: disable=not-callable if self.check_company: field_to_check = None if self.company_dependent: - cids = '[allowed_company_ids[0]]' - elif self.model_name == 'res.company': + cids = "[allowed_company_ids[0]]" + elif self.model_name == "res.company": # when using check_company=True on a field on 'res.company', the # company_id comes from the id of the current record - cids = '[id]' - elif 'company_id' in env[self.model_name]: - cids = '[company_id]' - field_to_check = 'company_id' - elif 'company_ids' in env[self.model_name]: - cids = 'company_ids' - field_to_check = 'company_ids' + cids = "[id]" + elif "company_id" in env[self.model_name]: + cids = "[company_id]" + field_to_check = "company_id" + elif "company_ids" in env[self.model_name]: + cids = "company_ids" + field_to_check = "company_ids" else: - _logger.warning(env._( - "Couldn't generate a company-dependent domain for field %s. " - "The model doesn't have a 'company_id' or 'company_ids' field, and isn't company-dependent either.", - f'{self.model_name}.{self.name}' - )) + _logger.warning( + env._( + "Couldn't generate a company-dependent domain for field %s. " + "The model doesn't have a 'company_id' or 'company_ids' field, and isn't company-dependent either.", + f"{self.model_name}.{self.name}", + ) + ) return domain - company_domain = env[self.comodel_name]._check_company_domain(companies=unquote(cids)) + company_domain = env[self.comodel_name]._check_company_domain( + companies=unquote(cids) + ) if not field_to_check: return f"{company_domain} + {domain or []}" else: - no_company_domain = env[self.comodel_name]._check_company_domain(companies='') + no_company_domain = env[self.comodel_name]._check_company_domain( + companies="" + ) return f"({field_to_check} and {company_domain} or {no_company_domain}) + ({domain or []})" return domain class Many2one(_Relational[M]): - """ The value of such a field is a recordset of size 0 (no + """The value of such a field is a recordset of size 0 (no record) or 1 (a single record). :param str comodel_name: name of the target model @@ -3139,15 +3477,23 @@ class Many2one(_Relational[M]): Constrains company_dependent fields to target records whose company_id(s) are compatible with the currently active company. """ - type = 'many2one' - _column_type = ('int4', 'int4') - ondelete = None # what to do when value is deleted - auto_join = False # whether joins are generated upon search - delegate = False # whether self implements delegation + type = "many2one" + _column_type = ("int4", "int4") - def __init__(self, comodel_name: str | Sentinel = SENTINEL, string: str | Sentinel = SENTINEL, **kwargs): - super(Many2one, self).__init__(comodel_name=comodel_name, string=string, **kwargs) + ondelete = None # what to do when value is deleted + auto_join = False # whether joins are generated upon search + delegate = False # whether self implements delegation + + def __init__( + self, + comodel_name: str | Sentinel = SENTINEL, + string: str | Sentinel = SENTINEL, + **kwargs, + ): + super(Many2one, self).__init__( + comodel_name=comodel_name, string=string, **kwargs + ) def _setup_attrs(self, model_class, name): super()._setup_attrs(model_class, name) @@ -3171,16 +3517,16 @@ class Many2one(_Relational[M]): # Many2one relations from TransientModel Model are annoying because # they can block deletion due to foreign keys. So unless stated # otherwise, we default them to ondelete='cascade'. - self.ondelete = 'cascade' if self.required else 'set null' + self.ondelete = "cascade" if self.required else "set null" else: - self.ondelete = 'restrict' if self.required else 'set null' - if self.ondelete == 'set null' and self.required: + self.ondelete = "restrict" if self.required else "set null" + if self.ondelete == "set null" and self.required: raise ValueError( "The m2o field %s of model %s is required but declares its ondelete policy " "as being 'set null'. Only 'restrict' and 'cascade' make sense." % (self.name, model._name) ) - if self.ondelete == 'restrict' and self.comodel_name in IR_MODELS: + if self.ondelete == "restrict" and self.comodel_name in IR_MODELS: raise ValueError( f"Field {self.name} of model {model._name} is defined as ondelete='restrict' " f"while having {self.comodel_name} as comodel, the 'restrict' mode is not " @@ -3190,7 +3536,9 @@ class Many2one(_Relational[M]): def update_db(self, model, columns): comodel = model.env[self.comodel_name] if not model.is_transient() and comodel.is_transient(): - raise ValueError('Many2one %s from Model to TransientModel is forbidden' % self) + raise ValueError( + "Many2one %s from Model to TransientModel is forbidden" % self + ) return super(Many2one, self).update_db(model, columns) def update_db_column(self, model, column): @@ -3205,19 +3553,26 @@ class Many2one(_Relational[M]): if not model._is_an_ordinary_table() or not comodel._is_an_ordinary_table(): return # ir_actions is inherited, so foreign key doesn't work on it - if not comodel._auto or comodel._table == 'ir_actions': + if not comodel._auto or comodel._table == "ir_actions": return # create/update the foreign key, and reflect it in 'ir.model.constraint' model.pool.add_foreign_key( - model._table, self.name, comodel._table, 'id', self.ondelete or 'set null', - model, self._module + model._table, + self.name, + comodel._table, + "id", + self.ondelete or "set null", + model, + self._module, ) def _update(self, records, value): - """ Update the cached value of ``self`` for ``records`` with ``value``. """ + """Update the cached value of ``self`` for ``records`` with ``value``.""" cache = records.env.cache for record in records: - cache.set(record, self, self.convert_to_cache(value, record, validate=False)) + cache.set( + record, self, self.convert_to_cache(value, record, validate=False) + ) def convert_to_column(self, value, record, values=None, validate=True): return value or None @@ -3236,7 +3591,7 @@ class Many2one(_Relational[M]): elif isinstance(value, dict): # return a new record (with the given field 'id' as origin) comodel = record.env[self.comodel_name] - origin = comodel.browse(value.get('id')) + origin = comodel.browse(value.get("id")) id_ = comodel.new(value, origin=origin).id else: id_ = None @@ -3288,7 +3643,7 @@ class Many2one(_Relational[M]): raise ValueError("Wrong value for %s: %r" % (self, value)) def convert_to_export(self, value, record): - return value.display_name if value else '' + return value.display_name if value else "" def convert_to_display_name(self, value, record): return value.display_name @@ -3315,12 +3670,14 @@ class Many2one(_Relational[M]): self._update_inverses(records, cache_value) def _remove_inverses(self, records, value): - """ Remove `records` from the cached values of the inverse fields of `self`. """ + """Remove `records` from the cached values of the inverse fields of `self`.""" cache = records.env.cache record_ids = set(records._ids) # align(id) returns a NewId if records are new, a real id otherwise - align = (lambda id_: id_) if all(record_ids) else (lambda id_: id_ and NewId(id_)) + align = ( + (lambda id_: id_) if all(record_ids) else (lambda id_: id_ and NewId(id_)) + ) for invf in records.pool.field_inverses[self]: corecords = records.env[self.comodel_name].browse( @@ -3333,7 +3690,7 @@ class Many2one(_Relational[M]): cache.set(corecord, invf, ids1) def _update_inverses(self, records, value): - """ Add `records` to the cached values of the inverse fields of `self`. """ + """Add `records` to the cached values of the inverse fields of `self`.""" if value is None: return cache = records.env.cache @@ -3352,7 +3709,7 @@ class Many2one(_Relational[M]): class Many2oneReference(Integer): - """ Pseudo-relational field (no FK in database). + """Pseudo-relational field (no FK in database). The field value is stored as an :class:`integer ` id in database. @@ -3362,14 +3719,15 @@ class Many2oneReference(Integer): :param str model_field: name of the :class:`Char` where the model name is stored. """ - type = 'many2one_reference' + + type = "many2one_reference" model_field = None aggregator = None - _related_model_field = property(attrgetter('model_field')) + _related_model_field = property(attrgetter("model_field")) - _description_model_field = property(attrgetter('model_field')) + _description_model_field = property(attrgetter("model_field")) def convert_to_cache(self, value, record, validate=True): # cache format: id or None @@ -3378,7 +3736,7 @@ class Many2oneReference(Integer): return super().convert_to_cache(value, record, validate) def _update_inverses(self, records, value): - """ Add `records` to the cached values of the inverse fields of `self`. """ + """Add `records` to the cached values of the inverse fields of `self`.""" if not value: return cache = records.env.cache @@ -3415,7 +3773,7 @@ class Many2oneReference(Integer): class Json(Field): - """ JSON Field that contain unstructured information in jsonb PostgreSQL column. + """JSON Field that contain unstructured information in jsonb PostgreSQL column. This field is still in beta Some features have not been implemented and won't be implemented in stable versions, including: * searching @@ -3423,11 +3781,11 @@ class Json(Field): * mutating the values. """ - type = 'json' - _column_type = ('jsonb', 'jsonb') + type = "json" + _column_type = ("jsonb", "jsonb") def convert_to_record(self, value, record): - """ Return a copy of the value """ + """Return a copy of the value""" return False if value is None else copy.deepcopy(value) def convert_to_cache(self, value, record, validate=True): @@ -3442,12 +3800,12 @@ class Json(Field): def convert_to_export(self, value, record): if not value: - return '' + return "" return json.dumps(value) class Properties(Field): - """ Field that contains a list of properties (aka "sub-field") based on + """Field that contains a list of properties (aka "sub-field") based on a definition defined on a container. Properties are pseudo-fields, acting like Odoo fields but without being independently stored in database. @@ -3466,11 +3824,12 @@ class Properties(Field): the value of the child. That way the web client has access to the full field definition (property type, ...). """ - type = 'properties' - _column_type = ('jsonb', 'jsonb') + + type = "properties" + _column_type = ("jsonb", "jsonb") copy = False prefetch = False - write_sequence = 10 # because it must be written after the definition field + write_sequence = 10 # because it must be written after the definition field # the field is computed editable by design (see the compute method below) store = True @@ -3478,19 +3837,31 @@ class Properties(Field): precompute = True definition = None - definition_record = None # field on the current model that point to the definition record - definition_record_field = None # field on the definition record which defined the Properties field definition + definition_record = ( + None # field on the current model that point to the definition record + ) + definition_record_field = None # field on the definition record which defined the Properties field definition - _description_definition_record = property(attrgetter('definition_record')) - _description_definition_record_field = property(attrgetter('definition_record_field')) + _description_definition_record = property(attrgetter("definition_record")) + _description_definition_record_field = property( + attrgetter("definition_record_field") + ) ALLOWED_TYPES = ( # standard types - 'boolean', 'integer', 'float', 'char', 'date', 'datetime', + "boolean", + "integer", + "float", + "char", + "date", + "datetime", # relational like types - 'many2one', 'many2many', 'selection', 'tags', + "many2one", + "many2many", + "selection", + "tags", # UI types - 'separator', + "separator", ) def _setup_attrs(self, model_class, name): @@ -3501,10 +3872,12 @@ class Properties(Field): if self.definition: # determine definition_record and definition_record_field assert self.definition.count(".") == 1 - self.definition_record, self.definition_record_field = self.definition.rsplit('.', 1) + self.definition_record, self.definition_record_field = ( + self.definition.rsplit(".", 1) + ) # make the field computed, and set its dependencies - self._depends = (self.definition_record, ) + self._depends = (self.definition_record,) self.compute = self._compute def setup_related(self, model): @@ -3634,15 +4007,15 @@ class Properties(Field): for record, record_values in zip(records, values_list): for property_definition in record_values: - comodel = property_definition.get('comodel') - type_ = property_definition.get('type') - property_value = property_definition.get('value') or [] - default = property_definition.get('default') or [] + comodel = property_definition.get("comodel") + type_ = property_definition.get("type") + property_value = property_definition.get("value") or [] + default = property_definition.get("default") or [] - if type_ not in ('many2one', 'many2many') or comodel not in records.env: + if type_ not in ("many2one", "many2many") or comodel not in records.env: continue - if type_ == 'many2one': + if type_ == "many2one": default = [default] if default else [] property_value = [property_value] if property_value else [] @@ -3681,27 +4054,31 @@ class Properties(Field): return super().write(records, value) definition_changed = any( - definition.get('definition_changed') - or definition.get('definition_deleted') + definition.get("definition_changed") or definition.get("definition_deleted") for definition in (value or []) ) if definition_changed: value = [ - definition for definition in value - if not definition.get('definition_deleted') + definition + for definition in value + if not definition.get("definition_deleted") ] for definition in value: - definition.pop('definition_changed', None) + definition.pop("definition_changed", None) # update the properties definition on the container container = records[self.definition_record] if container: properties_definition = copy.deepcopy(value) for property_definition in properties_definition: - property_definition.pop('value', None) + property_definition.pop("value", None) container[self.definition_record_field] = properties_definition - _logger.info('Properties field: User #%i changed definition of %r', records.env.user.id, container) + _logger.info( + "Properties field: User #%i changed definition of %r", + records.env.user.id, + container, + ) return super().write(records, value) @@ -3710,7 +4087,10 @@ class Properties(Field): for record in records: record[self.name] = self._add_default_values( record.env, - {self.name: record[self.name], self.definition_record: record[self.definition_record]}, + { + self.name: record[self.name], + self.definition_record: record[self.definition_record], + }, ) def _add_default_values(self, env, values): @@ -3741,10 +4121,13 @@ class Properties(Field): container_id = env[container_model_name].sudo().browse(container_id) properties_definition = container_id[self.definition_record_field] - if not (properties_definition or ( - isinstance(properties_values, list) - and any(d.get('definition_changed') for d in properties_values) - )): + if not ( + properties_definition + or ( + isinstance(properties_values, list) + and any(d.get("definition_changed") for d in properties_values) + ) + ): # If a parent is set without properties, we might want to change its definition # when we create the new record. But if we just set the value without changing # the definition, in that case we can just ignored the passed values @@ -3755,17 +4138,19 @@ class Properties(Field): self._remove_display_name(properties_values) properties_list_values = properties_values else: - properties_list_values = self._dict_to_list(properties_values, properties_definition) + properties_list_values = self._dict_to_list( + properties_values, properties_definition + ) for properties_value in properties_list_values: - if properties_value.get('value') is None: - property_name = properties_value.get('name') + if properties_value.get("value") is None: + property_name = properties_value.get("name") context_key = f"default_{self.name}.{property_name}" if property_name and context_key in env.context: default = env.context[context_key] else: - default = properties_value.get('default') or False - properties_value['value'] = default + default = properties_value.get("default") or False + properties_value["value"] = default return properties_list_values @@ -3776,7 +4161,7 @@ class Properties(Field): return container.sudo()[self.definition_record_field] @classmethod - def _add_display_name(cls, values_list, env, value_keys=('value', 'default')): + def _add_display_name(cls, values_list, env, value_keys=("value", "default")): """Add the "display_name" for each many2one / many2many properties. Modify in place "values_list". @@ -3785,17 +4170,23 @@ class Properties(Field): :param env: environment """ for property_definition in values_list: - property_type = property_definition.get('type') - property_model = property_definition.get('comodel') + property_type = property_definition.get("type") + property_model = property_definition.get("comodel") if not property_model: continue for value_key in value_keys: property_value = property_definition.get(value_key) - if property_type == 'many2one' and property_value and isinstance(property_value, int): + if ( + property_type == "many2one" + and property_value + and isinstance(property_value, int) + ): try: - display_name = env[property_model].browse(property_value).display_name + display_name = ( + env[property_model].browse(property_value).display_name + ) property_definition[value_key] = (property_value, display_name) except AccessError: # protect from access error message, show an empty name @@ -3803,19 +4194,25 @@ class Properties(Field): except MissingError: property_definition[value_key] = False - elif property_type == 'many2many' and property_value and is_list_of(property_value, int): + elif ( + property_type == "many2many" + and property_value + and is_list_of(property_value, int) + ): property_definition[value_key] = [] records = env[property_model].browse(property_value) for record in records: try: - property_definition[value_key].append((record.id, record.display_name)) + property_definition[value_key].append( + (record.id, record.display_name) + ) except AccessError: property_definition[value_key].append((record.id, None)) except MissingError: continue @classmethod - def _remove_display_name(cls, values_list, value_key='value'): + def _remove_display_name(cls, values_list, value_key="value"): """Remove the display name received by the web client for the relational properties. Modify in place "values_list". @@ -3827,24 +4224,27 @@ class Properties(Field): :param value_key: In which dict key we need to remove the display name """ for property_definition in values_list: - if not isinstance(property_definition, dict) or not property_definition.get('name'): + if not isinstance(property_definition, dict) or not property_definition.get( + "name" + ): continue property_value = property_definition.get(value_key) if not property_value: continue - property_type = property_definition.get('type') + property_type = property_definition.get("type") - if property_type == 'many2one' and has_list_types(property_value, [int, (str, NoneType)]): + if property_type == "many2one" and has_list_types( + property_value, [int, (str, NoneType)] + ): property_definition[value_key] = property_value[0] - elif property_type == 'many2many': + elif property_type == "many2many": if is_list_of(property_value, (list, tuple)): # [(35, 'Admin'), (36, 'Demo')] -> [35, 36] property_definition[value_key] = [ - many2many_value[0] - for many2many_value in property_value + many2many_value[0] for many2many_value in property_value ] @classmethod @@ -3856,9 +4256,9 @@ class Properties(Field): :param values_list: List of properties definition with properties value """ for definition in values_list: - if definition.get('definition_changed') and not definition.get('name'): + if definition.get("definition_changed") and not definition.get("name"): # keep only the first 64 bits - definition['name'] = str(uuid.uuid4()).replace('-', '')[:16] + definition["name"] = str(uuid.uuid4()).replace("-", "")[:16] @classmethod def _parse_json_types(cls, values_list, env, res_ids_per_model): @@ -3871,54 +4271,55 @@ class Properties(Field): :param env: environment """ for property_definition in values_list: - property_value = property_definition.get('value') - property_type = property_definition.get('type') - res_model = property_definition.get('comodel') + property_value = property_definition.get("value") + property_type = property_definition.get("type") + res_model = property_definition.get("comodel") if property_type not in cls.ALLOWED_TYPES: - raise ValueError(f'Wrong property type {property_type!r}') + raise ValueError(f"Wrong property type {property_type!r}") - if property_type == 'boolean': + if property_type == "boolean": # E.G. convert zero to False property_value = bool(property_value) - elif property_type == 'char' and not isinstance(property_value, str): + elif property_type == "char" and not isinstance(property_value, str): property_value = False - elif property_value and property_type == 'selection': + elif property_value and property_type == "selection": # check if the selection option still exists - options = property_definition.get('selection') or [] - options = {option[0] for option in options if option or ()} # always length 2 + options = property_definition.get("selection") or [] + options = { + option[0] for option in options if option or () + } # always length 2 if property_value not in options: # maybe the option has been removed on the container property_value = False - elif property_value and property_type == 'tags': + elif property_value and property_type == "tags": # remove all tags that are not defined on the container - all_tags = {tag[0] for tag in property_definition.get('tags') or ()} + all_tags = {tag[0] for tag in property_definition.get("tags") or ()} property_value = [tag for tag in property_value if tag in all_tags] - elif property_type == 'many2one' and property_value and res_model in env: + elif property_type == "many2one" and property_value and res_model in env: if not isinstance(property_value, int): - raise ValueError(f'Wrong many2one value: {property_value!r}.') + raise ValueError(f"Wrong many2one value: {property_value!r}.") if property_value not in res_ids_per_model[res_model]: property_value = False - elif property_type == 'many2many' and property_value and res_model in env: + elif property_type == "many2many" and property_value and res_model in env: if not is_list_of(property_value, int): - raise ValueError(f'Wrong many2many value: {property_value!r}.') + raise ValueError(f"Wrong many2many value: {property_value!r}.") if len(property_value) != len(set(property_value)): # remove duplicated value and preserve order property_value = list(dict.fromkeys(property_value)) property_value = [ - id_ for id_ in property_value - if id_ in res_ids_per_model[res_model] + id_ for id_ in property_value if id_ in res_ids_per_model[res_model] ] - property_definition['value'] = property_value + property_definition["value"] = property_value @classmethod def _list_to_dict(cls, values_list): @@ -3953,31 +4354,39 @@ class Properties(Field): :return: Generate a dict {name: value} from this definitions / values list """ if not is_list_of(values_list, dict): - raise ValueError(f'Wrong properties value {values_list!r}') + raise ValueError(f"Wrong properties value {values_list!r}") cls._add_missing_names(values_list) dict_value = {} for property_definition in values_list: - property_value = property_definition.get('value') - property_type = property_definition.get('type') - property_model = property_definition.get('comodel') + property_value = property_definition.get("value") + property_type = property_definition.get("type") + property_model = property_definition.get("comodel") - if property_type == 'separator': + if property_type == "separator": # "separator" is used as a visual separator in the form view UI # it does not have a value and does not need to be stored on children continue - if property_type not in ('integer', 'float') or property_value != 0: + if property_type not in ("integer", "float") or property_value != 0: property_value = property_value or False - if property_type in ('many2one', 'many2many') and property_model and property_value: + if ( + property_type in ("many2one", "many2many") + and property_model + and property_value + ): # check that value are correct before storing them in database - if property_type == 'many2many' and property_value and not is_list_of(property_value, int): + if ( + property_type == "many2many" + and property_value + and not is_list_of(property_value, int) + ): raise ValueError(f"Wrong many2many value {property_value!r}") - if property_type == 'many2one' and not isinstance(property_value, int): + if property_type == "many2one" and not isinstance(property_value, int): raise ValueError(f"Wrong many2one value {property_value!r}") - dict_value[property_definition['name']] = property_value + dict_value[property_definition["name"]] = property_value return dict_value @@ -3991,36 +4400,44 @@ class Properties(Field): Ignore every values in the child that is not defined on the container. """ if not is_list_of(properties_definition, dict): - raise ValueError(f'Wrong properties value {properties_definition!r}') + raise ValueError(f"Wrong properties value {properties_definition!r}") values_list = copy.deepcopy(properties_definition) for property_definition in values_list: - property_definition['value'] = values_dict.get(property_definition['name']) + property_definition["value"] = values_dict.get(property_definition["name"]) return values_list class PropertiesDefinition(Field): - """ Field used to define the properties definition (see :class:`~odoo.fields.Properties` + """Field used to define the properties definition (see :class:`~odoo.fields.Properties` field). This field is used on the container record to define the structure of expected properties on subrecords. It is used to check the properties - definition. """ - type = 'properties_definition' - _column_type = ('jsonb', 'jsonb') - copy = True # containers may act like templates, keep definitions to ease usage + definition.""" + + type = "properties_definition" + _column_type = ("jsonb", "jsonb") + copy = True # containers may act like templates, keep definitions to ease usage readonly = False prefetch = True - REQUIRED_KEYS = ('name', 'type') + REQUIRED_KEYS = ("name", "type") ALLOWED_KEYS = ( - 'name', 'string', 'type', 'comodel', 'default', - 'selection', 'tags', 'domain', 'view_in_cards', + "name", + "string", + "type", + "comodel", + "default", + "selection", + "tags", + "domain", + "view_in_cards", ) # those keys will be removed if the types does not match PROPERTY_PARAMETERS_MAP = { - 'comodel': {'many2one', 'many2many'}, - 'domain': {'many2one', 'many2many'}, - 'selection': {'selection'}, - 'tags': {'tags'}, + "comodel": {"many2one", "many2many"}, + "domain": {"many2one", "many2many"}, + "selection": {"selection"}, + "tags": {"tags"}, } def convert_to_column(self, value, record, values=None, validate=True): @@ -4051,9 +4468,9 @@ class PropertiesDefinition(Field): value = json.loads(value) if not isinstance(value, list): - raise ValueError(f'Wrong properties definition type {type(value)!r}') + raise ValueError(f"Wrong properties definition type {type(value)!r}") - Properties._remove_display_name(value, value_key='default') + Properties._remove_display_name(value, value_key="default") self._validate_properties_definition(value, record.env) @@ -4073,9 +4490,9 @@ class PropertiesDefinition(Field): value = json.loads(value) if not isinstance(value, list): - raise ValueError(f'Wrong properties definition type {type(value)!r}') + raise ValueError(f"Wrong properties definition type {type(value)!r}") - Properties._remove_display_name(value, value_key='default') + Properties._remove_display_name(value, value_key="default") self._validate_properties_definition(value, record.env) @@ -4100,19 +4517,19 @@ class PropertiesDefinition(Field): # check if the model still exists in the environment, the module of the # model might have been uninstalled so the model might not exist anymore - property_model = property_definition.get('comodel') + property_model = property_definition.get("comodel") if property_model and property_model not in record.env: - property_definition['comodel'] = property_model = False + property_definition["comodel"] = property_model = False - if not property_model and 'domain' in property_definition: - del property_definition['domain'] + if not property_model and "domain" in property_definition: + del property_definition["domain"] - if property_definition.get('type') in ('selection', 'tags'): + if property_definition.get("type") in ("selection", "tags"): # always set at least an empty array if there's no option - key = property_definition['type'] + key = property_definition["type"] property_definition[key] = property_definition.get(key) or [] - property_domain = property_definition.get('domain') + property_domain = property_definition.get("domain") if property_domain: # some fields in the domain might have been removed # (e.g. if the module has been uninstalled) @@ -4123,12 +4540,15 @@ class PropertiesDefinition(Field): record.env[property_model], ) except ValueError: - del property_definition['domain'] + del property_definition["domain"] result.append(property_definition) - for property_parameter, allowed_types in self.PROPERTY_PARAMETERS_MAP.items(): - if property_definition.get('type') not in allowed_types: + for ( + property_parameter, + allowed_types, + ) in self.PROPERTY_PARAMETERS_MAP.items(): + if property_definition.get("type") not in allowed_types: property_definition.pop(property_parameter, None) return result @@ -4139,7 +4559,7 @@ class PropertiesDefinition(Field): return value if use_display_name: - Properties._add_display_name(value, record.env, value_keys=('default',)) + Properties._add_display_name(value, record.env, value_keys=("default",)) return value @@ -4154,53 +4574,65 @@ class PropertiesDefinition(Field): invalid_keys = property_definition_keys - set(cls.ALLOWED_KEYS) if invalid_keys: raise ValueError( - 'Some key are not allowed for a properties definition [%s].' % - ', '.join(invalid_keys), + "Some key are not allowed for a properties definition [%s]." + % ", ".join(invalid_keys), ) - check_property_field_value_name(property_definition['name']) + check_property_field_value_name(property_definition["name"]) required_keys = set(cls.REQUIRED_KEYS) - property_definition_keys if required_keys: raise ValueError( - 'Some key are missing for a properties definition [%s].' % - ', '.join(required_keys), + "Some key are missing for a properties definition [%s]." + % ", ".join(required_keys), ) - property_name = property_definition.get('name') + property_name = property_definition.get("name") if not property_name or property_name in properties_names: - raise ValueError(f'The property name {property_name!r} is not set or duplicated.') + raise ValueError( + f"The property name {property_name!r} is not set or duplicated." + ) properties_names.add(property_name) - property_type = property_definition.get('type') + property_type = property_definition.get("type") if property_type and property_type not in Properties.ALLOWED_TYPES: - raise ValueError(f'Wrong property type {property_type!r}.') + raise ValueError(f"Wrong property type {property_type!r}.") - model = property_definition.get('comodel') - if model and (model not in env or env[model].is_transient() or env[model]._abstract): - raise ValueError(f'Invalid model name {model!r}') + model = property_definition.get("comodel") + if model and ( + model not in env or env[model].is_transient() or env[model]._abstract + ): + raise ValueError(f"Invalid model name {model!r}") - property_selection = property_definition.get('selection') + property_selection = property_definition.get("selection") if property_selection: - if (not is_list_of(property_selection, (list, tuple)) - or not all(len(selection) == 2 for selection in property_selection)): - raise ValueError(f'Wrong options {property_selection!r}.') + if not is_list_of(property_selection, (list, tuple)) or not all( + len(selection) == 2 for selection in property_selection + ): + raise ValueError(f"Wrong options {property_selection!r}.") all_options = [option[0] for option in property_selection] if len(all_options) != len(set(all_options)): - duplicated = set(filter(lambda x: all_options.count(x) > 1, all_options)) - raise ValueError(f'Some options are duplicated: {", ".join(duplicated)}.') + duplicated = set( + filter(lambda x: all_options.count(x) > 1, all_options) + ) + raise ValueError( + f'Some options are duplicated: {", ".join(duplicated)}.' + ) - property_tags = property_definition.get('tags') + property_tags = property_definition.get("tags") if property_tags: - if (not is_list_of(property_tags, (list, tuple)) - or not all(len(tag) == 3 and isinstance(tag[2], int) for tag in property_tags)): - raise ValueError(f'Wrong tags definition {property_tags!r}.') + if not is_list_of(property_tags, (list, tuple)) or not all( + len(tag) == 3 and isinstance(tag[2], int) for tag in property_tags + ): + raise ValueError(f"Wrong tags definition {property_tags!r}.") all_tags = [tag[0] for tag in property_tags] if len(all_tags) != len(set(all_tags)): duplicated = set(filter(lambda x: all_tags.count(x) > 1, all_tags)) - raise ValueError(f'Some tags are duplicated: {", ".join(duplicated)}.') + raise ValueError( + f'Some tags are duplicated: {", ".join(duplicated)}.' + ) class Command(enum.IntEnum): @@ -4322,6 +4754,7 @@ class Command(enum.IntEnum): class _RelationalMulti(_Relational[M], typing.Generic[M]): r"Abstract class for relational fields \*2many." + write_sequence = 20 # Important: the cache contains the ids of all the records in the relation, @@ -4329,7 +4762,7 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): # convert_to_record(), depending on the context. def _update(self, records, value): - """ Update the cached value of ``self`` for ``records`` with ``value``. """ + """Update the cached value of ``self`` for ``records`` with ``value``.""" records.env.cache.patch(records, self, value.id) records.modified([self.name]) @@ -4392,11 +4825,12 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): prefetch_ids = PrefetchX2many(record, self) Comodel = record.pool[self.comodel_name] corecords = Comodel(record.env, value, prefetch_ids) - if ( - Comodel._active_name - and self.context.get('active_test', record.env.context.get('active_test', True)) + if Comodel._active_name and self.context.get( + "active_test", record.env.context.get("active_test", True) ): - corecords = corecords.filtered(Comodel._active_name).with_prefetch(prefetch_ids) + corecords = corecords.filtered(Comodel._active_name).with_prefetch( + prefetch_ids + ) return corecords def convert_to_record_multi(self, values, records): @@ -4405,11 +4839,12 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): Comodel = records.pool[self.comodel_name] ids = tuple(unique(id_ for ids in values for id_ in ids)) corecords = Comodel(records.env, ids, prefetch_ids) - if ( - Comodel._active_name - and self.context.get('active_test', records.env.context.get('active_test', True)) + if Comodel._active_name and self.context.get( + "active_test", records.env.context.get("active_test", True) ): - corecords = corecords.filtered(Comodel._active_name).with_prefetch(prefetch_ids) + corecords = corecords.filtered(Comodel._active_name).with_prefetch( + prefetch_ids + ) return corecords def convert_to_read(self, value, record, use_display_name=True): @@ -4421,6 +4856,7 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): value = record.env[self.comodel_name].browse(value) if isinstance(value, BaseModel) and value._name == self.comodel_name: + def get_origin(val): return val._origin if isinstance(val, BaseModel) else val @@ -4430,20 +4866,25 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): for record in value: origin = record._origin if not origin: - values = record._convert_to_write({ - name: record[name] - for name in record._cache - if name not in inv_names - }) + values = record._convert_to_write( + { + name: record[name] + for name in record._cache + if name not in inv_names + } + ) result.append(Command.create(values)) else: result[0][2].append(origin.id) if record != origin: - values = record._convert_to_write({ - name: record[name] - for name in record._cache - if name not in inv_names and get_origin(record[name]) != origin[name] - }) + values = record._convert_to_write( + { + name: record[name] + for name in record._cache + if name not in inv_names + and get_origin(record[name]) != origin[name] + } + ) if values: result.append(Command.update(origin.id, values)) return result @@ -4457,7 +4898,7 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): raise ValueError("Wrong value for %s: %s" % (self, value)) def convert_to_export(self, value, record): - return ','.join(value.mapped('display_name')) if value else '' + return ",".join(value.mapped("display_name")) if value else "" def convert_to_display_name(self, value, record): raise NotImplementedError() @@ -4465,15 +4906,20 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): def get_depends(self, model): depends, depends_context = super().get_depends(model) if not self.compute and isinstance(self.domain, list): - depends = unique(itertools.chain(depends, ( - self.name + '.' + arg[0] - for arg in self.domain - if isinstance(arg, (tuple, list)) and isinstance(arg[0], str) - ))) + depends = unique( + itertools.chain( + depends, + ( + self.name + "." + arg[0] + for arg in self.domain + if isinstance(arg, (tuple, list)) and isinstance(arg[0], str) + ), + ) + ) return depends, depends_context def create(self, record_values): - """ Write the value of ``self`` on the given records, which have just + """Write the value of ``self`` on the given records, which have just been created. :param record_values: a list of pairs ``(record, value)``, where @@ -4497,7 +4943,11 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): value = [Command.set(value._ids)] elif value is False or value is None: value = [Command.clear()] - elif isinstance(value, list) and value and not isinstance(value[0], (tuple, list)): + elif ( + isinstance(value, list) + and value + and not isinstance(value[0], (tuple, list)) + ): value = [Command.set(tuple(value))] if not isinstance(value, list): raise ValueError("Wrong value for %s: %s" % (self, value)) @@ -4507,7 +4957,9 @@ class _RelationalMulti(_Relational[M], typing.Generic[M]): if all(record_ids): self.write_real(records_commands_list, create) else: - assert not any(record_ids), f"{records_commands_list} contains a mix of real and new records. It is not supported." + assert not any( + record_ids + ), f"{records_commands_list} contains a mix of real and new records. It is not supported." self.write_new(records_commands_list) def _check_sudo_commands(self, comodel): @@ -4541,19 +4993,25 @@ class One2many(_RelationalMulti[M]): The attributes ``comodel_name`` and ``inverse_name`` are mandatory except in the case of related fields or field extensions. """ - type = 'one2many' - inverse_name = None # name of the inverse field - auto_join = False # whether joins are generated upon search - copy = False # o2m are not copied by default + type = "one2many" - def __init__(self, comodel_name: str | Sentinel = SENTINEL, inverse_name: str | Sentinel = SENTINEL, - string: str | Sentinel = SENTINEL, **kwargs): + inverse_name = None # name of the inverse field + auto_join = False # whether joins are generated upon search + copy = False # o2m are not copied by default + + def __init__( + self, + comodel_name: str | Sentinel = SENTINEL, + inverse_name: str | Sentinel = SENTINEL, + string: str | Sentinel = SENTINEL, + **kwargs, + ): super(One2many, self).__init__( comodel_name=comodel_name, inverse_name=inverse_name, string=string, - **kwargs + **kwargs, ) def setup_nonrelated(self, model): @@ -4568,25 +5026,27 @@ class One2many(_RelationalMulti[M]): model.pool.field_inverses.add(self, invf) comodel.pool.field_inverses.add(invf, self) - _description_relation_field = property(attrgetter('inverse_name')) + _description_relation_field = property(attrgetter("inverse_name")) def update_db(self, model, columns): if self.comodel_name in model.env: comodel = model.env[self.comodel_name] if self.inverse_name not in comodel._fields: - raise UserError(model.env._( - 'No inverse field "%(inverse_field)s" found for "%(comodel)s"', - inverse_field=self.inverse_name, - comodel=self.comodel_name - )) + raise UserError( + model.env._( + 'No inverse field "%(inverse_field)s" found for "%(comodel)s"', + inverse_field=self.inverse_name, + comodel=self.comodel_name, + ) + ) def get_domain_list(self, records): domain = super().get_domain_list(records) if self.comodel_name and self.inverse_name: comodel = records.env.registry[self.comodel_name] inverse_field = comodel._fields[self.inverse_name] - if inverse_field.type == 'many2one_reference': - domain = domain + [(inverse_field.model_field, '=', records._name)] + if inverse_field.type == "many2one_reference": + domain = domain + [(inverse_field.model_field, "=", records._name)] return domain def __get__(self, records, owner=None): @@ -4600,21 +5060,21 @@ class One2many(_RelationalMulti[M]): def read(self, records): # retrieve the lines in the comodel - context = {'active_test': False} + context = {"active_test": False} context.update(self.context) comodel = records.env[self.comodel_name].with_context(**context) inverse = self.inverse_name inverse_field = comodel._fields[inverse] # optimization: fetch the inverse and active fields with search() - domain = self.get_domain_list(records) + [(inverse, 'in', records.ids)] + domain = self.get_domain_list(records) + [(inverse, "in", records.ids)] field_names = [inverse] if comodel._active_name: field_names.append(comodel._active_name) lines = comodel.search_fetch(domain, field_names) # group lines by inverse field (without prefetching other fields) - get_id = (lambda rec: rec.id) if inverse_field.type == 'many2one' else int + get_id = (lambda rec: rec.id) if inverse_field.type == "many2one" else int group = defaultdict(list) for line in lines: # line[inverse] may be a record or an integer @@ -4625,7 +5085,7 @@ class One2many(_RelationalMulti[M]): records.env.cache.insert_missing(records, self, values) def write_real(self, records_commands_list, create=False): - """ Update real records. """ + """Update real records.""" # records_commands_list = [(records, commands), ...] if not records_commands_list: return @@ -4636,13 +5096,13 @@ class One2many(_RelationalMulti[M]): if self.store: inverse = self.inverse_name - to_create = [] # line vals to create - to_delete = [] # line ids to delete - to_link = defaultdict(OrderedSet) # {record: line_ids} + to_create = [] # line vals to create + to_delete = [] # line ids to delete + to_link = defaultdict(OrderedSet) # {record: line_ids} allow_full_delete = not create def unlink(lines): - if getattr(comodel._fields[inverse], 'ondelete', False) == 'cascade': + if getattr(comodel._fields[inverse], "ondelete", False) == "cascade": to_delete.extend(lines._ids) else: lines[inverse] = False @@ -4667,14 +5127,16 @@ class One2many(_RelationalMulti[M]): to_link.clear() for recs, commands in records_commands_list: - for command in (commands or ()): + for command in commands or (): 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] == Command.UPDATE: prefetch_ids = recs[self.name]._prefetch_ids - comodel.browse(command[1]).with_prefetch(prefetch_ids).write(command[2]) + comodel.browse(command[1]).with_prefetch(prefetch_ids).write( + command[2] + ) elif command[0] == Command.DELETE: to_delete.append(command[1]) elif command[0] == Command.UNLINK: @@ -4696,15 +5158,19 @@ class One2many(_RelationalMulti[M]): flush() # assign the given lines to the last record only lines = comodel.browse(line_ids) - domain = self.get_domain_list(model) + \ - [(inverse, 'in', recs.ids), ('id', 'not in', lines.ids)] + domain = self.get_domain_list(model) + [ + (inverse, "in", recs.ids), + ("id", "not in", lines.ids), + ] unlink(comodel.search(domain)) lines[inverse] = recs[-1] flush() else: - ids = OrderedSet(rid for recs, cs in records_commands_list for rid in recs._ids) + ids = OrderedSet( + rid for recs, cs in records_commands_list for rid in recs._ids + ) records = records_commands_list[0][0].browse(ids) cache = records.env.cache @@ -4717,7 +5183,7 @@ class One2many(_RelationalMulti[M]): cache.set(record, self, (record[self.name] - lines)._ids) for recs, commands in records_commands_list: - for command in (commands or ()): + for command in commands or (): if command[0] == Command.CREATE: for record in recs: link(record, comodel.new(command[2], ref=command[1])) @@ -4732,7 +5198,9 @@ class One2many(_RelationalMulti[M]): elif command[0] in (Command.CLEAR, Command.SET): # assign the given lines to the last record only cache.update(recs, self, itertools.repeat(())) - lines = comodel.browse(command[2] if command[0] == Command.SET else []) + lines = comodel.browse( + command[2] if command[0] == Command.SET else [] + ) cache.set(recs[-1], self, lines._ids) def write_new(self, records_commands_list): @@ -4759,7 +5227,9 @@ class One2many(_RelationalMulti[M]): # make sure self's inverse is in cache inverse_field = comodel._fields[inverse] for record in records: - cache.update(record[self.name], inverse_field, itertools.repeat(record.id)) + cache.update( + record[self.name], inverse_field, itertools.repeat(record.id) + ) for recs, commands in records_commands_list: for command in commands: @@ -4785,6 +5255,7 @@ class One2many(_RelationalMulti[M]): cache.update(lines, inverse_field, itertools.repeat(last.id)) else: + def link(record, lines): ids = record[self.name]._ids cache.set(record, self, tuple(unique(ids + lines._ids))) @@ -4814,7 +5285,7 @@ class One2many(_RelationalMulti[M]): class Many2many(_RelationalMulti[M]): - """ Many2many field; the value of such a field is the recordset. + """Many2many field; the value of such a field is the recordset. :param comodel_name: name of the target model (string) mandatory except in the case of related or extended fields @@ -4854,25 +5325,32 @@ class Many2many(_RelationalMulti[M]): domain depending on the field attributes. """ - type = 'many2many' - _explicit = True # whether schema is explicitly given - relation = None # name of table - column1 = None # column of table referring to model - column2 = None # column of table referring to comodel - auto_join = False # whether joins are generated upon search - ondelete = 'cascade' # optional ondelete for the column2 fkey + type = "many2many" - def __init__(self, comodel_name: str | Sentinel = SENTINEL, relation: str | Sentinel = SENTINEL, - column1: str | Sentinel = SENTINEL, column2: str | Sentinel = SENTINEL, - string: str | Sentinel = SENTINEL, **kwargs): + _explicit = True # whether schema is explicitly given + relation = None # name of table + column1 = None # column of table referring to model + column2 = None # column of table referring to comodel + auto_join = False # whether joins are generated upon search + ondelete = "cascade" # optional ondelete for the column2 fkey + + def __init__( + self, + comodel_name: str | Sentinel = SENTINEL, + relation: str | Sentinel = SENTINEL, + column1: str | Sentinel = SENTINEL, + column2: str | Sentinel = SENTINEL, + string: str | Sentinel = SENTINEL, + **kwargs, + ): super(Many2many, self).__init__( comodel_name=comodel_name, relation=relation, column1=column1, column2=column2, string=string, - **kwargs + **kwargs, ) def setup_nonrelated(self, model): @@ -4881,7 +5359,7 @@ class Many2many(_RelationalMulti[M]): # 1) The ondelete attribute is defined and its definition makes sense # 2) The ondelete attribute is explicitly defined as 'set null' for a m2m, # this is considered a programming error. - if self.ondelete not in ('cascade', 'restrict'): + if self.ondelete not in ("cascade", "restrict"): raise ValueError( "The m2m field %s of model %s declares its ondelete policy " "as being %r. Only 'restrict' and 'cascade' make sense." @@ -4895,15 +5373,16 @@ class Many2many(_RelationalMulti[M]): comodel = model.env[self.comodel_name] if not self.relation: tables = sorted([model._table, comodel._table]) - assert tables[0] != tables[1], \ - "%s: Implicit/canonical naming of many2many relationship " \ - "table is not possible when source and destination models " \ + assert tables[0] != tables[1], ( + "%s: Implicit/canonical naming of many2many relationship " + "table is not possible when source and destination models " "are the same" % self - self.relation = '%s_%s_rel' % tuple(tables) + ) + self.relation = "%s_%s_rel" % tuple(tables) if not self.column1: - self.column1 = '%s_id' % model._table + self.column1 = "%s_id" % model._table if not self.column2: - self.column2 = '%s_id' % comodel._table + self.column2 = "%s_id" % comodel._table # check validity of table name check_pg_name(self.relation) else: @@ -4915,13 +5394,14 @@ class Many2many(_RelationalMulti[M]): # check whether other fields use the same schema fields = m2m[(self.relation, self.column1, self.column2)] for field in fields: - if ( # same model: relation parameters must be explicit - self.model_name == field.model_name and - self.comodel_name == field.comodel_name and - self._explicit and field._explicit + if ( # same model: relation parameters must be explicit + self.model_name == field.model_name + and self.comodel_name == field.comodel_name + and self._explicit + and field._explicit ) or ( # different models: one model must be _auto=False - self.model_name != field.model_name and - not (model._auto and model.env[field.model_name]._auto) + self.model_name != field.model_name + and not (model._auto and model.env[field.model_name]._auto) ): continue msg = "Many2many fields %s and %s use the same table and columns" @@ -4939,58 +5419,87 @@ class Many2many(_RelationalMulti[M]): # module. They are automatically removed when dropping the corresponding # 'ir.model.field'. if not self.manual: - model.pool.post_init(model.env['ir.model.relation']._reflect_relation, - model, self.relation, self._module) + model.pool.post_init( + model.env["ir.model.relation"]._reflect_relation, + model, + self.relation, + self._module, + ) comodel = model.env[self.comodel_name] if not sql.table_exists(cr, self.relation): - cr.execute(SQL( - """ CREATE TABLE %(rel)s (%(id1)s INTEGER NOT NULL, + cr.execute( + SQL( + """ CREATE TABLE %(rel)s (%(id1)s INTEGER NOT NULL, %(id2)s INTEGER NOT NULL, PRIMARY KEY(%(id1)s, %(id2)s)); COMMENT ON TABLE %(rel)s IS %(comment)s; CREATE INDEX ON %(rel)s (%(id2)s, %(id1)s); """, - rel=SQL.identifier(self.relation), - id1=SQL.identifier(self.column1), - id2=SQL.identifier(self.column2), - comment=f"RELATION BETWEEN {model._table} AND {comodel._table}", - )) - _schema.debug("Create table %r: m2m relation between %r and %r", self.relation, model._table, comodel._table) + rel=SQL.identifier(self.relation), + id1=SQL.identifier(self.column1), + id2=SQL.identifier(self.column2), + comment=f"RELATION BETWEEN {model._table} AND {comodel._table}", + ) + ) + _schema.debug( + "Create table %r: m2m relation between %r and %r", + self.relation, + model._table, + comodel._table, + ) model.pool.post_init(self.update_db_foreign_keys, model) return True model.pool.post_init(self.update_db_foreign_keys, model) def update_db_foreign_keys(self, model): - """ Add the foreign keys corresponding to the field's relation table. """ + """Add the foreign keys corresponding to the field's relation table.""" comodel = model.env[self.comodel_name] if model._is_an_ordinary_table(): model.pool.add_foreign_key( - self.relation, self.column1, model._table, 'id', 'cascade', - model, self._module, force=False, + self.relation, + self.column1, + model._table, + "id", + "cascade", + model, + self._module, + force=False, ) if comodel._is_an_ordinary_table(): model.pool.add_foreign_key( - self.relation, self.column2, comodel._table, 'id', self.ondelete, - model, self._module, + self.relation, + self.column2, + comodel._table, + "id", + self.ondelete, + model, + self._module, ) def read(self, records): - context = {'active_test': False} + context = {"active_test": False} context.update(self.context) comodel = records.env[self.comodel_name].with_context(**context) # make the query for the lines domain = self.get_domain_list(records) query = comodel._where_calc(domain) - comodel._apply_ir_rules(query, 'read') + comodel._apply_ir_rules(query, "read") query.order = comodel._order_to_sql(comodel._order, query) # join with many2many relation table sql_id1 = SQL.identifier(self.relation, self.column1) sql_id2 = SQL.identifier(self.relation, self.column2) - query.add_join('JOIN', self.relation, None, SQL( - "%s = %s", sql_id2, SQL.identifier(comodel._table, 'id'), - )) + query.add_join( + "JOIN", + self.relation, + None, + SQL( + "%s = %s", + sql_id2, + SQL.identifier(comodel._table, "id"), + ), + ) query.add_where(SQL("%s IN %s", sql_id1, tuple(records.ids))) # retrieve pairs (record, line) and group by record @@ -5053,14 +5562,16 @@ class Many2many(_RelationalMulti[M]): for recs, commands in records_commands_list: to_create = [] # line vals to create to_delete = [] # line ids to delete - for command in (commands or ()): + for command in commands or (): if not isinstance(command, (list, tuple)) or not command: continue if command[0] == Command.CREATE: to_create.append((recs._ids, command[2])) elif command[0] == Command.UPDATE: prefetch_ids = recs[self.name]._prefetch_ids - comodel.browse(command[1]).with_prefetch(prefetch_ids).write(command[2]) + comodel.browse(command[1]).with_prefetch(prefetch_ids).write( + command[2] + ) elif command[0] == Command.DELETE: to_delete.append(command[1]) elif command[0] == Command.UNLINK: @@ -5069,8 +5580,12 @@ class Many2many(_RelationalMulti[M]): relation_add(recs._ids, command[1]) 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] == Command.SET else ()) + to_create = [ + (set(ids) - set(recs._ids), vals) for (ids, vals) in to_create + ] + relation_set( + recs._ids, command[2] if command[0] == Command.SET else () + ) if to_create: # create lines in batch, and link them @@ -5095,13 +5610,15 @@ class Many2many(_RelationalMulti[M]): pairs = [(x, y) for x, ys in new_relation.items() for y in ys - old_relation[x]] if pairs: if self.store: - cr.execute(SQL( - "INSERT INTO %s (%s, %s) VALUES %s ON CONFLICT DO NOTHING", - SQL.identifier(self.relation), - SQL.identifier(self.column1), - SQL.identifier(self.column2), - SQL(", ").join(pairs), - )) + cr.execute( + SQL( + "INSERT INTO %s (%s, %s) VALUES %s ON CONFLICT DO NOTHING", + SQL.identifier(self.relation), + SQL.identifier(self.column1), + SQL.identifier(self.column2), + SQL(", ").join(pairs), + ) + ) # update the cache of inverse fields y_to_xs = defaultdict(set) @@ -5139,16 +5656,22 @@ class Many2many(_RelationalMulti[M]): for y, xs in y_to_xs.items(): xs_to_ys[frozenset(xs)].add(y) # delete the rows where (id1 IN xs AND id2 IN ys) OR ... - cr.execute(SQL( - "DELETE FROM %s WHERE %s", - SQL.identifier(self.relation), - SQL(" OR ").join( - SQL("%s IN %s AND %s IN %s", - SQL.identifier(self.column1), tuple(xs), - SQL.identifier(self.column2), tuple(ys)) - for xs, ys in xs_to_ys.items() - ), - )) + cr.execute( + SQL( + "DELETE FROM %s WHERE %s", + SQL.identifier(self.relation), + SQL(" OR ").join( + SQL( + "%s IN %s AND %s IN %s", + SQL.identifier(self.column1), + tuple(xs), + SQL.identifier(self.column2), + tuple(ys), + ) + for xs, ys in xs_to_ys.items() + ), + ) + ) # update the cache of inverse fields for invf in records.pool.field_inverses[self]: @@ -5165,14 +5688,16 @@ class Many2many(_RelationalMulti[M]): # trigger the recomputation of fields that depend on the inverse # fields of self on the modified corecords corecords = comodel.browse(modified_corecord_ids) - corecords.modified([ - invf.name - for invf in model.pool.field_inverses[self] - if invf.model_name == self.comodel_name - ]) + corecords.modified( + [ + invf.name + for invf in model.pool.field_inverses[self] + if invf.model_name == self.comodel_name + ] + ) def write_new(self, records_commands_list): - """ Update self on new records. """ + """Update self on new records.""" if not records_commands_list: return @@ -5183,7 +5708,11 @@ class Many2many(_RelationalMulti[M]): # determine old and new relation {x: ys} set = OrderedSet - old_relation = {record.id: set(record[self.name]._ids) for records, _ in records_commands_list for record in records} + old_relation = { + record.id: set(record[self.name]._ids) + for records, _ in records_commands_list + for record in records + } new_relation = {x: set(ys) for x, ys in old_relation.items()} for recs, commands in records_commands_list: @@ -5273,29 +5802,32 @@ class Many2many(_RelationalMulti[M]): # trigger the recomputation of fields that depend on the inverse # fields of self on the modified corecords corecords = comodel.browse(modified_corecord_ids) - corecords.modified([ - invf.name - for invf in model.pool.field_inverses[self] - if invf.model_name == self.comodel_name - ]) + corecords.modified( + [ + invf.name + for invf in model.pool.field_inverses[self] + if invf.model_name == self.comodel_name + ] + ) class Id(Field[IdType | typing.Literal[False]]): - """ Special case for field 'id'. """ - type = 'integer' - column_type = ('int4', 'int4') + """Special case for field 'id'.""" - string = 'ID' + type = "integer" + column_type = ("int4", "int4") + + string = "ID" store = True readonly = True prefetch = False def update_db(self, model, columns): - pass # this column is created with the table + pass # this column is created with the table def __get__(self, record, owner=None): if record is None: - return self # the field is accessed through the class owner + return self # the field is accessed through the class owner # the code below is written to make record.id as quick as possible ids = record._ids @@ -5311,8 +5843,9 @@ class Id(Field[IdType | typing.Literal[False]]): class PrefetchMany2one: - """ Iterable for the values of a many2one field on the prefetch set of a given record. """ - __slots__ = 'record', 'field' + """Iterable for the values of a many2one field on the prefetch set of a given record.""" + + __slots__ = "record", "field" def __init__(self, record, field): self.record = record @@ -5330,8 +5863,9 @@ class PrefetchMany2one: class PrefetchX2many: - """ Iterable for the values of an x2many field on the prefetch set of a given record. """ - __slots__ = 'record', 'field' + """Iterable for the values of an x2many field on the prefetch set of a given record.""" + + __slots__ = "record", "field" def __init__(self, record, field): self.record = record @@ -5349,7 +5883,7 @@ class PrefetchX2many: def apply_required(model, field_name): - """ Set a NOT NULL constraint on the given field, if necessary. """ + """Set a NOT NULL constraint on the given field, if necessary.""" # At the time this function is called, the model's _fields may have been reset, although # the model's class is still the same. Retrieve the field to see whether the NOT NULL # constraint still applies @@ -5362,6 +5896,9 @@ def apply_required(model, field_name): # pylint: disable=wrong-import-position from .exceptions import AccessError, MissingError, UserError from .models import ( - check_pg_name, expand_ids, is_definition_class, - BaseModel, PREFETCH_MAX, + check_pg_name, + expand_ids, + is_definition_class, + BaseModel, + PREFETCH_MAX, )