[IMP] base_view_inheritance_extension: real python_dict
parent
01532a0eab
commit
8e2e89bef6
|
@ -10,6 +10,6 @@
|
||||||
"summary": "Adds more operators for view inheritance",
|
"summary": "Adds more operators for view inheritance",
|
||||||
"website": "https://github.com/OCA/server-tools",
|
"website": "https://github.com/OCA/server-tools",
|
||||||
"depends": ["base"],
|
"depends": ["base"],
|
||||||
"external_dependencies": {"python": ["pyyaml"]},
|
"external_dependencies": {"python": ["astor"]},
|
||||||
"demo": ["demo/ir_ui_view.xml"],
|
"demo": ["demo/ir_ui_view.xml"],
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,38 +1,14 @@
|
||||||
# Copyright 2016 Therp BV <https://therp.nl>
|
# Copyright 2016 Therp BV <https://therp.nl>
|
||||||
# Copyright 2018 Tecnativa - Sergio Teruel
|
# Copyright 2018 Tecnativa - Sergio Teruel
|
||||||
|
# Copyright 2021 Camptocamp SA (https://www.camptocamp.com).
|
||||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
|
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
import ast
|
||||||
|
|
||||||
|
import astor
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
from yaml import safe_load
|
|
||||||
|
|
||||||
from odoo import api, models, tools
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
class UnquoteObject(str):
|
|
||||||
def __getattr__(self, name):
|
|
||||||
return UnquoteObject("{}.{}".format(self, name))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __call__(self, *args, **kwargs):
|
|
||||||
return UnquoteObject(
|
|
||||||
"%s(%s)"
|
|
||||||
% (
|
|
||||||
self,
|
|
||||||
",".join(
|
|
||||||
[
|
|
||||||
UnquoteObject(a if not isinstance(a, str) else "'%s'" % a)
|
|
||||||
for a in args
|
|
||||||
]
|
|
||||||
+ ["{}={}".format(UnquoteObject(k), v) for (k, v) in kwargs.items()]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UnquoteEvalObjectContext(tools.misc.UnquoteEvalContext):
|
|
||||||
def __missing__(self, key):
|
|
||||||
return UnquoteObject(key)
|
|
||||||
|
|
||||||
|
|
||||||
class IrUiView(models.Model):
|
class IrUiView(models.Model):
|
||||||
|
@ -79,24 +55,6 @@ class IrUiView(models.Model):
|
||||||
)
|
)
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
def _is_variable(self, value):
|
|
||||||
return not ("'" in value or '"' in value) and True or False
|
|
||||||
|
|
||||||
def _list_variables(self, str_dict):
|
|
||||||
"""
|
|
||||||
Store non literal dictionary values into a list to post-process
|
|
||||||
operations.
|
|
||||||
"""
|
|
||||||
variables = []
|
|
||||||
items = str_dict.replace("{", "").replace("}", "").split(",")
|
|
||||||
for item in items:
|
|
||||||
key_value = item.split(":")
|
|
||||||
if len(key_value) == 2:
|
|
||||||
value = key_value[1]
|
|
||||||
if self._is_variable(value):
|
|
||||||
variables.append(value.strip())
|
|
||||||
return variables
|
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def inheritance_handler_attributes_python_dict(self, source, specs):
|
def inheritance_handler_attributes_python_dict(self, source, specs):
|
||||||
"""Implement
|
"""Implement
|
||||||
|
@ -107,15 +65,35 @@ class IrUiView(models.Model):
|
||||||
</$node>"""
|
</$node>"""
|
||||||
node = self.locate_node(source, specs)
|
node = self.locate_node(source, specs)
|
||||||
for attribute_node in specs:
|
for attribute_node in specs:
|
||||||
str_dict = node.get(attribute_node.get("name")) or "{}"
|
attr_name = attribute_node.get("name")
|
||||||
variables = self._list_variables(str_dict)
|
attr_key = attribute_node.get("key")
|
||||||
if self._is_variable(attribute_node.text):
|
str_dict = node.get(attr_name) or "{}"
|
||||||
variables.append(attribute_node.text)
|
ast_dict = ast.parse(str_dict, mode="eval").body
|
||||||
my_dict = safe_load(str_dict)
|
assert isinstance(ast_dict, ast.Dict), f"'{attr_name}' is not a dict"
|
||||||
my_dict[attribute_node.get("key")] = attribute_node.text
|
assert attr_key, "No key specified for 'python_dict' operation"
|
||||||
for k, v in my_dict.items():
|
# Find the ast dict key
|
||||||
my_dict[k] = UnquoteObject(v) if v in variables else v
|
# python < 3.8 uses ast.Str; python >= 3.8 uses ast.Constant
|
||||||
node.attrib[attribute_node.get("name")] = str(my_dict)
|
key_idx = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, k in enumerate(ast_dict.keys)
|
||||||
|
if (isinstance(k, ast.Str) and k.s == attr_key)
|
||||||
|
or (isinstance(k, ast.Constant) and k.value == attr_key)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
# Update or create the key
|
||||||
|
value = ast.parse(attribute_node.text.strip(), mode="eval").body
|
||||||
|
if key_idx:
|
||||||
|
ast_dict.values[key_idx] = value
|
||||||
|
else:
|
||||||
|
ast_dict.keys.append(ast.Str(attr_key))
|
||||||
|
ast_dict.values.append(value)
|
||||||
|
# Dump the ast back to source
|
||||||
|
# TODO: once odoo requires python >= 3.9; use `ast.unparse` instead
|
||||||
|
node.attrib[attribute_node.get("name")] = astor.to_source(
|
||||||
|
ast_dict, pretty_source=lambda s: "".join(s).strip()
|
||||||
|
)
|
||||||
return source
|
return source
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
* Holger Brunn <hbrunn@therp.nl>
|
* Holger Brunn <hbrunn@therp.nl>
|
||||||
* Ronald Portier <rportier@therp.nl>
|
* Ronald Portier <rportier@therp.nl>
|
||||||
* Sergio Teruel <sergio.teruel@tecnativa.com>
|
* Sergio Teruel <sergio.teruel@tecnativa.com>
|
||||||
|
* Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||||
|
|
|
@ -1,11 +1,19 @@
|
||||||
# Copyright 2016 Therp BV <http://therp.nl>
|
# Copyright 2016 Therp BV <http://therp.nl>
|
||||||
|
# Copyright 2021 Camptocamp SA (https://www.camptocamp.com).
|
||||||
|
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
|
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from odoo.tests.common import TransactionCase
|
from odoo.tests.common import SavepointCase
|
||||||
|
|
||||||
|
|
||||||
class TestBaseViewInheritanceExtension(TransactionCase):
|
class TestBaseViewInheritanceExtension(SavepointCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.ViewModel = cls.env["ir.ui.view"]
|
||||||
|
|
||||||
def test_base_view_inheritance_extension(self):
|
def test_base_view_inheritance_extension(self):
|
||||||
view_id = self.env.ref("base.view_partner_simple_form").id
|
view_id = self.env.ref("base.view_partner_simple_form").id
|
||||||
fields_view_get = self.env["res.partner"].fields_view_get(view_id=view_id)
|
fields_view_get = self.env["res.partner"].fields_view_get(view_id=view_id)
|
||||||
|
@ -89,40 +97,194 @@ class TestBaseViewInheritanceExtension(TransactionCase):
|
||||||
button_node = modified_source.xpath('//button[@name="test"]')[0]
|
button_node = modified_source.xpath('//button[@name="test"]')[0]
|
||||||
self.assertEqual(button_node.attrib["states"], "draft,valid,paid")
|
self.assertEqual(button_node.attrib["states"], "draft,valid,paid")
|
||||||
|
|
||||||
def test_python_dict_inheritance(self):
|
def test_python_dict_inheritance_context_default(self):
|
||||||
view_model = self.env["ir.ui.view"]
|
|
||||||
source = etree.fromstring(
|
source = etree.fromstring(
|
||||||
"""<form>
|
"""
|
||||||
<field name="invoice_line_ids"
|
<form>
|
||||||
context="{
|
<field name="account_move_id" context="{'default_journal_id': journal_id}" />
|
||||||
'default_type': context.get('default_type'),
|
</form>
|
||||||
'journal_id': journal_id,
|
"""
|
||||||
'default_partner_id': commercial_partner_id,
|
|
||||||
'default_currency_id':
|
|
||||||
currency_id != company_currency_id and currency_id or False,
|
|
||||||
'default_name': 'The company name',
|
|
||||||
}"/>
|
|
||||||
</form>"""
|
|
||||||
)
|
)
|
||||||
specs = etree.fromstring(
|
specs = etree.fromstring(
|
||||||
"""\
|
"""
|
||||||
<field name="invoice_line_ids" position="attributes">
|
<field name="account_move_id" position="attributes">
|
||||||
<attribute name="context" operation="python_dict"
|
<attribute name="context" operation="python_dict" key="default_company_id">
|
||||||
key="my_key">my_value</attribute>
|
company_id
|
||||||
<attribute name="context" operation="python_dict"
|
</attribute>
|
||||||
key="my_key2">'my name'</attribute>
|
|
||||||
<attribute name="context" operation="python_dict"
|
|
||||||
key="default_cost_center_id">cost_center_id</attribute>
|
|
||||||
</field>
|
</field>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
modified_source = view_model.inheritance_handler_attributes_python_dict(
|
res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)
|
||||||
source, specs
|
self.assertEqual(
|
||||||
|
res.xpath('//field[@name="account_move_id"]')[0].attrib["context"],
|
||||||
|
"{'default_journal_id': journal_id, 'default_company_id': company_id}",
|
||||||
)
|
)
|
||||||
field_node = modified_source.xpath('//field[@name="invoice_line_ids"]')[0]
|
|
||||||
self.assertTrue(
|
def test_python_dict_inheritance_context_complex(self):
|
||||||
"currency_id != company_currency_id and currency_id or False"
|
source = etree.fromstring(
|
||||||
in field_node.attrib["context"]
|
"""
|
||||||
|
<form>
|
||||||
|
<field
|
||||||
|
name="invoice_line_ids"
|
||||||
|
context="{
|
||||||
|
'default_type': context.get('default_type'),
|
||||||
|
'journal_id': journal_id,
|
||||||
|
'default_partner_id': commercial_partner_id,
|
||||||
|
'default_currency_id': (
|
||||||
|
currency_id != company_currency_id and currency_id or False
|
||||||
|
),
|
||||||
|
'default_name': 'The company name',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
self.assertTrue("my_value" in field_node.attrib["context"])
|
specs = etree.fromstring(
|
||||||
self.assertFalse("'cost_center_id'" in field_node.attrib["context"])
|
"""
|
||||||
|
<field name="invoice_line_ids" position="attributes">
|
||||||
|
<attribute name="context" operation="python_dict" key="default_product_id">
|
||||||
|
product_id
|
||||||
|
</attribute>
|
||||||
|
<attribute name="context" operation="python_dict" key="default_cost_center_id">
|
||||||
|
context.get('handle_mrp_cost') and cost_center_id or False
|
||||||
|
</attribute>
|
||||||
|
</field>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)
|
||||||
|
self.assertEqual(
|
||||||
|
res.xpath('//field[@name="invoice_line_ids"]')[0].attrib["context"],
|
||||||
|
"{'default_type': context.get('default_type'), 'journal_id': journal_id, "
|
||||||
|
"'default_partner_id': commercial_partner_id, 'default_currency_id': "
|
||||||
|
"currency_id != company_currency_id and currency_id or False, "
|
||||||
|
"'default_name': 'The company name', 'default_product_id': product_id, "
|
||||||
|
"'default_cost_center_id': context.get('handle_mrp_cost') and "
|
||||||
|
"cost_center_id or False}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_python_dict_inheritance_attrs_add(self):
|
||||||
|
"""Test that we can add new keys to an existing dict"""
|
||||||
|
source = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<form>
|
||||||
|
<field
|
||||||
|
name="ref"
|
||||||
|
attrs="{'invisible': [('state', '=', 'draft')]}"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
specs = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<field name="ref" position="attributes">
|
||||||
|
<attribute name="attrs" operation="python_dict" key="required">
|
||||||
|
[('state', '!=', 'draft')]
|
||||||
|
</attribute>
|
||||||
|
</field>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)
|
||||||
|
self.assertEqual(
|
||||||
|
res.xpath('//field[@name="ref"]')[0].attrib["attrs"],
|
||||||
|
"{'invisible': [('state', '=', 'draft')], "
|
||||||
|
"'required': [('state', '!=', 'draft')]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_python_dict_inheritance_attrs_update(self):
|
||||||
|
"""Test that we can replace an existing dict key"""
|
||||||
|
source = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<form>
|
||||||
|
<field
|
||||||
|
name="ref"
|
||||||
|
attrs="{
|
||||||
|
'invisible': [('state', '=', 'draft')],
|
||||||
|
'required': [('state', '=', False)],
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
specs = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<field name="ref" position="attributes">
|
||||||
|
<attribute name="attrs" operation="python_dict" key="required">
|
||||||
|
[('state', '!=', 'draft')]
|
||||||
|
</attribute>
|
||||||
|
</field>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)
|
||||||
|
self.assertEqual(
|
||||||
|
res.xpath('//field[@name="ref"]')[0].attrib["attrs"],
|
||||||
|
"{'invisible': [('state', '=', 'draft')], "
|
||||||
|
"'required': [('state', '!=', 'draft')]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_python_dict_inheritance_attrs_new(self):
|
||||||
|
"""Test that we can add new keys by creating the dict if it's missing"""
|
||||||
|
source = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<form>
|
||||||
|
<field name="ref" />
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
specs = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<field name="ref" position="attributes">
|
||||||
|
<attribute name="attrs" operation="python_dict" key="required">
|
||||||
|
[('state', '!=', 'draft')]
|
||||||
|
</attribute>
|
||||||
|
</field>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)
|
||||||
|
self.assertEqual(
|
||||||
|
res.xpath('//field[@name="ref"]')[0].attrib["attrs"],
|
||||||
|
"{'required': [('state', '!=', 'draft')]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_python_dict_inheritance_attrs_missing_key(self):
|
||||||
|
"""We should get an error if we try to update a dict without specifing a key"""
|
||||||
|
source = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<form>
|
||||||
|
<field name="ref" />
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
specs = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<field name="ref" position="attributes">
|
||||||
|
<attribute name="attrs" operation="python_dict">
|
||||||
|
[('state', '!=', 'draft')]
|
||||||
|
</attribute>
|
||||||
|
</field>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AssertionError, "No key specified for 'python_dict' operation"
|
||||||
|
):
|
||||||
|
self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)
|
||||||
|
|
||||||
|
def test_python_dict_inheritance_error_if_not_a_dict(self):
|
||||||
|
"""We should get an error if we try to update a non-dict attribute"""
|
||||||
|
source = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<form>
|
||||||
|
<field name="child_ids" domain="[('state', '=', 'confirm')]" />
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
specs = etree.fromstring(
|
||||||
|
"""
|
||||||
|
<field name="child_ids" position="attributes">
|
||||||
|
<attribute name="domain" operation="python_dict" key="required">
|
||||||
|
[('state', '!=', 'draft')]
|
||||||
|
</attribute>
|
||||||
|
</field>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(AssertionError, "'domain' is not a dict"):
|
||||||
|
self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)
|
||||||
|
|
Loading…
Reference in New Issue