oca-ocb-core/odoo-bringout-oca-ocb-base/odoo/orm/fields_properties.py
Ernad Husremovic 2d3ee4855a 19.0 vanilla
2026-03-09 09:30:27 +01:00

1063 lines
44 KiB
Python

from __future__ import annotations
import ast
import contextlib
import copy
import json
import typing
import uuid
from collections import abc, defaultdict
from operator import attrgetter
from odoo.exceptions import AccessError, UserError, MissingError
from odoo.tools import SQL, OrderedSet, is_list_of, html_sanitize
from odoo.tools.misc import frozendict, has_list_types
from odoo.tools.translate import _
from .domains import Domain
from .fields import Field, _logger
from .models import BaseModel
from .utils import COLLECTION_TYPES, SQL_OPERATORS, parse_field_expr, regex_alphanumeric
if typing.TYPE_CHECKING:
from odoo.tools import Query
NoneType = type(None)
def check_property_field_value_name(property_name):
if not (0 < len(property_name) <= 512) or not regex_alphanumeric.match(property_name):
raise ValueError(f"Wrong property field value name {property_name!r}.")
class Properties(Field):
""" 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.
This field allows a light customization based on a container record. Used
for relationships such as <project.project> / <project.task>,... New
properties can be created on the fly without changing the structure of the
database.
The "definition_record" define the field used to find the container of the
current record. The container must have a :class:`~odoo.fields.PropertiesDefinition`
field "definition_record_field" that contains the properties definition
(type of each property, default value)...
Only the value of each property is stored on the child. When we read the
properties field, we read the definition on the container and merge it with
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')
copy = False
prefetch = False
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
readonly = False
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
_description_definition_record = property(attrgetter('definition_record'))
_description_definition_record_field = property(attrgetter('definition_record_field'))
HTML_SANITIZE_OPTIONS = {
'sanitize_attributes': True,
'sanitize_tags': True,
'sanitize_style': False,
'sanitize_form': True,
'sanitize_conditional_comments': True,
'strip_style': False,
'strip_classes': False,
}
ALLOWED_TYPES = (
# standard types
'boolean', 'integer', 'float', 'text', 'char', 'html', 'date', 'datetime', 'monetary',
# relational like types
'many2one', 'many2many', 'selection', 'tags',
# UI types
'separator',
)
def _setup_attrs__(self, model_class, name):
super()._setup_attrs__(model_class, name)
self._setup_definition_attrs(model_class)
def _setup_definition_attrs(self, model_class):
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)
if not self.inherited_field:
# make the field computed, and set its dependencies
self._depends = (self.definition_record, )
self.compute = self._compute
def setup(self, model):
if not self._setup_done and self.definition_record and self.definition_record_field:
definition_record_field = model.env[model._fields[self.definition_record].comodel_name]._fields[self.definition_record_field]
definition_record_field.properties_fields += (self,)
return super().setup(model)
def setup_related(self, model):
super().setup_related(model)
if self.inherited_field and not self.definition:
self.definition = self.inherited_field.definition
self._setup_definition_attrs(model)
# Database/cache format: a value is either None, or a dict mapping property
# names to their corresponding value, like
#
# {
# '3adf37f3258cfe40': 'red',
# 'aa34746a6851ee4e': 1337,
# }
#
def convert_to_column(self, value, record, values=None, validate=True):
if not value:
return None
value = self.convert_to_cache(value, record, validate=validate)
return json.dumps(value)
def convert_to_cache(self, value, record, validate=True):
# any format -> cache format {name: value} or None
if not value:
return None
if isinstance(value, Property):
value = value._values
elif isinstance(value, dict):
# avoid accidental side effects from shared mutable data
value = copy.deepcopy(value)
elif isinstance(value, str):
value = json.loads(value)
if not isinstance(value, dict):
raise ValueError(f"Wrong property value {value!r}")
elif isinstance(value, list):
# Convert the list with all definitions into a simple dict
# {name: value} to store the strict minimum on the child
self._remove_display_name(value)
value = self._list_to_dict(value)
else:
raise TypeError(f"Wrong property type {type(value)!r}")
if validate:
# Sanitize `_html` flagged properties
for property_name, property_value in value.items():
if property_name.endswith('_html'):
value[property_name] = html_sanitize(
property_value,
**self.HTML_SANITIZE_OPTIONS,
)
return value
# Record format: the value is either False, or a dict mapping property
# names to their corresponding value, like
#
# {
# '3adf37f3258cfe40': 'red',
# 'aa34746a6851ee4e': 1337,
# }
#
def convert_to_record(self, value, record):
return Property(value or {}, self, record)
# Read format: the value is a list, where each element is a dict containing
# the definition of a property, together with the property's corresponding
# value, where relational field values have a display name.
#
# [{
# 'name': '3adf37f3258cfe40',
# 'string': 'Color Code',
# 'type': 'char',
# 'default': 'blue',
# 'value': 'red',
# }, {
# 'name': 'aa34746a6851ee4e',
# 'string': 'Partner',
# 'type': 'many2one',
# 'comodel': 'test_orm.partner',
# 'value': [1337, 'Bob'],
# }]
#
def convert_to_read(self, value, record, use_display_name=True):
return self.convert_to_read_multi([value], record, use_display_name)[0]
def convert_to_read_multi(self, values, records, use_display_name=True):
if not records:
return values
assert len(values) == len(records)
# each value is either False or a dict
result = []
for record, value in zip(records, values):
value = value._values if isinstance(value, Property) else value # Property -> dict
if definition := self._get_properties_definition(record):
value = value or {}
assert isinstance(value, dict), f"Wrong type {value!r}"
result.append(self._dict_to_list(value, definition))
else:
result.append([])
res_ids_per_model = self._get_res_ids_per_model(records.env, result)
# value is in record format
for value in result:
self._parse_json_types(value, records.env, res_ids_per_model)
if use_display_name:
for value in result:
self._add_display_name(value, records.env)
return result
def convert_to_write(self, value, record):
"""If we write a list on the child, update the definition record."""
return value
def convert_to_export(self, value, record):
""" Convert value from the record format to the export format. """
if isinstance(value, Property):
value = value._values
return value or ''
def _get_res_ids_per_model(self, env, values_list):
"""Read everything needed in batch for the given records.
To retrieve relational properties names, or to check their existence,
we need to do some SQL queries. To reduce the number of queries when we read
in batch, we prefetch everything needed before calling
convert_to_record / convert_to_read.
Return a dict {model: record_ids} that contains
the existing ids for each needed models.
"""
# ids per model we need to fetch in batch to put in cache
ids_per_model = defaultdict(OrderedSet)
for record_values in 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 []
if type_ not in ('many2one', 'many2many') or comodel not in env:
continue
if type_ == 'many2one':
default = [default] if default else []
property_value = [property_value] if isinstance(property_value, int) else []
elif not is_list_of(property_value, int):
property_value = []
ids_per_model[comodel].update(default)
ids_per_model[comodel].update(property_value)
# check existence and pre-fetch in batch
res_ids_per_model = {}
for model, ids in ids_per_model.items():
recs = env[model].browse(ids).exists()
res_ids_per_model[model] = set(recs.ids)
for record in recs:
# read a field to pre-fetch the recordset
with contextlib.suppress(AccessError):
record.display_name
return res_ids_per_model
def write(self, records, value):
"""Check if the properties definition has been changed.
To avoid extra SQL queries used to detect definition change, we add a
flag in the properties list. Parent update is done only when this flag
is present, delegating the check to the caller (generally web client).
For deletion, we need to keep the removed property definition in the
list to be able to put the delete flag in it. Otherwise we have no way
to know that a property has been removed.
"""
if isinstance(value, str):
value = json.loads(value)
if isinstance(value, Property):
value = value._values
if len(records[self.definition_record]) > 1 and value:
raise UserError(records.env._("Updating records with different property fields definitions is not supported. Update by separate definition instead."))
if isinstance(value, dict):
# don't need to write on the container definition
return super().write(records, value)
definition_changed = any(
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')
]
for definition in value:
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)
container[self.definition_record_field] = properties_definition
_logger.info('Properties field: User #%i changed definition of %r', records.env.user.id, container)
return super().write(records, value)
def _compute(self, records):
"""Add the default properties value when the container is changed."""
for record in records.sudo():
record[self.name] = self._add_default_values(
record.env,
{self.name: record[self.name], self.definition_record: record[self.definition_record]},
)
def _add_default_values(self, env, values):
"""Read the properties definition to add default values.
Default values are defined on the container in the 'default' key of
the definition.
:param env: environment
:param values: All values that will be written on the record
:return: Return the default values in the "dict" format
"""
properties_values = values.get(self.name) or {}
if isinstance(properties_values, Property):
properties_values = properties_values._values
if not values.get(self.definition_record):
# container is not given in the value, can not find properties definition
return {}
container_id = values[self.definition_record]
if not isinstance(container_id, (int, BaseModel)):
raise ValueError(f"Wrong container value {container_id!r}")
if isinstance(container_id, int):
# retrieve the container record
current_model = env[self.model_name]
definition_record_field = current_model._fields[self.definition_record]
container_model_name = definition_record_field.comodel_name
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 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
return {}
assert isinstance(properties_values, (list, dict))
if isinstance(properties_values, list):
self._remove_display_name(properties_values)
properties_list_values = properties_values
else:
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')
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')
if default:
properties_value['value'] = default
return properties_list_values
def _get_properties_definition(self, record):
"""Return the properties definition of the given record."""
container = record[self.definition_record]
if container:
return container.sudo()[self.definition_record_field]
@classmethod
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".
:param values_list: List of properties definition and values
:param env: environment
"""
for property_definition in values_list:
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):
try:
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
property_definition[value_key] = (property_value, None)
except MissingError:
property_definition[value_key] = False
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))
except AccessError:
property_definition[value_key].append((record.id, None))
except MissingError:
continue
@classmethod
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".
- many2one: (35, 'Bob') -> 35
- many2many: [(35, 'Bob'), (36, 'Alice')] -> [35, 36]
:param values_list: List of properties definition with properties value
: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'):
continue
property_value = property_definition.get(value_key)
if not property_value:
continue
property_type = property_definition.get('type')
if property_type == 'many2one' and has_list_types(property_value, [int, (str, NoneType)]):
property_definition[value_key] = property_value[0]
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
]
@classmethod
def _add_missing_names(cls, values_list):
"""Generate new properties name if needed.
Modify in place "values_list".
: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'):
# keep only the first 64 bits
definition['name'] = str(uuid.uuid4()).replace('-', '')[:16]
@classmethod
def _parse_json_types(cls, values_list, env, res_ids_per_model):
"""Parse the value stored in the JSON.
Check for records existence, if we removed a selection option, ...
Modify in place "values_list".
:param values_list: List of properties definition and values
: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')
if property_type not in cls.ALLOWED_TYPES:
raise ValueError(f'Wrong property type {property_type!r}')
if property_value is None:
continue
if property_type == 'boolean':
# E.G. convert zero to False
property_value = bool(property_value)
elif property_type in ('char', 'text') and not isinstance(property_value, str):
property_value = False
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
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':
# remove all tags that are not defined on the container
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':
if not isinstance(property_value, int) \
or res_model not in env \
or property_value not in res_ids_per_model[res_model]:
property_value = False
elif property_type == 'many2many':
if not is_list_of(property_value, int):
property_value = []
elif 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]
] if res_model in env else []
elif property_type == 'html':
# field name should end with `_html` to be legit and sanitized,
# otherwise do not trust the value and force False
property_value = property_definition['name'].endswith('_html') and property_value
property_definition['value'] = property_value
@classmethod
def _list_to_dict(cls, values_list):
"""Convert a list of properties with definition into a dict {name: value}.
To not repeat data in database, we only store the value of each property on
the child. The properties definition is stored on the container.
E.G.
Input list:
[{
'name': '3adf37f3258cfe40',
'string': 'Color Code',
'type': 'char',
'default': 'blue',
'value': 'red',
}, {
'name': 'aa34746a6851ee4e',
'string': 'Partner',
'type': 'many2one',
'comodel': 'test_orm.partner',
'value': [1337, 'Bob'],
}]
Output dict:
{
'3adf37f3258cfe40': 'red',
'aa34746a6851ee4e': 1337,
}
:param values_list: List of properties definition and value
: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}')
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')
if property_value is None:
# Do not store None key
continue
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:
# 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):
raise ValueError(f"Wrong many2many value {property_value!r}")
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
return dict_value
@classmethod
def _dict_to_list(cls, values_dict, properties_definition):
"""Convert a dict of {property: value} into a list of property definition with values.
:param values_dict: JSON value coming from the child table
:param properties_definition: Properties definition coming from the container table
:return: Merge both value into a list of properties with value
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}')
values_list = copy.deepcopy(properties_definition)
for property_definition in values_list:
if property_definition['name'] in values_dict:
property_definition['value'] = values_dict[property_definition['name']]
else:
property_definition.pop('value', None)
return values_list
def expression_getter(self, field_expr):
_fname, property_name = parse_field_expr(field_expr)
if not property_name:
raise ValueError(f"Missing property name for {self}")
def get_property(record):
property_value = self.__get__(record.with_context(property_selection_get_key=True))
value = property_value.get(property_name)
if value:
return value
# find definition to check the type
for definition in self._get_properties_definition(record) or ():
if definition.get('name') == property_name:
break
else:
# definition not found
return value or False
if not value and definition['type'] in ('many2one', 'many2many'):
return record.env.get(definition.get('comodel'))
return value
return get_property
def filter_function(self, records, field_expr, operator, value):
getter = self.expression_getter(field_expr)
domain = None
if operator == 'any' or isinstance(value, Domain):
domain = Domain(value).optimize(records)
elif operator == 'in' and isinstance(value, COLLECTION_TYPES) and isinstance(getter(records.browse()), BaseModel):
domain = Domain('id', 'in', value).optimize(records)
if domain is not None:
return lambda rec: getter(rec).filtered_domain(domain)
return super().filter_function(records, field_expr, operator, value)
def property_to_sql(self, field_sql: SQL, property_name: str, model: BaseModel, alias: str, query: Query) -> SQL:
check_property_field_value_name(property_name)
return SQL("(%s -> %s)", field_sql, property_name)
def condition_to_sql(self, field_expr: str, operator: str, value, model: BaseModel, alias: str, query: Query) -> SQL:
fname, property_name = parse_field_expr(field_expr)
if not property_name:
raise ValueError(f"Missing property name for {self}")
raw_sql_field = model._field_to_sql(alias, fname, query)
sql_left = model._field_to_sql(alias, field_expr, query)
if operator in ('in', 'not in'):
assert isinstance(value, COLLECTION_TYPES)
if len(value) == 1 and True in value:
# inverse the condition
check_null_op_false = "!=" if operator == 'in' else "="
value = []
operator = 'in' if operator == 'not in' else 'not in'
elif False in value:
check_null_op_false = "=" if operator == 'in' else "!="
value = [v for v in value if v]
else:
value = list(value)
check_null_op_false = None
sqls = []
if check_null_op_false:
sqls.append(SQL(
"%s%s'%s'",
sql_left,
SQL_OPERATORS[check_null_op_false],
False,
))
if check_null_op_false == '=':
# check null value too
sqls.extend((
SQL("%s IS NULL", raw_sql_field),
SQL("NOT (%s ? %s)", raw_sql_field, property_name),
))
# left can be an array or a single value!
# Even if we use the '=' operator, we must check the list subset.
# There is an unsupported edge-case where left is a list and we
# have multiple values.
if len(value) == 1:
# check single value equality
sql_operator = SQL_OPERATORS['=' if operator == 'in' else '!=']
sql_right = SQL("%s", json.dumps(value[0]))
sqls.append(SQL("%s%s%s", sql_left, sql_operator, sql_right))
if value:
sql_not = SQL('NOT ') if operator == 'not in' else SQL()
# hackish operator to search values
if len(value) > 1:
# left <@ value_list -- single left value in value_list
# (here we suppose left is a single value)
sql_operator = SQL(" <@ ")
else:
# left @> value -- value_list in left
sql_operator = SQL(" @> ")
sql_right = SQL("%s", json.dumps(value))
sqls.append(SQL(
"%s%s%s%s",
sql_not, sql_left, sql_operator, sql_right,
))
assert sqls, "No SQL generated for property"
if len(sqls) == 1:
return sqls[0]
combine_sql = SQL(" OR ") if operator == 'in' else SQL(" AND ")
return SQL("(%s)", combine_sql.join(sqls))
unaccent = lambda x: x # noqa: E731
if operator.endswith('like'):
if operator.endswith('ilike'):
unaccent = model.env.registry.unaccent
if '=' in operator:
value = str(value)
else:
value = f'%{value}%'
try:
sql_operator = SQL_OPERATORS[operator]
except KeyError:
raise ValueError(f"Invalid operator {operator} for Properties")
if isinstance(value, str):
sql_left = SQL("(%s ->> %s)", raw_sql_field, property_name) # JSONified value
sql_right = SQL("%s", value)
sql = SQL(
"%s%s%s",
unaccent(sql_left), sql_operator, unaccent(sql_right),
)
if operator in Domain.NEGATIVE_OPERATORS:
sql = SQL("(%s OR %s IS NULL)", sql, sql_left)
return sql
sql_right = SQL("%s", json.dumps(value))
return SQL(
"%s%s%s",
unaccent(sql_left), sql_operator, unaccent(sql_right),
)
class Property(abc.Mapping):
"""Represent a collection of properties of a record.
An object that implements the value of a :class:`Properties` field in the "record"
format, i.e., the result of evaluating an expression like ``record.property_field``.
The value behaves as a ``dict``, and individual properties are returned in their
expected type, according to ORM conventions. For instance, the value of a many2one
property is returned as a recordset::
# attributes is a properties field, and 'partner_id' is a many2one property;
# partner is thus a recordset
partner = record.attributes['partner_id']
partner.name
When the accessed key does not exist, i.e., there is no corresponding property
definition for that record, the access raises a :class:`KeyError`.
"""
def __init__(self, values, field, record):
self._values = values
self.record = record
self.field = field
def __iter__(self):
for key in self._values:
with contextlib.suppress(KeyError):
self[key]
yield key
def __len__(self):
return len(self._values)
def __eq__(self, other):
return self._values == (other._values if isinstance(other, Property) else other)
def __getitem__(self, property_name):
"""Will make the verification."""
if not self.record:
return False
values = self.field.convert_to_read(
self._values,
self.record,
use_display_name=False,
)
prop = next((p for p in values if p['name'] == property_name), False)
if not prop:
raise KeyError(property_name)
if prop.get('type') == 'many2one' and prop.get('comodel'):
return self.record.env[prop.get('comodel')].browse(prop.get('value'))
if prop.get('type') == 'many2many' and prop.get('comodel'):
return self.record.env[prop.get('comodel')].browse(prop.get('value'))
if prop.get('type') == 'selection' and prop.get('value'):
if self.record.env.context.get('property_selection_get_key'):
return next((sel[0] for sel in prop.get('selection') if sel[0] == prop['value']), False)
return next((sel[1] for sel in prop.get('selection') if sel[0] == prop['value']), False)
if prop.get('type') == 'tags' and prop.get('value'):
return ', '.join(tag[1] for tag in prop.get('tags') if tag[0] in prop['value'])
return prop.get('value') or False
def __hash__(self):
return hash(frozendict(self._values))
class PropertiesDefinition(Field):
""" 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
readonly = False
prefetch = True
properties_fields = () # List of Properties fields using that definition
REQUIRED_KEYS = ('name', 'type')
ALLOWED_KEYS = (
'name', 'string', 'type', 'comodel', 'default', 'suffix',
'selection', 'tags', 'domain', 'view_in_cards', 'fold_by_default',
'currency_field'
)
# those keys will be removed if the types does not match
PROPERTY_PARAMETERS_MAP = {
'comodel': {'many2one', 'many2many'},
'currency_field': {'monetary'},
'domain': {'many2one', 'many2many'},
'selection': {'selection'},
'tags': {'tags'},
}
def convert_to_column(self, value, record, values=None, validate=True):
"""Convert the value before inserting it in database.
This method accepts a list properties definition.
The relational properties (many2one / many2many) default value
might contain the display_name of those records (and will be removed).
[{
'name': '3adf37f3258cfe40',
'string': 'Color Code',
'type': 'char',
'default': 'blue',
'value': 'red',
}, {
'name': 'aa34746a6851ee4e',
'string': 'Partner',
'type': 'many2one',
'comodel': 'test_orm.partner',
'default': [1337, 'Bob'],
}]
"""
if not value:
return None
if isinstance(value, str):
value = json.loads(value)
if not isinstance(value, list):
raise TypeError(f'Wrong properties definition type {type(value)!r}')
if validate:
Properties._remove_display_name(value, value_key='default')
self._validate_properties_definition(value, record.env)
return json.dumps(record._convert_to_cache_properties_definition(value))
def convert_to_cache(self, value, record, validate=True):
# any format -> cache format (list of dicts or None)
if not value:
return None
if isinstance(value, list):
# avoid accidental side effects from shared mutable data, and make
# the value strict with respect to JSON (tuple -> list, etc)
value = json.dumps(value)
if isinstance(value, str):
value = json.loads(value)
if not isinstance(value, list):
raise TypeError(f'Wrong properties definition type {type(value)!r}')
if validate:
Properties._remove_display_name(value, value_key='default')
self._validate_properties_definition(value, record.env)
return record._convert_to_column_properties_definition(value)
def convert_to_record(self, value, record):
# cache format -> record format (list of dicts)
if not value:
return []
# return a copy of the definition in cache where all property
# definitions have been cleaned up
result = []
for property_definition in value:
if not all(property_definition.get(key) for key in self.REQUIRED_KEYS):
# some required keys are missing, ignore this property definition
continue
# don't modify the value in cache
property_definition = copy.deepcopy(property_definition)
type_ = property_definition.get('type')
if type_ in ('many2one', 'many2many'):
# 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')
if property_model not in record.env:
property_definition['comodel'] = False
property_definition.pop('domain', None)
elif property_domain := property_definition.get('domain'):
# some fields in the domain might have been removed
# (e.g. if the module has been uninstalled)
# check if the domain is still valid
try:
dom = Domain(ast.literal_eval(property_domain))
model = record.env[property_model]
dom.validate(model)
except ValueError:
del property_definition['domain']
elif type_ in ('selection', 'tags'):
# always set at least an empty array if there's no option
property_definition[type_] = property_definition.get(type_) or []
result.append(property_definition)
return result
def convert_to_read(self, value, record, use_display_name=True):
# record format -> read format (list of dicts with display names)
if not value:
return value
if use_display_name:
Properties._add_display_name(value, record.env, value_keys=('default',))
return value
def convert_to_write(self, value, record):
return value
def _validate_properties_definition(self, properties_definition, env):
"""Raise an error if the property definition is not valid."""
allowed_keys = self.ALLOWED_KEYS + env["base"]._additional_allowed_keys_properties_definition()
env["base"]._validate_properties_definition(properties_definition, self)
properties_names = set()
for property_definition in properties_definition:
for property_parameter, allowed_types in self.PROPERTY_PARAMETERS_MAP.items():
if property_definition.get('type') not in allowed_types and property_parameter in property_definition:
raise ValueError(f'Invalid property parameter {property_parameter!r}')
property_definition_keys = set(property_definition.keys())
invalid_keys = property_definition_keys - set(allowed_keys)
if invalid_keys:
raise ValueError(
'Some key are not allowed for a properties definition [%s].' %
', '.join(invalid_keys),
)
check_property_field_value_name(property_definition['name'])
required_keys = set(self.REQUIRED_KEYS) - property_definition_keys
if required_keys:
raise ValueError(
'Some key are missing for a properties definition [%s].' %
', '.join(required_keys),
)
property_type = property_definition.get('type')
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.')
properties_names.add(property_name)
if property_type == 'html' and not property_name.endswith('_html'):
raise ValueError("HTML property name should end with `_html`.")
if property_type != 'html' and property_name.endswith('_html'):
raise ValueError("Only HTML properties can have the `_html` suffix.")
if property_type and property_type not in Properties.ALLOWED_TYPES:
raise ValueError(f'Wrong property type {property_type!r}.')
if property_type == 'html' and (default := property_definition.get('default')):
property_definition['default'] = html_sanitize(default, **Properties.HTML_SANITIZE_OPTIONS)
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')
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}.')
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)}.')
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}.')
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)}.')