odoo_17.0.1/odoo/tests/form.py

1003 lines
38 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""
The module :mod:`odoo.tests.form` provides an implementation of a client form
view for server-side unit tests.
"""
import ast
import collections
import itertools
import logging
import time
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from lxml import etree
import odoo
from odoo.models import BaseModel
from odoo.fields import Command
from odoo.tools.safe_eval import safe_eval
_logger = logging.getLogger(__name__)
MODIFIER_ALIASES = {'1': 'True', '0': 'False'}
class Form:
""" Server-side form view implementation (partial)
Implements much of the "form view" manipulation flow, such that server-side
tests can more properly reflect the behaviour which would be observed when
manipulating the interface:
* call the relevant onchanges on "creation";
* call the relevant onchanges on setting fields;
* properly handle defaults & onchanges around x2many fields.
Saving the form returns the current record (which means the created record
if in creation mode). It can also be accessed as ``form.record``, but only
when the form has no pending changes.
Regular fields can just be assigned directly to the form. In the case
of :class:`~odoo.fields.Many2one` fields, one can assign a recordset::
# empty recordset => creation mode
f = Form(self.env['sale.order'])
f.partner_id = a_partner
so = f.save()
One can also use the form as a context manager to create or edit a record.
The changes are automatically saved at the end of the scope::
with Form(self.env['sale.order']) as f1:
f1.partner_id = a_partner
# f1 is saved here
# retrieve the created record
so = f1.record
# call Form on record => edition mode
with Form(so) as f2:
f2.payment_term_id = env.ref('account.account_payment_term_15days')
# f2 is saved here
For :class:`~odoo.fields.Many2many` fields, the field itself is a
:class:`~odoo.tests.common.M2MProxy` and can be altered by adding or
removing records::
with Form(user) as u:
u.groups_id.add(env.ref('account.group_account_manager'))
u.groups_id.remove(id=env.ref('base.group_portal').id)
Finally :class:`~odoo.fields.One2many` are reified as :class:`~O2MProxy`.
Because the :class:`~odoo.fields.One2many` only exists through its parent,
it is manipulated more directly by creating "sub-forms" with
the :meth:`~O2MProxy.new` and :meth:`~O2MProxy.edit` methods. These would
normally be used as context managers since they get saved in the parent
record::
with Form(so) as f3:
f.partner_id = a_partner
# add support
with f3.order_line.new() as line:
line.product_id = env.ref('product.product_product_2')
# add a computer
with f3.order_line.new() as line:
line.product_id = env.ref('product.product_product_3')
# we actually want 5 computers
with f3.order_line.edit(1) as line:
line.product_uom_qty = 5
# remove support
f3.order_line.remove(index=0)
# SO is saved here
:param record: empty or singleton recordset. An empty recordset will put
the view in "creation" mode from default values, while a
singleton will put it in "edit" mode and only load the
view's data.
:type record: odoo.models.Model
:param view: the id, xmlid or actual view object to use for onchanges and
view constraints. If none is provided, simply loads the
default view for the model.
:type view: int | str | odoo.model.Model
.. versionadded:: 12.0
"""
def __init__(self, record, view=None):
assert isinstance(record, BaseModel)
assert len(record) <= 1
# use object.__setattr__ to bypass Form's override of __setattr__
object.__setattr__(self, '_record', record)
object.__setattr__(self, '_env', record.env)
# determine view and process it
if isinstance(view, BaseModel):
assert view._name == 'ir.ui.view', "the view parameter must be a view id, xid or record, got %s" % view
view_id = view.id
elif isinstance(view, str):
view_id = record.env.ref(view).id
else:
view_id = view or False
views = record.get_views([(view_id, 'form')])
object.__setattr__(self, '_models_info', views['models'])
# self._models_info = {model_name: {field_name: field_info}}
tree = etree.fromstring(views['views']['form']['arch'])
view = self._process_view(tree, record)
object.__setattr__(self, '_view', view)
# self._view = {
# 'tree': view_arch_etree,
# 'fields': {field_name: field_info},
# 'fields_spec': web_read_fields_spec,
# 'modifiers': {field_name: {modifier: expression}},
# 'contexts': {field_name: field_context_str},
# 'onchange': onchange_spec,
# }
# determine record values
object.__setattr__(self, '_values', UpdateDict())
if record:
self._init_from_record()
else:
self._init_from_defaults()
def _process_view(self, tree, model, level=2):
""" Post-processes to augment the view_get with:
* an id field (may not be present if not in the view but needed)
* pre-processed modifiers
* pre-processed onchanges list
"""
fields = {'id': {'type': 'id'}}
fields_spec = {}
modifiers = {'id': {'required': 'False', 'readonly': 'True'}}
contexts = {}
# retrieve <field> nodes at the current level
flevel = tree.xpath('count(ancestor::field)')
daterange_field_names = {}
for node in tree.xpath(f'.//field[count(ancestor::field) = {flevel}]'):
field_name = node.get('name')
# add field_info into fields
field_info = self._models_info.get(model._name, {}).get(field_name) or {'type': None}
fields[field_name] = field_info
fields_spec[field_name] = field_spec = {}
# determine modifiers
field_modifiers = {}
for attr in ('required', 'readonly', 'invisible', 'column_invisible'):
# use python field attribute as default value
default = attr in ('required', 'readonly') and field_info.get(attr, False)
expr = node.get(attr) or str(default)
field_modifiers[attr] = MODIFIER_ALIASES.get(expr, expr)
# Combine the field modifiers with its ancestor modifiers with an
# OR: A field is invisible if its own invisible modifier is True OR
# if one of its ancestor invisible modifier is True
for ancestor in node.xpath(f'ancestor::*[@invisible][count(ancestor::field) = {flevel}]'):
modifier = 'invisible'
expr = ancestor.get(modifier)
if expr == 'True' or field_modifiers[modifier] == 'True':
field_modifiers[modifier] = 'True'
if expr == 'False':
field_modifiers[modifier] = field_modifiers[modifier]
elif field_modifiers[modifier] == 'False':
field_modifiers[modifier] = expr
else:
field_modifiers[modifier] = f'({expr}) or ({field_modifiers[modifier]})'
# merge field_modifiers into modifiers[field_name]
if field_name in modifiers:
# The field is several times in the view, combine the modifier
# expression with an AND: a field is X if all occurences of the
# field in the view are X.
for modifier, expr in modifiers[field_name].items():
if expr == 'False' or field_modifiers[modifier] == 'False':
field_modifiers[modifier] = 'False'
if expr == 'True':
field_modifiers[modifier] = field_modifiers[modifier]
elif field_modifiers[modifier] == 'True':
field_modifiers[modifier] = expr
else:
field_modifiers[modifier] = f'({expr}) and ({field_modifiers[modifier]})'
modifiers[field_name] = field_modifiers
# determine context
ctx = node.get('context')
if ctx:
contexts[field_name] = ctx
field_spec['context'] = get_static_context(ctx)
# FIXME: better widgets support
# NOTE: selection breaks because of m2o widget=selection
if node.get('widget') in ['many2many']:
field_info['type'] = node.get('widget')
elif node.get('widget') == 'daterange':
options = ast.literal_eval(node.get('options', '{}'))
related_field = options.get('start_date_field') or options.get('end_date_field')
daterange_field_names[related_field] = field_name
# determine subview to use for edition
if field_info['type'] == 'one2many':
if level:
field_info['invisible'] = field_modifiers.get('invisible')
edition_view = self._get_one2many_edition_view(field_info, node, level)
field_info['edition_view'] = edition_view
field_spec['fields'] = edition_view['fields_spec']
else:
# this trick enables the following invariant: every one2many
# field has some 'edition_view' in its info dict
field_info['type'] = 'many2many'
for related_field, start_field in daterange_field_names.items():
modifiers[related_field]['invisible'] = modifiers[start_field].get('invisible', False)
return {
'tree': tree,
'fields': fields,
'fields_spec': fields_spec,
'modifiers': modifiers,
'contexts': contexts,
'onchange': model._onchange_spec({'arch': etree.tostring(tree)}),
}
def _get_one2many_edition_view(self, field_info, node, level):
""" Return a suitable view for editing records into a one2many field. """
submodel = self._env[field_info['relation']]
# by simplicity, ensure we always have tree and form views
views = {
view.tag: view for view in node.xpath('./*[descendant::field]')
}
for view_type in ['tree', 'form']:
if view_type in views:
continue
if field_info['invisible'] == 'True':
# add an empty view
views[view_type] = etree.Element(view_type)
continue
refs = self._env['ir.ui.view']._get_view_refs(node)
subviews = submodel.with_context(**refs).get_views([(None, view_type)])
subnode = etree.fromstring(subviews['views'][view_type]['arch'])
views[view_type] = subnode
node.append(subnode)
for model_name, fields in subviews['models'].items():
self._models_info.setdefault(model_name, {}).update(fields)
# pick the first editable subview
view_type = next(
vtype for vtype in node.get('mode', 'tree').split(',') if vtype != 'form'
)
if not (view_type == 'tree' and views['tree'].get('editable')):
view_type = 'form'
# don't recursively process o2ms in o2ms
return self._process_view(views[view_type], submodel, level=level-1)
def __str__(self):
return f"<{type(self).__name__} {self._record}>"
def _init_from_record(self):
""" Initialize the form for an existing record. """
assert self._record.id, "editing unstored records is not supported"
self._values.clear()
[record_values] = self._record.web_read(self._view['fields_spec'])
self._env.flush_all()
self._env.clear() # discard cache and pending recomputations
values = convert_read_to_form(record_values, self._view['fields'])
self._values.update(values)
def _init_from_defaults(self):
""" Initialize the form for a new record. """
vals = self._values
vals['id'] = False
# call onchange with no field; this retrieves default values, applies
# onchanges and return the result
self._perform_onchange()
# mark all fields as modified
self._values._changed.update(self._view['fields'])
def __getattr__(self, field_name):
""" Return the current value of the given field. """
return self[field_name]
def __getitem__(self, field_name):
""" Return the current value of the given field. """
field_info = self._view['fields'].get(field_name)
assert field_info is not None, f"{field_name!r} was not found in the view"
value = self._values[field_name]
if field_info['type'] == 'many2one':
Model = self._env[field_info['relation']]
return Model.browse(value)
elif field_info['type'] == 'one2many':
return O2MProxy(self, field_name)
elif field_info['type'] == 'many2many':
return M2MProxy(self, field_name)
return value
def __setattr__(self, field_name, value):
""" Set the given field to the given value, and proceed with the expected onchanges. """
self[field_name] = value
def __setitem__(self, field_name, value):
""" Set the given field to the given value, and proceed with the expected onchanges. """
field_info = self._view['fields'].get(field_name)
assert field_info is not None, f"{field_name!r} was not found in the view"
assert field_info['type'] != 'one2many', "Can't set an one2many field directly, use its proxy instead"
assert not self._get_modifier(field_name, 'readonly'), f"can't write on readonly field {field_name!r}"
assert not self._get_modifier(field_name, 'invisible'), f"can't write on invisible field {field_name!r}"
if field_info['type'] == 'many2many':
return M2MProxy(self, field_name).set(value)
if field_info['type'] == 'many2one':
assert isinstance(value, BaseModel) and value._name == field_info['relation']
value = value.id
self._values[field_name] = value
self._perform_onchange(field_name)
def _get_modifier(self, field_name, modifier, *, view=None, vals=None):
if view is None:
view = self._view
expr = view['modifiers'][field_name].get(modifier, False)
if isinstance(expr, bool):
return expr
if expr in ('True', 'False'):
return expr == 'True'
if vals is None:
vals = self._values
eval_context = self._get_eval_context(vals)
return bool(safe_eval(expr, eval_context))
def _get_context(self, field_name):
""" Return the context of a given field. """
context_str = self._view['contexts'].get(field_name)
if not context_str:
return {}
eval_context = self._get_eval_context()
return safe_eval(context_str, eval_context)
def _get_eval_context(self, values=None):
""" Return the context dict to eval something. """
context = {
'id': self._record.id,
'active_id': self._record.id,
'active_ids': self._record.ids,
'active_model': self._record._name,
'current_date': date.today().strftime("%Y-%m-%d"),
**self._env.context,
}
if values is None:
values = self._get_all_values()
return {
**context,
'context': context,
**values,
}
def _get_all_values(self):
""" Return the values of all fields. """
return self._get_values('all')
def __enter__(self):
""" This makes the Form usable as a context manager. """
return self
def __exit__(self, exc_type, exc_value, traceback):
if not exc_type:
self.save()
def save(self):
""" Save the form (if necessary) and return the current record:
* does not save ``readonly`` fields;
* does not save unmodified fields (during edition) any assignment
or onchange return marks the field as modified, even if set to its
current value.
When nothing must be saved, it simply returns the current record.
:raises AssertionError: if the form has any unfilled required field
"""
values = self._get_save_values()
if not self._record or values:
# save and reload
[record_values] = self._record.web_save(values, self._view['fields_spec'])
self._env.flush_all()
self._env.clear() # discard cache and pending recomputations
if not self._record:
record = self._record.browse(record_values['id'])
object.__setattr__(self, '_record', record)
values = convert_read_to_form(record_values, self._view['fields'])
self._values.clear()
self._values.update(values)
return self._record
@property
def record(self):
""" Return the record being edited by the form. This attribute is
readonly and can only be accessed when the form has no pending changes.
"""
assert not self._values._changed
return self._record
def _get_save_values(self):
""" Validate and return field values modified since load/save. """
return self._get_values('save')
def _get_values(self, mode, values=None, view=None, modifiers_values=None, parent_link=None):
""" Validate & extract values, recursively in order to handle o2ms properly.
:param mode: can be ``"save"`` (validate and return non-readonly modified fields),
``"onchange"`` (return modified fields) or ``"all"`` (return all field values)
:param UpdateDict values: values of the record to extract
:param view: view info
:param dict modifiers_values: defaults to ``values``, but o2ms need some additional massaging
:param parent_link: optional field representing "parent"
"""
assert mode in ('save', 'onchange', 'all')
if values is None:
values = self._values
if view is None:
view = self._view
assert isinstance(values, UpdateDict)
modifiers_values = modifiers_values or values
result = {}
for field_name, field_info in view['fields'].items():
if field_name == 'id' or field_name not in values:
continue
value = values[field_name]
# note: maybe `invisible` should not skip `required` if model attribute
if (
mode == 'save'
and value is False
and field_name != parent_link
and field_info['type'] != 'boolean'
and not self._get_modifier(field_name, 'invisible', view=view, vals=modifiers_values)
and not self._get_modifier(field_name, 'column_invisible', view=view, vals=modifiers_values)
and self._get_modifier(field_name, 'required', view=view, vals=modifiers_values)
):
raise AssertionError(f"{field_name} is a required field ({view['modifiers'][field_name]})")
# skip unmodified fields unless all_fields
if mode in ('save', 'onchange') and field_name not in values._changed:
continue
if mode == 'save' and self._get_modifier(field_name, 'readonly', view=view, vals=modifiers_values):
field_node = next(
node
for node in view['tree'].iter('field')
if node.get('name') == field_name
)
if not field_node.get('force_save'):
continue
if field_info['type'] == 'one2many':
if mode == 'all':
# in the context of an eval, format it as a list of ids
value = list(value)
else:
subview = field_info['edition_view']
value = value.to_commands(lambda vals: self._get_values(
mode, vals, subview,
modifiers_values={'id': False, **vals, 'parent': Dotter(values)},
# related o2m don't have a relation_field
parent_link=field_info.get('relation_field'),
))
elif field_info['type'] == 'many2many':
if mode == 'all':
# in the context of an eval, format it as a list of ids
value = list(value)
else:
value = value.to_commands()
result[field_name] = value
return result
def _perform_onchange(self, field_name=None):
assert field_name is None or isinstance(field_name, str)
# marks onchange source as changed
if field_name:
field_names = [field_name]
self._values._changed.add(field_name)
else:
field_names = []
# skip calling onchange() if there's no on_change on the field
if field_name and not self._view['onchange'][field_name]:
return
record = self._record
# if the onchange is triggered by a field, add the context of that field
if field_name:
context = self._get_context(field_name)
if context:
record = record.with_context(**context)
values = self._get_onchange_values()
result = record.onchange(values, field_names, self._view['fields_spec'])
self._env.flush_all()
self._env.clear() # discard cache and pending recomputations
if result.get('warning'):
_logger.getChild('onchange').warning("%(title)s %(message)s", result['warning'])
if not field_name:
# fill in whatever fields are still missing with falsy values
self._values.update({
field_name: _cleanup_from_default(field_info['type'], False)
for field_name, field_info in self._view['fields'].items()
if field_name not in self._values
})
if result.get('value'):
self._apply_onchange(result['value'])
return result
def _get_onchange_values(self):
""" Return modified field values for onchange. """
return self._get_values('onchange')
def _apply_onchange(self, values):
self._apply_onchange_(self._values, self._view['fields'], values)
def _apply_onchange_(self, values, fields, onchange_values):
assert isinstance(values, UpdateDict)
for fname, value in onchange_values.items():
field_info = fields[fname]
if field_info['type'] in ('one2many', 'many2many'):
subfields = {}
if field_info['type'] == 'one2many':
subfields = field_info['edition_view']['fields']
field_value = values[fname]
for cmd in value:
if cmd[0] == Command.CREATE:
vals = UpdateDict(convert_read_to_form(dict.fromkeys(subfields, False), subfields))
self._apply_onchange_(vals, subfields, cmd[2])
field_value.create(vals)
elif cmd[0] == Command.UPDATE:
vals = field_value.get_vals(cmd[1])
self._apply_onchange_(vals, subfields, cmd[2])
elif cmd[0] in (Command.DELETE, Command.UNLINK):
field_value.remove(cmd[1])
elif cmd[0] == Command.LINK:
field_value.add(cmd[1], convert_read_to_form(cmd[2], subfields))
else:
assert False, "Unexpected onchange() result"
else:
values[fname] = value
values._changed.add(fname)
class O2MForm(Form):
# noinspection PyMissingConstructor
# pylint: disable=super-init-not-called
def __init__(self, proxy, index=None):
model = proxy._model
object.__setattr__(self, '_proxy', proxy)
object.__setattr__(self, '_index', index)
object.__setattr__(self, '_record', model)
object.__setattr__(self, '_env', model.env)
object.__setattr__(self, '_models_info', proxy._form._models_info)
object.__setattr__(self, '_view', proxy._field_info['edition_view'])
object.__setattr__(self, '_values', UpdateDict())
if index is None:
self._init_from_defaults()
else:
vals = proxy._records[index]
self._values.update(vals)
if vals.get('id'):
object.__setattr__(self, '_record', model.browse(vals['id']))
def _get_modifier(self, field_name, modifier, *, view=None, vals=None):
if modifier != 'required' and self._proxy._form._get_modifier(self._proxy._field, modifier):
return True
return super()._get_modifier(field_name, modifier, view=view, vals=vals)
def _get_eval_context(self, values=None):
eval_context = super()._get_eval_context(values)
eval_context['parent'] = Dotter(self._proxy._form._values)
return eval_context
def _get_onchange_values(self):
values = super()._get_onchange_values()
# computed o2m may not have a relation_field(?)
field_info = self._proxy._field_info
if 'relation_field' in field_info: # note: should be fine because not recursive
parent_form = self._proxy._form
parent_values = parent_form._get_onchange_values()
if parent_form._record.id:
parent_values['id'] = parent_form._record.id
values[field_info['relation_field']] = parent_values
return values
def save(self):
proxy = self._proxy
field_value = proxy._form._values[proxy._field]
values = self._get_save_values()
if self._index is None:
field_value.create(values)
else:
id_ = field_value[self._index]
field_value.update(id_, values)
proxy._form._perform_onchange(proxy._field)
def _get_save_values(self):
""" Validate and return field values modified since load/save. """
values = UpdateDict(self._values)
for field_name in self._view['fields']:
if self._get_modifier(field_name, 'required') and not (
self._get_modifier(field_name, 'column_invisible')
or self._get_modifier(field_name, 'invisible')
):
assert values[field_name] is not False, f"{field_name!r} is a required field"
return values
class UpdateDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._changed = set()
if args and isinstance(args[0], UpdateDict):
self._changed.update(args[0]._changed)
def __repr__(self):
items = [
f"{key!r}{'*' if key in self._changed else ''}: {val!r}"
for key, val in self.items()
]
return f"{{{', '.join(items)}}}"
def changed_items(self):
return (
(k, v) for k, v in self.items()
if k in self._changed
)
def update(self, *args, **kw):
super().update(*args, **kw)
if args and isinstance(args[0], UpdateDict):
self._changed.update(args[0]._changed)
def clear(self):
super().clear()
self._changed.clear()
class X2MValue(collections.abc.Sequence):
""" The value of a one2many field, with the API of a sequence of record ids. """
_virtual_seq = itertools.count()
def __init__(self, iterable_of_vals=()):
self._data = {vals['id']: UpdateDict(vals) for vals in iterable_of_vals}
def __repr__(self):
return repr(self._data)
def __contains__(self, id_):
return id_ in self._data
def __getitem__(self, index):
return list(self._data)[index]
def __iter__(self):
return iter(self._data)
def __len__(self):
return len(self._data)
def __eq__(self, other):
# this enables to compare self with a list
return list(self) == other
def get_vals(self, id_):
return self._data[id_]
def add(self, id_, vals):
assert id_ not in self._data
self._data[id_] = UpdateDict(vals)
def remove(self, id_):
self._data.pop(id_)
def clear(self):
self._data.clear()
def create(self, vals):
id_ = f'virtual_{next(self._virtual_seq)}'
create_vals = UpdateDict(vals)
create_vals._changed.update(vals)
self._data[id_] = create_vals
def update(self, id_, changes, changed=()):
vals = self._data[id_]
vals.update(changes)
vals._changed.update(changed)
def to_list_of_vals(self):
return list(self._data.values())
class O2MValue(X2MValue):
def __init__(self, iterable_of_vals=()):
super().__init__(iterable_of_vals)
self._given = list(self._data)
def to_commands(self, convert_values=lambda vals: vals):
given = set(self._given)
result = []
for id_, vals in self._data.items():
if isinstance(id_, str) and id_.startswith('virtual_'):
result.append((Command.CREATE, id_, convert_values(vals)))
continue
if id_ not in given:
result.append(Command.link(id_))
if vals._changed:
result.append(Command.update(id_, convert_values(vals)))
for id_ in self._given:
if id_ not in self._data:
result.append(Command.delete(id_))
return result
class M2MValue(X2MValue):
def to_commands(self):
ids = []
result = [Command.set(ids)]
for id_, vals in self._data.items():
if isinstance(id_, str) and id_.startswith('virtual_'):
result.append((Command.CREATE, id_, {
key: val.to_commands() if isinstance(val, X2MValue) else val
for key, val in vals.changed_items()
}))
continue
ids.append(id_)
if vals._changed:
result.append(Command.update(id_, {
key: val.to_commands() if isinstance(val, X2MValue) else val
for key, val in vals.changed_items()
}))
return result
class X2MProxy:
""" A proxy represents the value of an x2many field, but not directly.
Instead, it provides an API to add, remove or edit records in the value.
"""
_form = None # Form containing the corresponding x2many field
_field = None # name of the x2many field
_field_info = None # field info
def __init__(self, form, field_name):
self._form = form
self._field = field_name
self._field_info = form._view['fields'][field_name]
self._field_value = form._values[field_name]
@property
def ids(self):
return list(self._field_value)
def _assert_editable(self):
assert not self._form._get_modifier(self._field, 'readonly'), f'field {self._field!r} is not editable'
assert not self._form._get_modifier(self._field, 'invisible'), f'field {self._field!r} is not visible'
class O2MProxy(X2MProxy):
""" Proxy object for editing the value of a one2many field. """
def __len__(self):
return len(self._field_value)
@property
def _model(self):
model = self._form._env[self._field_info['relation']]
context = self._form._get_context(self._field)
if context:
model = model.with_context(**context)
return model
@property
def _records(self):
return self._field_value.to_list_of_vals()
def new(self):
""" Returns a :class:`Form` for a new
:class:`~odoo.fields.One2many` record, properly initialised.
The form is created from the list view if editable, or the field's
form view otherwise.
:raises AssertionError: if the field is not editable
"""
self._assert_editable()
return O2MForm(self)
def edit(self, index):
""" Returns a :class:`Form` to edit the pre-existing
:class:`~odoo.fields.One2many` record.
The form is created from the list view if editable, or the field's
form view otherwise.
:raises AssertionError: if the field is not editable
"""
self._assert_editable()
return O2MForm(self, index)
def remove(self, index):
""" Removes the record at ``index`` from the parent form.
:raises AssertionError: if the field is not editable
"""
self._assert_editable()
self._field_value.remove(self._field_value[index])
self._form._perform_onchange(self._field)
class M2MProxy(X2MProxy, collections.abc.Sequence):
""" Proxy object for editing the value of a many2many field.
Behaves as a :class:`~collection.Sequence` of recordsets, can be
indexed or sliced to get actual underlying recordsets.
"""
def __getitem__(self, index):
comodel_name = self._field_info['relation']
return self._form._env[comodel_name].browse(self._field_value[index])
def __len__(self):
return len(self._field_value)
def __iter__(self):
comodel_name = self._field_info['relation']
records = self._form._env[comodel_name].browse(self._field_value)
return iter(records)
def __contains__(self, record):
comodel_name = self._field_info['relation']
assert isinstance(record, BaseModel) and record._name == comodel_name
return record.id in self._field_value
def add(self, record):
""" Adds ``record`` to the field, the record must already exist.
The addition will only be finalized when the parent record is saved.
"""
self._assert_editable()
parent = self._form
comodel_name = self._field_info['relation']
assert isinstance(record, BaseModel) and record._name == comodel_name, \
f"trying to assign a {record._name!r} object to a {comodel_name!r} field"
if record.id not in self._field_value:
self._field_value.add(record.id, {'id': record.id})
parent._perform_onchange(self._field)
# pylint: disable=redefined-builtin
def remove(self, id=None, index=None):
""" Removes a record at a certain index or with a provided id from
the field.
"""
self._assert_editable()
assert (id is None) ^ (index is None), "can remove by either id or index"
if id is None:
id = self._field_value[index]
self._field_value.remove(id)
self._form._perform_onchange(self._field)
def set(self, records):
""" Set the field value to be ``records``. """
self._assert_editable()
comodel_name = self._field_info['relation']
assert isinstance(records, BaseModel) and records._name == comodel_name, \
f"trying to assign a {records._name!r} object to a {comodel_name!r} field"
if set(records.ids) != set(self._field_value):
self._field_value.clear()
for id_ in records.ids:
self._field_value.add(id_, {'id': id_})
self._form._perform_onchange(self._field)
def clear(self):
""" Removes all existing records in the m2m
"""
self._assert_editable()
self._field_value.clear()
self._form._perform_onchange(self._field)
def convert_read_to_form(values, fields):
result = {}
for fname, value in values.items():
field_info = {'type': 'id'} if fname == 'id' else fields[fname]
if field_info['type'] == 'one2many':
if 'edition_view' in field_info:
subfields = field_info['edition_view']['fields']
value = O2MValue(convert_read_to_form(vals, subfields) for vals in (value or ()))
else:
value = O2MValue({'id': id_} for id_ in (value or ()))
elif field_info['type'] == 'many2many':
value = M2MValue({'id': id_} for id_ in (value or ()))
elif field_info['type'] == 'datetime' and isinstance(value, datetime):
value = odoo.fields.Datetime.to_string(value)
elif field_info['type'] == 'date' and isinstance(value, date):
value = odoo.fields.Date.to_string(value)
result[fname] = value
return result
def _cleanup_from_default(type_, value):
if not value:
if type_ == 'one2many':
return O2MValue()
elif type_ == 'many2many':
return M2MValue()
elif type_ in ('integer', 'float'):
return 0
return value
if type_ == 'one2many':
assert False, "not implemented yet"
return [cmd for cmd in value if cmd[0] != Command.SET]
elif type_ == 'datetime' and isinstance(value, datetime):
return odoo.fields.Datetime.to_string(value)
elif type_ == 'date' and isinstance(value, date):
return odoo.fields.Date.to_string(value)
return value
def get_static_context(context_str):
""" Parse the given context string, and return the literal part of it. """
context_ast = ast.parse(context_str.strip(), mode='eval').body
assert isinstance(context_ast, ast.Dict)
result = {}
for key_ast, val_ast in zip(context_ast.keys, context_ast.values):
try:
key = ast.literal_eval(key_ast)
val = ast.literal_eval(val_ast)
result[key] = val
except ValueError:
pass
return result
class Dotter:
""" Simple wrapper for a dict where keys are accessed as readonly attributes. """
__slots__ = ['__values']
def __init__(self, values):
self.__values = values
def __getattr__(self, key):
val = self.__values[key]
return Dotter(val) if isinstance(val, dict) else val