Coverage for adhoc-cicd-odoo-odoo / odoo / tests / form.py: 13%

592 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:15 +0000

1# -*- coding: utf-8 -*- 

2""" 

3The module :mod:`odoo.tests.form` provides an implementation of a client form 

4view for server-side unit tests. 

5""" 

6from __future__ import annotations 

7 

8import ast 

9import collections 

10import collections.abc 

11import itertools 

12import logging 

13from datetime import datetime, date 

14 

15from lxml import etree 

16 

17from odoo import api, fields 

18from odoo.models import BaseModel 

19from odoo.fields import Command 

20from odoo.tools.safe_eval import safe_eval 

21 

22_logger = logging.getLogger(__name__) 

23 

24MODIFIER_ALIASES = {'1': 'True', '0': 'False'} 

25 

26 

27class Form: 

28 """ Server-side form view implementation (partial) 

29 

30 Implements much of the "form view" manipulation flow, such that server-side 

31 tests can more properly reflect the behaviour which would be observed when 

32 manipulating the interface: 

33 

34 * call the relevant onchanges on "creation"; 

35 * call the relevant onchanges on setting fields; 

36 * properly handle defaults & onchanges around x2many fields. 

37 

38 Saving the form returns the current record (which means the created record 

39 if in creation mode). It can also be accessed as ``form.record``, but only 

40 when the form has no pending changes. 

41 

42 Regular fields can just be assigned directly to the form. In the case 

43 of :class:`~odoo.fields.Many2one` fields, one can assign a recordset:: 

44 

45 # empty recordset => creation mode 

46 f = Form(self.env['sale.order']) 

47 f.partner_id = a_partner 

48 so = f.save() 

49 

50 One can also use the form as a context manager to create or edit a record. 

51 The changes are automatically saved at the end of the scope:: 

52 

53 with Form(self.env['sale.order']) as f1: 

54 f1.partner_id = a_partner 

55 # f1 is saved here 

56 

57 # retrieve the created record 

58 so = f1.record 

59 

60 # call Form on record => edition mode 

61 with Form(so) as f2: 

62 f2.payment_term_id = env.ref('account.account_payment_term_15days') 

63 # f2 is saved here 

64 

65 For :class:`~odoo.fields.Many2many` fields, the field itself is a 

66 :class:`~odoo.tests.common.M2MProxy` and can be altered by adding or 

67 removing records:: 

68 

69 with Form(user) as u: 

70 u.group_ids.add(env.ref('account.group_account_manager')) 

71 u.group_ids.remove(id=env.ref('base.group_portal').id) 

72 

73 Finally :class:`~odoo.fields.One2many` are reified as :class:`~O2MProxy`. 

74 

75 Because the :class:`~odoo.fields.One2many` only exists through its parent, 

76 it is manipulated more directly by creating "sub-forms" with 

77 the :meth:`~O2MProxy.new` and :meth:`~O2MProxy.edit` methods. These would 

78 normally be used as context managers since they get saved in the parent 

79 record:: 

80 

81 with Form(so) as f3: 

82 f.partner_id = a_partner 

83 # add support 

84 with f3.order_line.new() as line: 

85 line.product_id = env.ref('product.product_product_2') 

86 # add a computer 

87 with f3.order_line.new() as line: 

88 line.product_id = env.ref('product.product_product_3') 

89 # we actually want 5 computers 

90 with f3.order_line.edit(1) as line: 

91 line.product_uom_qty = 5 

92 # remove support 

93 f3.order_line.remove(index=0) 

94 # SO is saved here 

95 

96 :param record: empty or singleton recordset. An empty recordset will put 

97 the view in "creation" mode from default values, while a 

98 singleton will put it in "edit" mode and only load the 

99 view's data. 

100 :param view: the id, xmlid or actual view object to use for onchanges and 

101 view constraints. If none is provided, simply loads the 

102 default view for the model. 

103 

104 .. versionadded:: 12.0 

105 """ 

106 def __init__(self, record: BaseModel, view: None | int | str | BaseModel = None) -> None: 

107 assert isinstance(record, BaseModel) 

108 assert len(record) <= 1 

109 

110 # use object.__setattr__ to bypass Form's override of __setattr__ 

111 object.__setattr__(self, '_record', record) 

112 object.__setattr__(self, '_env', record.env) 

113 

114 # determine view and process it 

115 if isinstance(view, BaseModel): 

116 assert view._name == 'ir.ui.view', "the view parameter must be a view id, xid or record, got %s" % view 

117 view_id = view.id 

118 elif isinstance(view, str): 

119 view_id = record.env.ref(view).id 

120 else: 

121 view_id = view or False 

122 

123 views = record.get_views([(view_id, 'form')]) 

124 object.__setattr__(self, '_models_info', views['models']) 

125 # self._models_info = {model_name: {fields: {field_name: field_info}}} 

126 tree = etree.fromstring(views['views']['form']['arch']) 

127 view = self._process_view(tree, record) 

128 object.__setattr__(self, '_view', view) 

