Coverage for adhoc-cicd-odoo-odoo / odoo / orm / fields_properties.py: 47%
545 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
1from __future__ import annotations
3import ast
4import contextlib
5import copy
6import json
7import typing
8import uuid
9from collections import abc, defaultdict
10from operator import attrgetter
12from odoo.exceptions import AccessError, UserError, MissingError
13from odoo.tools import SQL, OrderedSet, is_list_of, html_sanitize
14from odoo.tools.misc import frozendict, has_list_types
15from odoo.tools.translate import _
17from .domains import Domain
18from .fields import Field, _logger
19from .models import BaseModel
20from .utils import COLLECTION_TYPES, SQL_OPERATORS, parse_field_expr, regex_alphanumeric
21if typing.TYPE_CHECKING:
22 from odoo.tools import Query
24NoneType = type(None)
27def check_property_field_value_name(property_name):
28 if not (0 < len(property_name) <= 512) or not regex_alphanumeric.match(property_name): 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 raise ValueError(f"Wrong property field value name {property_name!r}.")
32class Properties(Field):
33 """ Field that contains a list of properties (aka "sub-field") based on
34 a definition defined on a container. Properties are pseudo-fields, acting
35 like Odoo fields but without being independently stored in database.
37 This field allows a light customization based on a container record. Used
38 for relationships such as <project.project> / <project.task>,... New
39 properties can be created on the fly without changing the structure of the
40 database.
42 The "definition_record" define the field used to find the container of the
43 current record. The container must have a :class:`~odoo.fields.PropertiesDefinition`
44 field "definition_record_field" that contains the properties definition
45 (type of each property, default value)...
47 Only the value of each property is stored on the child. When we read the
48 properties field, we read the definition on the container and merge it with
49 the value of the child. That way the web client has access to the full
50 field definition (property type, ...).
51 """
52 type = 'properties'
53 _column_type = ('jsonb', 'jsonb')
54 copy = False
55 prefetch = False
56 write_sequence = 10 # because it must be written after the definition field
58 # the field is computed editable by design (see the compute method below)
59 store = True
60 readonly = False
61 precompute = True
63 definition = None
64 definition_record = None # field on the current model that point to the definition record
65 definition_record_field = None # field on the definition record which defined the Properties field definition
67 _description_definition_record = property(attrgetter('definition_record'))
68 _description_definition_record_field = property(attrgetter('definition_record_field'))
70 HTML_SANITIZE_OPTIONS = {
71 'sanitize_attributes': True,
72 'sanitize_tags': True,
73 'sanitize_style': False,
74 'sanitize_form': True,
75 'sanitize_conditional_comments': True,
76 'strip_style': False,
77 'strip_classes': False,
78 }
80 ALLOWED_TYPES = (
81 # standard types
82 'boolean', 'integer', 'float', 'text', 'char', 'html', 'date', 'datetime', 'monetary',
83 # relational like types
84 'many2one', 'many2many', 'selection', 'tags',
85 # UI types
86 'separator',
87 )
89 def _setup_attrs__(self, model_class, name):
90 super()._setup_attrs__(model_class, name)
91 self._setup_definition_attrs(model_class)
93 def _setup_definition_attrs(self, model_class):
94 if self.definition:
95 # determine definition_record and definition_record_field
96 assert self.definition.count(".") == 1
97 self.definition_record, self.definition_record_field = self.definition.rsplit('.', 1)
99 if not self.inherited_field:
100 # make the field computed, and set its dependencies
101 self._depends = (self.definition_record, )
102 self.compute = self._compute
104 def setup(self, model):
105 if not self._setup_done and self.definition_record and self.definition_record_field:
106 definition_record_field = model.env[model._fields[self.definition_record].comodel_name]._fields[self.definition_record_field]
107 definition_record_field.properties_fields += (self,)
108 return super().setup(model)
110 def setup_related(self, model):
111 super().setup_related(model)
112 if self.inherited_field and not self.definition: 112 ↛ exitline 112 didn't return from function 'setup_related' because the condition on line 112 was always true
113 self.definition = self.inherited_field.definition
114 self._setup_definition_attrs(model)
116 # Database/cache format: a value is either None, or a dict mapping property
117 # names to their corresponding value, like
118 #
119 # {
120 # '3adf37f3258cfe40': 'red',
121 # 'aa34746a6851ee4e': 1337,
122 # }
123 #
124 def convert_to_column(self, value, record, values=None, validate=True):
125 if not value:
126 return None
128 value = self.convert_to_cache(value, record, validate=validate)
129 return json.dumps(value)
131 def convert_to_cache(self, value, record, validate=True):
132 # any format -> cache format {name: value} or None
133 if not value:
134 return None
136 if isinstance(value, Property): 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 value = value._values
139 elif isinstance(value, dict):
140 # avoid accidental side effects from shared mutable data
141 value = copy.deepcopy(value)
143 elif isinstance(value, str): 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true
144 value = json.loads(value)
145 if not isinstance(value, dict):
146 raise ValueError(f"Wrong property value {value!r}")
148 elif isinstance(value, list): 148 ↛ 155line 148 didn't jump to line 155 because the condition on line 148 was always true
149 # Convert the list with all definitions into a simple dict
150 # {name: value} to store the strict minimum on the child
151 self._remove_display_name(value)
152 value = self._list_to_dict(value)
154 else:
155 raise TypeError(f"Wrong property type {type(value)!r}")
157 if validate:
158 # Sanitize `_html` flagged properties
159 for property_name, property_value in value.items():
160 if property_name.endswith('_html'): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 value[property_name] = html_sanitize(
162 property_value,
163 **self.HTML_SANITIZE_OPTIONS,
164 )
166 return value
168 # Record format: the value is either False, or a dict mapping property
169 # names to their corresponding value, like
170 #
171 # {
172 # '3adf37f3258cfe40': 'red',
173 # 'aa34746a6851ee4e': 1337,
174 # }
175 #
176 def convert_to_record(self, value, record):
177 return Property(value or {}, self, record)
179 # Read format: the value is a list, where each element is a dict containing
180 # the definition of a property, together with the property's corresponding
181 # value, where relational field values have a display name.
182 #
183 # [{
184 # 'name': '3adf37f3258cfe40',
185 # 'string': 'Color Code',
186 # 'type': 'char',
187 # 'default': 'blue',
188 # 'value': 'red',
189 # }, {
190 # 'name': 'aa34746a6851ee4e',
191 # 'string': 'Partner',
192 # 'type': 'many2one',
193 # 'comodel': 'test_orm.partner',
194 # 'value': [1337, 'Bob'],
195 # }]
196 #
197 def convert_to_read(self, value, record, use_display_name=True):
198 return self.convert_to_read_multi([value], record, use_display_name)[0]
200 def convert_to_read_multi(self, values, records, use_display_name=True):
201 if not records: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 return values
203 assert len(values) == len(records)
205 # each value is either False or a dict
206 result = []
207 for record, value in zip(records, values):
208 value = value._values if isinstance(value, Property) else value # Property -> dict
209 if definition := self._get_properties_definition(record): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 value = value or {}
211 assert isinstance(value, dict), f"Wrong type {value!r}"
212 result.append(self._dict_to_list(value, definition))
213 else:
214 result.append([])
216 res_ids_per_model = self._get_res_ids_per_model(records.env, result)
218 # value is in record format
219 for value in result:
220 self._parse_json_types(value, records.env, res_ids_per_model)
222 if use_display_name: 222 ↛ 226line 222 didn't jump to line 226 because the condition on line 222 was always true
223 for value in result:
224 self._add_display_name(value, records.env)
226 return result
228 def convert_to_write(self, value, record):
229 """If we write a list on the child, update the definition record."""
230 return value
232 def convert_to_export(self, value, record):
233 """ Convert value from the record format to the export format. """
234 if isinstance(value, Property):
235 value = value._values
236 return value or ''
238 def _get_res_ids_per_model(self, env, values_list):
239 """Read everything needed in batch for the given records.
241 To retrieve relational properties names, or to check their existence,
242 we need to do some SQL queries. To reduce the number of queries when we read
243 in batch, we prefetch everything needed before calling
244 convert_to_record / convert_to_read.
246 Return a dict {model: record_ids} that contains
247 the existing ids for each needed models.
248 """
249 # ids per model we need to fetch in batch to put in cache
250 ids_per_model = defaultdict(OrderedSet)
252 for record_values in values_list:
253 for property_definition in record_values: 253 ↛ 254line 253 didn't jump to line 254 because the loop on line 253 never started
254 comodel = property_definition.get('comodel')
255 type_ = property_definition.get('type')
256 property_value = property_definition.get('value') or []
257 default = property_definition.get('default') or []
259 if type_ not in ('many2one', 'many2many') or comodel not in env:
260 continue
262 if type_ == 'many2one':
263 default = [default] if default else []
264 property_value = [property_value] if isinstance(property_value, int) else []
265 elif not is_list_of(property_value, int):
266 property_value = []
268 ids_per_model[comodel].update(default)
269 ids_per_model[comodel].update(property_value)
271 # check existence and pre-fetch in batch
272 res_ids_per_model = {}
273 for model, ids in ids_per_model.items(): 273 ↛ 274line 273 didn't jump to line 274 because the loop on line 273 never started
274 recs = env[model].browse(ids).exists()
275 res_ids_per_model[model] = set(recs.ids)
277 for record in recs:
278 # read a field to pre-fetch the recordset
279 with contextlib.suppress(AccessError):
280 record.display_name
282 return res_ids_per_model
284 def write(self, records, value):
285 """Check if the properties definition has been changed.
287 To avoid extra SQL queries used to detect definition change, we add a
288 flag in the properties list. Parent update is done only when this flag
289 is present, delegating the check to the caller (generally web client).
291 For deletion, we need to keep the removed property definition in the
292 list to be able to put the delete flag in it. Otherwise we have no way
293 to know that a property has been removed.
294 """
295 if isinstance(value, str): 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true
296 value = json.loads(value)
298 if isinstance(value, Property):
299 value = value._values
301 if len(records[self.definition_record]) > 1 and value: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 raise UserError(records.env._("Updating records with different property fields definitions is not supported. Update by separate definition instead."))
304 if isinstance(value, dict):
305 # don't need to write on the container definition
306 return super().write(records, value)
308 definition_changed = any(
309 definition.get('definition_changed')
310 or definition.get('definition_deleted')
311 for definition in (value or [])
312 )
313 if definition_changed: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 value = [
315 definition for definition in value
316 if not definition.get('definition_deleted')
317 ]
318 for definition in value:
319 definition.pop('definition_changed', None)
321 # update the properties definition on the container
322 container = records[self.definition_record]
323 if container:
324 properties_definition = copy.deepcopy(value)
325 for property_definition in properties_definition:
326 property_definition.pop('value', None)
327 container[self.definition_record_field] = properties_definition
329 _logger.info('Properties field: User #%i changed definition of %r', records.env.user.id, container)
331 return super().write(records, value)
333 def _compute(self, records):
334 """Add the default properties value when the container is changed."""
335 for record in records.sudo():
336 record[self.name] = self._add_default_values(
337 record.env,
338 {self.name: record[self.name], self.definition_record: record[self.definition_record]},
339 )
341 def _add_default_values(self, env, values):
342 """Read the properties definition to add default values.
344 Default values are defined on the container in the 'default' key of
345 the definition.
347 :param env: environment
348 :param values: All values that will be written on the record
349 :return: Return the default values in the "dict" format
350 """
351 properties_values = values.get(self.name) or {}
353 if isinstance(properties_values, Property): 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true
354 properties_values = properties_values._values
356 if not values.get(self.definition_record):
357 # container is not given in the value, can not find properties definition
358 return {}
360 container_id = values[self.definition_record]
361 if not isinstance(container_id, (int, BaseModel)): 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true
362 raise ValueError(f"Wrong container value {container_id!r}")
364 if isinstance(container_id, int):
365 # retrieve the container record
366 current_model = env[self.model_name]
367 definition_record_field = current_model._fields[self.definition_record]
368 container_model_name = definition_record_field.comodel_name
369 container_id = env[container_model_name].sudo().browse(container_id)
371 properties_definition = container_id[self.definition_record_field]
372 if not (properties_definition or (
373 isinstance(properties_values, list)
374 and any(d.get('definition_changed') for d in properties_values)
375 )):
376 # If a parent is set without properties, we might want to change its definition
377 # when we create the new record. But if we just set the value without changing
378 # the definition, in that case we can just ignored the passed values
379 return {}
381 assert isinstance(properties_values, (list, dict))
382 if isinstance(properties_values, list): 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true
383 self._remove_display_name(properties_values)
384 properties_list_values = properties_values
385 else:
386 properties_list_values = self._dict_to_list(properties_values, properties_definition)
388 for properties_value in properties_list_values:
389 if properties_value.get('value') is None: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true
390 property_name = properties_value.get('name')
391 context_key = f"default_{self.name}.{property_name}"
392 if property_name and context_key in env.context:
393 default = env.context[context_key]
394 else:
395 default = properties_value.get('default')
396 if default:
397 properties_value['value'] = default
399 return properties_list_values
401 def _get_properties_definition(self, record):
402 """Return the properties definition of the given record."""
403 container = record[self.definition_record]
404 if container:
405 return container.sudo()[self.definition_record_field]
407 @classmethod
408 def _add_display_name(cls, values_list, env, value_keys=('value', 'default')):
409 """Add the "display_name" for each many2one / many2many properties.
411 Modify in place "values_list".
413 :param values_list: List of properties definition and values
414 :param env: environment
415 """
416 for property_definition in values_list: 416 ↛ 417line 416 didn't jump to line 417 because the loop on line 416 never started
417 property_type = property_definition.get('type')
418 property_model = property_definition.get('comodel')
419 if not property_model:
420 continue
422 for value_key in value_keys:
423 property_value = property_definition.get(value_key)
425 if property_type == 'many2one' and property_value and isinstance(property_value, int):
426 try:
427 display_name = env[property_model].browse(property_value).display_name
428 property_definition[value_key] = (property_value, display_name)
429 except AccessError:
430 # protect from access error message, show an empty name
431 property_definition[value_key] = (property_value, None)
432 except MissingError:
433 property_definition[value_key] = False
435 elif property_type == 'many2many' and property_value and is_list_of(property_value, int):
436 property_definition[value_key] = []
437 records = env[property_model].browse(property_value)
438 for record in records:
439 try:
440 property_definition[value_key].append((record.id, record.display_name))
441 except AccessError:
442 property_definition[value_key].append((record.id, None))
443 except MissingError:
444 continue
446 @classmethod
447 def _remove_display_name(cls, values_list, value_key='value'):
448 """Remove the display name received by the web client for the relational properties.
450 Modify in place "values_list".
452 - many2one: (35, 'Bob') -> 35
453 - many2many: [(35, 'Bob'), (36, 'Alice')] -> [35, 36]
455 :param values_list: List of properties definition with properties value
456 :param value_key: In which dict key we need to remove the display name
457 """
458 for property_definition in values_list:
459 if not isinstance(property_definition, dict) or not property_definition.get('name'): 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 continue
462 property_value = property_definition.get(value_key)
463 if not property_value:
464 continue
466 property_type = property_definition.get('type')
468 if property_type == 'many2one' and has_list_types(property_value, [int, (str, NoneType)]): 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true
469 property_definition[value_key] = property_value[0]
471 elif property_type == 'many2many': 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true
472 if is_list_of(property_value, (list, tuple)):
473 # [(35, 'Admin'), (36, 'Demo')] -> [35, 36]
474 property_definition[value_key] = [
475 many2many_value[0]
476 for many2many_value in property_value
477 ]
479 @classmethod
480 def _add_missing_names(cls, values_list):
481 """Generate new properties name if needed.
483 Modify in place "values_list".
485 :param values_list: List of properties definition with properties value
486 """
487 for definition in values_list:
488 if definition.get('definition_changed') and not definition.get('name'): 488 ↛ 490line 488 didn't jump to line 490 because the condition on line 488 was never true
489 # keep only the first 64 bits
490 definition['name'] = str(uuid.uuid4()).replace('-', '')[:16]
492 @classmethod
493 def _parse_json_types(cls, values_list, env, res_ids_per_model):
494 """Parse the value stored in the JSON.
496 Check for records existence, if we removed a selection option, ...
497 Modify in place "values_list".
499 :param values_list: List of properties definition and values
500 :param env: environment
501 """
502 for property_definition in values_list: 502 ↛ 503line 502 didn't jump to line 503 because the loop on line 502 never started
503 property_value = property_definition.get('value')
504 property_type = property_definition.get('type')
505 res_model = property_definition.get('comodel')
507 if property_type not in cls.ALLOWED_TYPES:
508 raise ValueError(f'Wrong property type {property_type!r}')
510 if property_value is None:
511 continue
513 if property_type == 'boolean':
514 # E.G. convert zero to False
515 property_value = bool(property_value)
517 elif property_type in ('char', 'text') and not isinstance(property_value, str):
518 property_value = False
520 elif property_value and property_type == 'selection':
521 # check if the selection option still exists
522 options = property_definition.get('selection') or []
523 options = {option[0] for option in options if option or ()} # always length 2
524 if property_value not in options:
525 # maybe the option has been removed on the container
526 property_value = False
528 elif property_value and property_type == 'tags':
529 # remove all tags that are not defined on the container
530 all_tags = {tag[0] for tag in property_definition.get('tags') or ()}
531 property_value = [tag for tag in property_value if tag in all_tags]
533 elif property_type == 'many2one':
534 if not isinstance(property_value, int) \
535 or res_model not in env \
536 or property_value not in res_ids_per_model[res_model]:
537 property_value = False
539 elif property_type == 'many2many':
540 if not is_list_of(property_value, int):
541 property_value = []
543 elif len(property_value) != len(set(property_value)):
544 # remove duplicated value and preserve order
545 property_value = list(dict.fromkeys(property_value))
547 property_value = [
548 id_ for id_ in property_value
549 if id_ in res_ids_per_model[res_model]
550 ] if res_model in env else []
552 elif property_type == 'html':
553 # field name should end with `_html` to be legit and sanitized,
554 # otherwise do not trust the value and force False
555 property_value = property_definition['name'].endswith('_html') and property_value
557 property_definition['value'] = property_value
559 @classmethod
560 def _list_to_dict(cls, values_list):
561 """Convert a list of properties with definition into a dict {name: value}.
563 To not repeat data in database, we only store the value of each property on
564 the child. The properties definition is stored on the container.
566 E.G.
567 Input list:
568 [{
569 'name': '3adf37f3258cfe40',
570 'string': 'Color Code',
571 'type': 'char',
572 'default': 'blue',
573 'value': 'red',
574 }, {
575 'name': 'aa34746a6851ee4e',
576 'string': 'Partner',
577 'type': 'many2one',
578 'comodel': 'test_orm.partner',
579 'value': [1337, 'Bob'],
580 }]
582 Output dict:
583 {
584 '3adf37f3258cfe40': 'red',
585 'aa34746a6851ee4e': 1337,
586 }
588 :param values_list: List of properties definition and value
589 :return: Generate a dict {name: value} from this definitions / values list
590 """
591 if not is_list_of(values_list, dict): 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true
592 raise ValueError(f'Wrong properties value {values_list!r}')
594 cls._add_missing_names(values_list)
596 dict_value = {}
597 for property_definition in values_list:
598 property_value = property_definition.get('value')
599 property_type = property_definition.get('type')
600 property_model = property_definition.get('comodel')
601 if property_value is None: 601 ↛ 603line 601 didn't jump to line 603 because the condition on line 601 was never true
602 # Do not store None key
603 continue
605 if property_type not in ('integer', 'float') or property_value != 0: 605 ↛ 607line 605 didn't jump to line 607 because the condition on line 605 was always true
606 property_value = property_value or False
607 if property_type in ('many2one', 'many2many') and property_model and property_value: 607 ↛ 609line 607 didn't jump to line 609 because the condition on line 607 was never true
608 # check that value are correct before storing them in database
609 if property_type == 'many2many' and property_value and not is_list_of(property_value, int):
610 raise ValueError(f"Wrong many2many value {property_value!r}")
612 if property_type == 'many2one' and not isinstance(property_value, int):
613 raise ValueError(f"Wrong many2one value {property_value!r}")
615 dict_value[property_definition['name']] = property_value
617 return dict_value
619 @classmethod
620 def _dict_to_list(cls, values_dict, properties_definition):
621 """Convert a dict of {property: value} into a list of property definition with values.
623 :param values_dict: JSON value coming from the child table
624 :param properties_definition: Properties definition coming from the container table
625 :return: Merge both value into a list of properties with value
626 Ignore every values in the child that is not defined on the container.
627 """
628 if not is_list_of(properties_definition, dict): 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true
629 raise ValueError(f'Wrong properties value {properties_definition!r}')
631 values_list = copy.deepcopy(properties_definition)
632 for property_definition in values_list:
633 if property_definition['name'] in values_dict: 633 ↛ 636line 633 didn't jump to line 636 because the condition on line 633 was always true
634 property_definition['value'] = values_dict[property_definition['name']]
635 else:
636 property_definition.pop('value', None)
637 return values_list
639 def expression_getter(self, field_expr):
640 _fname, property_name = parse_field_expr(field_expr)
641 if not property_name:
642 raise ValueError(f"Missing property name for {self}")
644 def get_property(record):
645 property_value = self.__get__(record.with_context(property_selection_get_key=True))
646 value = property_value.get(property_name)
647 if value:
648 return value
649 # find definition to check the type
650 for definition in self._get_properties_definition(record) or ():
651 if definition.get('name') == property_name:
652 break
653 else:
654 # definition not found
655 return value or False
657 if not value and definition['type'] in ('many2one', 'many2many'):
658 return record.env.get(definition.get('comodel'))
659 return value
661 return get_property
663 def filter_function(self, records, field_expr, operator, value):
664 getter = self.expression_getter(field_expr)
665 domain = None
666 if operator == 'any' or isinstance(value, Domain):
667 domain = Domain(value).optimize(records)
668 elif operator == 'in' and isinstance(value, COLLECTION_TYPES) and isinstance(getter(records.browse()), BaseModel):
669 domain = Domain('id', 'in', value).optimize(records)
670 if domain is not None:
671 return lambda rec: getter(rec).filtered_domain(domain)
672 return super().filter_function(records, field_expr, operator, value)
674 def property_to_sql(self, field_sql: SQL, property_name: str, model: BaseModel, alias: str, query: Query) -> SQL:
675 check_property_field_value_name(property_name)
676 return SQL("(%s -> %s)", field_sql, property_name)
678 def condition_to_sql(self, field_expr: str, operator: str, value, model: BaseModel, alias: str, query: Query) -> SQL:
679 fname, property_name = parse_field_expr(field_expr)
680 if not property_name:
681 raise ValueError(f"Missing property name for {self}")
682 raw_sql_field = model._field_to_sql(alias, fname, query)
683 sql_left = model._field_to_sql(alias, field_expr, query)
685 if operator in ('in', 'not in'):
686 assert isinstance(value, COLLECTION_TYPES)
687 if len(value) == 1 and True in value:
688 # inverse the condition
689 check_null_op_false = "!=" if operator == 'in' else "="
690 value = []
691 operator = 'in' if operator == 'not in' else 'not in'
692 elif False in value:
693 check_null_op_false = "=" if operator == 'in' else "!="
694 value = [v for v in value if v]
695 else:
696 value = list(value)
697 check_null_op_false = None
699 sqls = []
700 if check_null_op_false:
701 sqls.append(SQL(
702 "%s%s'%s'",
703 sql_left,
704 SQL_OPERATORS[check_null_op_false],
705 False,
706 ))
707 if check_null_op_false == '=':
708 # check null value too
709 sqls.extend((
710 SQL("%s IS NULL", raw_sql_field),
711 SQL("NOT (%s ? %s)", raw_sql_field, property_name),
712 ))
713 # left can be an array or a single value!
714 # Even if we use the '=' operator, we must check the list subset.
715 # There is an unsupported edge-case where left is a list and we
716 # have multiple values.
717 if len(value) == 1:
718 # check single value equality
719 sql_operator = SQL_OPERATORS['=' if operator == 'in' else '!=']
720 sql_right = SQL("%s", json.dumps(value[0]))
721 sqls.append(SQL("%s%s%s", sql_left, sql_operator, sql_right))
722 if value:
723 sql_not = SQL('NOT ') if operator == 'not in' else SQL()
724 # hackish operator to search values
725 if len(value) > 1:
726 # left <@ value_list -- single left value in value_list
727 # (here we suppose left is a single value)
728 sql_operator = SQL(" <@ ")
729 else:
730 # left @> value -- value_list in left
731 sql_operator = SQL(" @> ")
732 sql_right = SQL("%s", json.dumps(value))
733 sqls.append(SQL(
734 "%s%s%s%s",
735 sql_not, sql_left, sql_operator, sql_right,
736 ))
737 assert sqls, "No SQL generated for property"
738 if len(sqls) == 1:
739 return sqls[0]
740 combine_sql = SQL(" OR ") if operator == 'in' else SQL(" AND ")
741 return SQL("(%s)", combine_sql.join(sqls))
743 unaccent = lambda x: x # noqa: E731
744 if operator.endswith('like'):
745 if operator.endswith('ilike'):
746 unaccent = model.env.registry.unaccent
747 if '=' in operator:
748 value = str(value)
749 else:
750 value = f'%{value}%'
752 try:
753 sql_operator = SQL_OPERATORS[operator]
754 except KeyError:
755 raise ValueError(f"Invalid operator {operator} for Properties")
757 if isinstance(value, str):
758 sql_left = SQL("(%s ->> %s)", raw_sql_field, property_name) # JSONified value
759 sql_right = SQL("%s", value)
760 sql = SQL(
761 "%s%s%s",
762 unaccent(sql_left), sql_operator, unaccent(sql_right),
763 )
764 if operator in Domain.NEGATIVE_OPERATORS:
765 sql = SQL("(%s OR %s IS NULL)", sql, sql_left)
766 return sql
768 sql_right = SQL("%s", json.dumps(value))
769 return SQL(
770 "%s%s%s",
771 unaccent(sql_left), sql_operator, unaccent(sql_right),
772 )
775class Property(abc.Mapping):
776 """Represent a collection of properties of a record.
778 An object that implements the value of a :class:`Properties` field in the "record"
779 format, i.e., the result of evaluating an expression like ``record.property_field``.
780 The value behaves as a ``dict``, and individual properties are returned in their
781 expected type, according to ORM conventions. For instance, the value of a many2one
782 property is returned as a recordset::
784 # attributes is a properties field, and 'partner_id' is a many2one property;
785 # partner is thus a recordset
786 partner = record.attributes['partner_id']
787 partner.name
789 When the accessed key does not exist, i.e., there is no corresponding property
790 definition for that record, the access raises a :class:`KeyError`.
791 """
793 def __init__(self, values, field, record):
794 self._values = values
795 self.record = record
796 self.field = field
798 def __iter__(self):
799 for key in self._values:
800 with contextlib.suppress(KeyError):
801 self[key]
802 yield key
804 def __len__(self):
805 return len(self._values)
807 def __eq__(self, other):
808 return self._values == (other._values if isinstance(other, Property) else other)
810 def __getitem__(self, property_name):
811 """Will make the verification."""
812 if not self.record:
813 return False
815 values = self.field.convert_to_read(
816 self._values,
817 self.record,
818 use_display_name=False,
819 )
820 prop = next((p for p in values if p['name'] == property_name), False)
821 if not prop:
822 raise KeyError(property_name)
824 if prop.get('type') == 'many2one' and prop.get('comodel'):
825 return self.record.env[prop.get('comodel')].browse(prop.get('value'))
827 if prop.get('type') == 'many2many' and prop.get('comodel'):
828 return self.record.env[prop.get('comodel')].browse(prop.get('value'))
830 if prop.get('type') == 'selection' and prop.get('value'):
831 if self.record.env.context.get('property_selection_get_key'):
832 return next((sel[0] for sel in prop.get('selection') if sel[0] == prop['value']), False)
833 return next((sel[1] for sel in prop.get('selection') if sel[0] == prop['value']), False)
835 if prop.get('type') == 'tags' and prop.get('value'):
836 return ', '.join(tag[1] for tag in prop.get('tags') if tag[0] in prop['value'])
838 return prop.get('value') or False
840 def __hash__(self):
841 return hash(frozendict(self._values))
844class PropertiesDefinition(Field):
845 """ Field used to define the properties definition (see :class:`~odoo.fields.Properties`
846 field). This field is used on the container record to define the structure
847 of expected properties on subrecords. It is used to check the properties
848 definition. """
849 type = 'properties_definition'
850 _column_type = ('jsonb', 'jsonb')
851 copy = True # containers may act like templates, keep definitions to ease usage
852 readonly = False
853 prefetch = True
854 properties_fields = () # List of Properties fields using that definition
856 REQUIRED_KEYS = ('name', 'type')
857 ALLOWED_KEYS = (
858 'name', 'string', 'type', 'comodel', 'default', 'suffix',
859 'selection', 'tags', 'domain', 'view_in_cards', 'fold_by_default',
860 'currency_field'
861 )
862 # those keys will be removed if the types does not match
863 PROPERTY_PARAMETERS_MAP = {
864 'comodel': {'many2one', 'many2many'},
865 'currency_field': {'monetary'},
866 'domain': {'many2one', 'many2many'},
867 'selection': {'selection'},
868 'tags': {'tags'},
869 }
871 def convert_to_column(self, value, record, values=None, validate=True):
872 """Convert the value before inserting it in database.
874 This method accepts a list properties definition.
876 The relational properties (many2one / many2many) default value
877 might contain the display_name of those records (and will be removed).
879 [{
880 'name': '3adf37f3258cfe40',
881 'string': 'Color Code',
882 'type': 'char',
883 'default': 'blue',
884 'value': 'red',
885 }, {
886 'name': 'aa34746a6851ee4e',
887 'string': 'Partner',
888 'type': 'many2one',
889 'comodel': 'test_orm.partner',
890 'default': [1337, 'Bob'],
891 }]
892 """
893 if not value:
894 return None
896 if isinstance(value, str): 896 ↛ 897line 896 didn't jump to line 897 because the condition on line 896 was never true
897 value = json.loads(value)
899 if not isinstance(value, list): 899 ↛ 900line 899 didn't jump to line 900 because the condition on line 899 was never true
900 raise TypeError(f'Wrong properties definition type {type(value)!r}')
902 if validate:
903 Properties._remove_display_name(value, value_key='default')
905 self._validate_properties_definition(value, record.env)
907 return json.dumps(record._convert_to_cache_properties_definition(value))
909 def convert_to_cache(self, value, record, validate=True):
910 # any format -> cache format (list of dicts or None)
911 if not value:
912 return None
914 if isinstance(value, list): 914 ↛ 919line 914 didn't jump to line 919 because the condition on line 914 was always true
915 # avoid accidental side effects from shared mutable data, and make
916 # the value strict with respect to JSON (tuple -> list, etc)
917 value = json.dumps(value)
919 if isinstance(value, str): 919 ↛ 922line 919 didn't jump to line 922 because the condition on line 919 was always true
920 value = json.loads(value)
922 if not isinstance(value, list): 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 raise TypeError(f'Wrong properties definition type {type(value)!r}')
925 if validate: 925 ↛ 930line 925 didn't jump to line 930 because the condition on line 925 was always true
926 Properties._remove_display_name(value, value_key='default')
928 self._validate_properties_definition(value, record.env)
930 return record._convert_to_column_properties_definition(value)
932 def convert_to_record(self, value, record):
933 # cache format -> record format (list of dicts)
934 if not value:
935 return []
937 # return a copy of the definition in cache where all property
938 # definitions have been cleaned up
939 result = []
941 for property_definition in value:
942 if not all(property_definition.get(key) for key in self.REQUIRED_KEYS): 942 ↛ 944line 942 didn't jump to line 944 because the condition on line 942 was never true
943 # some required keys are missing, ignore this property definition
944 continue
946 # don't modify the value in cache
947 property_definition = copy.deepcopy(property_definition)
949 type_ = property_definition.get('type')
951 if type_ in ('many2one', 'many2many'): 951 ↛ 954line 951 didn't jump to line 954 because the condition on line 951 was never true
952 # check if the model still exists in the environment, the module of the
953 # model might have been uninstalled so the model might not exist anymore
954 property_model = property_definition.get('comodel')
955 if property_model not in record.env:
956 property_definition['comodel'] = False
957 property_definition.pop('domain', None)
958 elif property_domain := property_definition.get('domain'):
959 # some fields in the domain might have been removed
960 # (e.g. if the module has been uninstalled)
961 # check if the domain is still valid
962 try:
963 dom = Domain(ast.literal_eval(property_domain))
964 model = record.env[property_model]
965 dom.validate(model)
966 except ValueError:
967 del property_definition['domain']
969 elif type_ in ('selection', 'tags'):
970 # always set at least an empty array if there's no option
971 property_definition[type_] = property_definition.get(type_) or []
973 result.append(property_definition)
975 return result
977 def convert_to_read(self, value, record, use_display_name=True):
978 # record format -> read format (list of dicts with display names)
979 if not value:
980 return value
982 if use_display_name:
983 Properties._add_display_name(value, record.env, value_keys=('default',))
985 return value
987 def convert_to_write(self, value, record):
988 return value
990 def _validate_properties_definition(self, properties_definition, env):
991 """Raise an error if the property definition is not valid."""
992 allowed_keys = self.ALLOWED_KEYS + env["base"]._additional_allowed_keys_properties_definition()
994 env["base"]._validate_properties_definition(properties_definition, self)
996 properties_names = set()
998 for property_definition in properties_definition:
999 for property_parameter, allowed_types in self.PROPERTY_PARAMETERS_MAP.items():
1000 if property_definition.get('type') not in allowed_types and property_parameter in property_definition: 1000 ↛ 1001line 1000 didn't jump to line 1001 because the condition on line 1000 was never true
1001 raise ValueError(f'Invalid property parameter {property_parameter!r}')
1003 property_definition_keys = set(property_definition.keys())
1005 invalid_keys = property_definition_keys - set(allowed_keys)
1006 if invalid_keys: 1006 ↛ 1007line 1006 didn't jump to line 1007 because the condition on line 1006 was never true
1007 raise ValueError(
1008 'Some key are not allowed for a properties definition [%s].' %
1009 ', '.join(invalid_keys),
1010 )
1012 check_property_field_value_name(property_definition['name'])
1014 required_keys = set(self.REQUIRED_KEYS) - property_definition_keys
1015 if required_keys: 1015 ↛ 1016line 1015 didn't jump to line 1016 because the condition on line 1015 was never true
1016 raise ValueError(
1017 'Some key are missing for a properties definition [%s].' %
1018 ', '.join(required_keys),
1019 )
1021 property_type = property_definition.get('type')
1022 property_name = property_definition.get('name')
1023 if not property_name or property_name in properties_names: 1023 ↛ 1024line 1023 didn't jump to line 1024 because the condition on line 1023 was never true
1024 raise ValueError(f'The property name {property_name!r} is not set or duplicated.')
1025 properties_names.add(property_name)
1027 if property_type == 'html' and not property_name.endswith('_html'): 1027 ↛ 1028line 1027 didn't jump to line 1028 because the condition on line 1027 was never true
1028 raise ValueError("HTML property name should end with `_html`.")
1030 if property_type != 'html' and property_name.endswith('_html'): 1030 ↛ 1031line 1030 didn't jump to line 1031 because the condition on line 1030 was never true
1031 raise ValueError("Only HTML properties can have the `_html` suffix.")
1033 if property_type and property_type not in Properties.ALLOWED_TYPES: 1033 ↛ 1034line 1033 didn't jump to line 1034 because the condition on line 1033 was never true
1034 raise ValueError(f'Wrong property type {property_type!r}.')
1036 if property_type == 'html' and (default := property_definition.get('default')): 1036 ↛ 1037line 1036 didn't jump to line 1037 because the condition on line 1036 was never true
1037 property_definition['default'] = html_sanitize(default, **Properties.HTML_SANITIZE_OPTIONS)
1039 model = property_definition.get('comodel')
1040 if model and (model not in env or env[model].is_transient() or env[model]._abstract): 1040 ↛ 1041line 1040 didn't jump to line 1041 because the condition on line 1040 was never true
1041 raise ValueError(f'Invalid model name {model!r}')
1043 property_selection = property_definition.get('selection')
1044 if property_selection:
1045 if (not is_list_of(property_selection, (list, tuple)) 1045 ↛ 1047line 1045 didn't jump to line 1047 because the condition on line 1045 was never true
1046 or not all(len(selection) == 2 for selection in property_selection)):
1047 raise ValueError(f'Wrong options {property_selection!r}.')
1049 all_options = [option[0] for option in property_selection]
1050 if len(all_options) != len(set(all_options)): 1050 ↛ 1051line 1050 didn't jump to line 1051 because the condition on line 1050 was never true
1051 duplicated = set(filter(lambda x: all_options.count(x) > 1, all_options))
1052 raise ValueError(f'Some options are duplicated: {", ".join(duplicated)}.')
1054 property_tags = property_definition.get('tags')
1055 if property_tags:
1056 if (not is_list_of(property_tags, (list, tuple)) 1056 ↛ 1058line 1056 didn't jump to line 1058 because the condition on line 1056 was never true
1057 or not all(len(tag) == 3 and isinstance(tag[2], int) for tag in property_tags)):
1058 raise ValueError(f'Wrong tags definition {property_tags!r}.')
1060 all_tags = [tag[0] for tag in property_tags]
1061 if len(all_tags) != len(set(all_tags)): 1061 ↛ 1062line 1061 didn't jump to line 1062 because the condition on line 1061 was never true
1062 duplicated = set(filter(lambda x: all_tags.count(x) > 1, all_tags))
1063 raise ValueError(f'Some tags are duplicated: {", ".join(duplicated)}.')