diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000000..4ad8e0eceaa8 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-test-helper diff --git a/web_m2x_options_manager/README.rst b/web_m2x_options_manager/README.rst index e472993ad669..e8cf2a76fe8a 100644 --- a/web_m2x_options_manager/README.rst +++ b/web_m2x_options_manager/README.rst @@ -41,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: -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 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 623712a8b572..12c989ee194b 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": "15.0.1.0.0", + "version": "15.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/es.po b/web_m2x_options_manager/i18n/es.po index 29142ef792cb..821e95a4a547 100644 --- a/web_m2x_options_manager/i18n/es.po +++ b/web_m2x_options_manager/i18n/es.po @@ -34,7 +34,7 @@ msgid "Create & Edit Option" msgstr "Opción Crear y Editar" #. 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 "Asistente de Creación y Edición" @@ -59,7 +59,7 @@ msgid "Created on" msgstr "Creado el" #. 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" @@ -166,7 +166,7 @@ msgid "Last Updated on" msgstr "Última Actualización el" #. 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 "M2X Crear Editar Opción" 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 b7598b32ede5..d63af8129521 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" @@ -149,7 +149,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/15.0.2.0.0/pre-mig.py b/web_m2x_options_manager/migrations/15.0.2.0.0/pre-mig.py new file mode 100644 index 000000000000..20c66af03e83 --- /dev/null +++ b/web_m2x_options_manager/migrations/15.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 66393622d334..0874e2bbd7b8 100644 --- a/web_m2x_options_manager/models/ir_ui_view.py +++ b/web_m2x_options_manager/models/ir_ui_view.py @@ -8,13 +8,14 @@ class IrUiView(models.Model): _inherit = "ir.ui.view" 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) - 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) + 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 d3c5bb1ea0fa..8e024e0585a2 100644 --- a/web_m2x_options_manager/models/m2x_create_edit_option.py +++ b/web_m2x_options_manager/models/m2x_create_edit_option.py @@ -8,36 +8,49 @@ 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, ) - + 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"), @@ -56,7 +69,6 @@ class M2xCreateEditOption(models.Model): required=True, string="Create Option", ) - option_create_edit = fields.Selection( [ ("none", "Do nothing"), @@ -75,86 +87,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): - for opt in self: - opt.model_name = opt.model_id.model - - def _inverse_model_name(self): - getter = self.env["ir.model"]._get + @api.depends("field_id") + def _compute_name(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) + try: + opt.name = str(self.env[opt.field_id.model]._fields[opt.field_id.name]) + except KeyError: + opt.name = "Invalid field" - @api.constrains("model_id", "field_id") - def _check_field_in_model(self): + @api.constrains("field_id") + def _check_field_can_have_options(self): for opt in self: - if opt.field_id.model_id != opt.model_id: - msg = _( - "%(field)s is not a valid field for model %(model)s!", - field=opt.field_name, - model=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, + ) ) - raise ValidationError(msg) - - @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) def _apply_options(self, node): - """Applies options ``self`` to ``node``""" + """Applies options ``self`` to ``node`` + + :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 _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" - node.set("options", str(options)) - if not self.option_create_edit_wizard: - node.set("can_create", "false") - node.set("can_write", "false") + 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): @@ -162,22 +213,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 0ddbb62bb1d2..ec59de204813 100644 --- a/web_m2x_options_manager/static/description/index.html +++ b/web_m2x_options_manager/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -388,10 +388,17 @@

Web M2X Options Manager

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

@@ -421,7 +428,9 @@

Contributors

Maintainers

This module is maintained by the OCA.

-Odoo Community Association + +Odoo Community Association +

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 @@ -
-
- - - + + 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..43f96a749b07 --- /dev/null +++ b/web_m2x_options_manager/views/m2x_create_edit_option.xml @@ -0,0 +1,165 @@ + + + + + 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 + + + + { + 'o2m_list_view_m2x_domain': [('model_id', '=', parent.id)], + } + + + + + + m2x.create.edit.option tree view - filter by comodel + m2x.create.edit.option + primary + + + + { + 'o2m_list_view_m2x_domain': [('comodel_id', '=', parent.id)], + } + + + + + + Fields' Create & Edit Options + m2x.create.edit.option + tree,form + + + + Fields' Create & Edit Options + + + + +