129 # self._view = { 

130 # 'tree': view_arch_etree, 

131 # 'fields': {field_name: field_info}, 

132 # 'fields_spec': web_read_fields_spec, 

133 # 'modifiers': {field_name: {modifier: expression}}, 

134 # 'contexts': {field_name: field_context_str}, 

135 # 'onchange': onchange_spec, 

136 # } 

137 

138 # determine record values 

139 object.__setattr__(self, '_values', UpdateDict()) 

140 if record: 

141 self._init_from_record() 

142 else: 

143 self._init_from_defaults() 

144 

145 @classmethod 

146 def from_action(cls, env: api.Environment, action: dict) -> Form: 

147 assert action['type'] == 'ir.actions.act_window', \ 

148 f"only window actions are valid, got {action['type']}" 

149 # ensure the first-requested view is a form view 

150 if views := action.get('views'): 

151 assert views[0][1] == 'form', \ 

152 f"the actions dict should have a form as first view, got {views[0][1]}" 

153 view_id = views[0][0] 

154 else: 

155 view_mode = action.get('view_mode', '') 

156 if not view_mode.startswith('form'): 

157 raise ValueError(f"The actions dict should have a form first view mode, got {view_mode}") 

158 view_id = action.get('view_id') 

159 if view_id and ',' in view_mode: 

160 raise ValueError(f"A `view_id` is only valid if the action has a single `view_mode`, got {view_mode}") 

161 context = action.get('context', {}) 

162 if isinstance(context, str): 

163 context = ast.literal_eval(context) 

164 record = env[action['res_model']]\ 

165 .with_context(context)\ 

166 .browse(action.get('res_id')) 

167 

168 return cls(record, view_id) 

169 

170 def _process_view(self, tree, model, level=2): 

171 """ Post-processes to augment the view_get with: 

172 * an id field (may not be present if not in the view but needed) 

173 * pre-processed modifiers 

174 * pre-processed onchanges list 

175 """ 

176 fields = {'id': {'type': 'id'}} 

177 fields_spec = {} 

178 modifiers = {'id': {'required': 'False', 'readonly': 'True'}} 

179 contexts = {} 

180 # retrieve <field> nodes at the current level 

181 flevel = tree.xpath('count(ancestor::field)') 

182 daterange_field_names = {} 

183 field_infos = self._models_info.get(model._name, {}).get("fields", {}) 

184 

185 for node in tree.xpath(f'.//field[count(ancestor::field) = {flevel}]'): 

186 field_name = node.get('name') 

187 

188 # add field_info into fields 

189 field_info = field_infos.get(field_name) or {'type': None} 

190 fields[field_name] = field_info 

191 fields_spec[field_name] = field_spec = {} 

192 

193 # determine modifiers 

194 field_modifiers = {} 

195 for attr in ('required', 'readonly', 'invisible', 'column_invisible'): 

196 # use python field attribute as default value 

197 default = attr in ('required', 'readonly') and field_info.get(attr, False) 

198 expr = node.get(attr) or str(default) 

199 field_modifiers[attr] = MODIFIER_ALIASES.get(expr, expr) 

200 

201 # Combine the field modifiers with its ancestor modifiers with an 

202 # OR: A field is invisible if its own invisible modifier is True OR 

203 # if one of its ancestor invisible modifier is True 

204 for ancestor in node.xpath(f'ancestor::*[@invisible][count(ancestor::field) = {flevel}]'): 

205 modifier = 'invisible' 

206 expr = ancestor.get(modifier) 

207 if expr == 'True' or field_modifiers[modifier] == 'True': 

208 field_modifiers[modifier] = 'True' 

209 if expr == 'False': 

210 field_modifiers[modifier] = field_modifiers[modifier] 

211 elif field_modifiers[modifier] == 'False': 

212 field_modifiers[modifier] = expr 

213 else: 

214 field_modifiers[modifier] = f'({expr}) or ({field_modifiers[modifier]})' 

215 

216 # merge field_modifiers into modifiers[field_name] 

217 if field_name in modifiers: 

218 # The field is several times in the view, combine the modifier 

219 # expression with an AND: a field is X if all occurences of the 

220 # field in the view are X. 

221 for modifier, expr in modifiers[field_name].items(): 

222 if expr == 'False' or field_modifiers[modifier] == 'False': 

223 field_modifiers[modifier] = 'False' 

224 if expr == 'True': 

225 field_modifiers[modifier] = field_modifiers[modifier] 

226 elif field_modifiers[modifier] == 'True': 

227 field_modifiers[modifier] = expr 

228 else: 

229 field_modifiers[modifier] = f'({expr}) and ({field_modifiers[modifier]})' 

230 

231 modifiers[field_name] = field_modifiers 

232 

233 # determine context 

234 ctx = node.get('context') 

235 if ctx: 

236 contexts[field_name] = ctx 

237 field_spec['context'] = get_static_context(ctx) 

238 

239 # FIXME: better widgets support 

240 # NOTE: selection breaks because of m2o widget=selection 

