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