diff --git a/setup/web_m2x_options_manager/odoo/addons/web_m2x_options_manager b/setup/web_m2x_options_manager/odoo/addons/web_m2x_options_manager new file mode 120000 index 000000000..53b3da28c --- /dev/null +++ b/setup/web_m2x_options_manager/odoo/addons/web_m2x_options_manager @@ -0,0 +1 @@ +../../../../web_m2x_options_manager \ No newline at end of file diff --git a/setup/web_m2x_options_manager/setup.py b/setup/web_m2x_options_manager/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/web_m2x_options_manager/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/web_m2x_options_manager/__init__.py b/web_m2x_options_manager/__init__.py new file mode 100644 index 000000000..2a7f1c54a --- /dev/null +++ b/web_m2x_options_manager/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/web_m2x_options_manager/__manifest__.py b/web_m2x_options_manager/__manifest__.py new file mode 100644 index 000000000..68d81d280 --- /dev/null +++ b/web_m2x_options_manager/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Web M2X Options Manager", + "summary": 'Adds an interface to manage the "Create" and' + ' "Create and Edit" options for specific models and' + " fields.", + "version": "14.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Web", + "data": [ + "security/ir.model.access.csv", + "views/ir_model.xml", + ], + "demo": [ + "demo/res_partner_demo_view.xml", + ], + "depends": ["base", "web_m2x_options"], + "website": "https://github.com/OCA/web", + "installable": True, +} diff --git a/web_m2x_options_manager/demo/res_partner_demo_view.xml b/web_m2x_options_manager/demo/res_partner_demo_view.xml new file mode 100644 index 000000000..8a53c630c --- /dev/null +++ b/web_m2x_options_manager/demo/res_partner_demo_view.xml @@ -0,0 +1,29 @@ + + + + + res.partner.demo.form.view + res.partner + 1000 + +
+ + + + + + + + + + + + + + + +
+
+
+ +
diff --git a/web_m2x_options_manager/models/__init__.py b/web_m2x_options_manager/models/__init__.py new file mode 100644 index 000000000..699352799 --- /dev/null +++ b/web_m2x_options_manager/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import ir_model +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 new file mode 100644 index 000000000..e51446572 --- /dev/null +++ b/web_m2x_options_manager/models/ir_model.py @@ -0,0 +1,52 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class IrModel(models.Model): + _inherit = "ir.model" + + m2x_create_edit_option_ids = fields.One2many( + "m2x.create.edit.option", + "model_id", + ) + + 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() diff --git a/web_m2x_options_manager/models/ir_ui_view.py b/web_m2x_options_manager/models/ir_ui_view.py new file mode 100644 index 000000000..172ef6696 --- /dev/null +++ b/web_m2x_options_manager/models/ir_ui_view.py @@ -0,0 +1,20 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +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) + 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 new file mode 100644 index 000000000..07ae01e77 --- /dev/null +++ b/web_m2x_options_manager/models/m2x_create_edit_option.py @@ -0,0 +1,170 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.cache import ormcache +from odoo.tools.safe_eval import safe_eval + + +class M2xCreateEditOption(models.Model): + _name = "m2x.create.edit.option" + _description = "Manage Options 'Create/Edit' For Fields" + + field_id = fields.Many2one( + "ir.model.fields", + domain=[("ttype", "in", ("many2many", "many2one"))], + ondelete="cascade", + required=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, + string="Model", + ) + + model_name = fields.Char( + compute="_compute_model_name", + inverse="_inverse_model_name", + store=True, + string="Model Name", + ) + + option_create = 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' option:\n" + "* Do nothing: nothing is done\n" + "* Add/Remove: option 'Create' is set to True/False only if not" + " already present in view definition\n" + "* Force Add/Remove: option 'Create' is always set to True/False," + " overriding any pre-existing option", + required=True, + string="Create Option", + ) + + option_create_edit = 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' option:\n" + "* Do nothing: nothing is done\n" + "* Add/Remove: option 'Create & Edit' is set to True/False only if not" + " already present in view definition\n" + "* Force Add/Remove: option 'Create & Edit' is always set to" + " True/False, overriding any pre-existing option", + required=True, + string="Create & Edit Option", + ) + + _sql_constraints = [ + ( + "model_field_uniqueness", + "unique(field_id,model_id)", + "Options must be unique for each model/field couple!", + ), + ] + + @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()) + 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()) + return super().write(vals) + + def unlink(self): + # Clear cache to avoid misbehavior from cached :meth:`_get()` + type(self)._get.clear_cache(self.browse()) + 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 + 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) + + @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)) + + @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``""" + 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)) + + @api.model + def get(self, model_name, field_name): + """Returns specific record for ``field_name`` in ``model_name`` + + :param str model_name: technical model name (i.e. "sale.order") + :param str field_name: technical field name (i.e. "partner_id") + """ + return self.browse(self._get(model_name, field_name)) + + @api.model + @ormcache("model_name", "field_name") + def _get(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") + """ + dom = [ + ("model_name", "=", model_name), + ("field_name", "=", field_name), + ] + # `_check_field_model_uniqueness()` grants uniqueness if existing + return self.search(dom, limit=1).id diff --git a/web_m2x_options_manager/readme/CONTRIBUTORS.rst b/web_m2x_options_manager/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..02ad49af4 --- /dev/null +++ b/web_m2x_options_manager/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `__: + + * Silvio Gregorini diff --git a/web_m2x_options_manager/readme/DESCRIPTION.rst b/web_m2x_options_manager/readme/DESCRIPTION.rst new file mode 100644 index 000000000..8a8a770d3 --- /dev/null +++ b/web_m2x_options_manager/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Allows managing the "Create..." and "Create and Edit..." options for `Many2one` +and `Many2many` fields directly from the `ir.model` form view. diff --git a/web_m2x_options_manager/readme/USAGE.rst b/web_m2x_options_manager/readme/USAGE.rst new file mode 100644 index 000000000..a68d0f1b6 --- /dev/null +++ b/web_m2x_options_manager/readme/USAGE.rst @@ -0,0 +1,7 @@ +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. diff --git a/web_m2x_options_manager/security/ir.model.access.csv b/web_m2x_options_manager/security/ir.model.access.csv new file mode 100644 index 000000000..796d5b922 --- /dev/null +++ b/web_m2x_options_manager/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_m2x_create_edit_option_user,access_m2x_create_edit_option_user,model_m2x_create_edit_option,base.group_user,1,0,0,0 +access_m2x_create_edit_option_system,access_m2x_create_edit_option_system,model_m2x_create_edit_option,base.group_system,1,1,1,1 diff --git a/web_m2x_options_manager/static/description/icon.png b/web_m2x_options_manager/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/web_m2x_options_manager/static/description/icon.png differ diff --git a/web_m2x_options_manager/tests/__init__.py b/web_m2x_options_manager/tests/__init__.py new file mode 100644 index 000000000..f803479e2 --- /dev/null +++ b/web_m2x_options_manager/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_m2x_create_edit_option 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 new file mode 100644 index 000000000..de6e1d8c0 --- /dev/null +++ b/web_m2x_options_manager/tests/test_m2x_create_edit_option.py @@ -0,0 +1,109 @@ +# 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 + + +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( + { + "field_id": self.title_field.id, + "model_id": self.res_partner_model.id, + "option_create": "set_true", + "option_create_edit": "set_true", + } + ) + self.categories_opt = self.env["m2x.create.edit.option"].create( + { + "field_id": self.categ_field.id, + "model_id": self.res_partner_model.id, + "option_create": "set_true", + "option_create_edit": "set_true", + } + ) + self.company_opt = self.env["m2x.create.edit.option"].create( + { + "field_id": self.company_field.id, + "model_id": self.res_users_model.id, + "option_create": "force_true", + "option_create_edit": "set_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] + self.assertEqual( + safe_eval(title_node.attrib.get("options"), nocopy=True), + {"create": True, "create_edit": 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}, + ) + + # 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, 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}, + ) diff --git a/web_m2x_options_manager/views/ir_model.xml b/web_m2x_options_manager/views/ir_model.xml new file mode 100644 index 000000000..d05011571 --- /dev/null +++ b/web_m2x_options_manager/views/ir_model.xml @@ -0,0 +1,37 @@ + + + + + view.model.form.inherit + ir.model + + + + +
+
+ + + + + + + + +
+
+
+
+ +