241 if node.get('widget') in ['many2many']: 

242 field_info['type'] = node.get('widget') 

243 elif node.get('widget') == 'daterange': 

244 options = ast.literal_eval(node.get('options', '{}')) 

245 related_field = options.get('start_date_field') or options.get('end_date_field') 

246 daterange_field_names[related_field] = field_name 

247 

248 # determine subview to use for edition 

249 if field_info['type'] == 'one2many': 

250 if level: 

251 field_info['invisible'] = field_modifiers.get('invisible') 

252 edition_view = self._get_one2many_edition_view(field_info, node, level) 

253 field_info['edition_view'] = edition_view 

254 field_spec['fields'] = edition_view['fields_spec'] 

255 else: 

256 # this trick enables the following invariant: every one2many 

257 # field has some 'edition_view' in its info dict 

258 field_info['type'] = 'many2many' 

259 

260 for related_field, start_field in daterange_field_names.items(): 

261 # If the field doesn't exist in the view add it implicitly 

262 if related_field not in modifiers: 

263 field_info = field_infos.get(related_field) or {'type': None} 

264 fields[related_field] = field_info 

265 fields_spec[related_field] = {} 

266 modifiers[related_field] = { 

267 'required': field_info.get('required', False), 

268 'readonly': field_info.get('readonly', False), 

269 } 

270 modifiers[related_field]['invisible'] = modifiers[start_field].get('invisible', False) 

271 

272 return { 

273 'tree': tree, 

274 'fields': fields, 

275 'fields_spec': fields_spec, 

276 'modifiers': modifiers, 

277 'contexts': contexts, 

278 'onchange': model._onchange_spec({'arch': etree.tostring(tree)}), 

279 } 

280 

281 def _get_one2many_edition_view(self, field_info, node, level): 

282 """ Return a suitable view for editing records into a one2many field. """ 

283 submodel = self._env[field_info['relation']] 

284 

285 # by simplicity, ensure we always have tree and form views 

286 views = { 

287 view.tag: view for view in node.xpath('./*[descendant::field]') 

288 } 

289 for view_type in ['list', 'form']: 

290 if view_type in views: 

291 continue 

292 if field_info['invisible'] == 'True': 

293 # add an empty view 

294 views[view_type] = etree.Element(view_type) 

295 continue 

296 refs = self._env['ir.ui.view']._get_view_refs(node) 

297 subviews = submodel.with_context(**refs).get_views([(None, view_type)]) 

298 subnode = etree.fromstring(subviews['views'][view_type]['arch']) 

299 views[view_type] = subnode 

300 node.append(subnode) 

301 for model_name, value in subviews['models'].items(): 

302 model_info = self._models_info.setdefault(model_name, {}) 

303 if "fields" not in model_info: 

304 model_info["fields"] = {} 

305 model_info["fields"].update(value["fields"]) 

306 

307 # pick the first editable subview 

308 view_type = next( 

309 vtype for vtype in node.get('mode', 'list').split(',') if vtype != 'form' 

310 ) 

311 if not (view_type == 'list' and views['list'].get('editable')): 

312 view_type = 'form' 

313 

314 # don't recursively process o2ms in o2ms 

315 return self._process_view(views[view_type], submodel, level=level-1) 

316 

317 def __str__(self): 

318 return f"<{type(self).__name__} {self._record}>" 

319 

320 def _init_from_record(self): 

321 """ Initialize the form for an existing record. """ 

322 assert self._record.id, "editing unstored records is not supported" 

323 self._values.clear() 

324 

325 [record_values] = self._record.web_read(self._view['fields_spec']) 

326 self._env.flush_all() 

327 self._env.clear() # discard cache and pending recomputations 

328 

329 values = convert_read_to_form(record_values, self._view['fields']) 

330 self._values.update(values) 

331 

332 def _init_from_defaults(self): 

333 """ Initialize the form for a new record. """ 

334 vals = self._values 

335 vals['id'] = False 

336 

337 # call onchange with no field; this retrieves default values, applies 

338 # onchanges and return the result 

339 self._perform_onchange() 

340 # mark all fields as modified 

341 self._values._changed.update(self._view['fields']) 

342 

343 def __getattr__(self, field_name): 

344 """ Return the current value of the given field. """ 

345 return self[field_name] 

346 

347 def __getitem__(self, field_name): 

348 """ Return the current value of the given field. """ 

349 field_info = self._view['fields'].get(field_name) 

350 assert field_info is not None, f"{field_name!r} was not found in the view" 

351 

352 value = self._values[field_name] 

353 if field_info['type'] == 'many2one': 

354 Model = self._env[field_info['relation']] 

355 return Model.browse(value) 

356 elif field_info['type'] == 'one2many': 

357 return O2MProxy(self, field_name) 

358 elif field_info['type'] == 'many2many': 

359 return M2MProxy(self, field_name) 

360 return value 

361 

362 def __setattr__(self, field_name, value): 

363 """ Set the given field to the given value, and proceed with the expected onchanges. """ 

