diff --git a/web_m2x_options_manager/README.rst b/web_m2x_options_manager/README.rst index 882470619bb3..4b0d5e7d2e15 100644 --- a/web_m2x_options_manager/README.rst +++ b/web_m2x_options_manager/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ======================= Web M2X Options Manager ======================= @@ -17,7 +13,7 @@ Web M2X Options Manager .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github @@ -45,11 +41,16 @@ Usage Go to Settings > Technical > Models. -Choose the model you wish to edit, and open its form view. Go to the -"Create/Edit Options" tab, and add the fields you want to manage. +Choose the model you wish to edit, and open its form view. Go to the "Create/Edit Options" tab, +and add the fields you want to manage in 2 different sections: + +* the first list view allows you to handle fields for the selected model +* the second list view allows you to handle fields where the selected model is the comodel + +For both sections: -Button "Fill" will add every missing field to the options. -Button "Empty" will remove every option. +* button "Fill" will add every missing field to the options +* button "Empty" will remove every option Bug Tracker =========== diff --git a/web_m2x_options_manager/__init__.py b/web_m2x_options_manager/__init__.py index 2a7f1c54aafa..6d58305f5ddc 100644 --- a/web_m2x_options_manager/__init__.py +++ b/web_m2x_options_manager/__init__.py @@ -1,4 +1,2 @@ -# Copyright 2021 Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - from . import models +from .hooks import pre_init_hook diff --git a/web_m2x_options_manager/__manifest__.py b/web_m2x_options_manager/__manifest__.py index 98d3c75522fb..2415ebde740f 100644 --- a/web_m2x_options_manager/__manifest__.py +++ b/web_m2x_options_manager/__manifest__.py @@ -6,18 +6,25 @@ "summary": 'Adds an interface to manage the "Create" and' ' "Create and Edit" options for specific models and' " fields.", - "version": "14.0.1.4.0", + "version": "14.0.2.0.0", "author": "Camptocamp, Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Web", "data": [ "security/ir.model.access.csv", "views/ir_model.xml", + "views/m2x_create_edit_option.xml", ], "demo": [ "demo/res_partner_demo_view.xml", ], - "depends": ["base", "web_m2x_options"], + "depends": [ + # OCA/server-tools + "base_view_inheritance_extension", + # OCA/web + "web_m2x_options", + ], "website": "https://github.com/OCA/web", "installable": True, + "pre_init_hook": "pre_init_hook", } diff --git a/web_m2x_options_manager/demo/res_partner_demo_view.xml b/web_m2x_options_manager/demo/res_partner_demo_view.xml index 8a53c630c561..1861d5379e42 100644 --- a/web_m2x_options_manager/demo/res_partner_demo_view.xml +++ b/web_m2x_options_manager/demo/res_partner_demo_view.xml @@ -9,17 +9,18 @@
- + - - - - - - - - - + + + +
diff --git a/web_m2x_options_manager/hooks.py b/web_m2x_options_manager/hooks.py new file mode 100644 index 000000000000..3e3195c8ca3b --- /dev/null +++ b/web_m2x_options_manager/hooks.py @@ -0,0 +1,11 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .tools import prepare_column_can_have_options, prepare_column_comodel_id + + +def pre_init_hook(cr): + # Pre-create and pre-fill these columns for perf reasons (might take a while to + # let Odoo do it via the ORM for huge DBs) + prepare_column_can_have_options(cr) + prepare_column_comodel_id(cr) diff --git a/web_m2x_options_manager/i18n/it.po b/web_m2x_options_manager/i18n/it.po index 6ad77573f954..4328039cc326 100644 --- a/web_m2x_options_manager/i18n/it.po +++ b/web_m2x_options_manager/i18n/it.po @@ -34,7 +34,7 @@ msgid "Create & Edit Option" msgstr "Opzione crea & modifica" #. module: web_m2x_options_manager -#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__option_create_edit_wizard +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__option_m2o_dialog msgid "Create & Edit Wizard" msgstr "Procedura guidata crea & modifica" @@ -59,7 +59,7 @@ msgid "Created on" msgstr "Creato il" #. module: web_m2x_options_manager -#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__option_create_edit_wizard +#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__option_m2o_dialog msgid "" "Defines behaviour for 'Create & Edit' Wizard\n" "Set to False to prevent 'Create & Edit' Wizard to pop up" @@ -180,7 +180,7 @@ msgid "Last Updated on" msgstr "Ultimo aggiornamento il" #. module: web_m2x_options_manager -#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model__m2x_create_edit_option_ids +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model__m2x_option_ids msgid "M2X Create Edit Option" msgstr "Crea opzione modifica M2X" diff --git a/web_m2x_options_manager/i18n/web_m2x_options_manager.pot b/web_m2x_options_manager/i18n/web_m2x_options_manager.pot index 37bc7324b0cb..ba45c8d8906e 100644 --- a/web_m2x_options_manager/i18n/web_m2x_options_manager.pot +++ b/web_m2x_options_manager/i18n/web_m2x_options_manager.pot @@ -31,7 +31,7 @@ msgid "Create & Edit Option" msgstr "" #. module: web_m2x_options_manager -#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__option_create_edit_wizard +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__option_m2o_dialog msgid "Create & Edit Wizard" msgstr "" @@ -56,7 +56,7 @@ msgid "Created on" msgstr "" #. module: web_m2x_options_manager -#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__option_create_edit_wizard +#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__option_m2o_dialog msgid "" "Defines behaviour for 'Create & Edit' Wizard\n" "Set to False to prevent 'Create & Edit' Wizard to pop up" @@ -158,7 +158,7 @@ msgid "Last Updated on" msgstr "" #. module: web_m2x_options_manager -#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model__m2x_create_edit_option_ids +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model__m2x_option_ids msgid "M2X Create Edit Option" msgstr "" diff --git a/web_m2x_options_manager/migrations/14.0.2.0.0/pre-mig.py b/web_m2x_options_manager/migrations/14.0.2.0.0/pre-mig.py new file mode 100644 index 000000000000..20c66af03e83 --- /dev/null +++ b/web_m2x_options_manager/migrations/14.0.2.0.0/pre-mig.py @@ -0,0 +1,41 @@ +# 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, drop_constraint + +# pylint: disable=odoo-addons-relative-import +from odoo.addons.web_m2x_options_manager.tools import ( + prepare_column_can_have_options, + prepare_column_comodel_id, +) + + +def migrate(cr, version): + if not version: + return + + # Migrate values from ``option_create_edit_wizard`` to ``option_m2o_dialog`` + if not column_exists(cr, "m2x_create_edit_option", "option_m2o_dialog"): + create_column(cr, "m2x_create_edit_option", "option_m2o_dialog", "varchar") + cr.execute( + """ + UPDATE m2x_create_edit_option + SET option_m2o_dialog = + CASE + WHEN not option_create_edit_wizard THEN 'set_false' + ELSE 'null' + END + """ + ) + + # Pre-create and pre-fill these columns for perf reasons (might take a while to + # let Odoo do it via the ORM for huge DBs) + prepare_column_can_have_options(cr) + prepare_column_comodel_id(cr) + + # Replaced by SQL constraint ``m2x_create_edit_option_field_uniqueness`` + drop_constraint( + cr, + tablename="m2x_create_edit_option", + constraintname="m2x_create_edit_option_model_field_uniqueness", + ) diff --git a/web_m2x_options_manager/models/__init__.py b/web_m2x_options_manager/models/__init__.py index 699352799994..5e0ca203bf12 100644 --- a/web_m2x_options_manager/models/__init__.py +++ b/web_m2x_options_manager/models/__init__.py @@ -1,6 +1,4 @@ -# Copyright 2021 Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - from . import ir_model +from . import ir_model_fields from . import ir_ui_view from . import m2x_create_edit_option diff --git a/web_m2x_options_manager/models/ir_model.py b/web_m2x_options_manager/models/ir_model.py index e514465720d3..9900c25d5418 100644 --- a/web_m2x_options_manager/models/ir_model.py +++ b/web_m2x_options_manager/models/ir_model.py @@ -1,52 +1,64 @@ # Copyright 2021 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, fields, models +from odoo import fields, models class IrModel(models.Model): _inherit = "ir.model" - m2x_create_edit_option_ids = fields.One2many( + m2x_option_ids = fields.One2many( "m2x.create.edit.option", "model_id", ) + m2x_comodels_option_ids = fields.One2many( + "m2x.create.edit.option", + "comodel_id", + ) + comodel_field_ids = fields.One2many("ir.model.fields", "comodel_id") + + def button_empty_m2x_options(self): + self._empty_m2x_options(own=True) + + def button_fill_m2x_options(self): + self._fill_m2x_options(own=True) + + def button_empty_m2x_comodels_options(self): + self._empty_m2x_options(comodels=True) + + def button_fill_m2x_comodels_options(self): + self._fill_m2x_options(comodels=True) + + def _empty_m2x_options(self, own=False, comodels=False): + """Removes every option for model ``self``'s fields + + :param bool own: if True, deletes options for model's fields + :param bool comodels: if True, deletes options for fields where ``self`` is + the field's comodel + """ + to_delete = self.env["m2x.create.edit.option"] + if own: + to_delete += self.m2x_option_ids + if comodels: + to_delete += self.m2x_comodels_option_ids + if to_delete: + to_delete.unlink() + + def _fill_m2x_options(self, own=False, comodels=False): + """Adds every missing field option for model ``self`` (with default values) - def button_empty(self): - for ir_model in self: - ir_model._empty_m2x_create_edit_option() - - def button_fill(self): - for ir_model in self: - ir_model._fill_m2x_create_edit_option() - - def _empty_m2x_create_edit_option(self): - """Removes every option for model ``self``""" - self.ensure_one() - self.m2x_create_edit_option_ids.unlink() - - def _fill_m2x_create_edit_option(self): - """Adds every missing field option for model ``self``""" - self.ensure_one() - existing = self.m2x_create_edit_option_ids.mapped("field_id") - valid = self.field_id.filtered(lambda f: f.ttype in ("many2many", "many2one")) - vals = [(0, 0, {"field_id": f.id}) for f in valid - existing] - self.write({"m2x_create_edit_option_ids": vals}) - - -class IrModelFields(models.Model): - _inherit = "ir.model.fields" - - @api.model - def name_search(self, name="", args=None, operator="ilike", limit=100): - res = super().name_search(name, args, operator, limit) - if not (name and self.env.context.get("search_by_technical_name")): - return res - domain = list(args or []) + [("name", operator, name)] - new_fids = self.search(domain, limit=limit).ids - for fid in [x[0] for x in res]: - if fid not in new_fids: - new_fids.append(fid) - if limit and limit > 0: - new_fids = new_fids[:limit] - return self.browse(new_fids).sudo().name_get() + :param bool own: if True, creates options for model's fields + :param bool comodels: if True, creates options for fields where ``self`` is + the field's comodel + """ + todo = set() + if own: + exist = self.m2x_option_ids.field_id + valid = self.field_id.filtered("can_have_options") + todo.update((valid - exist).ids) + if comodels: + exist = self.m2x_comodels_option_ids.field_id + valid = self.comodel_field_ids.filtered("can_have_options") + todo.update((valid - exist).ids) + if todo: + self.env["m2x.create.edit.option"].create([{"field_id": i} for i in todo]) diff --git a/web_m2x_options_manager/models/ir_model_fields.py b/web_m2x_options_manager/models/ir_model_fields.py new file mode 100644 index 000000000000..efff18e74c68 --- /dev/null +++ b/web_m2x_options_manager/models/ir_model_fields.py @@ -0,0 +1,53 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.osv.expression import AND + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + can_have_options = fields.Boolean(compute="_compute_can_have_options", store=True) + comodel_id = fields.Many2one( + "ir.model", compute="_compute_comodel_id", store=True, index=True + ) + + @api.depends("ttype") + def _compute_can_have_options(self): + for field in self: + field.can_have_options = field.ttype in ("many2many", "many2one") + + @api.depends("relation") + def _compute_comodel_id(self): + empty = self.env["ir.model"] + getter = self.env["ir.model"]._get + for field in self: + if field.relation: + field.comodel_id = getter(field.relation) + else: + field.comodel_id = empty + + @api.model + def name_search(self, name="", args=None, operator="ilike", limit=100): + # OVERRIDE: allow searching by field tech name if the correct context key is + # used; in this case, fields fetched by tech name are prepended to other fields + result = super().name_search(name, args, operator, limit) + if not (name and self.env.context.get("search_by_technical_name")): + return result + domain = AND([args or [], [("name", operator, name)]]) + new_fields = self.search_read(domain, fields=["display_name"], limit=limit) + new_result = {f["id"]: f["display_name"] for f in new_fields} + while result and not (limit and 0 < limit <= len(new_result)): + field_id, field_display_name = result.pop(0) + if field_id not in new_result: + new_result[field_id] = field_display_name + return list(new_result.items()) + + @api.model + def _search(self, args, **kwargs): + # OVERRIDE: allow defining filtering custom domain on model/comodel when + # searching fields for O2M list views on ``m2x.create.edit.option`` + if self.env.context.get("o2m_list_view_m2x_domain"): + args = AND([list(args or []), self.env.context["o2m_list_view_m2x_domain"]]) + return super()._search(args, **kwargs) diff --git a/web_m2x_options_manager/models/ir_ui_view.py b/web_m2x_options_manager/models/ir_ui_view.py index 172ef66964cf..3c0cc97d3485 100644 --- a/web_m2x_options_manager/models/ir_ui_view.py +++ b/web_m2x_options_manager/models/ir_ui_view.py @@ -7,14 +7,15 @@ class IrUiView(models.Model): _inherit = "ir.ui.view" - def postprocess(self, node, current_node_path, editable, name_manager): - res = super().postprocess(node, current_node_path, editable, name_manager) - if node.tag == "field": - mname = name_manager.Model._name - fname = node.attrib["name"] - field = self.env[mname]._fields.get(fname) - if field and field.type in ("many2many", "many2one"): - rec = self.env["m2x.create.edit.option"].get(mname, field.name) - if rec: - rec._apply_options(node) + def _postprocess_tag_field(self, node, name_manager, node_info): + # OVERRIDE: check ``m2x.create.edit.option`` config when processing a ``field`` + # node in views + res = super()._postprocess_tag_field(node, name_manager, node_info) + m2x_option = self.env["m2x.create.edit.option"].get( + name_manager.Model._name, + # ``name`` is required in ```` items + node.attrib["name"], + ) + if m2x_option: + m2x_option._apply_options(node) return res diff --git a/web_m2x_options_manager/models/m2x_create_edit_option.py b/web_m2x_options_manager/models/m2x_create_edit_option.py index 0d76fcd003d0..494ccaa3222f 100644 --- a/web_m2x_options_manager/models/m2x_create_edit_option.py +++ b/web_m2x_options_manager/models/m2x_create_edit_option.py @@ -8,37 +8,50 @@ class M2xCreateEditOption(models.Model): + """Technical model to define M2X option at single field level. + + Each record is uniquely defined by its ``field_id``. + """ + _name = "m2x.create.edit.option" - _description = "Manage Options 'Create/Edit' For Fields" + _description = "Field 'Create & Edit' Options" + name = fields.Char(compute="_compute_name", store=True) field_id = fields.Many2one( "ir.model.fields", - domain=[("ttype", "in", ("many2many", "many2one"))], + domain=[("can_have_options", "=", True)], ondelete="cascade", required=True, + index=True, string="Field", ) - field_name = fields.Char( related="field_id.name", store=True, string="Field Name", ) - model_id = fields.Many2one( "ir.model", - ondelete="cascade", - required=True, + related="field_id.model_id", + store=True, string="Model", ) - model_name = fields.Char( - compute="_compute_model_name", - inverse="_inverse_model_name", + related="field_id.model", store=True, string="Model Name", ) - + comodel_id = fields.Many2one( + "ir.model", + related="field_id.comodel_id", + store=True, + string="Comodel", + ) + comodel_name = fields.Char( + related="field_id.relation", + store=True, + string="Comodel Name", + ) option_create = fields.Selection( [ ("none", "Do nothing"), @@ -57,7 +70,6 @@ class M2xCreateEditOption(models.Model): required=True, string="Create Option", ) - option_create_edit = fields.Selection( [ ("none", "Do nothing"), @@ -76,80 +88,125 @@ class M2xCreateEditOption(models.Model): required=True, string="Create & Edit Option", ) - - option_create_edit_wizard = fields.Boolean( - default=True, + option_m2o_dialog = fields.Selection( + [ + ("none", "Do nothing"), + ("set_true", "Add"), + ("force_true", "Force Add"), + ("set_false", "Remove"), + ("force_false", "Force Remove"), + ], + default="set_false", help="Defines behaviour for 'Create & Edit' Wizard\n" - "Set to False to prevent 'Create & Edit' Wizard to pop up", + "* Do nothing: nothing is done\n" + "* Add/Remove: the 'Create & Edit' Wizard is displayed/hidden only if the" + " related attribute is not explicitly set in the view definition\n" + "* Force Add/Remove: the 'Create & Edit' Wizard is displayed/hidden overriding" + " the related attribute, even if explicitly set in the view definition", string="Create & Edit Wizard", ) _sql_constraints = [ ( - "model_field_uniqueness", - "unique(field_id,model_id)", - "Options must be unique for each model/field couple!", + "field_uniqueness", + "unique(field_id)", + "Options must be unique for each field!", ), ] @api.model_create_multi def create(self, vals_list): - # Clear cache to avoid misbehavior from cached :meth:`_get()` - type(self)._get.clear_cache(self.browse()) + # Clear cache to avoid misbehavior from cached methods + self.clear_caches() return super().create(vals_list) def write(self, vals): - # Clear cache to avoid misbehavior from cached :meth:`_get()` - type(self)._get.clear_cache(self.browse()) + # Clear cache to avoid misbehavior from cached methods + if set(vals).intersection(["field_id"] + self._get_option_fields()): + self.clear_caches() return super().write(vals) def unlink(self): - # Clear cache to avoid misbehavior from cached :meth:`_get()` - type(self)._get.clear_cache(self.browse()) + # Clear cache to avoid misbehavior from cached methods + self.clear_caches() return super().unlink() - @api.depends("model_id") - def _compute_model_name(self): + @api.depends("field_id") + def _compute_name(self): for opt in self: - opt.model_name = opt.model_id.model + try: + opt.name = str(self.env[opt.field_id.model]._fields[opt.field_id.name]) + except KeyError: + opt.name = "Invalid field" - def _inverse_model_name(self): - getter = self.env["ir.model"]._get + @api.constrains("field_id") + def _check_field_can_have_options(self): for opt in self: - # This also works as a constrain: if ``model_name`` is not a - # valid model name, then ``model_id`` will be emptied, but it's - # a required field! - opt.model_id = getter(opt.model_name) + if opt.field_id and not opt.field_id.can_have_options: + raise ValidationError( + _( + "Field %(field)s cannot have M2X options", + field=opt.field_id.display_name, + ) + ) - @api.constrains("model_id", "field_id") - def _check_field_in_model(self): - for opt in self: - if opt.field_id.model_id != opt.model_id: - msg = _("'%s' is not a valid field for model '%s'!") - raise ValidationError(msg % (opt.field_name, opt.model_name)) + def _apply_options(self, node): + """Applies options ``self`` to ``node`` - @api.constrains("field_id") - def _check_field_type(self): - ttypes = ("many2many", "many2one") - if any(o.field_id.ttype not in ttypes for o in self): - msg = _("Only Many2many and Many2one fields can be chosen!") - raise ValidationError(msg) + :param etree._Element node: view ```` node to update + :rtype: None + """ + self.ensure_one() + node_options = self._read_node_options(node) + for key, (mode, value) in self._read_own_options().items(): + if mode == "force" or key not in node_options: + node_options[key] = value + node.set("options", str(node_options)) - def _apply_options(self, node): - """Applies options ``self`` to ``node``""" + def _read_node_options(self, node): + """Helper method to read "options" attribute on ``node`` + + :param etree._Element node: view ```` node to parse + :rtype: dict[str, Any] + """ self.ensure_one() options = node.attrib.get("options") or {} if isinstance(options, str): - options = safe_eval(options, dict(self.env.context or [])) or {} - for k in ("create", "create_edit"): - opt = self["option_%s" % k] - if opt == "none": - continue - mode, val = opt.split("_") - if mode == "force" or k not in options: - options[k] = val == "true" - options["m2o_dialog"] = self.option_create_edit_wizard - node.set("options", str(options)) + options = safe_eval(options, self._get_node_options_eval_context()) or {} + return dict(options) + + def _get_node_options_eval_context(self): + """Helper method to get eval context to read "options" attribute from a node + + :rtype: dict + """ + self.ensure_one() + eval_ctx = dict(self.env.context or []) + eval_ctx.update({"context": dict(eval_ctx)}) + return eval_ctx + + def _read_own_options(self): + """Helper method to retrieve M2X options from ``self`` + + :return: a dictionary mapping each M2X option to its mode and value, eg: + {'create': ('force', 'true'), 'create_edit': ('set', 'false')} + :rtype: dict[str, tuple[str, Any]] + """ + self.ensure_one() + res = {} + for fname, fvalue in self.read(self._get_option_fields())[0].items(): + if fname != "id" and fvalue != "none": + mode, value = tuple(fvalue.split("_")) + res[fname.replace("option_", "")] = (mode, value == "true") + return res + + def _get_option_fields(self): + """Helper method to retrieve field names to parse as M2X options + + :return: list of field names to parse as M2X options + :rtype: list[str] + """ + return ["option_create", "option_create_edit", "option_m2o_dialog"] @api.model def get(self, model_name, field_name): @@ -157,22 +214,29 @@ def get(self, model_name, field_name): :param str model_name: technical model name (i.e. "sale.order") :param str field_name: technical field name (i.e. "partner_id") + :return: a ``m2x.create.edit.option`` record + :rtype: M2xCreateEditOption """ - return self.browse(self._get(model_name, field_name)) + return self.browse(self._get_id(model_name, field_name)) @api.model @ormcache("model_name", "field_name") - def _get(self, model_name, field_name): + def _get_id(self, model_name, field_name): """Inner implementation of ``get``. + An ID is returned to allow caching (see :class:`ormcache`); :meth:`get` will then convert it to a proper record. :param str model_name: technical model name (i.e. "sale.order") :param str field_name: technical field name (i.e. "partner_id") + :return: a ``m2x.create.edit.option`` record ID + :rtype: int """ - dom = [ - ("model_name", "=", model_name), - ("field_name", "=", field_name), - ] - # `_check_field_model_uniqueness()` grants uniqueness if existing - return self.search(dom, limit=1).id + opt_id = 0 + field = self.env["ir.model.fields"]._get(model_name, field_name) + if field: + # SQL constraint grants record uniqueness (if existing) + opt = self.search([("field_id", "=", field.id)], limit=1) + if opt: + opt_id = opt.id + return opt_id diff --git a/web_m2x_options_manager/readme/USAGE.rst b/web_m2x_options_manager/readme/USAGE.rst index a68d0f1b6a03..19605ccd25c7 100644 --- a/web_m2x_options_manager/readme/USAGE.rst +++ b/web_m2x_options_manager/readme/USAGE.rst @@ -1,7 +1,12 @@ Go to Settings > Technical > Models. -Choose the model you wish to edit, and open its form view. Go to the -"Create/Edit Options" tab, and add the fields you want to manage. +Choose the model you wish to edit, and open its form view. Go to the "Create/Edit Options" tab, +and add the fields you want to manage in 2 different sections: -Button "Fill" will add every missing field to the options. -Button "Empty" will remove every option. +* the first list view allows you to handle fields for the selected model +* the second list view allows you to handle fields where the selected model is the comodel + +For both sections: + +* button "Fill" will add every missing field to the options +* button "Empty" will remove every option diff --git a/web_m2x_options_manager/static/description/index.html b/web_m2x_options_manager/static/description/index.html index 980029621204..2a42f3bc69c5 100644 --- a/web_m2x_options_manager/static/description/index.html +++ b/web_m2x_options_manager/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Web M2X Options Manager -
+
+

