1003 lines
38 KiB
Python
1003 lines
38 KiB
Python
# -*- 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
|