364 self[field_name] = value 

365 

366 def __setitem__(self, field_name, value): 

367 """ Set the given field to the given value, and proceed with the expected onchanges. """ 

368 field_info = self._view['fields'].get(field_name) 

369 assert field_info is not None, f"{field_name!r} was not found in the view" 

370 assert field_info['type'] != 'one2many', "Can't set an one2many field directly, use its proxy instead" 

371 assert not self._get_modifier(field_name, 'readonly'), f"can't write on readonly field {field_name!r}" 

372 assert not self._get_modifier(field_name, 'invisible'), f"can't write on invisible field {field_name!r}" 

373 

374 if field_info['type'] == 'many2many': 

375 return M2MProxy(self, field_name).set(value) 

376 

377 if field_info['type'] == 'many2one': 

378 assert isinstance(value, BaseModel) and value._name == field_info['relation'] 

379 value = value.id 

380 

381 self._values[field_name] = value 

382 self._perform_onchange(field_name) 

383 

384 def _get_modifier(self, field_name, modifier, *, view=None, vals=None): 

385 if view is None: 

386 view = self._view 

387 

388 expr = view['modifiers'][field_name].get(modifier, False) 

389 if isinstance(expr, bool): 

390 return expr 

391 if expr in ('True', 'False'): 

392 return expr == 'True' 

393 

394 if vals is None: 

395 vals = self._values 

396 

397 eval_context = self._get_eval_context(vals) 

398 

399 return bool(safe_eval(expr, eval_context)) 

400 

401 def _get_context(self, field_name): 

402 """ Return the context of a given field. """ 

403 context_str = self._view['contexts'].get(field_name) 

404 if not context_str: 

405 return {} 

406 eval_context = self._get_eval_context() 

407 return safe_eval(context_str, eval_context) 

408 

409 def _get_eval_context(self, values=None): 

410 """ Return the context dict to eval something. """ 

411 context = { 

412 'id': self._record.id, 

413 'active_id': self._record.id, 

414 'active_ids': self._record.ids, 

415 'active_model': self._record._name, 

416 'current_date': date.today().strftime("%Y-%m-%d"), 

417 **self._env.context, 

418 } 

419 if values is None: 

420 values = self._get_all_values() 

421 return { 

422 **context, 

423 'context': context, 

424 **values, 

425 } 

426 

427 def _get_all_values(self): 

428 """ Return the values of all fields. """ 

429 return self._get_values('all') 

430 

431 def __enter__(self): 

432 """ This makes the Form usable as a context manager. """ 

433 return self 

434 

435 def __exit__(self, exc_type, exc_value, traceback): 

436 if not exc_type: 

437 self.save() 

438 

439 def save(self): 

440 """ Save the form (if necessary) and return the current record: 

441 

442 * does not save ``readonly`` fields; 

443 * does not save unmodified fields (during edition) — any assignment 

444 or onchange return marks the field as modified, even if set to its 

445 current value. 

446 

447 When nothing must be saved, it simply returns the current record. 

448 

449 :raises AssertionError: if the form has any unfilled required field 

450 """ 

451 values = self._get_save_values() 

452 if not self._record or values: 

453 # save and reload 

454 [record_values] = self._record.web_save(values, self._view['fields_spec']) 

455 self._env.flush_all() 

456 self._env.clear() # discard cache and pending recomputations 

457 

458 if not self._record: 

459 record = self._record.browse(record_values['id']) 

460 object.__setattr__(self, '_record', record) 

461 

462 values = convert_read_to_form(record_values, self._view['fields']) 

463 self._values.clear() 

464 self._values.update(values) 

465 

466 return self._record 

467 

468 @property 

469 def record(self): 

470 """ Return the record being edited by the form. This attribute is 

471 readonly and can only be accessed when the form has no pending changes. 

472 """ 

473 assert not self._values._changed 

474 return self._record 

475 

476 def _get_save_values(self): 

477 """ Validate and return field values modified since load/save. """ 

478 return self._get_values('save') 

479 

480 def _get_values(self, mode, values=None, view=None, modifiers_values=None, parent_link=None): 

481 """ Validate & extract values, recursively in order to handle o2ms properly. 

482 

483 :param mode: can be ``"save"`` (validate and return non-readonly modified fields), 

484 ``"onchange"`` (return modified fields) or ``"all"`` (return all field values) 

485 :param UpdateDict values: values of the record to extract 

486 :param view: view info 

487 :param dict modifiers_values: defaults to ``values``, but o2ms need some additional massaging 

488 :param parent_link: optional field representing "parent" 

489 """ 

490 assert mode in ('save', 'onchange', 'all') 

491 

492 if values is None: 

493 values = self._values 

494 if view is None: 

495 view = self._view 

496 assert isinstance(values, UpdateDict) 

497 

498 modifiers_values = modifiers_values or values 

499 

500 result = {} 

501 for field_name, field_info in view['fields'].items(): 

502 if field_name == 'id' or field_name not in values: 

503 continue 

504 

505 value = values[field_name] 

