diff --git a/base_view_inheritance_extension/__manifest__.py b/base_view_inheritance_extension/__manifest__.py index 2cafc9a74..8910a8644 100644 --- a/base_view_inheritance_extension/__manifest__.py +++ b/base_view_inheritance_extension/__manifest__.py @@ -10,6 +10,6 @@ "summary": "Adds more operators for view inheritance", "website": "https://github.com/OCA/server-tools", "depends": ["base"], - "external_dependencies": {"python": ["pyyaml"]}, + "external_dependencies": {"python": ["astor"]}, "demo": ["demo/ir_ui_view.xml"], } diff --git a/base_view_inheritance_extension/models/ir_ui_view.py b/base_view_inheritance_extension/models/ir_ui_view.py index f7492052c..4be7df898 100644 --- a/base_view_inheritance_extension/models/ir_ui_view.py +++ b/base_view_inheritance_extension/models/ir_ui_view.py @@ -1,38 +1,14 @@ # Copyright 2016 Therp BV # 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). + +import ast + +import astor from lxml import etree -from yaml import safe_load -from odoo import api, models, tools - - -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) +from odoo import api, models class IrUiView(models.Model): @@ -79,24 +55,6 @@ class IrUiView(models.Model): ) 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 def inheritance_handler_attributes_python_dict(self, source, specs): """Implement @@ -107,15 +65,35 @@ class IrUiView(models.Model): """ node = self.locate_node(source, specs) for attribute_node in specs: - str_dict = node.get(attribute_node.get("name")) or "{}" - variables = self._list_variables(str_dict) - if self._is_variable(attribute_node.text): - variables.append(attribute_node.text) - my_dict = safe_load(str_dict) - my_dict[attribute_node.get("key")] = attribute_node.text - for k, v in my_dict.items(): - my_dict[k] = UnquoteObject(v) if v in variables else v - node.attrib[attribute_node.get("name")] = str(my_dict) + attr_name = attribute_node.get("name") + attr_key = attribute_node.get("key") + str_dict = node.get(attr_name) or "{}" + ast_dict = ast.parse(str_dict, mode="eval").body + assert isinstance(ast_dict, ast.Dict), f"'{attr_name}' is not a dict" + assert attr_key, "No key specified for 'python_dict' operation" + # Find the ast dict key + # python < 3.8 uses ast.Str; python >= 3.8 uses ast.Constant + 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 @api.model diff --git a/base_view_inheritance_extension/readme/CONTRIBUTORS.rst b/base_view_inheritance_extension/readme/CONTRIBUTORS.rst index 41d2a472c..be9defb1d 100644 --- a/base_view_inheritance_extension/readme/CONTRIBUTORS.rst +++ b/base_view_inheritance_extension/readme/CONTRIBUTORS.rst @@ -1,3 +1,4 @@ * Holger Brunn * Ronald Portier * Sergio Teruel +* Iván Todorovich diff --git a/base_view_inheritance_extension/tests/test_base_view_inheritance_extension.py b/base_view_inheritance_extension/tests/test_base_view_inheritance_extension.py index 13d18968b..1ef452187 100644 --- a/base_view_inheritance_extension/tests/test_base_view_inheritance_extension.py +++ b/base_view_inheritance_extension/tests/test_base_view_inheritance_extension.py @@ -1,11 +1,19 @@ # Copyright 2016 Therp BV +# Copyright 2021 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + 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): 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) @@ -89,40 +97,194 @@ class TestBaseViewInheritanceExtension(TransactionCase): button_node = modified_source.xpath('//button[@name="test"]')[0] self.assertEqual(button_node.attrib["states"], "draft,valid,paid") - def test_python_dict_inheritance(self): - view_model = self.env["ir.ui.view"] + def test_python_dict_inheritance_context_default(self): source = etree.fromstring( - """
- - """ + """ +
+ + + """ ) specs = etree.fromstring( - """\ - - my_value - 'my name' - cost_center_id + """ + + + company_id + """ ) - modified_source = view_model.inheritance_handler_attributes_python_dict( - source, specs + res = self.ViewModel.inheritance_handler_attributes_python_dict(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( - "currency_id != company_currency_id and currency_id or False" - in field_node.attrib["context"] + + def test_python_dict_inheritance_context_complex(self): + source = etree.fromstring( + """ +
+ + + """ ) - self.assertTrue("my_value" in field_node.attrib["context"]) - self.assertFalse("'cost_center_id'" in field_node.attrib["context"]) + specs = etree.fromstring( + """ + + + product_id + + + context.get('handle_mrp_cost') and cost_center_id or False + + + """ + ) + 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( + """ +
+ + + """ + ) + specs = etree.fromstring( + """ + + + [('state', '!=', 'draft')] + + + """ + ) + 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( + """ +
+ + + """ + ) + specs = etree.fromstring( + """ + + + [('state', '!=', 'draft')] + + + """ + ) + 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( + """ +
+ + + """ + ) + specs = etree.fromstring( + """ + + + [('state', '!=', 'draft')] + + + """ + ) + 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( + """ +
+ + + """ + ) + specs = etree.fromstring( + """ + + + [('state', '!=', 'draft')] + + + """ + ) + 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( + """ +
+ + + """ + ) + specs = etree.fromstring( + """ + + + [('state', '!=', 'draft')] + + + """ + ) + with self.assertRaisesRegex(AssertionError, "'domain' is not a dict"): + self.ViewModel.inheritance_handler_attributes_python_dict(source, specs)