diff --git a/base_view_inheritance_extension/__manifest__.py b/base_view_inheritance_extension/__manifest__.py index 1c3e46ba5..cc2879c62 100644 --- a/base_view_inheritance_extension/__manifest__.py +++ b/base_view_inheritance_extension/__manifest__.py @@ -3,7 +3,7 @@ # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). { "name": "Extended view inheritance", - "version": "14.0.1.1.0", + "version": "15.0.1.0.0", "author": "Therp BV,Odoo Community Association (OCA)", "license": "LGPL-3", "category": "Hidden/Dependency", diff --git a/base_view_inheritance_extension/demo/ir_ui_view.xml b/base_view_inheritance_extension/demo/ir_ui_view.xml index e40a4a98b..ffa3e9e52 100644 --- a/base_view_inheritance_extension/demo/ir_ui_view.xml +++ b/base_view_inheritance_extension/demo/ir_ui_view.xml @@ -8,16 +8,12 @@ Partner form - 'The company name' - context.get('company_id', context.get('company')) + + { + "default_name": "The company name", + "default_company_id": context.get("company_id", context.get("company")) + } + diff --git a/base_view_inheritance_extension/models/ir_ui_view.py b/base_view_inheritance_extension/models/ir_ui_view.py index 4be7df898..c04a3960b 100644 --- a/base_view_inheritance_extension/models/ir_ui_view.py +++ b/base_view_inheritance_extension/models/ir_ui_view.py @@ -11,6 +11,45 @@ from lxml import etree from odoo import api, models +def ast_dict_update(source, update): + """Perform a dict `update` on an ast.Dict + + Behaves similar to :meth:`dict.update`, but on ast.Dict instead. + Only compares string-like ast.Dict keys (ast.Str or ast.Constant). + + :returns: The updated ast.Dict + :rtype: ast.Dict + """ + if not isinstance(source, ast.Dict): + raise TypeError("`source` must be an AST dict") + if not isinstance(update, ast.Dict): + raise TypeError("`update` must be an AST dict") + + def ast_key_eq(k1, k2): + # python < 3.8 uses ast.Str; python >= 3.8 uses ast.Constant + if type(k1) != type(k2): + return False + elif isinstance(k1, ast.Str): + return k1.s == k2.s + elif isinstance(k1, ast.Constant): + return k1.value == k2.value + + toadd_uidx = [] + for uidx, ukey in enumerate(update.keys): + found = False + for sidx, skey in enumerate(source.keys): + if ast_key_eq(ukey, skey): + source.values[sidx] = update.values[uidx] + found = True + break + if not found: + toadd_uidx.append(uidx) + for uidx in toadd_uidx: + source.keys.append(update.keys[uidx]) + source.values.append(update.values[uidx]) + return source + + class IrUiView(models.Model): _inherit = "ir.ui.view" @@ -41,14 +80,14 @@ class IrUiView(models.Model): @api.model def _get_inheritance_handler(self, node): - handler = super(IrUiView, self).apply_inheritance_specs + handler = super().apply_inheritance_specs if hasattr(self, "inheritance_handler_%s" % node.tag): handler = getattr(self, "inheritance_handler_%s" % node.tag) return handler @api.model def _get_inheritance_handler_attributes(self, node): - handler = super(IrUiView, self).apply_inheritance_specs + handler = super().apply_inheritance_specs if hasattr(self, "inheritance_handler_attributes_%s" % node.get("operation")): handler = getattr( self, "inheritance_handler_attributes_%s" % node.get("operation") @@ -56,75 +95,35 @@ class IrUiView(models.Model): return handler @api.model - def inheritance_handler_attributes_python_dict(self, source, specs): - """Implement - <$node position="attributes"> - - $keyvalue - - """ + def inheritance_handler_attributes_update(self, source, specs): + """Implement dict `update` operation on the attribute node. + + .. code-block:: xml + + + + { + "key": "value", + } + + + """ node = self.locate_node(source, specs) - for attribute_node in specs: - 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) + for spec in specs: + attr_name = spec.get("name") + # Parse ast from both node and spec + source_ast = ast.parse(node.get(attr_name) or "{}", mode="eval").body + update_ast = ast.parse(spec.text.strip(), mode="eval").body + if not isinstance(source_ast, ast.Dict): + raise TypeError(f"Attribute `{attr_name}` is not a dict") + if not isinstance(update_ast, ast.Dict): + raise TypeError(f"Operation for attribute `{attr_name}` is not a dict") + # Update node ast dict + source_ast = ast_dict_update(source_ast, update_ast) # 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() + node.attrib[attr_name] = astor.to_source( + source_ast, + pretty_source=lambda s: "".join(s).strip(), ) return source - - @api.model - def inheritance_handler_attributes_list_add(self, source, specs): - """Implement - <$node position="attributes"> - - $new_value - - """ - node = self.locate_node(source, specs) - for attribute_node in specs: - attribute_name = attribute_node.get("name") - old_value = node.get(attribute_name) or "" - new_value = old_value + "," + attribute_node.text - node.attrib[attribute_name] = new_value - return source - - @api.model - def inheritance_handler_attributes_list_remove(self, source, specs): - """Implement - <$node position="attributes"> - - $value_to_remove - - """ - node = self.locate_node(source, specs) - for attribute_node in specs: - attribute_name = attribute_node.get("name") - old_values = (node.get(attribute_name) or "").split(",") - remove_values = attribute_node.text.split(",") - new_values = [x for x in old_values if x not in remove_values] - node.attrib[attribute_name] = ",".join([_f for _f in new_values if _f]) - return source diff --git a/base_view_inheritance_extension/readme/ROADMAP.rst b/base_view_inheritance_extension/readme/ROADMAP.rst index c999faa2f..ad27c61b8 100644 --- a/base_view_inheritance_extension/readme/ROADMAP.rst +++ b/base_view_inheritance_extension/readme/ROADMAP.rst @@ -1,2 +1 @@ -* On `15.0`, remove `list_add` and `list_remove` fetures. * Support an ``eval`` attribute for our new node types. diff --git a/base_view_inheritance_extension/readme/USAGE.rst b/base_view_inheritance_extension/readme/USAGE.rst index 18c469b6c..66ae50d75 100644 --- a/base_view_inheritance_extension/readme/USAGE.rst +++ b/base_view_inheritance_extension/readme/USAGE.rst @@ -3,21 +3,14 @@ .. code-block:: xml - - $new_value - + + + { + "key": "value", + } + + + Note that views are subject to evaluation of xmlids anyways, so if you need to refer to some xmlid, say ``%(xmlid)s``. - -**Add to values in a list (states for example)** - -Deprecated. This feature is now native, use ``. - -**Remove values from a list (states for example)** - -Deprecated. This feature is now native, use ``. - -**Move an element in the view** - -This feature is now native, cf the `official Odoo documentation `_. 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 1ef452187..d1ba7ff50 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 @@ -5,22 +5,22 @@ from lxml import etree -from odoo.tests.common import SavepointCase +from odoo.tests.common import TransactionCase -class TestBaseViewInheritanceExtension(SavepointCase): +class TestBaseViewInheritanceExtension(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.ViewModel = cls.env["ir.ui.view"] + cls.maxDiff = None 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) view = etree.fromstring(fields_view_get["arch"]) - # verify normal attributes work + # Verify normal attributes work self.assertEqual(view.xpath("//form")[0].get("string"), "Partner form") - # verify our extra context key worked + # Verify our extra context key worked self.assertTrue( "default_name" in view.xpath('//field[@name="parent_id"]')[0].get("context") ) @@ -29,75 +29,7 @@ class TestBaseViewInheritanceExtension(SavepointCase): in view.xpath('//field[@name="parent_id"]')[0].get("context") ) - def test_list_add(self): - view_model = self.env["ir.ui.view"] - source = etree.fromstring( - """\ -
-
- """ - ) - modified_source = view_model.inheritance_handler_attributes_list_add( - source, specs - ) - button_node = modified_source.xpath('//button[@name="test"]')[0] - self.assertEqual(button_node.attrib["states"], "draft,open,valid") - # extend with list of values - specs = etree.fromstring( - """\ - - """ - ) - modified_source = view_model.inheritance_handler_attributes_list_add( - source, specs - ) - button_node = modified_source.xpath('//button[@name="test"]')[0] - self.assertEqual(button_node.attrib["states"], "draft,open,valid,payable,paid") - - def test_list_remove(self): - view_model = self.env["ir.ui.view"] - source = etree.fromstring( - """\ -
-
- """ - ) - modified_source = view_model.inheritance_handler_attributes_list_remove( - source, specs - ) - button_node = modified_source.xpath('//button[@name="test"]')[0] - self.assertEqual(button_node.attrib["states"], "draft,valid,paid") - - def test_python_dict_inheritance_context_default(self): + def test_update_context_default(self): source = etree.fromstring( """
@@ -108,19 +40,19 @@ class TestBaseViewInheritanceExtension(SavepointCase): specs = etree.fromstring( """ - - company_id + + {"default_company_id": company_id} """ ) - res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs) + res = self.env["ir.ui.view"].apply_inheritance_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}", ) - def test_python_dict_inheritance_context_complex(self): + def test_update_context_complex(self): source = etree.fromstring( """ @@ -142,27 +74,39 @@ class TestBaseViewInheritanceExtension(SavepointCase): specs = etree.fromstring( """ - - product_id - - - context.get('handle_mrp_cost') and cost_center_id or False + + { + "default_product_id": product_id, + "default_cost_center_id": ( + context.get("handle_mrp_cost") and cost_center_id or False + ), + } """ ) - res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs) + res = self.env["ir.ui.view"].apply_inheritance_specs(source, specs) + expected_items = [ + "'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" + ), + ] 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}", + "{%s}" % ", ".join(expected_items), ) - def test_python_dict_inheritance_attrs_add(self): + def test_update_attrs_new_key(self): """Test that we can add new keys to an existing dict""" source = etree.fromstring( """ @@ -177,20 +121,22 @@ class TestBaseViewInheritanceExtension(SavepointCase): specs = etree.fromstring( """ - - [('state', '!=', 'draft')] + + { + "required": [("state", "!=", "draft")], + } """ ) - res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs) + res = self.env["ir.ui.view"].apply_inheritance_specs(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): + def test_update_attrs_replace(self): """Test that we can replace an existing dict key""" source = etree.fromstring( """ @@ -208,20 +154,22 @@ class TestBaseViewInheritanceExtension(SavepointCase): specs = etree.fromstring( """ - - [('state', '!=', 'draft')] + + { + "required": [('state', '!=', 'draft')], + } """ ) - res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs) + res = self.env["ir.ui.view"].apply_inheritance_specs(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): + def test_update_empty_source_dict(self): """Test that we can add new keys by creating the dict if it's missing""" source = etree.fromstring( """ @@ -233,20 +181,22 @@ class TestBaseViewInheritanceExtension(SavepointCase): specs = etree.fromstring( """ - - [('state', '!=', 'draft')] + + { + "required": [('state', '!=', 'draft')], + } """ ) - res = self.ViewModel.inheritance_handler_attributes_python_dict(source, specs) + res = self.env["ir.ui.view"].apply_inheritance_specs(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""" + def test_update_operation_not_a_dict(self): + """We should get an error if we try to update a dict with a non-dict spec""" source = etree.fromstring( """ @@ -257,18 +207,18 @@ class TestBaseViewInheritanceExtension(SavepointCase): specs = etree.fromstring( """ - - [('state', '!=', 'draft')] + + ["not", "a", "dict"] """ ) with self.assertRaisesRegex( - AssertionError, "No key specified for 'python_dict' operation" + TypeError, "Operation for attribute `attrs` is not a dict" ): - self.ViewModel.inheritance_handler_attributes_python_dict(source, specs) + self.env["ir.ui.view"].apply_inheritance_specs(source, specs) - def test_python_dict_inheritance_error_if_not_a_dict(self): + def test_update_source_not_a_dict(self): """We should get an error if we try to update a non-dict attribute""" source = etree.fromstring( """ @@ -280,11 +230,13 @@ class TestBaseViewInheritanceExtension(SavepointCase): specs = etree.fromstring( """ - - [('state', '!=', 'draft')] + + { + "required": [('state', '!=', 'draft')], + } """ ) - with self.assertRaisesRegex(AssertionError, "'domain' is not a dict"): - self.ViewModel.inheritance_handler_attributes_python_dict(source, specs) + with self.assertRaisesRegex(TypeError, "Attribute `domain` is not a dict"): + self.env["ir.ui.view"].apply_inheritance_specs(source, specs)