506 

507 # note: maybe `invisible` should not skip `required` if model attribute 

508 if ( 

509 mode == 'save' 

510 and value is False 

511 and field_name != parent_link 

512 and field_info['type'] != 'boolean' 

513 and not self._get_modifier(field_name, 'invisible', view=view, vals=modifiers_values) 

514 and not self._get_modifier(field_name, 'column_invisible', view=view, vals=modifiers_values) 

515 and self._get_modifier(field_name, 'required', view=view, vals=modifiers_values) 

516 ): 

517 raise AssertionError(f"{field_name} is a required field ({view['modifiers'][field_name]})") 

518 

519 # skip unmodified fields unless all_fields 

520 if mode in ('save', 'onchange') and field_name not in values._changed: 

521 continue 

522 

523 if mode == 'save' and self._get_modifier(field_name, 'readonly', view=view, vals=modifiers_values): 

524 field_node = next( 

525 node 

526 for node in view['tree'].iter('field') 

527 if node.get('name') == field_name 

528 ) 

529 if not field_node.get('force_save'): 

530 continue 

531 

532 if field_info['type'] == 'one2many': 

533 if mode == 'all': 

534 # in the context of an eval, format it as a list of ids 

535 value = list(value) 

536 else: 

537 subview = field_info['edition_view'] 

538 value = value.to_commands(lambda vals: self._get_values( 

539 mode, vals, subview, 

540 modifiers_values={'id': False, **vals, 'parent': Dotter(values)}, 

541 # related o2m don't have a relation_field 

542 parent_link=field_info.get('relation_field'), 

543 )) 

544 

545 elif field_info['type'] == 'many2many': 

546 if mode == 'all': 

547 # in the context of an eval, format it as a list of ids 

548 value = list(value) 

549 else: 

550 value = value.to_commands() 

551 

552 result[field_name] = value 

553 

554 return result 

555 

556 def _perform_onchange(self, field_name=None): 

557 assert field_name is None or isinstance(field_name, str) 

558 

559 # marks onchange source as changed 

560 if field_name: 

561 field_names = [field_name] 

562 self._values._changed.add(field_name) 

563 else: 

564 field_names = [] 

565 

566 # skip calling onchange() if there's no on_change on the field 

567 if field_name and not self._view['onchange'][field_name]: 

568 return 

569 

570 record = self._record 

571 

572 # if the onchange is triggered by a field, add the context of that field 

573 if field_name: 

574 context = self._get_context(field_name) 

575 if context: 

576 record = record.with_context(**context) 

577 

578 values = self._get_onchange_values() 

579 result = record.onchange(values, field_names, self._view['fields_spec']) 

580 self._env.flush_all() 

581 self._env.clear() # discard cache and pending recomputations 

582 

583 if w := result.get('warning'): 

584 if isinstance(w, collections.abc.Mapping) and w.keys() >= {'title', 'message'}: 

585 _logger.getChild('onchange').warning("%(title)s %(message)s", w) 

586 else: 

587 _logger.getChild('onchange').error( 

588 "received invalid warning %r from onchange on %r (should be a dict with keys `title` and `message`)", 

589 w, 

590 field_names, 

591 ) 

592 

593 if not field_name: 

594 # fill in whatever fields are still missing with falsy values 

595 self._values.update({ 

596 field_name: _cleanup_from_default(field_info['type'], False) 

597 for field_name, field_info in self._view['fields'].items() 

598 if field_name not in self._values 

599 }) 

600 

601 if result.get('value'): 

602 self._apply_onchange(result['value']) 

603 

604 return result 

605 

606 def _get_onchange_values(self): 

607 """ Return modified field values for onchange. """ 

608 return self._get_values('onchange') 

609 

610 def _apply_onchange(self, values): 

611 self._apply_onchange_(self._values, self._view['fields'], values) 

612 

613 def _apply_onchange_(self, values, fields, onchange_values): 

614 assert isinstance(values, UpdateDict) 

615 for fname, value in onchange_values.items(): 

616 field_info = fields[fname] 

617 if field_info['type'] in ('one2many', 'many2many'): 

618 subfields = {} 

619 if field_info['type'] == 'one2many': 

620 subfields = field_info['edition_view']['fields'] 

621 field_value = values[fname] 

622 for cmd in value: 

623 match cmd[0]: 

624 case Command.CREATE: 

625 vals = UpdateDict(convert_read_to_form(dict.fromkeys(subfields, False), subfields)) 

626 self._apply_onchange_(vals, subfields, cmd[2]) 

627 field_value.create(vals) 

628 case Command.UPDATE: 

629 vals = field_value.get_vals(cmd[1]) 

630 self._apply_onchange_(vals, subfields, cmd[2]) 

631 case Command.DELETE | Command.UNLINK: 

632 field_value.remove(cmd[1]) 

633 case Command.LINK: 

634 field_value.add(cmd[1], convert_read_to_form(cmd[2], subfields)) 

635 case c: 

636 raise ValueError(f"Unexpected onchange() o2m command {c!r}") 

