From 4bf97bdbe28cc3dc3456a272b0acc3741fdf6e1b Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 14 Nov 2017 11:47:44 +0100 Subject: [PATCH] [ADD] base: server-side formview Useful to better test business flows & replace yaml test files: instantiating records & calling onchanges by hand is very error-prone (it's easy to miss onchanges, or to manually set fields which can't be set through the view we're interested in, ...) odoo.tests.common.Form implements the basic *creation* flow (fields_get, default_get, onchange) up to saving the record, including proper handling of the required and readonly modifiers & their domains. * edition of existing records, including filtering out unmodified fields when saving * possibly buttons * ensuring the API is convenient --- .../addons/test_testing_utilities/__init__.py | 2 + .../test_testing_utilities/__manifest__.py | 15 + .../ir.model.access.csv | 14 + odoo/addons/test_testing_utilities/menu.xml | 94 ++++ odoo/addons/test_testing_utilities/models.py | 171 ++++++ .../test_testing_utilities/tests/__init__.py | 2 + .../tests/test_form_impl.py | 461 +++++++++++++++ odoo/tests/common.py | 532 +++++++++++++++++- 8 files changed, 1288 insertions(+), 3 deletions(-) create mode 100644 odoo/addons/test_testing_utilities/__init__.py create mode 100644 odoo/addons/test_testing_utilities/__manifest__.py create mode 100644 odoo/addons/test_testing_utilities/ir.model.access.csv create mode 100644 odoo/addons/test_testing_utilities/menu.xml create mode 100644 odoo/addons/test_testing_utilities/models.py create mode 100644 odoo/addons/test_testing_utilities/tests/__init__.py create mode 100644 odoo/addons/test_testing_utilities/tests/test_form_impl.py diff --git a/odoo/addons/test_testing_utilities/__init__.py b/odoo/addons/test_testing_utilities/__init__.py new file mode 100644 index 00000000000..a0fdc10fe11 --- /dev/null +++ b/odoo/addons/test_testing_utilities/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/odoo/addons/test_testing_utilities/__manifest__.py b/odoo/addons/test_testing_utilities/__manifest__.py new file mode 100644 index 00000000000..eb7731a7826 --- /dev/null +++ b/odoo/addons/test_testing_utilities/__manifest__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Test testing utilities", + 'category': "Tests", + 'description': """Non-trivial testing utilities can require models & all + +This here module is useful to validate that they're doing what they're +supposed to do + """, + 'depends': ['base'], + 'data': [ + 'ir.model.access.csv', + 'menu.xml', + ] +} diff --git a/odoo/addons/test_testing_utilities/ir.model.access.csv b/odoo/addons/test_testing_utilities/ir.model.access.csv new file mode 100644 index 00000000000..ced191d38e2 --- /dev/null +++ b/odoo/addons/test_testing_utilities/ir.model.access.csv @@ -0,0 +1,14 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_test_testing_utilities_a,access_test_testing_utilities_a,model_test_testing_utilities_a,,1,0,0,0 +access_test_testing_utilities_readonly,access_test_testing_utilities_readonly,model_test_testing_utilities_readonly,,1,0,0,0 +access_test_testing_utilities_c,access_test_testing_utilities_c,model_test_testing_utilities_c,,1,0,0,0 +access_test_testing_utilities_d,access_test_testing_utilities_d,model_test_testing_utilities_d,,1,0,0,0 +access_test_testing_utilities_e,access_test_testing_utilities_e,model_test_testing_utilities_e,,1,0,0,0 +access_test_testing_utilities_f,access_test_testing_utilities_f,model_test_testing_utilities_f,,1,0,0,0 +access_test_testing_utilities_g,access_test_testing_utilities_g,model_test_testing_utilities_g,,1,0,0,0 +access_test_testing_utilities_m2o,access_test_testing_utilities_m2o,model_test_testing_utilities_m2o,,1,0,0,0 +access_test_testing_utilities_default,access_test_testing_utilities_default,model_test_testing_utilities_default,,1,0,0,0 +access_test_testing_utilities_parent,access_test_testing_utilities_parent,model_test_testing_utilities_parent,,1,0,0,0 +access_test_testing_utilities_sub,access_test_testing_utilities_sub,model_test_testing_utilities_sub,,1,0,0,0 +access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test_testing_utilities_sub2,,1,0,0,0 +access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,,1,0,0,0 diff --git a/odoo/addons/test_testing_utilities/menu.xml b/odoo/addons/test_testing_utilities/menu.xml new file mode 100644 index 00000000000..72a4ba79157 --- /dev/null +++ b/odoo/addons/test_testing_utilities/menu.xml @@ -0,0 +1,94 @@ + + + Test o2m onchange thing + ir.actions.act_window + test_testing_utilities.parent + tree,form + + + + + + Parent External Form + 1 + test_testing_utilities.parent + +
+ + + + + + + + + +
+
+
+ + Parent readonly o2m field + test_testing_utilities.parent + + primary + + + 1 + + + + + Sub regular + 1 + test_testing_utilities.sub + + + + + + + + + + + Parent External Tree + test_testing_utilities.parent + +
+ + + + + +
+ + Sub regular + test_testing_utilities.sub + + + + + + + + + + + Parent Inline Tree + test_testing_utilities.parent + +
+ + + + + + + + + + +
+
diff --git a/odoo/addons/test_testing_utilities/models.py b/odoo/addons/test_testing_utilities/models.py new file mode 100644 index 00000000000..2bb6e5e5dd6 --- /dev/null +++ b/odoo/addons/test_testing_utilities/models.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +from __future__ import division +from odoo import api, fields, models + +class A(models.Model): + _name = 'test_testing_utilities.a' + + f1 = fields.Integer(required=True) + f2 = fields.Integer(default=42) + f3 = fields.Integer() + f4 = fields.Integer(compute='_compute_f4') + + @api.onchange('f2') + def _on_change_f2(self): + self.f3 = int(self.f2 / 2) + + @api.depends('f1', 'f2') + def _compute_f4(self): + for r in self: + r.f4 = r.f2 / (r.f1 or 1) + +class B(models.Model): + _name = 'test_testing_utilities.readonly' + + f1 = fields.Integer(default=1, readonly=True) + f2 = fields.Integer(compute='_compute_f2') + + @api.depends('f1') + def _compute_f2(self): + for r in self: + r.f2 = 2 * r.f1 + +class C(models.Model): + _name = 'test_testing_utilities.c' + + name = fields.Char("name", required=True) + f2 = fields.Many2one('test_testing_utilities.m2o') + + @api.onchange('f2') + def _on_change_f2(self): + self.name = self.f2.name + +class M2O(models.Model): + _name = 'test_testing_utilities.m2o' + + name = fields.Char(required=True) + +class M2Onchange(models.Model): + _name = 'test_testing_utilities.d' + + # used to check that defaults & onchange to m2o work + f = fields.Many2one( + 'test_testing_utilities.m2o', + required=True, + default=lambda self: self.env['test_testing_utilities.m2o'].search( + [], limit=1 + ) + ) + f2 = fields.Char() + + @api.onchange('f2') + def _on_change_f2(self): + self.f = self.env['test_testing_utilities.m2o'].search([ + ('name', 'ilike', self.f2), + ], limit=1) + +class M2MChange(models.Model): + _name = 'test_testing_utilities.e' + + m2m = fields.Many2many('test_testing_utilities.sub2') + count = fields.Integer(compute='_m2m_count') + + @api.depends('m2m') + def _m2m_count(self): + for r in self: + r.count = len(r.m2m) + +class M2MSub(models.Model): + _name = 'test_testing_utilities.sub2' + + name = fields.Char() + +class M2MChange2(models.Model): + _name = 'test_testing_utilities.f' + + def _get_some(self): + r = self.env['test_testing_utilities.sub2'].search([], limit=2) + return r + + m2m = fields.Many2many( + 'test_testing_utilities.sub2', + default=_get_some, + ) + m2o = fields.Many2one('test_testing_utilities.sub2') + + @api.onchange('m2o') + def _on_change_m2o(self): + self.m2m = self.m2m | self.m2o + +class M2MReadonly(models.Model): + _name = 'test_testing_utilities.g' + + m2m = fields.Many2many('test_testing_utilities.sub3', readonly=True) + +class M2MSub3(models.Model): + _name = 'test_testing_utilities.sub3' + + name = fields.Char() + +class O2MChange(models.Model): + _name = 'test_testing_utilities.parent' + + value = fields.Integer(default=1) + v = fields.Integer() + subs = fields.One2many('test_testing_utilities.sub', 'parent_id') + + @api.onchange('value', 'subs') + def _onchange_values(self): + self.v = self.value + sum(s.value for s in self.subs) + +class O2MSub(models.Model): + _name = 'test_testing_utilities.sub' + + name = fields.Char(compute='_compute_name') + value = fields.Integer(default=2) + v = fields.Integer() + parent_id = fields.Many2one('test_testing_utilities.parent') + has_parent = fields.Boolean() + + @api.onchange('value') + def _onchange_value(self): + self.v = self.value + + @api.depends('v') + def _compute_name(self): + for r in self: + r.name = str(r.v) + + @api.onchange('has_parent') + def _onchange_has_parent(self): + self.has_parent = bool(self.parent_id) + if self.has_parent: + self.value = self.parent_id.value + +class O2MDefault(models.Model): + _name = 'test_testing_utilities.default' + + def _default_subs(self): + return [ + (0, 0, {'v': 5}) + ] + value = fields.Integer(default=1) + v = fields.Integer() + subs = fields.One2many('test_testing_utilities.sub3', 'parent_id', default=_default_subs) + +class O2MSub3(models.Model): + _name = 'test_testing_utilities.sub3' + + name = fields.Char(compute='_compute_name') + value = fields.Integer(default=2) + v = fields.Integer(default=6) + parent_id = fields.Many2one('test_testing_utilities.default') + + @api.onchange('value') + def _onchange_value(self): + self.v = self.value + + @api.depends('v') + def _compute_name(self): + for r in self: + r.name = str(r.v) diff --git a/odoo/addons/test_testing_utilities/tests/__init__.py b/odoo/addons/test_testing_utilities/tests/__init__.py new file mode 100644 index 00000000000..0c0095dfd37 --- /dev/null +++ b/odoo/addons/test_testing_utilities/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_form_impl diff --git a/odoo/addons/test_testing_utilities/tests/test_form_impl.py b/odoo/addons/test_testing_utilities/tests/test_form_impl.py new file mode 100644 index 00000000000..19d192365a5 --- /dev/null +++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +""" +Test for the pseudo-form implementation (odoo.tests.common.Form), which should +basically be a server-side implementation of form views (though probably not +complete) intended for properly validating business "view" flows (onchanges, +readonly, required, ...) and make it easier to generate sensible & coherent +business objects. +""" +from operator import itemgetter + +from odoo.tests.common import TransactionCase, Form + + +class TestBasic(TransactionCase): + def test_defaults(self): + """ + Checks that we can load a default form view and perform trivial + default_get & onchanges & computations + """ + f = Form(self.env['test_testing_utilities.a']) + self.assertEqual(f.id, False, "check that our record is not in db (yet)") + + self.assertEqual(f.f2, 42) + self.assertEqual(f.f3, 21) + self.assertEqual(f.f4, 42) + + f.f1 = 4 + self.assertEqual(f.f2, 42) + self.assertEqual(f.f3, 21) + self.assertEqual(f.f4, 10) + + f.f2 = 8 + self.assertEqual(f.f3, 4) + self.assertEqual(f.f4, 2) + + r = f.save() + self.assertEqual( + (r.f1, r.f2, r.f3, r.f4), + (4, 8, 4, 2), + ) + + def test_required(self): + f = Form(self.env['test_testing_utilities.a']) + # f1 no default & no value => should fail + with self.assertRaisesRegexp(AssertionError, 'f1 is a required field'): + f.save() + # set f1 and unset f2 => should work + f.f1 = 1 + f.f2 = False + r = f.save() + self.assertEqual( + (r.f1, r.f2, r.f3, r.f4), + (1, 0, 0, 0) + ) + + def test_readonly(self): + """ + Checks that fields with readonly modifiers (marked as readonly or + computed w/o set) raise an error when set. + """ + f = Form(self.env['test_testing_utilities.readonly']) + + with self.assertRaises(AssertionError): + f.f1 = 5 + with self.assertRaises(AssertionError): + f.f2 = 42 + +class TestM2O(TransactionCase): + def test_default_and_onchange(self): + """ Checks defaults & onchanges impacting m2o fields + """ + Sub = self.env['test_testing_utilities.m2o'] + a = Sub.create({'name': "A"}) + b = Sub.create({'name': "B"}) + + f = Form(self.env['test_testing_utilities.d']) + + self.assertEqual( + f.f, a, + "The default value for the m2o should be the first Sub record" + ) + f.f2 = "B" + self.assertEqual( + f.f, b, + "The new m2o value should match the second field by name" + ) + + f.save() + + def test_set(self): + """ + Checks that we get/set recordsets for m2o & that set correctly + triggers onchange + """ + r1 = self.env['test_testing_utilities.m2o'].create({'name': "A"}) + r2 = self.env['test_testing_utilities.m2o'].create({'name': "B"}) + + f = Form(self.env['test_testing_utilities.c']) + + # check that basic manipulations work + f.f2 = r1 + self.assertEqual(f.f2, r1) + self.assertEqual(f.name, 'A') + f.f2 = r2 + self.assertEqual(f.name, 'B') + + # can't set an int to an m2o field + with self.assertRaises(AssertionError): + f.f2 = r1.id + self.assertEqual(f.f2, r2) + self.assertEqual(f.name, 'B') + + # can't set a record of the wrong model + temp = self.env['test_testing_utilities.readonly'].create({}) + with self.assertRaises(AssertionError): + f.f2 = temp + self.assertEqual(f.f2, r2) + self.assertEqual(f.name, 'B') + + r = f.save() + self.assertEqual(r.f2, r2) + +class TestM2M(TransactionCase): + def test_add(self): + Sub = self.env['test_testing_utilities.sub2'] + f = Form(self.env['test_testing_utilities.e']) + + r1 = Sub.create({'name': "Item"}) + r2 = Sub.create({'name': "Item2"}) + + f.m2m.add(r1) + f.m2m.add(r2) + + r = f.save() + + self.assertEqual( + r.m2m, + r1 | r2 + ) + + def test_remove_by_index(self): + Sub = self.env['test_testing_utilities.sub2'] + f = Form(self.env['test_testing_utilities.e']) + + r1 = Sub.create({'name': "Item"}) + r2 = Sub.create({'name': "Item2"}) + + f.m2m.add(r1) + f.m2m.add(r2) + f.m2m.remove(index=0) + + r = f.save() + + self.assertEqual( + r.m2m, + r2 + ) + + def test_remove_by_id(self): + Sub = self.env['test_testing_utilities.sub2'] + f = Form(self.env['test_testing_utilities.e']) + + r1 = Sub.create({'name': "Item"}) + r2 = Sub.create({'name': "Item2"}) + + f.m2m.add(r1) + f.m2m.add(r2) + f.m2m.remove(id=r1.id) + + r = f.save() + + self.assertEqual( + r.m2m, + r2 + ) + + def test_on_m2m_change(self): + Sub = self.env['test_testing_utilities.sub2'] + f = Form(self.env['test_testing_utilities.e']) + + self.assertEqual(f.count, 0) + f.m2m.add(Sub.create({'name': 'a'})) + self.assertEqual(f.count, 1) + f.m2m.add(Sub.create({'name': 'a'})) + f.m2m.add(Sub.create({'name': 'a'})) + f.m2m.add(Sub.create({'name': 'a'})) + self.assertEqual(f.count, 4) + f.m2m.remove(index=0) + f.m2m.remove(index=0) + f.m2m.remove(index=0) + self.assertEqual(f.count, 1) + + def test_m2m_changed(self): + Sub = self.env['test_testing_utilities.sub2'] + a = Sub.create({'name': 'a'}) + b = Sub.create({'name': 'b'}) + c = Sub.create({'name': 'c'}) + d = Sub.create({'name': 'd'}) + + f = Form(self.env['test_testing_utilities.f']) + # check default_get + self.assertEqual(f.m2m[:], a | b) + + f.m2o = c + self.assertEqual(f.m2m[:], a | b | c) + + f.m2o = d + self.assertEqual(f.m2m[:], a | b | c | d) + + def test_m2m_readonly(self): + Sub = self.env['test_testing_utilities.sub3'] + a = Sub.create({'name': 'a'}) + b = Sub.create({'name': 'b'}) + r = self.env['test_testing_utilities.g'].create({ + 'm2m': [(6, 0, a.ids)] + }) + + f = Form(r) + + with self.assertRaises(AssertionError): + f.m2m.add(b) + with self.assertRaises(AssertionError): + f.m2m.remove(id=a.id) + + f.save() + self.assertEqual(r.m2m, a) + + +get = itemgetter('name', 'value', 'v') +class TestO2M(TransactionCase): + def test_basic_alterations(self): + """ Tests that the o2m proxy allows adding, removing and editing o2m + records + """ + f = Form(self.env['test_testing_utilities.parent'], view='test_testing_utilities.o2m_parent') + + f.subs.new().save() + f.subs.new().save() + f.subs.new().save() + f.subs.remove(index=0) + + r = f.save() + + self.assertEqual( + [get(s) for s in r.subs], + [("2", 2, 2), ("2", 2, 2)] + ) + + with Form(r, view='test_testing_utilities.o2m_parent') as f: + with f.subs.new() as sub: + sub.value = 5 + f.subs.new().save() + + with f.subs.edit(index=2) as sub: + self.assertEqual(sub.v, 5) + + f.subs.remove(index=0) + + self.assertEqual( + [get(s) for s in r.subs], + [("2", 2, 2), ("5", 5, 5), ("2", 2, 2)] + ) + + with Form(r, view='test_testing_utilities.o2m_parent') as f, \ + f.subs.edit(index=0) as sub,\ + self.assertRaises(AssertionError): + sub.name = "whop whop" + + def test_o2m_editable_list(self): + """ Tests the o2m proxy when the list view is editable rather than + delegating to a separate form view + """ + f = Form(self.env['test_testing_utilities.parent'], view='test_testing_utilities.o2m_parent_ed') + + with f.subs.new() as s: + s.value = 1 + with f.subs.new() as s: + s.value = 3 + with f.subs.new() as s: + s.value = 7 + + r = f.save() + + self.assertEqual(r.v, 12) + self.assertEqual( + [get(s) for s in r.subs], + [('1', 1, 1), ('3', 3, 3), ('7', 7, 7)] + ) + + def test_o2m_inline(self): + """ Tests the o2m proxy when the list and form views are provided + inline rather than fetched separately + """ + f = Form(self.env['test_testing_utilities.parent'], view='test_testing_utilities.o2m_parent_inline') + + with f.subs.new() as s: + s.value = 42 + + r = f.save() + + self.assertEqual( + [get(s) for s in r.subs], + [("0", 42, 0)], + "should not have set v (and thus not name)" + ) + + def test_o2m_default(self): + """ Tests that default_get can return defaults for the o2m + """ + f = Form(self.env['test_testing_utilities.default']) + + with f.subs.edit(index=0) as s: + self.assertEqual(s.v, 5) + self.assertEqual(s.value, False) + + r = f.save() + + self.assertEqual( + [get(s) for s in r.subs], + [("5", 2, 5)] + ) + + def test_o2m_inner_default(self): + """ Tests that creating an o2m record will get defaults for it + """ + f = Form(self.env['test_testing_utilities.default']) + + with f.subs.new() as s: + self.assertEqual(s.value, 2) + self.assertEqual(s.v, 2, "should have onchanged value to v") + + def test_o2m_onchange_parent(self): + """ Tests that changing o2m content triggers onchange in the parent + """ + f = Form(self.env['test_testing_utilities.parent']) + + self.assertEqual(f.value, 1, "value should have its default") + self.assertEqual(f.v, 1, "v should be equal to value") + + f.subs.new().save() + + self.assertEqual(f.v, 3, "should be sum of value & children v") + + def test_o2m_onchange_inner(self): + """ Tests that editing a field of an o2m record triggers onchange + in the o2m record and its parent + """ + f = Form(self.env['test_testing_utilities.parent']) + + # only apply the onchange on edition end (?) + with f.subs.new() as sub: + sub.value = 6 + self.assertEqual(sub.v, 6) + self.assertEqual(f.v, 1) + self.assertEqual(f.v, 7) + + def test_o2m_parent_content(self): + """ Tests that when editing a field of an o2m the data sent contains + the parent data + """ + f = Form(self.env['test_testing_utilities.parent']) + + # only apply the onchange on edition end (?) + with f.subs.new() as sub: + sub.has_parent = True + self.assertEqual(sub.has_parent, True) + self.assertEqual(sub.value, 1) + self.assertEqual(sub.v, 1) + + def test_m2o_readonly(self): + r = self.env['test_testing_utilities.parent'].create({ + 'subs': [(0, 0, {})] + }) + f = Form(r, view='test_testing_utilities.o2m_parent_readonly') + + with self.assertRaises(AssertionError): + f.subs.new() + with self.assertRaises(AssertionError): + f.subs.edit(index=0) + with self.assertRaises(AssertionError): + f.subs.remove(index=0) + +class TestEdition(TransactionCase): + """ These use the context manager form as we don't need the record + post-save (we already have it) and it's easier to see what bits act on + the form (inside `with`) versus outside. That let me catch a few + mistakes. + """ + def test_trivial(self): + r = self.env['test_testing_utilities.a'].create({ + 'f1': 5, + }) + + with Form(r) as f: + self.assertEqual(f.id, r.id) + self.assertEqual(f.f1, 5) + self.assertEqual(f.f4, 8) + + f.f2 = 5 + self.assertEqual(f.f3, 2) + self.assertEqual(f.f4, 1) + + self.assertEqual(r.f2, 5) + self.assertEqual(r.f3, 2) + + def test_m2o(self): + Sub = self.env['test_testing_utilities.m2o'] + a = Sub.create({'name': 'a'}) + b = Sub.create({'name': 'b'}) + c = Sub.create({'name': 'c'}) + + r = self.env['test_testing_utilities.d'].create({ + 'f': c.id, + 'f2': "OK", + }) + + with Form(r) as f: + # no default/onchange should have run so loading an incoherent + # record should still be incoherent + self.assertEqual(f.f, c) + self.assertEqual(f.f2, 'OK') + + f.f2 = "b" + self.assertEqual(f.f, b) + f.f2 = "Whoops" + self.assertEqual(f.f, Sub) + f.f2 = "a" + self.assertEqual(f.f, a) + + self.assertEqual(r.f2, "a") + self.assertEqual(r.f, a) + + def test_m2m_empty(self): + sub = self.env['test_testing_utilities.sub2'].create({'name': 'a'}) + + r = self.env['test_testing_utilities.f'].create({ + 'm2m': [] + }) + + with Form(r) as f: + f.m2o = sub + + self.assertEqual(r.m2o, sub) + self.assertEqual(r.m2m, sub) + + def test_m2m_nonempty(self): + Sub = self.env['test_testing_utilities.sub2'] + a = Sub.create({'name': 'a'}) + b = Sub.create({'name': 'b'}) + c = Sub.create({'name': 'c'}) + + r = self.env['test_testing_utilities.f'].create({ + 'm2m': [(6, 0, (a | b | c).ids)] + }) + + with Form(r) as f: + self.assertEqual(f.m2m[:], (a | b | c)) + f.m2m.remove(index=0) + self.assertEqual(f.m2m[:], (b | c)) + + self.assertEqual(r.m2m, (b | c)) diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 7bbc57246e3..53c30b8db5f 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -7,23 +7,26 @@ helpers and classes to write tests. import errno import glob import importlib +import itertools import json import logging +import operator import os +import re import select import subprocess import threading import time -import itertools import unittest from contextlib import contextmanager from datetime import datetime, timedelta -from lxml import etree, html from pprint import pformat import requests +from lxml import etree, html -from odoo.tools import pycompat +from odoo.models import BaseModel +from odoo.tools import pycompat, safe_eval try: from itertools import zip_longest as izip_longest @@ -477,3 +480,526 @@ def can_import(module): return False else: return True + +# TODO: sub-views (o2m, m2m) -> sub-form? +# TODO: domains +ref_re = re.compile(""" +# first match 'form_view_ref' key, backrefs are used to handle single or +# double quoting of the value +(['"])(?P\w+)_view_ref\1 +# colon separator (with optional spaces around) +\s*:\s* +# open quote for value +(['"]) +(?P + # we'll just match stuff which is normally part of an xid: + # word and "." characters + [.\w]+ +) +# close with same quote as opening +\2 +""", re.VERBOSE) +class Form(object): + def __init__(self, recordp, view=None): + """ + :param recordp: empty or singleton recordset. An empty recordset will + put the view in "creation" mode and trigger calls to + default_get and on-load onchanges, a singleton will + put it in "edit" mode and only load the view'd data. + :type recordp: odoo.models.BaseModel + :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.BaseModel + """ + # necessary as we're overriding setattr + assert isinstance(recordp, BaseModel) + env = recordp.env + object.__setattr__(self, '_env', env) + + # store model bit only + object.__setattr__(self, '_model', recordp.browse(())) + if isinstance(view, BaseModel): + view_id = view.id + elif isinstance(view, pycompat.string_types): + view_id = env.ref(view).id + else: + view_id = view or False + fvg = recordp.fields_view_get(view_id, 'form') + arch = etree.fromstring(fvg['arch']) + + object.__setattr__(self, '_view', fvg) + # TODO: make this less crappy? + # look up edition view for the O2M + for f, descr in fvg['fields'].items(): + if descr['type'] != 'one2many': + continue + + node = next(n for n in arch.iter('field') if n.get('name') == f) + default_view = next( + (m for m in node.get('mode', 'tree').split(',') if m != 'form'), + 'tree' + ) + + refs = { + m.group('view_type'): m.group('view_id') + for m in ref_re.finditer(node.get('context', '')) + } + # always fetch for simplicity, ensure we always have a tree and + # a form view + submodel = env[descr['relation']] + views = submodel.with_context(**refs) \ + .load_views([(False, 'tree'), (False, 'form')])['fields_views'] + # embedded views should take the priority on externals + views.update(descr['views']) + + # if the default view is a kanban or a non-editable list, the + # "edition controller" is the form view + edition = views['form'] + if default_view == 'tree': + subarch = etree.fromstring(views['tree']['arch']) + if subarch.get('editable'): + edition = views['tree'] + + self._process_fvg(submodel, edition) + descr['views']['edition'] = edition + + self._process_fvg(recordp, fvg) + + # ordered? + vals = dict.fromkeys(fvg['fields'], False) + object.__setattr__(self, '_values', vals) + object.__setattr__(self, '_changed', set()) + if recordp: + assert recordp['id'], "editing unstored records is not supported" + # always load the id + vals['id'] = recordp['id'] + + self._init_from_values(recordp) + else: + self._init_from_defaults(self._model) + + def _process_fvg(self, model, fvg): + """ Post-processes to augment the fields_view_get with: + + * an id field (may not be present if not in the view but needed) + * pre-processed modifiers (map of modifier name to json-loaded domain) + * pre-processed onchanges list + """ + fvg['fields']['id'] = {'type': 'id'} + # pre-resolve modifiers & bind to arch toplevel + modifiers = fvg['modifiers'] = {} + contexts = fvg['contexts'] = {} + for f in etree.fromstring(fvg['arch']).iter('field'): + fname = f.get('name') + modifiers[fname] = json.loads(f.get('modifiers')) + ctx = f.get('context') + if ctx: + contexts[fname] = ctx + fvg['modifiers']['id'] = {'required': False, 'readonly': True} + fvg['onchange'] = model._onchange_spec(fvg) + + def _init_from_defaults(self, model): + vals = self._values + fields = self._view['fields'] + def cleanup(k, v): + if fields[k]['type'] == 'one2many': + # o2m default gets a (6) at the start, makes no sense + return [c for c in v if c[0] != 6] + return v + defaults = { + k: cleanup(k, v) + for k, v in model.default_get(list(fields)).items() + if k in fields + } + vals.update(defaults) + # m2m should all be rep'd as command list + for k, v in vals.items(): + if not v: + type_ = fields[k]['type'] + if type_ == 'many2many': + vals[k] = [(6, False, [])] + elif type_ == 'one2many': + vals[k] = [] + + # TODO: check that only fields with default values should be sent + self._perform_onchange(list(defaults.keys())) + + def _init_from_values(self, values): + self._values.update( + record_to_values(self._view['fields'], values)) + + def __getattr__(self, field): + descr = self._view['fields'].get(field) + assert descr is not None, "%s was not found in the view" % field + + v = self._values[field] + if descr['type'] == 'many2one': + Model = self._env[descr['relation']] + if not v: + return Model + return Model.browse(v) + elif descr['type'] == 'many2many': + return M2MProxy(self, field) + elif descr['type'] == 'one2many': + return O2MProxy(self, field) + return v + + def _get_modifier(self, field, modifier, default=False): + d = self._view['modifiers'][field].get(modifier, default) + if isinstance(d, bool): + return d + + vals = self._values + stack = [] + for it in reversed(d): + if it == '!': + stack.append(not stack.pop()) + elif it == '&': + e1 = stack.pop() + e2 = stack.pop() + stack.append(e1 and e2) + elif it == '|': + e1 = stack.pop() + e2 = stack.pop() + stack.append(e1 or e2) + elif isinstance(it, list): + f, op, val = it + field_val = vals[f] + stack.append(self._OPS[op](field_val, val)) + else: + raise ValueError("Unknown domain element %s" % it) + [result] = stack + return result + _OPS = { + '=': operator.eq, + '==': operator.eq, + '!=': operator.ne, + '<': operator.lt, + '<=': operator.le, + '>=': operator.ge, + '>': operator.gt, + 'in': lambda a, b: a in b, + 'not in': lambda a, b: a not in b + } + def _get_context(self, field): + c = self._view['contexts'].get(field) + if not c: + return {} + + return safe_eval(c, self._values) + + def __setattr__(self, field, value): + descr = self._view['fields'].get(field) + assert descr is not None, "%s was not found in the view" % field + assert descr['type'] not in ('many2many', 'one2many'),\ + "Can't set an o2m or m2m field, manipulate the corresponding proxies" + + # TODO: consider invisible to be the same as readonly? + assert not self._get_modifier(field, 'readonly'), \ + "can't write on readonly field {}".format(field) + + if descr['type'] == 'many2one': + assert isinstance(value, BaseModel) and value._name == descr['relation'] + # store just the id: that's the output of default_get & (more + # or less) onchange. + value = value.id + + self._values[field] = value + self._perform_onchange([field]) + + # enables with Form(...) as f: f.a = 1; f.b = 2; f.c = 3 + # q: how to get recordset? + def __enter__(self): + return self + def __exit__(self, *_): + self.save() + + def save(self): + id_ = self._values.get('id') + values = self._values_to_save() + if id_: + r = self._model.browse(id_) + if values: + r.write(values) + else: + r = self._model.create(values) + [data] = r.read(list(self._view['fields'])) + # FIXME: process relational fields + # alternative: iterate on record & read from it directly? pb: would + # provide recordsets for relational fields which may or may not be + # what we ultimately want, but we've got to re-process them anyway so...? + self._values.update(data) + self._changed.clear() + return r + + def _values_to_save(self): + """ Validates values and returns only fields modified since + load/save + """ + values = {} + for f in self._view['fields']: + v = self._values[f] + if self._get_modifier(f, 'required'): + assert v, "{} is a required field".format(f) + + # skip unmodified fields + if f not in self._changed: + continue + if self._get_modifier(f, 'readonly'): + continue + # TODO: filter out (1, _, {}) from o2m values + values[f] = v + return values + + def _perform_onchange(self, fields): + assert isinstance(fields, list) + + # marks any onchange source as changed (default_get or explicit set) + self._changed.update(fields) + + result = self._model.onchange( + self._onchange_values(), + fields, + self._view['onchange'], + ) + assert not result.get('warning') + values = result.get('value', {}) + # mark onchange output as changed + self._changed.update(values.keys()) + self._values.update( + (k, self._cleanup_onchange( + self._view['fields'][k], + v, self._values[k], + )) + for k, v in values.items() + if k in self._view['fields'] + ) + + def _onchange_values(self): + return dict(self._values) + + def _cleanup_onchange(self, descr, value, current): + if descr['type'] == 'many2one': + if not value: + return False + # out of onchange, m2o are name-gotten + return value[0] + elif descr['type'] == 'one2many': + # ignore o2ms nested in o2ms + if not descr['views']: + return [] + + v = [] + # which view should this be??? + subfields = descr['views']['edition']['fields'] + for command in value: + # TODO: get existing sub-values so we can pass them along? + if command[0] in (0, 1): + v.append((command[0], command[1], { + k: self._cleanup_onchange( + subfields[k], v, None + ) + for k, v in command[2].items() + if k in subfields + })) + # TODO: should reuse existing values if not 5? + return v + elif descr['type'] == 'many2many': + # onchange result is a bunch of commands, normalize to single 6 + if current is None: + ids = [] + else: + ids = list(current[0][2]) + for command in value: + if command[0] == 3: + ids.remove(command[1]) + elif command[0] == 4: + ids.append(command[1]) + elif command[0] == 5: + del ids[:] + elif command[0] == 6: + ids[:] = command[2] + else: + raise ValueError( + "Unsupported M2M command %d" % command[0]) + return [(6, 0, ids)] + + return value + +class O2MForm(Form): + # noinspection PyMissingConstructor + def __init__(self, proxy, index=None): + m = proxy._model + object.__setattr__(self, '_proxy', proxy) + object.__setattr__(self, '_index', index) + + object.__setattr__(self, '_env', m.env) + object.__setattr__(self, '_model', m) + + # copy so we don't risk breaking it too much (?) + fvg = dict(proxy._descr['views']['edition']) + object.__setattr__(self, '_view', fvg) + self._process_fvg(m, fvg) + + vals = dict.fromkeys(fvg['fields'], False) + object.__setattr__(self, '_values', vals) + object.__setattr__(self, '_changed', set()) + if index is None: + self._init_from_defaults(m) + else: + self._values.update(proxy._records[index]) + + def _onchange_values(self): + values = super(O2MForm, self)._onchange_values() + values[self._proxy._descr['relation_field']] = \ + self._proxy._parent._values + return values + + def save(self): + proxy = self._proxy + commands = proxy._parent._values[proxy._field] + values = self._values_to_save() + if self._index is None: + commands.append((0, 0, values)) + else: + (c, _, vs) = commands[proxy._command_index(self._index)] + assert c in (0, 1) + vs.update(values) + + # FIXME: should be called when performing on change => value needs to be serialised into parent every time? + proxy._parent._perform_onchange([proxy._field]) + +class X2MProxy(object): + _parent = None + _field = None + def _assert_editable(self): + assert not self._parent._get_modifier(self._field, 'readonly'),\ + 'field %s is not editable' % self._field + +class O2MProxy(X2MProxy): + def __init__(self, parent, field): + self._parent = parent + self._field = field + # reify records to a list so they can be manipulated easily? + self._records = [] + model = self._model + fields = self._descr['views']['edition']['fields'] + for (command, rid, values) in self._parent._values[self._field]: + if command == 0: + self._records.append(values) + elif command == 1: + # read based on view info + r = model.browse(rid) + record = record_to_values(fields, r) + record.update(values) + self._records.append(record) + elif command == 2: + pass + else: + raise AssertionError("O2M proxy only supports commands 0, 1 and 2") + + @property + def _model(self): + model = self._parent._env[self._descr['relation']] + ctx = self._parent._get_context(self._field) + if ctx: + model = model.with_context(**ctx) + return model + + @property + def _descr(self): + return self._parent._view['fields'][self._field] + + def _command_index(self, for_record): + """ Takes a record index and finds the corresponding record index + (skips all 2s, basically) + + :param int for_record: + """ + commands = self._parent._values[self._field] + return next( + cidx + for ridx, cidx in enumerate( + cidx for cidx, (c, _1, _2) in enumerate(commands) + if c in (0, 1) + ) + if ridx == for_record + ) + + def new(self): + self._assert_editable() + return O2MForm(self) + + def edit(self, index): + self._assert_editable() + return O2MForm(self, index) + + def remove(self, index): + self._assert_editable() + # remove reified record from local list & either remove 0 from + # commands list or replace 1 (update) by 2 (remove) + cidx = self._command_index(index) + commands = self._parent._values[self._field] + (command, rid, _) = commands[cidx] + if command == 0: + # record not saved yet -> just remove the command + del commands[cidx] + elif command == 1: + # record already saved, replace by 2 + commands[cidx] = (2, rid, 0) + else: + raise AssertionError("Expected command 0 or 1, got %s" % commands[cidx]) + # remove reified record + del self._records[index] + self._parent._perform_onchange([self._field]) + +class M2MProxy(X2MProxy): + def __init__(self, parent, field): + self._parent = parent + self._field = field + + def __getitem__(self, it): + p = self._parent + model = p._view['fields'][self._field]['relation'] + return p._env[model].browse(self._get_ids()[it]) + + def add(self, record): + self._assert_editable() + parent = self._parent + assert isinstance(record, BaseModel) \ + and record._name == parent._view['fields'][self._field]['relation'] + self._get_ids().append(record.id) + + parent._perform_onchange([self._field]) + + def _get_ids(self): + return self._parent._values[self._field][0][2] + + def remove(self, id=None, index=None): + self._assert_editable() + assert (id is None) ^ (index is None), \ + "can remove by either id or index" + + if id is None: + # remove by index + del self._get_ids()[index] + else: + self._get_ids().remove(id) + + self._parent._perform_onchange([self._field]) + +def record_to_values(fields, record): + r = {} + for f, descr in fields.items(): + v = record[f] + if descr['type'] == 'many2one': + assert v._name == descr['relation'] + v = v.id + elif descr['type'] == 'many2many': + assert v._name == descr['relation'] + v = [(6, 0, v.ids)] + elif descr['type'] == 'one2many': + v = [(1, r.id, {}) for r in v] + r[f] = v + return r