[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
This commit is contained in:
Xavier Morel
2017-12-04 13:11:53 +01:00
parent 026979c278
commit 4bf97bdbe2
8 changed files with 1288 additions and 3 deletions
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models
@@ -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',
]
}
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_test_testing_utilities_a access_test_testing_utilities_a model_test_testing_utilities_a 1 0 0 0
3 access_test_testing_utilities_readonly access_test_testing_utilities_readonly model_test_testing_utilities_readonly 1 0 0 0
4 access_test_testing_utilities_c access_test_testing_utilities_c model_test_testing_utilities_c 1 0 0 0
5 access_test_testing_utilities_d access_test_testing_utilities_d model_test_testing_utilities_d 1 0 0 0
6 access_test_testing_utilities_e access_test_testing_utilities_e model_test_testing_utilities_e 1 0 0 0
7 access_test_testing_utilities_f access_test_testing_utilities_f model_test_testing_utilities_f 1 0 0 0
8 access_test_testing_utilities_g access_test_testing_utilities_g model_test_testing_utilities_g 1 0 0 0
9 access_test_testing_utilities_m2o access_test_testing_utilities_m2o model_test_testing_utilities_m2o 1 0 0 0
10 access_test_testing_utilities_default access_test_testing_utilities_default model_test_testing_utilities_default 1 0 0 0
11 access_test_testing_utilities_parent access_test_testing_utilities_parent model_test_testing_utilities_parent 1 0 0 0
12 access_test_testing_utilities_sub access_test_testing_utilities_sub model_test_testing_utilities_sub 1 0 0 0
13 access_test_testing_utilities_sub2 access_test_testing_utilities_sub2 model_test_testing_utilities_sub2 1 0 0 0
14 access_test_testing_utilities_sub3 access_test_testing_utilities_sub3 model_test_testing_utilities_sub3 1 0 0 0
@@ -0,0 +1,94 @@
<odoo>
<record id="action_test_o2m_onchange" model="ir.actions.act_window">
<field name="name">Test o2m onchange thing</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">test_testing_utilities.parent</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_o2m" parent="base.menu_tests"
name="O2M onchange test thing"
action="action_test_o2m_onchange"/>
<record id="o2m_parent" model="ir.ui.view">
<field name="name">Parent External Form</field>
<field name="priority">1</field>
<field name="model">test_testing_utilities.parent</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="value"/>
<field name="v"/>
</group>
<group>
<field name="subs"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="o2m_parent_readonly" model="ir.ui.view">
<field name="name">Parent readonly o2m field</field>
<field name="model">test_testing_utilities.parent</field>
<field name="inherit_id" ref="o2m_parent"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='subs']" position="attributes">
<attribute name="readonly">1</attribute>
</xpath>
</field>
</record>
<record id="o2m_tree" model="ir.ui.view">
<field name="name">Sub regular</field>
<field name="priority">1</field>
<field name="model">test_testing_utilities.sub</field>
<field name="arch" type="xml">
<tree>
<field name="value"/>
<field name="v"/>
<field name="name"/>
</tree>
</field>
</record>
<record id="o2m_parent_ed" model="ir.ui.view">
<field name="name">Parent External Tree</field>
<field name="model">test_testing_utilities.parent</field>
<field name="arch" type="xml">
<form>
<field name="value"/>
<field name="v"/>
<field name="subs" context="{'tree_view_ref': 'test_testing_utilities.editable_external'}"/>
</form>
</field>
</record>
<record id="editable_external" model="ir.ui.view">
<field name="name">Sub regular</field>
<field name="model">test_testing_utilities.sub</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="value"/>
<field name="v"/>
<field name="name"/>
</tree>
</field>
</record>
<record id="o2m_parent_inline" model="ir.ui.view">
<field name="name">Parent Inline Tree</field>
<field name="model">test_testing_utilities.parent</field>
<field name="arch" type="xml">
<form>
<field name="value"/>
<field name="v"/>
<field name="subs">
<tree editable="bottom">
<field name="value"/>
<field name="name"/>
</tree>
</field>
</form>
</field>
</record>
</odoo>
@@ -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)
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_form_impl
@@ -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))
+529 -3
View File
@@ -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<view_type>\w+)_view_ref\1
# colon separator (with optional spaces around)
\s*:\s*
# open quote for value
(['"])
(?P<view_id>
# 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