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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+