637 else: 

638 values[fname] = value 

639 values._changed.add(fname) 

640 

641 

642class O2MForm(Form): 

643 # noinspection PyMissingConstructor 

644 # pylint: disable=super-init-not-called 

645 def __init__(self, proxy, index=None): 

646 model = proxy._model 

647 object.__setattr__(self, '_proxy', proxy) 

648 object.__setattr__(self, '_index', index) 

649 

650 object.__setattr__(self, '_record', model) 

651 object.__setattr__(self, '_env', model.env) 

652 

653 object.__setattr__(self, '_models_info', proxy._form._models_info) 

654 object.__setattr__(self, '_view', proxy._field_info['edition_view']) 

655 

656 object.__setattr__(self, '_values', UpdateDict()) 

657 if index is None: 

658 self._init_from_defaults() 

659 else: 

660 vals = proxy._records[index] 

661 self._values.update(vals) 

662 if vals.get('id'): 

663 object.__setattr__(self, '_record', model.browse(vals['id'])) 

664 

665 def _get_modifier(self, field_name, modifier, *, view=None, vals=None): 

666 if modifier != 'required' and self._proxy._form._get_modifier(self._proxy._field, modifier): 

667 return True 

668 return super()._get_modifier(field_name, modifier, view=view, vals=vals) 

669 

670 def _get_eval_context(self, values=None): 

671 eval_context = super()._get_eval_context(values) 

672 eval_context['parent'] = Dotter(self._proxy._form._values) 

673 return eval_context 

674 

675 def _get_onchange_values(self): 

676 values = super()._get_onchange_values() 

677 # computed o2m may not have a relation_field(?) 

678 field_info = self._proxy._field_info 

679 if 'relation_field' in field_info: # note: should be fine because not recursive 

680 parent_form = self._proxy._form 

681 parent_values = parent_form._get_onchange_values() 

682 if parent_form._record.id: 

683 parent_values['id'] = parent_form._record.id 

684 values[field_info['relation_field']] = parent_values 

685 return values 

686 

687 def save(self): 

688 proxy = self._proxy 

689 field_value = proxy._form._values[proxy._field] 

690 values = self._get_save_values() 

691 if self._index is None: 

692 field_value.create(values) 

693 else: 

694 id_ = field_value[self._index] 

695 field_value.update(id_, values) 

696 

697 proxy._form._perform_onchange(proxy._field) 

698 

699 def _get_save_values(self): 

700 """ Validate and return field values modified since load/save. """ 

701 values = UpdateDict(self._values) 

702 

703 for field_name in self._view['fields']: 

704 if self._get_modifier(field_name, 'required') and not ( 

705 self._get_modifier(field_name, 'column_invisible') 

706 or self._get_modifier(field_name, 'invisible') 

707 ): 

708 assert values[field_name] is not False, f"{field_name!r} is a required field" 

709 

710 return values 

711 

712 

713class UpdateDict(dict): 

714 def __init__(self, *args, **kwargs): 

715 super().__init__(*args, **kwargs) 

716 self._changed = set() 

717 if args and isinstance(args[0], UpdateDict): 

718 self._changed.update(args[0]._changed) 

719 

720 def __repr__(self): 

721 items = [ 

722 f"{key!r}{'*' if key in self._changed else ''}: {val!r}" 

723 for key, val in self.items() 

724 ] 

725 return f"{{{', '.join(items)}}}" 

726 

727 def changed_items(self): 

728 return ( 

729 (k, v) for k, v in self.items() 

730 if k in self._changed 

731 ) 

732 

733 def update(self, *args, **kw): 

734 super().update(*args, **kw) 

735 if args and isinstance(args[0], UpdateDict): 

736 self._changed.update(args[0]._changed) 

737 

738 def clear(self): 

739 super().clear() 

740 self._changed.clear() 

741 

742 

743class X2MValue(collections.abc.Sequence): 

744 """ The value of a one2many field, with the API of a sequence of record ids. """ 

745 _virtual_seq = itertools.count() 

746 

747 def __init__(self, iterable_of_vals=()): 

748 self._data = {vals['id']: UpdateDict(vals) for vals in iterable_of_vals} 

749 

750 def __repr__(self): 

751 return repr(self._data) 

752 

753 def __contains__(self, id_): 

754 return id_ in self._data 

755 

756 def __getitem__(self, index): 

757 return list(self._data)[index] 

758 

759 def __iter__(self): 

760 return iter(self._data) 

761 

762 def __len__(self): 

763 return len(self._data) 

764 

765 def __eq__(self, other): 

766 # this enables to compare self with a list 

767 return list(self) == other 

768 

769 def get_vals(self, id_): 

770 return self._data[id_] 

771 

772 def add(self, id_, vals): 

773 assert id_ not in self._data 

774 self._data[id_] = UpdateDict(vals) 

775 

776 def remove(self, id_): 

777 self._data.pop(id_) 

778 

779 def clear(self): 

780 self._data.clear() 

781 

782 def create(self, vals): 

