Coverage for adhoc-cicd-odoo-odoo / odoo / tests / form.py: 13%
592 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
1# -*- coding: utf-8 -*-
2"""
3The module :mod:`odoo.tests.form` provides an implementation of a client form
4view for server-side unit tests.
5"""
6from __future__ import annotations
8import ast
9import collections
10import collections.abc
11import itertools
12import logging
13from datetime import datetime, date
15from lxml import etree
17from odoo import api, fields
18from odoo.models import BaseModel
19from odoo.fields import Command
20from odoo.tools.safe_eval import safe_eval
22_logger = logging.getLogger(__name__)
24MODIFIER_ALIASES = {'1': 'True', '0': 'False'}
27class Form:
28 """ Server-side form view implementation (partial)
30 Implements much of the "form view" manipulation flow, such that server-side
31 tests can more properly reflect the behaviour which would be observed when
32 manipulating the interface:
34 * call the relevant onchanges on "creation";
35 * call the relevant onchanges on setting fields;
36 * properly handle defaults & onchanges around x2many fields.
38 Saving the form returns the current record (which means the created record
39 if in creation mode). It can also be accessed as ``form.record``, but only
40 when the form has no pending changes.
42 Regular fields can just be assigned directly to the form. In the case
43 of :class:`~odoo.fields.Many2one` fields, one can assign a recordset::
45 # empty recordset => creation mode
46 f = Form(self.env['sale.order'])
47 f.partner_id = a_partner
48 so = f.save()
50 One can also use the form as a context manager to create or edit a record.
51 The changes are automatically saved at the end of the scope::
53 with Form(self.env['sale.order']) as f1:
54 f1.partner_id = a_partner
55 # f1 is saved here
57 # retrieve the created record
58 so = f1.record
60 # call Form on record => edition mode
61 with Form(so) as f2:
62 f2.payment_term_id = env.ref('account.account_payment_term_15days')
63 # f2 is saved here
65 For :class:`~odoo.fields.Many2many` fields, the field itself is a
66 :class:`~odoo.tests.common.M2MProxy` and can be altered by adding or
67 removing records::
69 with Form(user) as u:
70 u.group_ids.add(env.ref('account.group_account_manager'))
71 u.group_ids.remove(id=env.ref('base.group_portal').id)
73 Finally :class:`~odoo.fields.One2many` are reified as :class:`~O2MProxy`.
75 Because the :class:`~odoo.fields.One2many` only exists through its parent,
76 it is manipulated more directly by creating "sub-forms" with
77 the :meth:`~O2MProxy.new` and :meth:`~O2MProxy.edit` methods. These would
78 normally be used as context managers since they get saved in the parent
79 record::
81 with Form(so) as f3:
82 f.partner_id = a_partner
83 # add support
84 with f3.order_line.new() as line:
85 line.product_id = env.ref('product.product_product_2')
86 # add a computer
87 with f3.order_line.new() as line:
88 line.product_id = env.ref('product.product_product_3')
89 # we actually want 5 computers
90 with f3.order_line.edit(1) as line:
91 line.product_uom_qty = 5
92 # remove support
93 f3.order_line.remove(index=0)
94 # SO is saved here
96 :param record: empty or singleton recordset. An empty recordset will put
97 the view in "creation" mode from default values, while a
98 singleton will put it in "edit" mode and only load the
99 view's data.
100 :param view: the id, xmlid or actual view object to use for onchanges and
101 view constraints. If none is provided, simply loads the
102 default view for the model.
104 .. versionadded:: 12.0
105 """
106 def __init__(self, record: BaseModel, view: None | int | str | BaseModel = None) -> None:
107 assert isinstance(record, BaseModel)
108 assert len(record) <= 1
110 # use object.__setattr__ to bypass Form's override of __setattr__
111 object.__setattr__(self, '_record', record)
112 object.__setattr__(self, '_env', record.env)
114 # determine view and process it
115 if isinstance(view, BaseModel):
116 assert view._name == 'ir.ui.view', "the view parameter must be a view id, xid or record, got %s" % view
117 view_id = view.id
118 elif isinstance(view, str):
119 view_id = record.env.ref(view).id
120 else:
121 view_id = view or False
123 views = record.get_views([(view_id, 'form')])
124 object.__setattr__(self, '_models_info', views['models'])
125 # self._models_info = {model_name: {fields: {field_name: field_info}}}
126 tree = etree.fromstring(views['views']['form']['arch'])
127 view = self._process_view(tree, record)
128 object.__setattr__(self, '_view', view)
129 # self._view = {
130 # 'tree': view_arch_etree,
131 # 'fields': {field_name: field_info},
132 # 'fields_spec': web_read_fields_spec,
133 # 'modifiers': {field_name: {modifier: expression}},
134 # 'contexts': {field_name: field_context_str},
135 # 'onchange': onchange_spec,
136 # }
138 # determine record values
139 object.__setattr__(self, '_values', UpdateDict())
140 if record:
141 self._init_from_record()
142 else:
143 self._init_from_defaults()
145 @classmethod
146 def from_action(cls, env: api.Environment, action: dict) -> Form:
147 assert action['type'] == 'ir.actions.act_window', \
148 f"only window actions are valid, got {action['type']}"
149 # ensure the first-requested view is a form view
150 if views := action.get('views'):
151 assert views[0][1] == 'form', \
152 f"the actions dict should have a form as first view, got {views[0][1]}"
153 view_id = views[0][0]
154 else:
155 view_mode = action.get('view_mode', '')
156 if not view_mode.startswith('form'):
157 raise ValueError(f"The actions dict should have a form first view mode, got {view_mode}")
158 view_id = action.get('view_id')
159 if view_id and ',' in view_mode:
160 raise ValueError(f"A `view_id` is only valid if the action has a single `view_mode`, got {view_mode}")
161 context = action.get('context', {})
162 if isinstance(context, str):
163 context = ast.literal_eval(context)
164 record = env[action['res_model']]\
165 .with_context(context)\
166 .browse(action.get('res_id'))
168 return cls(record, view_id)
170 def _process_view(self, tree, model, level=2):
171 """ Post-processes to augment the view_get with:
172 * an id field (may not be present if not in the view but needed)
173 * pre-processed modifiers
174 * pre-processed onchanges list
175 """
176 fields = {'id': {'type': 'id'}}
177 fields_spec = {}
178 modifiers = {'id': {'required': 'False', 'readonly': 'True'}}
179 contexts = {}
180 # retrieve <field> nodes at the current level
181 flevel = tree.xpath('count(ancestor::field)')
182 daterange_field_names = {}
183 field_infos = self._models_info.get(model._name, {}).get("fields", {})
185 for node in tree.xpath(f'.//field[count(ancestor::field) = {flevel}]'):
186 field_name = node.get('name')
188 # add field_info into fields
189 field_info = field_infos.get(field_name) or {'type': None}
190 fields[field_name] = field_info
191 fields_spec[field_name] = field_spec = {}
193 # determine modifiers
194 field_modifiers = {}
195 for attr in ('required', 'readonly', 'invisible', 'column_invisible'):
196 # use python field attribute as default value
197 default = attr in ('required', 'readonly') and field_info.get(attr, False)
198 expr = node.get(attr) or str(default)
199 field_modifiers[attr] = MODIFIER_ALIASES.get(expr, expr)
201 # Combine the field modifiers with its ancestor modifiers with an
202 # OR: A field is invisible if its own invisible modifier is True OR
203 # if one of its ancestor invisible modifier is True
204 for ancestor in node.xpath(f'ancestor::*[@invisible][count(ancestor::field) = {flevel}]'):
205 modifier = 'invisible'
206 expr = ancestor.get(modifier)
207 if expr == 'True' or field_modifiers[modifier] == 'True':
208 field_modifiers[modifier] = 'True'
209 if expr == 'False':
210 field_modifiers[modifier] = field_modifiers[modifier]
211 elif field_modifiers[modifier] == 'False':
212 field_modifiers[modifier] = expr
213 else:
214 field_modifiers[modifier] = f'({expr}) or ({field_modifiers[modifier]})'
216 # merge field_modifiers into modifiers[field_name]
217 if field_name in modifiers:
218 # The field is several times in the view, combine the modifier
219 # expression with an AND: a field is X if all occurences of the
220 # field in the view are X.
221 for modifier, expr in modifiers[field_name].items():
222 if expr == 'False' or field_modifiers[modifier] == 'False':
223 field_modifiers[modifier] = 'False'
224 if expr == 'True':
225 field_modifiers[modifier] = field_modifiers[modifier]
226 elif field_modifiers[modifier] == 'True':
227 field_modifiers[modifier] = expr
228 else:
229 field_modifiers[modifier] = f'({expr}) and ({field_modifiers[modifier]})'
231 modifiers[field_name] = field_modifiers
233 # determine context
234 ctx = node.get('context')
235 if ctx:
236 contexts[field_name] = ctx
237 field_spec['context'] = get_static_context(ctx)
239 # FIXME: better widgets support
240 # NOTE: selection breaks because of m2o widget=selection
241 if node.get('widget') in ['many2many']:
242 field_info['type'] = node.get('widget')
243 elif node.get('widget') == 'daterange':
244 options = ast.literal_eval(node.get('options', '{}'))
245 related_field = options.get('start_date_field') or options.get('end_date_field')
246 daterange_field_names[related_field] = field_name
248 # determine subview to use for edition
249 if field_info['type'] == 'one2many':
250 if level:
251 field_info['invisible'] = field_modifiers.get('invisible')
252 edition_view = self._get_one2many_edition_view(field_info, node, level)
253 field_info['edition_view'] = edition_view
254 field_spec['fields'] = edition_view['fields_spec']
255 else:
256 # this trick enables the following invariant: every one2many
257 # field has some 'edition_view' in its info dict
258 field_info['type'] = 'many2many'
260 for related_field, start_field in daterange_field_names.items():
261 # If the field doesn't exist in the view add it implicitly
262 if related_field not in modifiers:
263 field_info = field_infos.get(related_field) or {'type': None}
264 fields[related_field] = field_info
265 fields_spec[related_field] = {}
266 modifiers[related_field] = {
267 'required': field_info.get('required', False),
268 'readonly': field_info.get('readonly', False),
269 }
270 modifiers[related_field]['invisible'] = modifiers[start_field].get('invisible', False)
272 return {
273 'tree': tree,
274 'fields': fields,
275 'fields_spec': fields_spec,
276 'modifiers': modifiers,
277 'contexts': contexts,
278 'onchange': model._onchange_spec({'arch': etree.tostring(tree)}),
279 }
281 def _get_one2many_edition_view(self, field_info, node, level):
282 """ Return a suitable view for editing records into a one2many field. """
283 submodel = self._env[field_info['relation']]
285 # by simplicity, ensure we always have tree and form views
286 views = {
287 view.tag: view for view in node.xpath('./*[descendant::field]')
288 }
289 for view_type in ['list', 'form']:
290 if view_type in views:
291 continue
292 if field_info['invisible'] == 'True':
293 # add an empty view
294 views[view_type] = etree.Element(view_type)
295 continue
296 refs = self._env['ir.ui.view']._get_view_refs(node)
297 subviews = submodel.with_context(**refs).get_views([(None, view_type)])
298 subnode = etree.fromstring(subviews['views'][view_type]['arch'])
299 views[view_type] = subnode
300 node.append(subnode)
301 for model_name, value in subviews['models'].items():
302 model_info = self._models_info.setdefault(model_name, {})
303 if "fields" not in model_info:
304 model_info["fields"] = {}
305 model_info["fields"].update(value["fields"])
307 # pick the first editable subview
308 view_type = next(
309 vtype for vtype in node.get('mode', 'list').split(',') if vtype != 'form'
310 )
311 if not (view_type == 'list' and views['list'].get('editable')):
312 view_type = 'form'
314 # don't recursively process o2ms in o2ms
315 return self._process_view(views[view_type], submodel, level=level-1)
317 def __str__(self):
318 return f"<{type(self).__name__} {self._record}>"
320 def _init_from_record(self):
321 """ Initialize the form for an existing record. """
322 assert self._record.id, "editing unstored records is not supported"
323 self._values.clear()
325 [record_values] = self._record.web_read(self._view['fields_spec'])
326 self._env.flush_all()
327 self._env.clear() # discard cache and pending recomputations
329 values = convert_read_to_form(record_values, self._view['fields'])
330 self._values.update(values)
332 def _init_from_defaults(self):
333 """ Initialize the form for a new record. """
334 vals = self._values
335 vals['id'] = False
337 # call onchange with no field; this retrieves default values, applies
338 # onchanges and return the result
339 self._perform_onchange()
340 # mark all fields as modified
341 self._values._changed.update(self._view['fields'])
343 def __getattr__(self, field_name):
344 """ Return the current value of the given field. """
345 return self[field_name]
347 def __getitem__(self, field_name):
348 """ Return the current value of the given field. """
349 field_info = self._view['fields'].get(field_name)
350 assert field_info is not None, f"{field_name!r} was not found in the view"
352 value = self._values[field_name]
353 if field_info['type'] == 'many2one':
354 Model = self._env[field_info['relation']]
355 return Model.browse(value)
356 elif field_info['type'] == 'one2many':
357 return O2MProxy(self, field_name)
358 elif field_info['type'] == 'many2many':
359 return M2MProxy(self, field_name)
360 return value
362 def __setattr__(self, field_name, value):
363 """ Set the given field to the given value, and proceed with the expected onchanges. """
364 self[field_name] = value
366 def __setitem__(self, field_name, value):
367 """ Set the given field to the given value, and proceed with the expected onchanges. """
368 field_info = self._view['fields'].get(field_name)
369 assert field_info is not None, f"{field_name!r} was not found in the view"
370 assert field_info['type'] != 'one2many', "Can't set an one2many field directly, use its proxy instead"
371 assert not self._get_modifier(field_name, 'readonly'), f"can't write on readonly field {field_name!r}"
372 assert not self._get_modifier(field_name, 'invisible'), f"can't write on invisible field {field_name!r}"
374 if field_info['type'] == 'many2many':
375 return M2MProxy(self, field_name).set(value)
377 if field_info['type'] == 'many2one':
378 assert isinstance(value, BaseModel) and value._name == field_info['relation']
379 value = value.id
381 self._values[field_name] = value
382 self._perform_onchange(field_name)
384 def _get_modifier(self, field_name, modifier, *, view=None, vals=None):
385 if view is None:
386 view = self._view
388 expr = view['modifiers'][field_name].get(modifier, False)
389 if isinstance(expr, bool):
390 return expr
391 if expr in ('True', 'False'):
392 return expr == 'True'
394 if vals is None:
395 vals = self._values
397 eval_context = self._get_eval_context(vals)
399 return bool(safe_eval(expr, eval_context))
401 def _get_context(self, field_name):
402 """ Return the context of a given field. """
403 context_str = self._view['contexts'].get(field_name)
404 if not context_str:
405 return {}
406 eval_context = self._get_eval_context()
407 return safe_eval(context_str, eval_context)
409 def _get_eval_context(self, values=None):
410 """ Return the context dict to eval something. """
411 context = {
412 'id': self._record.id,
413 'active_id': self._record.id,
414 'active_ids': self._record.ids,
415 'active_model': self._record._name,
416 'current_date': date.today().strftime("%Y-%m-%d"),
417 **self._env.context,
418 }
419 if values is None:
420 values = self._get_all_values()
421 return {
422 **context,
423 'context': context,
424 **values,
425 }
427 def _get_all_values(self):
428 """ Return the values of all fields. """
429 return self._get_values('all')
431 def __enter__(self):
432 """ This makes the Form usable as a context manager. """
433 return self
435 def __exit__(self, exc_type, exc_value, traceback):
436 if not exc_type:
437 self.save()
439 def save(self):
440 """ Save the form (if necessary) and return the current record:
442 * does not save ``readonly`` fields;
443 * does not save unmodified fields (during edition) — any assignment
444 or onchange return marks the field as modified, even if set to its
445 current value.
447 When nothing must be saved, it simply returns the current record.
449 :raises AssertionError: if the form has any unfilled required field
450 """
451 values = self._get_save_values()
452 if not self._record or values:
453 # save and reload
454 [record_values] = self._record.web_save(values, self._view['fields_spec'])
455 self._env.flush_all()
456 self._env.clear() # discard cache and pending recomputations
458 if not self._record:
459 record = self._record.browse(record_values['id'])
460 object.__setattr__(self, '_record', record)
462 values = convert_read_to_form(record_values, self._view['fields'])
463 self._values.clear()
464 self._values.update(values)
466 return self._record
468 @property
469 def record(self):
470 """ Return the record being edited by the form. This attribute is
471 readonly and can only be accessed when the form has no pending changes.
472 """
473 assert not self._values._changed
474 return self._record
476 def _get_save_values(self):
477 """ Validate and return field values modified since load/save. """
478 return self._get_values('save')
480 def _get_values(self, mode, values=None, view=None, modifiers_values=None, parent_link=None):
481 """ Validate & extract values, recursively in order to handle o2ms properly.
483 :param mode: can be ``"save"`` (validate and return non-readonly modified fields),
484 ``"onchange"`` (return modified fields) or ``"all"`` (return all field values)
485 :param UpdateDict values: values of the record to extract
486 :param view: view info
487 :param dict modifiers_values: defaults to ``values``, but o2ms need some additional massaging
488 :param parent_link: optional field representing "parent"
489 """
490 assert mode in ('save', 'onchange', 'all')
492 if values is None:
493 values = self._values
494 if view is None:
495 view = self._view
496 assert isinstance(values, UpdateDict)
498 modifiers_values = modifiers_values or values
500 result = {}
501 for field_name, field_info in view['fields'].items():
502 if field_name == 'id' or field_name not in values:
503 continue
505 value = values[field_name]
507 # note: maybe `invisible` should not skip `required` if model attribute
508 if (
509 mode == 'save'
510 and value is False
511 and field_name != parent_link
512 and field_info['type'] != 'boolean'
513 and not self._get_modifier(field_name, 'invisible', view=view, vals=modifiers_values)
514 and not self._get_modifier(field_name, 'column_invisible', view=view, vals=modifiers_values)
515 and self._get_modifier(field_name, 'required', view=view, vals=modifiers_values)
516 ):
517 raise AssertionError(f"{field_name} is a required field ({view['modifiers'][field_name]})")
519 # skip unmodified fields unless all_fields
520 if mode in ('save', 'onchange') and field_name not in values._changed:
521 continue
523 if mode == 'save' and self._get_modifier(field_name, 'readonly', view=view, vals=modifiers_values):
524 field_node = next(
525 node
526 for node in view['tree'].iter('field')
527 if node.get('name') == field_name
528 )
529 if not field_node.get('force_save'):
530 continue
532 if field_info['type'] == 'one2many':
533 if mode == 'all':
534 # in the context of an eval, format it as a list of ids
535 value = list(value)
536 else:
537 subview = field_info['edition_view']
538 value = value.to_commands(lambda vals: self._get_values(
539 mode, vals, subview,
540 modifiers_values={'id': False, **vals, 'parent': Dotter(values)},
541 # related o2m don't have a relation_field
542 parent_link=field_info.get('relation_field'),
543 ))
545 elif field_info['type'] == 'many2many':
546 if mode == 'all':
547 # in the context of an eval, format it as a list of ids
548 value = list(value)
549 else:
550 value = value.to_commands()
552 result[field_name] = value
554 return result
556 def _perform_onchange(self, field_name=None):
557 assert field_name is None or isinstance(field_name, str)
559 # marks onchange source as changed
560 if field_name:
561 field_names = [field_name]
562 self._values._changed.add(field_name)
563 else:
564 field_names = []
566 # skip calling onchange() if there's no on_change on the field
567 if field_name and not self._view['onchange'][field_name]:
568 return
570 record = self._record
572 # if the onchange is triggered by a field, add the context of that field
573 if field_name:
574 context = self._get_context(field_name)
575 if context:
576 record = record.with_context(**context)
578 values = self._get_onchange_values()
579 result = record.onchange(values, field_names, self._view['fields_spec'])
580 self._env.flush_all()
581 self._env.clear() # discard cache and pending recomputations
583 if w := result.get('warning'):
584 if isinstance(w, collections.abc.Mapping) and w.keys() >= {'title', 'message'}:
585 _logger.getChild('onchange').warning("%(title)s %(message)s", w)
586 else:
587 _logger.getChild('onchange').error(
588 "received invalid warning %r from onchange on %r (should be a dict with keys `title` and `message`)",
589 w,
590 field_names,
591 )
593 if not field_name:
594 # fill in whatever fields are still missing with falsy values
595 self._values.update({
596 field_name: _cleanup_from_default(field_info['type'], False)
597 for field_name, field_info in self._view['fields'].items()
598 if field_name not in self._values
599 })
601 if result.get('value'):
602 self._apply_onchange(result['value'])
604 return result
606 def _get_onchange_values(self):
607 """ Return modified field values for onchange. """
608 return self._get_values('onchange')
610 def _apply_onchange(self, values):
611 self._apply_onchange_(self._values, self._view['fields'], values)
613 def _apply_onchange_(self, values, fields, onchange_values):
614 assert isinstance(values, UpdateDict)
615 for fname, value in onchange_values.items():
616 field_info = fields[fname]
617 if field_info['type'] in ('one2many', 'many2many'):
618 subfields = {}
619 if field_info['type'] == 'one2many':
620 subfields = field_info['edition_view']['fields']
621 field_value = values[fname]
622 for cmd in value:
623 match cmd[0]:
624 case Command.CREATE:
625 vals = UpdateDict(convert_read_to_form(dict.fromkeys(subfields, False), subfields))
626 self._apply_onchange_(vals, subfields, cmd[2])
627 field_value.create(vals)
628 case Command.UPDATE:
629 vals = field_value.get_vals(cmd[1])
630 self._apply_onchange_(vals, subfields, cmd[2])
631 case Command.DELETE | Command.UNLINK:
632 field_value.remove(cmd[1])
633 case Command.LINK:
634 field_value.add(cmd[1], convert_read_to_form(cmd[2], subfields))
635 case c:
636 raise ValueError(f"Unexpected onchange() o2m command {c!r}")
637 else:
638 values[fname] = value
639 values._changed.add(fname)
642class O2MForm(Form):
643 # noinspection PyMissingConstructor
644 # pylint: disable=super-init-not-called
645 def __init__(self, proxy, index=None):
646 model = proxy._model
647 object.__setattr__(self, '_proxy', proxy)
648 object.__setattr__(self, '_index', index)
650 object.__setattr__(self, '_record', model)
651 object.__setattr__(self, '_env', model.env)
653 object.__setattr__(self, '_models_info', proxy._form._models_info)
654 object.__setattr__(self, '_view', proxy._field_info['edition_view'])
656 object.__setattr__(self, '_values', UpdateDict())
657 if index is None:
658 self._init_from_defaults()
659 else:
660 vals = proxy._records[index]
661 self._values.update(vals)
662 if vals.get('id'):
663 object.__setattr__(self, '_record', model.browse(vals['id']))
665 def _get_modifier(self, field_name, modifier, *, view=None, vals=None):
666 if modifier != 'required' and self._proxy._form._get_modifier(self._proxy._field, modifier):
667 return True
668 return super()._get_modifier(field_name, modifier, view=view, vals=vals)
670 def _get_eval_context(self, values=None):
671 eval_context = super()._get_eval_context(values)
672 eval_context['parent'] = Dotter(self._proxy._form._values)
673 return eval_context
675 def _get_onchange_values(self):
676 values = super()._get_onchange_values()
677 # computed o2m may not have a relation_field(?)
678 field_info = self._proxy._field_info
679 if 'relation_field' in field_info: # note: should be fine because not recursive
680 parent_form = self._proxy._form
681 parent_values = parent_form._get_onchange_values()
682 if parent_form._record.id:
683 parent_values['id'] = parent_form._record.id
684 values[field_info['relation_field']] = parent_values
685 return values
687 def save(self):
688 proxy = self._proxy
689 field_value = proxy._form._values[proxy._field]
690 values = self._get_save_values()
691 if self._index is None:
692 field_value.create(values)
693 else:
694 id_ = field_value[self._index]
695 field_value.update(id_, values)
697 proxy._form._perform_onchange(proxy._field)
699 def _get_save_values(self):
700 """ Validate and return field values modified since load/save. """
701 values = UpdateDict(self._values)
703 for field_name in self._view['fields']:
704 if self._get_modifier(field_name, 'required') and not (
705 self._get_modifier(field_name, 'column_invisible')
706 or self._get_modifier(field_name, 'invisible')
707 ):
708 assert values[field_name] is not False, f"{field_name!r} is a required field"
710 return values
713class UpdateDict(dict):
714 def __init__(self, *args, **kwargs):
715 super().__init__(*args, **kwargs)
716 self._changed = set()
717 if args and isinstance(args[0], UpdateDict):
718 self._changed.update(args[0]._changed)
720 def __repr__(self):
721 items = [
722 f"{key!r}{'*' if key in self._changed else ''}: {val!r}"
723 for key, val in self.items()
724 ]
725 return f"{{{', '.join(items)}}}"
727 def changed_items(self):
728 return (
729 (k, v) for k, v in self.items()
730 if k in self._changed
731 )
733 def update(self, *args, **kw):
734 super().update(*args, **kw)
735 if args and isinstance(args[0], UpdateDict):
736 self._changed.update(args[0]._changed)
738 def clear(self):
739 super().clear()
740 self._changed.clear()
743class X2MValue(collections.abc.Sequence):
744 """ The value of a one2many field, with the API of a sequence of record ids. """
745 _virtual_seq = itertools.count()
747 def __init__(self, iterable_of_vals=()):
748 self._data = {vals['id']: UpdateDict(vals) for vals in iterable_of_vals}
750 def __repr__(self):
751 return repr(self._data)
753 def __contains__(self, id_):
754 return id_ in self._data
756 def __getitem__(self, index):
757 return list(self._data)[index]
759 def __iter__(self):
760 return iter(self._data)
762 def __len__(self):
763 return len(self._data)
765 def __eq__(self, other):
766 # this enables to compare self with a list
767 return list(self) == other
769 def get_vals(self, id_):
770 return self._data[id_]
772 def add(self, id_, vals):
773 assert id_ not in self._data
774 self._data[id_] = UpdateDict(vals)
776 def remove(self, id_):
777 self._data.pop(id_)
779 def clear(self):
780 self._data.clear()
782 def create(self, vals):
783 id_ = f'virtual_{next(self._virtual_seq)}'
784 create_vals = UpdateDict(vals)
785 create_vals._changed.update(vals)
786 self._data[id_] = create_vals
788 def update(self, id_, changes, changed=()):
789 vals = self._data[id_]
790 vals.update(changes)
791 vals._changed.update(changed)
793 def to_list_of_vals(self):
794 return list(self._data.values())
797class O2MValue(X2MValue):
798 def __init__(self, iterable_of_vals=()):
799 super().__init__(iterable_of_vals)
800 self._given = list(self._data)
802 def to_commands(self, convert_values=lambda vals: vals):
803 given = set(self._given)
804 result = []
805 for id_, vals in self._data.items():
806 if isinstance(id_, str) and id_.startswith('virtual_'):
807 result.append((Command.CREATE, id_, convert_values(vals)))
808 continue
809 if id_ not in given:
810 result.append(Command.link(id_))
811 if vals._changed:
812 result.append(Command.update(id_, convert_values(vals)))
813 for id_ in self._given:
814 if id_ not in self._data:
815 result.append(Command.delete(id_))
816 return result
819class M2MValue(X2MValue):
820 def __init__(self, iterable_of_vals=()):
821 super().__init__(iterable_of_vals)
822 self._given = list(self._data)
824 def to_commands(self):
825 given = set(self._given)
826 result = []
827 for id_, vals in self._data.items():
828 if isinstance(id_, str) and id_.startswith('virtual_'):
829 result.append((Command.CREATE, id_, {
830 key: val.to_commands() if isinstance(val, X2MValue) else val
831 for key, val in vals.changed_items()
832 }))
833 continue
834 if id_ not in given:
835 result.append(Command.link(id_))
836 if vals._changed:
837 result.append(Command.update(id_, {
838 key: val.to_commands() if isinstance(val, X2MValue) else val
839 for key, val in vals.changed_items()
840 }))
841 for id_ in self._given:
842 if id_ not in self._data:
843 result.append(Command.unlink(id_))
844 return result
847class X2MProxy:
848 """ A proxy represents the value of an x2many field, but not directly.
849 Instead, it provides an API to add, remove or edit records in the value.
850 """
851 _form = None # Form containing the corresponding x2many field
852 _field = None # name of the x2many field
853 _field_info = None # field info
855 def __init__(self, form, field_name):
856 self._form = form
857 self._field = field_name
858 self._field_info = form._view['fields'][field_name]
859 self._field_value = form._values[field_name]
861 @property
862 def ids(self):
863 return list(self._field_value)
865 def _assert_editable(self):
866 assert not self._form._get_modifier(self._field, 'readonly'), f'field {self._field!r} is not editable'
867 assert not self._form._get_modifier(self._field, 'invisible'), f'field {self._field!r} is not visible'
870class O2MProxy(X2MProxy):
871 """ Proxy object for editing the value of a one2many field. """
872 def __len__(self):
873 return len(self._field_value)
875 @property
876 def _model(self):
877 model = self._form._env[self._field_info['relation']]
878 context = self._form._get_context(self._field)
879 if context:
880 model = model.with_context(**context)
881 return model
883 @property
884 def _records(self):
885 return self._field_value.to_list_of_vals()
887 def new(self):
888 """ Returns a :class:`Form` for a new
889 :class:`~odoo.fields.One2many` record, properly initialised.
891 The form is created from the list view if editable, or the field's
892 form view otherwise.
894 :raises AssertionError: if the field is not editable
895 """
896 self._assert_editable()
897 return O2MForm(self)
899 def edit(self, index):
900 """ Returns a :class:`Form` to edit the pre-existing
901 :class:`~odoo.fields.One2many` record.
903 The form is created from the list view if editable, or the field's
904 form view otherwise.
906 :raises AssertionError: if the field is not editable
907 """
908 self._assert_editable()
909 return O2MForm(self, index)
911 def remove(self, index):
912 """ Removes the record at ``index`` from the parent form.
914 :raises AssertionError: if the field is not editable
915 """
916 self._assert_editable()
917 self._field_value.remove(self._field_value[index])
918 self._form._perform_onchange(self._field)
921class M2MProxy(X2MProxy, collections.abc.Sequence):
922 """ Proxy object for editing the value of a many2many field.
924 Behaves as a :class:`~collection.Sequence` of recordsets, can be
925 indexed or sliced to get actual underlying recordsets.
926 """
927 def __getitem__(self, index):
928 comodel_name = self._field_info['relation']
929 return self._form._env[comodel_name].browse(self._field_value[index])
931 def __len__(self):
932 return len(self._field_value)
934 def __iter__(self):
935 comodel_name = self._field_info['relation']
936 records = self._form._env[comodel_name].browse(self._field_value)
937 return iter(records)
939 def __contains__(self, record):
940 comodel_name = self._field_info['relation']
941 assert isinstance(record, BaseModel) and record._name == comodel_name
942 return record.id in self._field_value
944 def add(self, record):
945 """ Adds ``record`` to the field, the record must already exist.
947 The addition will only be finalized when the parent record is saved.
948 """
949 self._assert_editable()
950 parent = self._form
951 comodel_name = self._field_info['relation']
952 assert isinstance(record, BaseModel) and record._name == comodel_name, \
953 f"trying to assign a {record._name!r} object to a {comodel_name!r} field"
955 if record.id not in self._field_value:
956 self._field_value.add(record.id, {'id': record.id})
957 parent._perform_onchange(self._field)
959 # pylint: disable=redefined-builtin
960 def remove(self, id=None, index=None):
961 """ Removes a record at a certain index or with a provided id from
962 the field.
963 """
964 self._assert_editable()
965 assert (id is None) ^ (index is None), "can remove by either id or index"
966 if id is None:
967 id = self._field_value[index]
968 self._field_value.remove(id)
969 self._form._perform_onchange(self._field)
971 def set(self, records):
972 """ Set the field value to be ``records``. """
973 self._assert_editable()
974 comodel_name = self._field_info['relation']
975 assert isinstance(records, BaseModel) and records._name == comodel_name, \
976 f"trying to assign a {records._name!r} object to a {comodel_name!r} field"
978 if set(records.ids) != set(self._field_value):
979 self._field_value.clear()
980 for id_ in records.ids:
981 self._field_value.add(id_, {'id': id_})
982 self._form._perform_onchange(self._field)
984 def clear(self):
985 """ Removes all existing records in the m2m
986 """
987 self._assert_editable()
988 self._field_value.clear()
989 self._form._perform_onchange(self._field)
992def convert_read_to_form(values, model_fields):
993 result = {}
994 for fname, value in values.items():
995 field_info = {'type': 'id'} if fname == 'id' else model_fields[fname]
996 if field_info['type'] == 'one2many':
997 if 'edition_view' in field_info:
998 subfields = field_info['edition_view']['fields']
999 value = O2MValue(convert_read_to_form(vals, subfields) for vals in (value or ()))
1000 else:
1001 value = O2MValue({'id': id_} for id_ in (value or ()))
1002 elif field_info['type'] == 'many2many':
1003 value = M2MValue({'id': id_} for id_ in (value or ()))
1004 elif field_info['type'] == 'datetime' and isinstance(value, datetime):
1005 value = fields.Datetime.to_string(value)
1006 elif field_info['type'] == 'date' and isinstance(value, date):
1007 value = fields.Date.to_string(value)
1008 result[fname] = value
1009 return result
1012def _cleanup_from_default(type_, value):
1013 if not value:
1014 if type_ == 'one2many':
1015 return O2MValue()
1016 elif type_ == 'many2many':
1017 return M2MValue()
1018 elif type_ in ('integer', 'float'):
1019 return 0
1020 return value
1022 if type_ == 'one2many':
1023 raise NotImplementedError()
1024 elif type_ == 'datetime' and isinstance(value, datetime):
1025 return fields.Datetime.to_string(value)
1026 elif type_ == 'date' and isinstance(value, date):
1027 return fields.Date.to_string(value)
1028 return value
1031def get_static_context(context_str):
1032 """ Parse the given context string, and return the literal part of it. """
1033 context_ast = ast.parse(context_str.strip(), mode='eval').body
1034 assert isinstance(context_ast, ast.Dict)
1035 result = {}
1036 for key_ast, val_ast in zip(context_ast.keys, context_ast.values):
1037 try:
1038 key = ast.literal_eval(key_ast)
1039 val = ast.literal_eval(val_ast)
1040 result[key] = val
1041 except ValueError:
1042 pass
1043 return result
1046class Dotter:
1047 """ Simple wrapper for a dict where keys are accessed as readonly attributes. """
1048 __slots__ = ['__values']
1050 def __init__(self, values):
1051 self.__values = values
1053 def __getattr__(self, key):
1054 val = self.__values[key]
1055 return Dotter(val) if isinstance(val, dict) else val