Maintainers
This module is maintained by the OCA.
-
+
+
+
OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.
diff --git a/web_m2x_options_manager/tests/__init__.py b/web_m2x_options_manager/tests/__init__.py index f803479e2580..d5026ae9a5f4 100644 --- a/web_m2x_options_manager/tests/__init__.py +++ b/web_m2x_options_manager/tests/__init__.py @@ -1,4 +1,3 @@ -# Copyright 2021 Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - +from . import test_ir_model +from . import test_ir_model_fields from . import test_m2x_create_edit_option diff --git a/web_m2x_options_manager/tests/common.py b/web_m2x_options_manager/tests/common.py new file mode 100644 index 000000000000..714b787e0c99 --- /dev/null +++ b/web_m2x_options_manager/tests/common.py @@ -0,0 +1,42 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from lxml import etree + +from odoo.tests.common import TransactionCase +from odoo.tools.safe_eval import safe_eval + + +class Common(TransactionCase): + @classmethod + def _create_opt(cls, model_name, field_name, vals=None): + field = cls._get_field(model_name, field_name) + vals = dict(vals or []) + return cls.env["m2x.create.edit.option"].create(dict(field_id=field.id, **vals)) + + @classmethod + def _get_field(cls, model_name, field_name): + return cls.env["ir.model.fields"]._get(model_name, field_name) + + @classmethod + def _get_model(cls, model_name): + return cls.env["ir.model"]._get(model_name) + + @staticmethod + def _eval_node_options(node): + opt = node.attrib.get("options") + if opt: + return safe_eval(opt, nocopy=True) + return {} + + @classmethod + def _get_test_view(cls): + return cls.env.ref("web_m2x_options_manager.res_partner_demo_form_view") + + @classmethod + def _get_test_view_fields_view_get(cls): + return cls.env["res.partner"].fields_view_get(cls._get_test_view().id) + + @classmethod + def _get_test_view_parsed(cls): + return etree.XML(cls._get_test_view_fields_view_get()["arch"]) diff --git a/web_m2x_options_manager/tests/test_ir_model.py b/web_m2x_options_manager/tests/test_ir_model.py new file mode 100644 index 000000000000..c76ec1cd043f --- /dev/null +++ b/web_m2x_options_manager/tests/test_ir_model.py @@ -0,0 +1,37 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tools import mute_logger + +from .common import Common + + +class TestIrModel(Common): + @mute_logger("odoo.models.unlink") + def test_model_buttons(self): + model = self._get_model("res.users") + (model.m2x_option_ids + model.m2x_comodels_option_ids).unlink() + + # Model's fields workflow + # 1- fill: check options have been created + model.button_fill_m2x_options() + options = model.m2x_option_ids + self.assertTrue(options) + # 2- refill: check no option has been created (they all existed already) + model.button_fill_m2x_options() + self.assertFalse(model.m2x_option_ids - options) + # 3- empty: check no option exists anymore + model.button_empty_m2x_options() + self.assertFalse(model.m2x_option_ids) + + # Model's inverse fields workflow + # 1- fill: check options have been created + model.button_fill_m2x_comodels_options() + comodels_options = model.m2x_comodels_option_ids + self.assertTrue(comodels_options) + # 2- refill: check no option has been created (they all existed already) + model.button_fill_m2x_comodels_options() + self.assertFalse(model.m2x_comodels_option_ids - comodels_options) + # 3- empty: check no option exists anymore + model.button_empty_m2x_comodels_options() + self.assertFalse(model.m2x_comodels_option_ids) diff --git a/web_m2x_options_manager/tests/test_ir_model_fields.py b/web_m2x_options_manager/tests/test_ir_model_fields.py new file mode 100644 index 000000000000..1c122ac173b9 --- /dev/null +++ b/web_m2x_options_manager/tests/test_ir_model_fields.py @@ -0,0 +1,99 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo_test_helper import FakeModelLoader + +from odoo import fields, models + +from .common import Common + + +class TestIrModelFields(Common): + def test_field_can_have_options(self): + # M2O field + self.assertTrue(self._get_field("res.partner", "parent_id").can_have_options) + # M2M field + self.assertTrue(self._get_field("res.partner", "category_id").can_have_options) + # O2M field + self.assertFalse(self._get_field("res.partner", "user_ids").can_have_options) + # non-relational field + self.assertFalse(self._get_field("res.partner", "id").can_have_options) + + def test_field_comodel_id(self): + # M2O field + self.assertEqual( + self._get_field("res.partner", "parent_id").comodel_id, + self._get_model("res.partner"), + ) + # M2M field + self.assertEqual( + self._get_field("res.partner", "category_id").comodel_id, + self._get_model("res.partner.category"), + ) + # O2M field + self.assertEqual( + self._get_field("res.partner", "user_ids").comodel_id, + self._get_model("res.users"), + ) + # Non-relational field + self.assertFalse(self._get_field("res.partner", "id").comodel_id) + + def test_field_search(self): + loader = FakeModelLoader(self.env, self.__module__) + loader.backup_registry() + + class ResUsers(models.Model): + _inherit = "res.users" + + test_field = fields.Many2one("res.groups", string="Abc") + + class ResGroups(models.Model): + _inherit = "res.groups" + + test_field_abc = fields.Many2one("res.users", string="Abc") + + loader.update_registry((ResUsers, ResGroups)) + + test_field = self._get_field("res.users", "test_field") + test_field_abc = self._get_field("res.groups", "test_field_abc") + + ir_model_fields = self.env["ir.model.fields"] + name = "ABC" + domain = [("model", "in", ("res.users", "res.groups"))] + + # String "abc" is contained in both fields' description: basic ``name_search()`` + # will return them both sorted by ID + ir_model_fields = ir_model_fields.with_context(search_by_technical_name=False) + self.assertEqual( + [r[0] for r in ir_model_fields.name_search(name, domain, limit=None)], + [test_field.id, test_field_abc.id], + ) + + # Use context key ``search_by_technical_name``: ``test_field_abc`` should now be + # returned first + ir_model_fields = ir_model_fields.with_context(search_by_technical_name=True) + self.assertEqual( + [r[0] for r in ir_model_fields.name_search(name, domain, limit=None)], + [test_field_abc.id, test_field.id], + ) + + ir_model_fields.with_context(search_by_technical_name=False) + + # Search again by mimicking the ``ir.model`` Form view for m2x options + users_model = self._get_model("res.users") + ir_model_fields = ir_model_fields.with_context( + o2m_list_view_m2x_domain=[("model_id", "=", users_model.id)] + ) + self.assertEqual( + [r[0] for r in ir_model_fields.name_search(name, domain, limit=None)], + [test_field.id], + ) + ir_model_fields = ir_model_fields.with_context( + o2m_list_view_m2x_domain=[("comodel_id", "=", users_model.id)] + ) + self.assertEqual( + [r[0] for r in ir_model_fields.name_search(name, domain, limit=None)], + [test_field_abc.id], + ) + + loader.restore_registry() diff --git a/web_m2x_options_manager/tests/test_m2x_create_edit_option.py b/web_m2x_options_manager/tests/test_m2x_create_edit_option.py index 7f61c9c2bf6f..ceed44f8e0e9 100644 --- a/web_m2x_options_manager/tests/test_m2x_create_edit_option.py +++ b/web_m2x_options_manager/tests/test_m2x_create_edit_option.py @@ -1,133 +1,101 @@ # Copyright 2021 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from lxml import etree from odoo.exceptions import ValidationError -from odoo.tests.common import TransactionCase -from odoo.tools.safe_eval import safe_eval +from .common import Common -class TestM2xCreateEditOption(TransactionCase): - def setUp(self): - super(TestM2xCreateEditOption, self).setUp() - ref = self.env.ref - # View to be used - self.view = ref("web_m2x_options_manager.res_partner_demo_form_view") - # res.partner model and fields - self.res_partner_model = ref("base.model_res_partner") - self.categ_field = ref("base.field_res_partner__category_id") - self.title_field = ref("base.field_res_partner__title") - self.users_field = ref("base.field_res_partner__user_ids") - # res.users model and fields - self.res_users_model = ref("base.model_res_users") - self.company_field = ref("base.field_res_users__company_id") - # Options setup - self.title_opt = self.env["m2x.create.edit.option"].create( + +class TestM2xCreateEditOption(Common): + def test_errors(self): + with self.assertRaises(ValidationError): + # Fails ``_check_field_type``: users_field is a One2many + self._create_opt("res.partner", "user_ids") + + def test_apply_options(self): + # Check fields on res.partner form view before applying options + form_doc = self._get_test_view_parsed() + self.assertEqual( + self._eval_node_options(form_doc.xpath("//field[@name='title']")[0]), {} + ) + self.assertEqual( + self._eval_node_options(form_doc.xpath("//field[@name='parent_id']")[0]), + {"create": False, "create_edit": False, "m2o_dialog": False}, + ) + self.assertEqual( + self._eval_node_options(form_doc.xpath("//field[@name='category_id']")[0]), + {"create": False, "create_edit": False, "m2o_dialog": False}, + ) + + # Create options, check view has been updated + self._create_opt( + "res.partner", + "title", { - "field_id": self.title_field.id, - "model_id": self.res_partner_model.id, "option_create": "set_true", "option_create_edit": "set_true", - "option_create_edit_wizard": True, - } + "option_m2o_dialog": "set_true", + }, ) - self.categories_opt = self.env["m2x.create.edit.option"].create( + self._create_opt( + "res.partner", + "parent_id", { - "field_id": self.categ_field.id, - "model_id": self.res_partner_model.id, "option_create": "set_true", "option_create_edit": "set_true", - "option_create_edit_wizard": True, - } + "option_m2o_dialog": "set_true", + }, ) - self.company_opt = self.env["m2x.create.edit.option"].create( + self._create_opt( + "res.partner", + "category_id", { - "field_id": self.company_field.id, - "model_id": self.res_users_model.id, "option_create": "force_true", - "option_create_edit": "set_true", - "option_create_edit_wizard": False, - } - ) - - def test_errors(self): - with self.assertRaises(ValidationError): - # Fails ``_check_field_in_model``: model is res.partner, field is - # res.users's company_id - self.env["m2x.create.edit.option"].create( - { - "field_id": self.company_field.id, - "model_id": self.res_partner_model.id, - "option_create": "set_true", - "option_create_edit": "set_true", - } - ) - with self.assertRaises(ValidationError): - # Fails ``_check_field_type``: users_field is a One2many - self.env["m2x.create.edit.option"].create( - { - "field_id": self.users_field.id, - "model_id": self.res_partner_model.id, - "option_create": "set_true", - "option_create_edit": "set_true", - } - ) - - def test_apply_options(self): - res = self.env["res.partner"].fields_view_get(self.view.id) - - # Check fields on res.partner form view - form_arch = res["arch"] - form_doc = etree.XML(form_arch) - title_node = form_doc.xpath("//field[@name='title']")[0] - self.assertEqual( - safe_eval(title_node.attrib.get("options"), nocopy=True), - {"create": True, "create_edit": True}, + "option_create_edit": "force_true", + "option_m2o_dialog": "force_true", + }, ) + form_doc = self._get_test_view_parsed() self.assertEqual( - ( - title_node.attrib.get("can_create"), - title_node.attrib.get("can_write"), - ), - ("true", "true"), + self._eval_node_options(form_doc.xpath("//field[@name='title']")[0]), + {"create": True, "create_edit": True, "m2o_dialog": True}, ) - categ_node = form_doc.xpath("//field[@name='category_id']")[0] self.assertEqual( - safe_eval(categ_node.attrib.get("options"), nocopy=True), - {"create": False, "create_edit": True}, + self._eval_node_options(form_doc.xpath("//field[@name='parent_id']")[0]), + # These remain the same because the options are defined w/ 'set_true': + # but the node already contains them, so no override is applied + {"create": False, "create_edit": False, "m2o_dialog": False}, ) self.assertEqual( - ( - categ_node.attrib.get("can_create"), - categ_node.attrib.get("can_write"), - ), - ("true", "true"), + self._eval_node_options(form_doc.xpath("//field[@name='category_id']")[0]), + # These change values because the options are defined w/ 'force_true': + # options' values are overridden even if the node already contains them + {"create": True, "create_edit": True, "m2o_dialog": True}, ) - # Check fields on res.users tree view (contained in ``user_ids`` field) - tree_arch = res["fields"]["user_ids"]["views"]["tree"]["arch"] - tree_doc = etree.XML(tree_arch) - company_node = tree_doc.xpath("//field[@name='company_id']")[0] - self.assertEqual( - safe_eval(company_node.attrib.get("options"), nocopy=True), - {"create": True, "create_edit": True}, - ) + # Update options on ``res.partner.parent_id``, check its node has been updated + opt = self.env["m2x.create.edit.option"].get("res.partner", "parent_id") + opt.option_create = "force_true" + opt.option_create_edit = "force_true" + opt.option_m2o_dialog = "force_true" + form_doc = self._get_test_view_parsed() self.assertEqual( - ( - company_node.attrib.get("can_create"), - company_node.attrib.get("can_write"), - ), - ("false", "false"), + self._eval_node_options(form_doc.xpath("//field[@name='parent_id']")[0]), + {"create": True, "create_edit": True, "m2o_dialog": True}, ) - # Update options, check that node has been updated too - self.title_opt.option_create_edit = "force_false" - res = self.env["res.partner"].fields_view_get(self.view.id) - form_arch = res["arch"] - form_doc = etree.XML(form_arch) - title_node = form_doc.xpath("//field[@name='title']")[0] - self.assertEqual( - safe_eval(title_node.attrib.get("options"), nocopy=True), - {"create": True, "create_edit": False}, + def test_m2x_option_name(self): + # Mostly to make Codecov happy... + opt = self._create_opt( + "res.partner", + "title", + { + "option_create": "set_true", + "option_create_edit": "set_true", + "option_m2o_dialog": "set_true", + }, ) + self.assertEqual(opt.name, "res.partner.title") + opt = opt.new({"field_id": self._get_field("res.partner", "parent_id").id}) + self.assertEqual(opt.name, "res.partner.parent_id") diff --git a/web_m2x_options_manager/tools.py b/web_m2x_options_manager/tools.py new file mode 100644 index 000000000000..7da428176b18 --- /dev/null +++ b/web_m2x_options_manager/tools.py @@ -0,0 +1,34 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tools.sql import column_exists, create_column + + +def prepare_column_can_have_options(cr): + """Creates column ``ir_model_fields.can_have_options`` and fills its values""" + if not column_exists(cr, "ir_model_fields", "can_have_options"): + create_column(cr, "ir_model_fields", "can_have_options", "boolean") + cr.execute( + """ + UPDATE ir_model_fields + SET can_have_options = + CASE + WHEN ttype in ('many2many', 'many2one') THEN true + ELSE false + END + """ + ) + + +def prepare_column_comodel_id(cr): + """Creates column ``ir_model_fields.comodel_id`` and fills its values""" + if not column_exists(cr, "ir_model_fields", "comodel_id"): + create_column(cr, "ir_model_fields", "comodel_id", "int4") + cr.execute( + """ + UPDATE ir_model_fields + SET comodel_id = m.id + FROM ir_model m + WHERE relation = m.model + """ + ) diff --git a/web_m2x_options_manager/views/ir_model.xml b/web_m2x_options_manager/views/ir_model.xml index e52a1b864b3e..c1ff1332fa56 100644 --- a/web_m2x_options_manager/views/ir_model.xml +++ b/web_m2x_options_manager/views/ir_model.xml @@ -8,28 +8,48 @@+
+