783 id_ = f'virtual_{next(self._virtual_seq)}' 

784 create_vals = UpdateDict(vals) 

785 create_vals._changed.update(vals) 

786 self._data[id_] = create_vals 

787 

788 def update(self, id_, changes, changed=()): 

789 vals = self._data[id_] 

790 vals.update(changes) 

791 vals._changed.update(changed) 

792 

793 def to_list_of_vals(self): 

794 return list(self._data.values()) 

795 

796 

797class O2MValue(X2MValue): 

798 def __init__(self, iterable_of_vals=()): 

799 super().__init__(iterable_of_vals) 

800 self._given = list(self._data) 

801 

802 def to_commands(self, convert_values=lambda vals: vals): 

803 given = set(self._given) 

804 result = [] 

805 for id_, vals in self._data.items(): 

806 if isinstance(id_, str) and id_.startswith('virtual_'): 

807 result.append((Command.CREATE, id_, convert_values(vals))) 

808 continue 

809 if id_ not in given: 

810 result.append(Command.link(id_)) 

811 if vals._changed: 

812 result.append(Command.update(id_, convert_values(vals))) 

813 for id_ in self._given: 

814 if id_ not in self._data: 

815 result.append(Command.delete(id_)) 

816 return result 

817 

818 

819class M2MValue(X2MValue): 

820 def __init__(self, iterable_of_vals=()): 

821 super().__init__(iterable_of_vals) 

822 self._given = list(self._data) 

823 

824 def to_commands(self): 

825 given = set(self._given) 

826 result = [] 

827 for id_, vals in self._data.items(): 

828 if isinstance(id_, str) and id_.startswith('virtual_'): 

829 result.append((Command.CREATE, id_, { 

830 key: val.to_commands() if isinstance(val, X2MValue) else val 

831 for key, val in vals.changed_items() 

832 })) 

833 continue 

834 if id_ not in given: 

835 result.append(Command.link(id_)) 

836 if vals._changed: 

837 result.append(Command.update(id_, { 

838 key: val.to_commands() if isinstance(val, X2MValue) else val 

839 for key, val in vals.changed_items() 

840 })) 

841 for id_ in self._given: 

842 if id_ not in self._data: 

843 result.append(Command.unlink(id_)) 

844 return result 

845 

846 

847class X2MProxy: 

848 """ A proxy represents the value of an x2many field, but not directly. 

849 Instead, it provides an API to add, remove or edit records in the value. 

850 """ 

851 _form = None # Form containing the corresponding x2many field 

852 _field = None # name of the x2many field 

853 _field_info = None # field info 

854 

855 def __init__(self, form, field_name): 

856 self._form = form 

857 self._field = field_name 

858 self._field_info = form._view['fields'][field_name] 

859 self._field_value = form._values[field_name] 

860 

861 @property 

862 def ids(self): 

863 return list(self._field_value) 

864 

865 def _assert_editable(self): 

866 assert not self._form._get_modifier(self._field, 'readonly'), f'field {self._field!r} is not editable' 

867 assert not self._form._get_modifier(self._field, 'invisible'), f'field {self._field!r} is not visible' 

868 

869 

870class O2MProxy(X2MProxy): 

871 """ Proxy object for editing the value of a one2many field. """ 

872 def __len__(self): 

873 return len(self._field_value) 

874 

875 @property 

876 def _model(self): 

877 model = self._form._env[self._field_info['relation']] 

878 context = self._form._get_context(self._field) 

879 if context: 

880 model = model.with_context(**context) 

881 return model 

882 

883 @property 

884 def _records(self): 

885 return self._field_value.to_list_of_vals() 

886 

887 def new(self): 

888 """ Returns a :class:`Form` for a new 

889 :class:`~odoo.fields.One2many` record, properly initialised. 

890 

891 The form is created from the list view if editable, or the field's 

892 form view otherwise. 

893 

894 :raises AssertionError: if the field is not editable 

895 """ 

896 self._assert_editable() 

897 return O2MForm(self) 

898 

899 def edit(self, index): 

900 """ Returns a :class:`Form` to edit the pre-existing 

901 :class:`~odoo.fields.One2many` record. 

902 

903 The form is created from the list view if editable, or the field's 

904 form view otherwise. 

905 

906 :raises AssertionError: if the field is not editable 

907 """ 

908 self._assert_editable() 

909 return O2MForm(self, index) 

910 

911 def remove(self, index): 

912 """ Removes the record at ``index`` from the parent form. 

913 

914 :raises AssertionError: if the field is not editable 

915 """ 

916 self._assert_editable() 

917 self._field_value.remove(self._field_value[index]) 

918 self._form._perform_onchange(self._field) 

919 

920 

921class M2MProxy(X2MProxy, collections.abc.Sequence): 

922 """ Proxy object for editing the value of a many2many field. 

923 

924 Behaves as a :class:`~collection.Sequence` of recordsets, can be 

925 indexed or sliced to get actual underlying recordsets. 

926 """ 

927 def __getitem__(self, index): 