Web M2X Options Manager

- - -Odoo Community Association - -
-

Web M2X Options Manager

-

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

Allows managing the “Create…” and “Create and Edit…” options for Many2one and Many2many fields directly from the ir.model form view.

Table of contents

@@ -391,15 +386,22 @@

Web M2X Options Manager

-

Usage

+

Usage

Go to Settings > Technical > Models.

-

Choose the model you wish to edit, and open its form view. Go to the -“Create/Edit Options” tab, and add the fields you want to manage.

-

Button “Fill” will add every missing field to the options. -Button “Empty” will remove every option.

+

Choose the model you wish to edit, and open its form view. Go to the “Create/Edit Options” tab, +and add the fields you want to manage in 2 different sections:

+
    +
  • the first list view allows you to handle fields for the selected model
  • +
  • the second list view allows you to handle fields where the selected model is the comodel
  • +
+

For both sections:

+
    +
  • button “Fill” will add every missing field to the options
  • +
  • button “Empty” will remove every option
  • +
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -407,15 +409,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -437,6 +439,5 @@

Maintainers

-
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..37fed6118108 --- /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 SavepointCase +from odoo.tools.safe_eval import safe_eval + + +class Common(SavepointCase): + @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 723f618986c6..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,112 +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 SavepointCase -from odoo.tools.safe_eval import safe_eval +from .common import Common -class TestM2xCreateEditOption(SavepointCase): - 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, - } + "option_create_edit": "force_true", + "option_m2o_dialog": "force_true", + }, ) - - 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] + form_doc = self._get_test_view_parsed() self.assertEqual( - safe_eval(title_node.attrib.get("options"), nocopy=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, "m2o_dialog": 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}, ) - - # 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, "m2o_dialog": False}, + 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}, ) - # 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] + # 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( - safe_eval(title_node.attrib.get("options"), nocopy=True), - {"create": True, "create_edit": False, "m2o_dialog": True}, + self._eval_node_options(form_doc.xpath("//field[@name='parent_id']")[0]), + {"create": True, "create_edit": True, "m2o_dialog": True}, + ) + + 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 @@ -
-
- - - + +
diff --git a/web_m2x_options_manager/views/m2x_create_edit_option.xml b/web_m2x_options_manager/views/m2x_create_edit_option.xml new file mode 100644 index 000000000000..1181f979614a --- /dev/null +++ b/web_m2x_options_manager/views/m2x_create_edit_option.xml @@ -0,0 +1,169 @@ + + + + + m2x.create.edit.option search view + m2x.create.edit.option + + + + + + + + + + + + + + + + + + m2x.create.edit.option form view + m2x.create.edit.option + +
+ + + + + + + + + + + + + + +
+
+
+ + + m2x.create.edit.option tree view - base + m2x.create.edit.option + + + + + + + + + + + + + + + + + m2x.create.edit.option tree view - editable + m2x.create.edit.option + primary + + + + bottom + + + {'search_by_technical_name': True, 'display_technical_name': True} + {'create': False, 'create_edit': False} + + + + + + m2x.create.edit.option tree view - filter by model + m2x.create.edit.option + primary + + + + [('model_id', '=', parent.id)] + + + + + + m2x.create.edit.option tree view - filter by comodel + m2x.create.edit.option + primary + + + + [('comodel_id', '=', parent.id)] + + + + + + Fields' Create & Edit Options + m2x.create.edit.option + tree,form + + + + Fields' Create & Edit Options + + + + +