gooderp18绿色标准版
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1037 lines
39KB

  1. # -*- coding: utf-8 -*-
  2. """
  3. The module :mod:`odoo.tests.form` provides an implementation of a client form
  4. view for server-side unit tests.
  5. """
  6. from __future__ import annotations
  7. import ast
  8. import collections
  9. import itertools
  10. import logging
  11. from datetime import datetime, date
  12. from lxml import etree
  13. import odoo
  14. from odoo.models import BaseModel
  15. from odoo.fields import Command
  16. from odoo.tools.safe_eval import safe_eval
  17. _logger = logging.getLogger(__name__)
  18. MODIFIER_ALIASES = {'1': 'True', '0': 'False'}
  19. class Form:
  20. """ Server-side form view implementation (partial)
  21. Implements much of the "form view" manipulation flow, such that server-side
  22. tests can more properly reflect the behaviour which would be observed when
  23. manipulating the interface:
  24. * call the relevant onchanges on "creation";
  25. * call the relevant onchanges on setting fields;
  26. * properly handle defaults & onchanges around x2many fields.
  27. Saving the form returns the current record (which means the created record
  28. if in creation mode). It can also be accessed as ``form.record``, but only
  29. when the form has no pending changes.
  30. Regular fields can just be assigned directly to the form. In the case
  31. of :class:`~odoo.fields.Many2one` fields, one can assign a recordset::
  32. # empty recordset => creation mode
  33. f = Form(self.env['sale.order'])
  34. f.partner_id = a_partner
  35. so = f.save()
  36. One can also use the form as a context manager to create or edit a record.
  37. The changes are automatically saved at the end of the scope::
  38. with Form(self.env['sale.order']) as f1:
  39. f1.partner_id = a_partner
  40. # f1 is saved here
  41. # retrieve the created record
  42. so = f1.record
  43. # call Form on record => edition mode
  44. with Form(so) as f2:
  45. f2.payment_term_id = env.ref('account.account_payment_term_15days')
  46. # f2 is saved here
  47. For :class:`~odoo.fields.Many2many` fields, the field itself is a
  48. :class:`~odoo.tests.common.M2MProxy` and can be altered by adding or
  49. removing records::
  50. with Form(user) as u:
  51. u.groups_id.add(env.ref('account.group_account_manager'))
  52. u.groups_id.remove(id=env.ref('base.group_portal').id)
  53. Finally :class:`~odoo.fields.One2many` are reified as :class:`~O2MProxy`.
  54. Because the :class:`~odoo.fields.One2many` only exists through its parent,
  55. it is manipulated more directly by creating "sub-forms" with
  56. the :meth:`~O2MProxy.new` and :meth:`~O2MProxy.edit` methods. These would
  57. normally be used as context managers since they get saved in the parent
  58. record::
  59. with Form(so) as f3:
  60. f.partner_id = a_partner
  61. # add support
  62. with f3.order_line.new() as line:
  63. line.product_id = env.ref('product.product_product_2')
  64. # add a computer
  65. with f3.order_line.new() as line:
  66. line.product_id = env.ref('product.product_product_3')
  67. # we actually want 5 computers
  68. with f3.order_line.edit(1) as line:
  69. line.product_uom_qty = 5
  70. # remove support
  71. f3.order_line.remove(index=0)
  72. # SO is saved here
  73. :param record: empty or singleton recordset. An empty recordset will put
  74. the view in "creation" mode from default values, while a
  75. singleton will put it in "edit" mode and only load the
  76. view's data.
  77. :param view: the id, xmlid or actual view object to use for onchanges and
  78. view constraints. If none is provided, simply loads the
  79. default view for the model.
  80. .. versionadded:: 12.0
  81. """
  82. def __init__(self, record: BaseModel, view: None | int | str | BaseModel = None) -> None:
  83. assert isinstance(record, BaseModel)
  84. assert len(record) <= 1
  85. # use object.__setattr__ to bypass Form's override of __setattr__
  86. object.__setattr__(self, '_record', record)
  87. object.__setattr__(self, '_env', record.env)
  88. # determine view and process it
  89. if isinstance(view, BaseModel):
  90. assert view._name == 'ir.ui.view', "the view parameter must be a view id, xid or record, got %s" % view
  91. view_id = view.id
  92. elif isinstance(view, str):
  93. view_id = record.env.ref(view).id
  94. else:
  95. view_id = view or False
  96. views = record.get_views([(view_id, 'form')])
  97. object.__setattr__(self, '_models_info', views['models'])
  98. # self._models_info = {model_name: {fields: {field_name: field_info}}}
  99. tree = etree.fromstring(views['views']['form']['arch'])
  100. view = self._process_view(tree, record)
  101. object.__setattr__(self, '_view', view)
  102. # self._view = {
  103. # 'tree': view_arch_etree,
  104. # 'fields': {field_name: field_info},
  105. # 'fields_spec': web_read_fields_spec,
  106. # 'modifiers': {field_name: {modifier: expression}},
  107. # 'contexts': {field_name: field_context_str},
  108. # 'onchange': onchange_spec,
  109. # }
  110. # determine record values
  111. object.__setattr__(self, '_values', UpdateDict())
  112. if record:
  113. self._init_from_record()
  114. else:
  115. self._init_from_defaults()
  116. @classmethod
  117. def from_action(cls, env: odoo.api.Environment, action: dict) -> Form:
  118. assert action['type'] == 'ir.actions.act_window', \
  119. f"only window actions are valid, got {action['type']}"
  120. # ensure the first-requested view is a form view
  121. if views := action.get('views'):
  122. assert views[0][1] == 'form', \
  123. f"the actions dict should have a form as first view, got {views[0][1]}"
  124. view_id = views[0][0]
  125. else:
  126. view_mode = action.get('view_mode', '')
  127. if not view_mode.startswith('form'):
  128. raise ValueError(f"The actions dict should have a form first view mode, got {view_mode}")
  129. view_id = action.get('view_id')
  130. if view_id and ',' in view_mode:
  131. raise ValueError(f"A `view_id` is only valid if the action has a single `view_mode`, got {view_mode}")
  132. context = action.get('context', {})
  133. if isinstance(context, str):
  134. context = ast.literal_eval(context)
  135. record = env[action['res_model']]\
  136. .with_context(context)\
  137. .browse(action.get('res_id'))
  138. return cls(record, view_id)
  139. def _process_view(self, tree, model, level=2):
  140. """ Post-processes to augment the view_get with:
  141. * an id field (may not be present if not in the view but needed)
  142. * pre-processed modifiers
  143. * pre-processed onchanges list
  144. """
  145. fields = {'id': {'type': 'id'}}
  146. fields_spec = {}
  147. modifiers = {'id': {'required': 'False', 'readonly': 'True'}}
  148. contexts = {}
  149. # retrieve <field> nodes at the current level
  150. flevel = tree.xpath('count(ancestor::field)')
  151. daterange_field_names = {}
  152. for node in tree.xpath(f'.//field[count(ancestor::field) = {flevel}]'):
  153. field_name = node.get('name')
  154. # add field_info into fields
  155. field_info = self._models_info.get(model._name, {}).get("fields", {}).get(field_name) or {'type': None}
  156. fields[field_name] = field_info
  157. fields_spec[field_name] = field_spec = {}
  158. # determine modifiers
  159. field_modifiers = {}
  160. for attr in ('required', 'readonly', 'invisible', 'column_invisible'):
  161. # use python field attribute as default value
  162. default = attr in ('required', 'readonly') and field_info.get(attr, False)
  163. expr = node.get(attr) or str(default)
  164. field_modifiers[attr] = MODIFIER_ALIASES.get(expr, expr)
  165. # Combine the field modifiers with its ancestor modifiers with an
  166. # OR: A field is invisible if its own invisible modifier is True OR
  167. # if one of its ancestor invisible modifier is True
  168. for ancestor in node.xpath(f'ancestor::*[@invisible][count(ancestor::field) = {flevel}]'):
  169. modifier = 'invisible'
  170. expr = ancestor.get(modifier)
  171. if expr == 'True' or field_modifiers[modifier] == 'True':
  172. field_modifiers[modifier] = 'True'
  173. if expr == 'False':
  174. field_modifiers[modifier] = field_modifiers[modifier]
  175. elif field_modifiers[modifier] == 'False':
  176. field_modifiers[modifier] = expr
  177. else:
  178. field_modifiers[modifier] = f'({expr}) or ({field_modifiers[modifier]})'
  179. # merge field_modifiers into modifiers[field_name]
  180. if field_name in modifiers:
  181. # The field is several times in the view, combine the modifier
  182. # expression with an AND: a field is X if all occurences of the
  183. # field in the view are X.
  184. for modifier, expr in modifiers[field_name].items():
  185. if expr == 'False' or field_modifiers[modifier] == 'False':
  186. field_modifiers[modifier] = 'False'
  187. if expr == 'True':
  188. field_modifiers[modifier] = field_modifiers[modifier]
  189. elif field_modifiers[modifier] == 'True':
  190. field_modifiers[modifier] = expr
  191. else:
  192. field_modifiers[modifier] = f'({expr}) and ({field_modifiers[modifier]})'
  193. modifiers[field_name] = field_modifiers
  194. # determine context
  195. ctx = node.get('context')
  196. if ctx:
  197. contexts[field_name] = ctx
  198. field_spec['context'] = get_static_context(ctx)
  199. # FIXME: better widgets support
  200. # NOTE: selection breaks because of m2o widget=selection
  201. if node.get('widget') in ['many2many']:
  202. field_info['type'] = node.get('widget')
  203. elif node.get('widget') == 'daterange':
  204. options = ast.literal_eval(node.get('options', '{}'))
  205. related_field = options.get('start_date_field') or options.get('end_date_field')
  206. daterange_field_names[related_field] = field_name
  207. # determine subview to use for edition
  208. if field_info['type'] == 'one2many':
  209. if level:
  210. field_info['invisible'] = field_modifiers.get('invisible')
  211. edition_view = self._get_one2many_edition_view(field_info, node, level)
  212. field_info['edition_view'] = edition_view
  213. field_spec['fields'] = edition_view['fields_spec']
  214. else:
  215. # this trick enables the following invariant: every one2many
  216. # field has some 'edition_view' in its info dict
  217. field_info['type'] = 'many2many'
  218. for related_field, start_field in daterange_field_names.items():
  219. modifiers[related_field]['invisible'] = modifiers[start_field].get('invisible', False)
  220. return {
  221. 'tree': tree,
  222. 'fields': fields,
  223. 'fields_spec': fields_spec,
  224. 'modifiers': modifiers,
  225. 'contexts': contexts,
  226. 'onchange': model._onchange_spec({'arch': etree.tostring(tree)}),
  227. }
  228. def _get_one2many_edition_view(self, field_info, node, level):
  229. """ Return a suitable view for editing records into a one2many field. """
  230. submodel = self._env[field_info['relation']]
  231. # by simplicity, ensure we always have tree and form views
  232. views = {
  233. view.tag: view for view in node.xpath('./*[descendant::field]')
  234. }
  235. for view_type in ['list', 'form']:
  236. if view_type in views:
  237. continue
  238. if field_info['invisible'] == 'True':
  239. # add an empty view
  240. views[view_type] = etree.Element(view_type)
  241. continue
  242. refs = self._env['ir.ui.view']._get_view_refs(node)
  243. subviews = submodel.with_context(**refs).get_views([(None, view_type)])
  244. subnode = etree.fromstring(subviews['views'][view_type]['arch'])
  245. views[view_type] = subnode
  246. node.append(subnode)
  247. for model_name, value in subviews['models'].items():
  248. model_info = self._models_info.setdefault(model_name, {})
  249. if "fields" not in model_info:
  250. model_info["fields"] = {}
  251. model_info["fields"].update(value["fields"])
  252. # pick the first editable subview
  253. view_type = next(
  254. vtype for vtype in node.get('mode', 'list').split(',') if vtype != 'form'
  255. )
  256. if not (view_type == 'list' and views['list'].get('editable')):
  257. view_type = 'form'
  258. # don't recursively process o2ms in o2ms
  259. return self._process_view(views[view_type], submodel, level=level-1)
  260. def __str__(self):
  261. return f"<{type(self).__name__} {self._record}>"
  262. def _init_from_record(self):
  263. """ Initialize the form for an existing record. """
  264. assert self._record.id, "editing unstored records is not supported"
  265. self._values.clear()
  266. [record_values] = self._record.web_read(self._view['fields_spec'])
  267. self._env.flush_all()
  268. self._env.clear() # discard cache and pending recomputations
  269. values = convert_read_to_form(record_values, self._view['fields'])
  270. self._values.update(values)
  271. def _init_from_defaults(self):
  272. """ Initialize the form for a new record. """
  273. vals = self._values
  274. vals['id'] = False
  275. # call onchange with no field; this retrieves default values, applies
  276. # onchanges and return the result
  277. self._perform_onchange()
  278. # mark all fields as modified
  279. self._values._changed.update(self._view['fields'])
  280. def __getattr__(self, field_name):
  281. """ Return the current value of the given field. """
  282. return self[field_name]
  283. def __getitem__(self, field_name):
  284. """ Return the current value of the given field. """
  285. field_info = self._view['fields'].get(field_name)
  286. assert field_info is not None, f"{field_name!r} was not found in the view"
  287. value = self._values[field_name]
  288. if field_info['type'] == 'many2one':
  289. Model = self._env[field_info['relation']]
  290. return Model.browse(value)
  291. elif field_info['type'] == 'one2many':
  292. return O2MProxy(self, field_name)
  293. elif field_info['type'] == 'many2many':
  294. return M2MProxy(self, field_name)
  295. return value
  296. def __setattr__(self, field_name, value):
  297. """ Set the given field to the given value, and proceed with the expected onchanges. """
  298. self[field_name] = value
  299. def __setitem__(self, field_name, value):
  300. """ Set the given field to the given value, and proceed with the expected onchanges. """
  301. field_info = self._view['fields'].get(field_name)
  302. assert field_info is not None, f"{field_name!r} was not found in the view"
  303. assert field_info['type'] != 'one2many', "Can't set an one2many field directly, use its proxy instead"
  304. assert not self._get_modifier(field_name, 'readonly'), f"can't write on readonly field {field_name!r}"
  305. assert not self._get_modifier(field_name, 'invisible'), f"can't write on invisible field {field_name!r}"
  306. if field_info['type'] == 'many2many':
  307. return M2MProxy(self, field_name).set(value)
  308. if field_info['type'] == 'many2one':
  309. assert isinstance(value, BaseModel) and value._name == field_info['relation']
  310. value = value.id
  311. self._values[field_name] = value
  312. self._perform_onchange(field_name)
  313. def _get_modifier(self, field_name, modifier, *, view=None, vals=None):
  314. if view is None:
  315. view = self._view
  316. expr = view['modifiers'][field_name].get(modifier, False)
  317. if isinstance(expr, bool):
  318. return expr
  319. if expr in ('True', 'False'):
  320. return expr == 'True'
  321. if vals is None:
  322. vals = self._values
  323. eval_context = self._get_eval_context(vals)
  324. return bool(safe_eval(expr, eval_context))
  325. def _get_context(self, field_name):
  326. """ Return the context of a given field. """
  327. context_str = self._view['contexts'].get(field_name)
  328. if not context_str:
  329. return {}
  330. eval_context = self._get_eval_context()
  331. return safe_eval(context_str, eval_context)
  332. def _get_eval_context(self, values=None):
  333. """ Return the context dict to eval something. """
  334. context = {
  335. 'id': self._record.id,
  336. 'active_id': self._record.id,
  337. 'active_ids': self._record.ids,
  338. 'active_model': self._record._name,
  339. 'current_date': date.today().strftime("%Y-%m-%d"),
  340. **self._env.context,
  341. }
  342. if values is None:
  343. values = self._get_all_values()
  344. return {
  345. **context,
  346. 'context': context,
  347. **values,
  348. }
  349. def _get_all_values(self):
  350. """ Return the values of all fields. """
  351. return self._get_values('all')
  352. def __enter__(self):
  353. """ This makes the Form usable as a context manager. """
  354. return self
  355. def __exit__(self, exc_type, exc_value, traceback):
  356. if not exc_type:
  357. self.save()
  358. def save(self):
  359. """ Save the form (if necessary) and return the current record:
  360. * does not save ``readonly`` fields;
  361. * does not save unmodified fields (during edition) — any assignment
  362. or onchange return marks the field as modified, even if set to its
  363. current value.
  364. When nothing must be saved, it simply returns the current record.
  365. :raises AssertionError: if the form has any unfilled required field
  366. """
  367. values = self._get_save_values()
  368. if not self._record or values:
  369. # save and reload
  370. [record_values] = self._record.web_save(values, self._view['fields_spec'])
  371. self._env.flush_all()
  372. self._env.clear() # discard cache and pending recomputations
  373. if not self._record:
  374. record = self._record.browse(record_values['id'])
  375. object.__setattr__(self, '_record', record)
  376. values = convert_read_to_form(record_values, self._view['fields'])
  377. self._values.clear()
  378. self._values.update(values)
  379. return self._record
  380. @property
  381. def record(self):
  382. """ Return the record being edited by the form. This attribute is
  383. readonly and can only be accessed when the form has no pending changes.
  384. """
  385. assert not self._values._changed
  386. return self._record
  387. def _get_save_values(self):
  388. """ Validate and return field values modified since load/save. """
  389. return self._get_values('save')
  390. def _get_values(self, mode, values=None, view=None, modifiers_values=None, parent_link=None):
  391. """ Validate & extract values, recursively in order to handle o2ms properly.
  392. :param mode: can be ``"save"`` (validate and return non-readonly modified fields),
  393. ``"onchange"`` (return modified fields) or ``"all"`` (return all field values)
  394. :param UpdateDict values: values of the record to extract
  395. :param view: view info
  396. :param dict modifiers_values: defaults to ``values``, but o2ms need some additional massaging
  397. :param parent_link: optional field representing "parent"
  398. """
  399. assert mode in ('save', 'onchange', 'all')
  400. if values is None:
  401. values = self._values
  402. if view is None:
  403. view = self._view
  404. assert isinstance(values, UpdateDict)
  405. modifiers_values = modifiers_values or values
  406. result = {}
  407. for field_name, field_info in view['fields'].items():
  408. if field_name == 'id' or field_name not in values:
  409. continue
  410. value = values[field_name]
  411. # note: maybe `invisible` should not skip `required` if model attribute
  412. if (
  413. mode == 'save'
  414. and value is False
  415. and field_name != parent_link
  416. and field_info['type'] != 'boolean'
  417. and not self._get_modifier(field_name, 'invisible', view=view, vals=modifiers_values)
  418. and not self._get_modifier(field_name, 'column_invisible', view=view, vals=modifiers_values)
  419. and self._get_modifier(field_name, 'required', view=view, vals=modifiers_values)
  420. ):
  421. raise AssertionError(f"{field_name} is a required field ({view['modifiers'][field_name]})")
  422. # skip unmodified fields unless all_fields
  423. if mode in ('save', 'onchange') and field_name not in values._changed:
  424. continue
  425. if mode == 'save' and self._get_modifier(field_name, 'readonly', view=view, vals=modifiers_values):
  426. field_node = next(
  427. node
  428. for node in view['tree'].iter('field')
  429. if node.get('name') == field_name
  430. )
  431. if not field_node.get('force_save'):
  432. continue
  433. if field_info['type'] == 'one2many':
  434. if mode == 'all':
  435. # in the context of an eval, format it as a list of ids
  436. value = list(value)
  437. else:
  438. subview = field_info['edition_view']
  439. value = value.to_commands(lambda vals: self._get_values(
  440. mode, vals, subview,
  441. modifiers_values={'id': False, **vals, 'parent': Dotter(values)},
  442. # related o2m don't have a relation_field
  443. parent_link=field_info.get('relation_field'),
  444. ))
  445. elif field_info['type'] == 'many2many':
  446. if mode == 'all':
  447. # in the context of an eval, format it as a list of ids
  448. value = list(value)
  449. else:
  450. value = value.to_commands()
  451. result[field_name] = value
  452. return result
  453. def _perform_onchange(self, field_name=None):
  454. assert field_name is None or isinstance(field_name, str)
  455. # marks onchange source as changed
  456. if field_name:
  457. field_names = [field_name]
  458. self._values._changed.add(field_name)
  459. else:
  460. field_names = []
  461. # skip calling onchange() if there's no on_change on the field
  462. if field_name and not self._view['onchange'][field_name]:
  463. return
  464. record = self._record
  465. # if the onchange is triggered by a field, add the context of that field
  466. if field_name:
  467. context = self._get_context(field_name)
  468. if context:
  469. record = record.with_context(**context)
  470. values = self._get_onchange_values()
  471. result = record.onchange(values, field_names, self._view['fields_spec'])
  472. self._env.flush_all()
  473. self._env.clear() # discard cache and pending recomputations
  474. if result.get('warning'):
  475. _logger.getChild('onchange').warning("%(title)s %(message)s", result['warning'])
  476. if not field_name:
  477. # fill in whatever fields are still missing with falsy values
  478. self._values.update({
  479. field_name: _cleanup_from_default(field_info['type'], False)
  480. for field_name, field_info in self._view['fields'].items()
  481. if field_name not in self._values
  482. })
  483. if result.get('value'):
  484. self._apply_onchange(result['value'])
  485. return result
  486. def _get_onchange_values(self):
  487. """ Return modified field values for onchange. """
  488. return self._get_values('onchange')
  489. def _apply_onchange(self, values):
  490. self._apply_onchange_(self._values, self._view['fields'], values)
  491. def _apply_onchange_(self, values, fields, onchange_values):
  492. assert isinstance(values, UpdateDict)
  493. for fname, value in onchange_values.items():
  494. field_info = fields[fname]
  495. if field_info['type'] in ('one2many', 'many2many'):
  496. subfields = {}
  497. if field_info['type'] == 'one2many':
  498. subfields = field_info['edition_view']['fields']
  499. field_value = values[fname]
  500. for cmd in value:
  501. if cmd[0] == Command.CREATE:
  502. vals = UpdateDict(convert_read_to_form(dict.fromkeys(subfields, False), subfields))
  503. self._apply_onchange_(vals, subfields, cmd[2])
  504. field_value.create(vals)
  505. elif cmd[0] == Command.UPDATE:
  506. vals = field_value.get_vals(cmd[1])
  507. self._apply_onchange_(vals, subfields, cmd[2])
  508. elif cmd[0] in (Command.DELETE, Command.UNLINK):
  509. field_value.remove(cmd[1])
  510. elif cmd[0] == Command.LINK:
  511. field_value.add(cmd[1], convert_read_to_form(cmd[2], subfields))
  512. else:
  513. assert False, "Unexpected onchange() result"
  514. else:
  515. values[fname] = value
  516. values._changed.add(fname)
  517. class O2MForm(Form):
  518. # noinspection PyMissingConstructor
  519. # pylint: disable=super-init-not-called
  520. def __init__(self, proxy, index=None):
  521. model = proxy._model
  522. object.__setattr__(self, '_proxy', proxy)
  523. object.__setattr__(self, '_index', index)
  524. object.__setattr__(self, '_record', model)
  525. object.__setattr__(self, '_env', model.env)
  526. object.__setattr__(self, '_models_info', proxy._form._models_info)
  527. object.__setattr__(self, '_view', proxy._field_info['edition_view'])
  528. object.__setattr__(self, '_values', UpdateDict())
  529. if index is None:
  530. self._init_from_defaults()
  531. else:
  532. vals = proxy._records[index]
  533. self._values.update(vals)
  534. if vals.get('id'):
  535. object.__setattr__(self, '_record', model.browse(vals['id']))
  536. def _get_modifier(self, field_name, modifier, *, view=None, vals=None):
  537. if modifier != 'required' and self._proxy._form._get_modifier(self._proxy._field, modifier):
  538. return True
  539. return super()._get_modifier(field_name, modifier, view=view, vals=vals)
  540. def _get_eval_context(self, values=None):
  541. eval_context = super()._get_eval_context(values)
  542. eval_context['parent'] = Dotter(self._proxy._form._values)
  543. return eval_context
  544. def _get_onchange_values(self):
  545. values = super()._get_onchange_values()
  546. # computed o2m may not have a relation_field(?)
  547. field_info = self._proxy._field_info
  548. if 'relation_field' in field_info: # note: should be fine because not recursive
  549. parent_form = self._proxy._form
  550. parent_values = parent_form._get_onchange_values()
  551. if parent_form._record.id:
  552. parent_values['id'] = parent_form._record.id
  553. values[field_info['relation_field']] = parent_values
  554. return values
  555. def save(self):
  556. proxy = self._proxy
  557. field_value = proxy._form._values[proxy._field]
  558. values = self._get_save_values()
  559. if self._index is None:
  560. field_value.create(values)
  561. else:
  562. id_ = field_value[self._index]
  563. field_value.update(id_, values)
  564. proxy._form._perform_onchange(proxy._field)
  565. def _get_save_values(self):
  566. """ Validate and return field values modified since load/save. """
  567. values = UpdateDict(self._values)
  568. for field_name in self._view['fields']:
  569. if self._get_modifier(field_name, 'required') and not (
  570. self._get_modifier(field_name, 'column_invisible')
  571. or self._get_modifier(field_name, 'invisible')
  572. ):
  573. assert values[field_name] is not False, f"{field_name!r} is a required field"
  574. return values
  575. class UpdateDict(dict):
  576. def __init__(self, *args, **kwargs):
  577. super().__init__(*args, **kwargs)
  578. self._changed = set()
  579. if args and isinstance(args[0], UpdateDict):
  580. self._changed.update(args[0]._changed)
  581. def __repr__(self):
  582. items = [
  583. f"{key!r}{'*' if key in self._changed else ''}: {val!r}"
  584. for key, val in self.items()
  585. ]
  586. return f"{{{', '.join(items)}}}"
  587. def changed_items(self):
  588. return (
  589. (k, v) for k, v in self.items()
  590. if k in self._changed
  591. )
  592. def update(self, *args, **kw):
  593. super().update(*args, **kw)
  594. if args and isinstance(args[0], UpdateDict):
  595. self._changed.update(args[0]._changed)
  596. def clear(self):
  597. super().clear()
  598. self._changed.clear()
  599. class X2MValue(collections.abc.Sequence):
  600. """ The value of a one2many field, with the API of a sequence of record ids. """
  601. _virtual_seq = itertools.count()
  602. def __init__(self, iterable_of_vals=()):
  603. self._data = {vals['id']: UpdateDict(vals) for vals in iterable_of_vals}
  604. def __repr__(self):
  605. return repr(self._data)
  606. def __contains__(self, id_):
  607. return id_ in self._data
  608. def __getitem__(self, index):
  609. return list(self._data)[index]
  610. def __iter__(self):
  611. return iter(self._data)
  612. def __len__(self):
  613. return len(self._data)
  614. def __eq__(self, other):
  615. # this enables to compare self with a list
  616. return list(self) == other
  617. def get_vals(self, id_):
  618. return self._data[id_]
  619. def add(self, id_, vals):
  620. assert id_ not in self._data
  621. self._data[id_] = UpdateDict(vals)
  622. def remove(self, id_):
  623. self._data.pop(id_)
  624. def clear(self):
  625. self._data.clear()
  626. def create(self, vals):
  627. id_ = f'virtual_{next(self._virtual_seq)}'
  628. create_vals = UpdateDict(vals)
  629. create_vals._changed.update(vals)
  630. self._data[id_] = create_vals
  631. def update(self, id_, changes, changed=()):
  632. vals = self._data[id_]
  633. vals.update(changes)
  634. vals._changed.update(changed)
  635. def to_list_of_vals(self):
  636. return list(self._data.values())
  637. class O2MValue(X2MValue):
  638. def __init__(self, iterable_of_vals=()):
  639. super().__init__(iterable_of_vals)
  640. self._given = list(self._data)
  641. def to_commands(self, convert_values=lambda vals: vals):
  642. given = set(self._given)
  643. result = []
  644. for id_, vals in self._data.items():
  645. if isinstance(id_, str) and id_.startswith('virtual_'):
  646. result.append((Command.CREATE, id_, convert_values(vals)))
  647. continue
  648. if id_ not in given:
  649. result.append(Command.link(id_))
  650. if vals._changed:
  651. result.append(Command.update(id_, convert_values(vals)))
  652. for id_ in self._given:
  653. if id_ not in self._data:
  654. result.append(Command.delete(id_))
  655. return result
  656. class M2MValue(X2MValue):
  657. def __init__(self, iterable_of_vals=()):
  658. super().__init__(iterable_of_vals)
  659. self._given = list(self._data)
  660. def to_commands(self):
  661. given = set(self._given)
  662. result = []
  663. for id_, vals in self._data.items():
  664. if isinstance(id_, str) and id_.startswith('virtual_'):
  665. result.append((Command.CREATE, id_, {
  666. key: val.to_commands() if isinstance(val, X2MValue) else val
  667. for key, val in vals.changed_items()
  668. }))
  669. continue
  670. if id_ not in given:
  671. result.append(Command.link(id_))
  672. if vals._changed:
  673. result.append(Command.update(id_, {
  674. key: val.to_commands() if isinstance(val, X2MValue) else val
  675. for key, val in vals.changed_items()
  676. }))
  677. for id_ in self._given:
  678. if id_ not in self._data:
  679. result.append(Command.unlink(id_))
  680. return result
  681. class X2MProxy:
  682. """ A proxy represents the value of an x2many field, but not directly.
  683. Instead, it provides an API to add, remove or edit records in the value.
  684. """
  685. _form = None # Form containing the corresponding x2many field
  686. _field = None # name of the x2many field
  687. _field_info = None # field info
  688. def __init__(self, form, field_name):
  689. self._form = form
  690. self._field = field_name
  691. self._field_info = form._view['fields'][field_name]
  692. self._field_value = form._values[field_name]
  693. @property
  694. def ids(self):
  695. return list(self._field_value)
  696. def _assert_editable(self):
  697. assert not self._form._get_modifier(self._field, 'readonly'), f'field {self._field!r} is not editable'
  698. assert not self._form._get_modifier(self._field, 'invisible'), f'field {self._field!r} is not visible'
  699. class O2MProxy(X2MProxy):
  700. """ Proxy object for editing the value of a one2many field. """
  701. def __len__(self):
  702. return len(self._field_value)
  703. @property
  704. def _model(self):
  705. model = self._form._env[self._field_info['relation']]
  706. context = self._form._get_context(self._field)
  707. if context:
  708. model = model.with_context(**context)
  709. return model
  710. @property
  711. def _records(self):
  712. return self._field_value.to_list_of_vals()
  713. def new(self):
  714. """ Returns a :class:`Form` for a new
  715. :class:`~odoo.fields.One2many` record, properly initialised.
  716. The form is created from the list view if editable, or the field's
  717. form view otherwise.
  718. :raises AssertionError: if the field is not editable
  719. """
  720. self._assert_editable()
  721. return O2MForm(self)
  722. def edit(self, index):
  723. """ Returns a :class:`Form` to edit the pre-existing
  724. :class:`~odoo.fields.One2many` record.
  725. The form is created from the list view if editable, or the field's
  726. form view otherwise.
  727. :raises AssertionError: if the field is not editable
  728. """
  729. self._assert_editable()
  730. return O2MForm(self, index)
  731. def remove(self, index):
  732. """ Removes the record at ``index`` from the parent form.
  733. :raises AssertionError: if the field is not editable
  734. """
  735. self._assert_editable()
  736. self._field_value.remove(self._field_value[index])
  737. self._form._perform_onchange(self._field)
  738. class M2MProxy(X2MProxy, collections.abc.Sequence):
  739. """ Proxy object for editing the value of a many2many field.
  740. Behaves as a :class:`~collection.Sequence` of recordsets, can be
  741. indexed or sliced to get actual underlying recordsets.
  742. """
  743. def __getitem__(self, index):
  744. comodel_name = self._field_info['relation']
  745. return self._form._env[comodel_name].browse(self._field_value[index])
  746. def __len__(self):
  747. return len(self._field_value)
  748. def __iter__(self):
  749. comodel_name = self._field_info['relation']
  750. records = self._form._env[comodel_name].browse(self._field_value)
  751. return iter(records)
  752. def __contains__(self, record):
  753. comodel_name = self._field_info['relation']
  754. assert isinstance(record, BaseModel) and record._name == comodel_name
  755. return record.id in self._field_value
  756. def add(self, record):
  757. """ Adds ``record`` to the field, the record must already exist.
  758. The addition will only be finalized when the parent record is saved.
  759. """
  760. self._assert_editable()
  761. parent = self._form
  762. comodel_name = self._field_info['relation']
  763. assert isinstance(record, BaseModel) and record._name == comodel_name, \
  764. f"trying to assign a {record._name!r} object to a {comodel_name!r} field"
  765. if record.id not in self._field_value:
  766. self._field_value.add(record.id, {'id': record.id})
  767. parent._perform_onchange(self._field)
  768. # pylint: disable=redefined-builtin
  769. def remove(self, id=None, index=None):
  770. """ Removes a record at a certain index or with a provided id from
  771. the field.
  772. """
  773. self._assert_editable()
  774. assert (id is None) ^ (index is None), "can remove by either id or index"
  775. if id is None:
  776. id = self._field_value[index]
  777. self._field_value.remove(id)
  778. self._form._perform_onchange(self._field)
  779. def set(self, records):
  780. """ Set the field value to be ``records``. """
  781. self._assert_editable()
  782. comodel_name = self._field_info['relation']
  783. assert isinstance(records, BaseModel) and records._name == comodel_name, \
  784. f"trying to assign a {records._name!r} object to a {comodel_name!r} field"
  785. if set(records.ids) != set(self._field_value):
  786. self._field_value.clear()
  787. for id_ in records.ids:
  788. self._field_value.add(id_, {'id': id_})
  789. self._form._perform_onchange(self._field)
  790. def clear(self):
  791. """ Removes all existing records in the m2m
  792. """
  793. self._assert_editable()
  794. self._field_value.clear()
  795. self._form._perform_onchange(self._field)
  796. def convert_read_to_form(values, fields):
  797. result = {}
  798. for fname, value in values.items():
  799. field_info = {'type': 'id'} if fname == 'id' else fields[fname]
  800. if field_info['type'] == 'one2many':
  801. if 'edition_view' in field_info:
  802. subfields = field_info['edition_view']['fields']
  803. value = O2MValue(convert_read_to_form(vals, subfields) for vals in (value or ()))
  804. else:
  805. value = O2MValue({'id': id_} for id_ in (value or ()))
  806. elif field_info['type'] == 'many2many':
  807. value = M2MValue({'id': id_} for id_ in (value or ()))
  808. elif field_info['type'] == 'datetime' and isinstance(value, datetime):
  809. value = odoo.fields.Datetime.to_string(value)
  810. elif field_info['type'] == 'date' and isinstance(value, date):
  811. value = odoo.fields.Date.to_string(value)
  812. result[fname] = value
  813. return result
  814. def _cleanup_from_default(type_, value):
  815. if not value:
  816. if type_ == 'one2many':
  817. return O2MValue()
  818. elif type_ == 'many2many':
  819. return M2MValue()
  820. elif type_ in ('integer', 'float'):
  821. return 0
  822. return value
  823. if type_ == 'one2many':
  824. assert False, "not implemented yet"
  825. return [cmd for cmd in value if cmd[0] != Command.SET]
  826. elif type_ == 'datetime' and isinstance(value, datetime):
  827. return odoo.fields.Datetime.to_string(value)
  828. elif type_ == 'date' and isinstance(value, date):
  829. return odoo.fields.Date.to_string(value)
  830. return value
  831. def get_static_context(context_str):
  832. """ Parse the given context string, and return the literal part of it. """
  833. context_ast = ast.parse(context_str.strip(), mode='eval').body
  834. assert isinstance(context_ast, ast.Dict)
  835. result = {}
  836. for key_ast, val_ast in zip(context_ast.keys, context_ast.values):
  837. try:
  838. key = ast.literal_eval(key_ast)
  839. val = ast.literal_eval(val_ast)
  840. result[key] = val
  841. except ValueError:
  842. pass
  843. return result
  844. class Dotter:
  845. """ Simple wrapper for a dict where keys are accessed as readonly attributes. """
  846. __slots__ = ['__values']
  847. def __init__(self, values):
  848. self.__values = values
  849. def __getattr__(self, key):
  850. val = self.__values[key]
  851. return Dotter(val) if isinstance(val, dict) else val
上海开阖软件有限公司 沪ICP备12045867号-1