928 comodel_name = self._field_info['relation'] 

929 return self._form._env[comodel_name].browse(self._field_value[index]) 

930 

931 def __len__(self): 

932 return len(self._field_value) 

933 

934 def __iter__(self): 

935 comodel_name = self._field_info['relation'] 

936 records = self._form._env[comodel_name].browse(self._field_value) 

937 return iter(records) 

938 

939 def __contains__(self, record): 

940 comodel_name = self._field_info['relation'] 

941 assert isinstance(record, BaseModel) and record._name == comodel_name 

942 return record.id in self._field_value 

943 

944 def add(self, record): 

945 """ Adds ``record`` to the field, the record must already exist. 

946 

947 The addition will only be finalized when the parent record is saved. 

948 """ 

949 self._assert_editable() 

950 parent = self._form 

951 comodel_name = self._field_info['relation'] 

952 assert isinstance(record, BaseModel) and record._name == comodel_name, \ 

953 f"trying to assign a {record._name!r} object to a {comodel_name!r} field" 

954 

955 if record.id not in self._field_value: 

956 self._field_value.add(record.id, {'id': record.id}) 

957 parent._perform_onchange(self._field) 

958 

959 # pylint: disable=redefined-builtin 

960 def remove(self, id=None, index=None): 

961 """ Removes a record at a certain index or with a provided id from 

962 the field. 

963 """ 

964 self._assert_editable() 

965 assert (id is None) ^ (index is None), "can remove by either id or index" 

966 if id is None: 

967 id = self._field_value[index] 

968 self._field_value.remove(id) 

969 self._form._perform_onchange(self._field) 

970 

971 def set(self, records): 

972 """ Set the field value to be ``records``. """ 

973 self._assert_editable() 

974 comodel_name = self._field_info['relation'] 

975 assert isinstance(records, BaseModel) and records._name == comodel_name, \ 

976 f"trying to assign a {records._name!r} object to a {comodel_name!r} field" 

977 

978 if set(records.ids) != set(self._field_value): 

979 self._field_value.clear() 

980 for id_ in records.ids: 

981 self._field_value.add(id_, {'id': id_}) 

982 self._form._perform_onchange(self._field) 

983 

984 def clear(self): 

985 """ Removes all existing records in the m2m 

986 """ 

987 self._assert_editable() 

988 self._field_value.clear() 

989 self._form._perform_onchange(self._field) 

990 

991 

992def convert_read_to_form(values, model_fields): 

993 result = {} 

994 for fname, value in values.items(): 

995 field_info = {'type': 'id'} if fname == 'id' else model_fields[fname] 

996 if field_info['type'] == 'one2many': 

997 if 'edition_view' in field_info: 

998 subfields = field_info['edition_view']['fields'] 

999 value = O2MValue(convert_read_to_form(vals, subfields) for vals in (value or ())) 

1000 else: 

1001 value = O2MValue({'id': id_} for id_ in (value or ())) 

1002 elif field_info['type'] == 'many2many': 

1003 value = M2MValue({'id': id_} for id_ in (value or ())) 

1004 elif field_info['type'] == 'datetime' and isinstance(value, datetime): 

1005 value = fields.Datetime.to_string(value) 

1006 elif field_info['type'] == 'date' and isinstance(value, date): 

1007 value = fields.Date.to_string(value) 

1008 result[fname] = value 

1009 return result 

1010 

1011 

1012def _cleanup_from_default(type_, value): 

1013 if not value: 

1014 if type_ == 'one2many': 

1015 return O2MValue() 

1016 elif type_ == 'many2many': 

1017 return M2MValue() 

1018 elif type_ in ('integer', 'float'): 

1019 return 0 

1020 return value 

1021 

1022 if type_ == 'one2many': 

1023 raise NotImplementedError() 

1024 elif type_ == 'datetime' and isinstance(value, datetime): 

1025 return fields.Datetime.to_string(value) 

1026 elif type_ == 'date' and isinstance(value, date): 

1027 return fields.Date.to_string(value) 

1028 return value 

1029 

1030 

1031def get_static_context(context_str): 

1032 """ Parse the given context string, and return the literal part of it. """ 

1033 context_ast = ast.parse(context_str.strip(), mode='eval').body 

1034 assert isinstance(context_ast, ast.Dict) 

1035 result = {} 

1036 for key_ast, val_ast in zip(context_ast.keys, context_ast.values): 

1037 try: 

1038 key = ast.literal_eval(key_ast) 

1039 val = ast.literal_eval(val_ast) 

1040 result[key] = val 

1041 except ValueError: 

1042 pass 

1043 return result 

1044 

1045 

1046class Dotter: 

1047 """ Simple wrapper for a dict where keys are accessed as readonly attributes. """ 

1048 __slots__ = ['__values'] 

1049 

1050 def __init__(self, values): 

1051 self.__values = values 

1052 

1053 def __getattr__(self, key): 

1054 val = self.__values[key] 

1055 return Dotter(val) if isinstance(val, dict) else val 

1056