diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000000..66bc2cbae3f9 --- /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 new file mode 100644 index 000000000000..a8104156bd75 --- /dev/null +++ b/web_m2x_options_manager/README.rst @@ -0,0 +1,99 @@ +======================= +Web M2X Options Manager +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:496ff9c028368302f839913a2c4c825d59aa913df9048705ab11ce524d388144 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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 + :target: https://github.com/OCA/web/tree/18.0/web_m2x_options_manager + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_m2x_options_manager + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allows managing the "Create..." and "Create and Edit..." options for +``Many2one`` and ``Many2many`` fields directly from the ``ir.model`` +form view. + +**Table of contents** + +.. contents:: + :local: + +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 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 +=========== + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- `Camptocamp `__: + + - Silvio Gregorini + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_m2x_options_manager/__init__.py b/web_m2x_options_manager/__init__.py new file mode 100644 index 000000000000..6d58305f5ddc --- /dev/null +++ b/web_m2x_options_manager/__init__.py @@ -0,0 +1,2 @@ +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 new file mode 100644 index 000000000000..29c82e888372 --- /dev/null +++ b/web_m2x_options_manager/__manifest__.py @@ -0,0 +1,30 @@ +# 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": "18.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", + "views/m2x_create_edit_option.xml", + ], + "demo": [ + "demo/res_partner_demo_view.xml", + ], + "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 new file mode 100644 index 000000000000..4f8bf612e6b4 --- /dev/null +++ b/web_m2x_options_manager/demo/res_partner_demo_view.xml @@ -0,0 +1,28 @@ + + + + res.partner.demo.form.view + res.partner + 1000 + +
+ + + + + + + + + + +
+
+
+
diff --git a/web_m2x_options_manager/hooks.py b/web_m2x_options_manager/hooks.py new file mode 100644 index 000000000000..5dbc13c6a7a0 --- /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(env): + # 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(env.cr) + prepare_column_comodel_id(env.cr) diff --git a/web_m2x_options_manager/i18n/it.po b/web_m2x_options_manager/i18n/it.po new file mode 100644 index 000000000000..2d43593cffdc --- /dev/null +++ b/web_m2x_options_manager/i18n/it.po @@ -0,0 +1,201 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_m2x_options_manager +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-11-14 18:06+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: web_m2x_options_manager +#. odoo-python +#: code:addons/web_m2x_options_manager/models/m2x_create_edit_option.py:0 +#, python-format +msgid "'%(field_name)s' is not a valid field for model '%(model_name)s'!" +msgstr "'%(field_name)s' non è un campo valido per il modello '%(model_name)s'!" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__set_true +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__set_true +msgid "Add" +msgstr "Aggiungi" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__option_create_edit +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 +msgid "Create Option" +msgstr "Opzione crea" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "Create/Edit Options" +msgstr "Opzioni crea/modifica" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__create_date +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 +msgid "" +"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" +msgstr "" +"Definisce il comportamento per l'opzione 'Crea e modifica':\n" +"* Non fare nulla: non viene fatto nulla\n" +"* Aggiungi/Rimuovi: l'opzione 'Crea e modifica' è impostata su Vero/Falso " +"solo se non è già presente nella definizione della vista\n" +"* Forza Aggiungi/Rimuovi: l'opzione 'Crea e modifica' è sempre impostata su " +"Vero/Falso, sovrascrivendo qualsiasi opzione preesistente" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__option_create +msgid "" +"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" +msgstr "" +"Definisce il comportamento per l'opzione 'Crea':\n" +"* Non fare nulla: non viene fatto nulla\n" +"* Aggiungi/Rimuovi: l'opzione 'Crea' è impostata su Vero/Falso solo se non è " +"già presente nella definizione della vista\n" +"* Forza Aggiungi/Rimuovi: l'opzione 'Crea' è sempre impostata su Vero/Falso, " +"sovrascrivendo qualsiasi opzione preesistente" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__none +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__none +msgid "Do nothing" +msgstr "Non fare nulla" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "Empty" +msgstr "Vuoto" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__field_id +msgid "Field" +msgstr "Campo" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__field_name +msgid "Field Name" +msgstr "Nome campo" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_ir_model_fields +msgid "Fields" +msgstr "Campi" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "Fill" +msgstr "Popola" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__force_true +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__force_true +msgid "Force Add" +msgstr "Forza aggiungi" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__force_false +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__force_false +msgid "Force Remove" +msgstr "Forza rimuovi" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__id +msgid "ID" +msgstr "ID" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__write_date +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_option_ids +msgid "M2X Create Edit Option" +msgstr "Crea opzione modifica M2X" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_m2x_create_edit_option +msgid "Manage Options 'Create/Edit' For Fields" +msgstr "Gestione opzioni 'Crea/Modifica' per i campi" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__model_id +msgid "Model" +msgstr "Modello" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__model_name +msgid "Model Name" +msgstr "Nome modello" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_ir_model +msgid "Models" +msgstr "Modelli" + +#. module: web_m2x_options_manager +#. odoo-python +#: code:addons/web_m2x_options_manager/models/m2x_create_edit_option.py:0 +#, python-format +msgid "Only Many2many and Many2one fields can be chosen!" +msgstr "Si possono scegliere solo campi Many2many e Many2one!" + +#. module: web_m2x_options_manager +#: model:ir.model.constraint,message:web_m2x_options_manager.constraint_m2x_create_edit_option_model_field_uniqueness +msgid "Options must be unique for each model/field couple!" +msgstr "Le opzioni devono essere univoche per ogni coppia modello/campo!" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__set_false +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__set_false +msgid "Remove" +msgstr "Rimuovi" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_ir_ui_view +msgid "View" +msgstr "Vista" diff --git a/web_m2x_options_manager/i18n/web_m2x_options_manager.pot b/web_m2x_options_manager/i18n/web_m2x_options_manager.pot new file mode 100644 index 000000000000..67365dbd56f7 --- /dev/null +++ b/web_m2x_options_manager/i18n/web_m2x_options_manager.pot @@ -0,0 +1,246 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_m2x_options_manager +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__set_true +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__set_true +msgid "Add" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model_fields__can_have_options +msgid "Can Have Options" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model_fields__comodel_id +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__comodel_id +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.m2x_create_edit_option_search +msgid "Comodel" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model__comodel_field_ids +msgid "Comodel Field" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__comodel_name +msgid "Comodel Name" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__option_create_edit +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.m2x_create_edit_option_search +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 +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.m2x_create_edit_option_search +msgid "Create Option" +msgstr "" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "Create/Edit Options" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__create_uid +msgid "Created by" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__create_date +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 +msgid "" +"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" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__option_create +msgid "" +"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" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__display_name +msgid "Display Name" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__none +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__none +msgid "Do nothing" +msgstr "" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "Empty" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__field_id +msgid "Field" +msgstr "" + +#. module: web_m2x_options_manager +#. odoo-python +#: code:addons/web_m2x_options_manager/models/m2x_create_edit_option.py:0 +#, python-format +msgid "Field %(field)s cannot have M2X options" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_m2x_create_edit_option +msgid "Field 'Create & Edit' Options" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__field_name +msgid "Field Name" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_ir_model_fields +msgid "Fields" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.actions.act_window,name:web_m2x_options_manager.m2x_create_edit_option_action +#: model:ir.ui.menu,name:web_m2x_options_manager.m2x_create_edit_option_menu +msgid "Fields' Create & Edit Options" +msgstr "" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "Fill" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__comodel_name +msgid "For relationship fields, the technical name of the target model" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__force_true +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__force_true +msgid "Force Add" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__force_false +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__force_false +msgid "Force Remove" +msgstr "" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.m2x_create_edit_option_search +msgid "Group By" +msgstr "" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "" +"Here are displayed options for other models' fields related to this model" +msgstr "" + +#. module: web_m2x_options_manager +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.view_model_form_inherit +msgid "Here are displayed options for this model's fields" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__id +msgid "ID" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__write_date +msgid "Last Updated on" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model__m2x_comodels_option_ids +msgid "M2X Comodels Option" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_ir_model__m2x_option_ids +msgid "M2X Option" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__model_id +#: model_terms:ir.ui.view,arch_db:web_m2x_options_manager.m2x_create_edit_option_search +msgid "Model" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__model_name +msgid "Model Name" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_ir_model +msgid "Models" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,field_description:web_m2x_options_manager.field_m2x_create_edit_option__name +msgid "Name" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.constraint,message:web_m2x_options_manager.constraint_m2x_create_edit_option_field_uniqueness +msgid "Options must be unique for each field!" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create__set_false +#: model:ir.model.fields.selection,name:web_m2x_options_manager.selection__m2x_create_edit_option__option_create_edit__set_false +msgid "Remove" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__model_id +msgid "The model this field belongs to" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model.fields,help:web_m2x_options_manager.field_m2x_create_edit_option__model_name +msgid "The technical name of the model this field belongs to" +msgstr "" + +#. module: web_m2x_options_manager +#: model:ir.model,name:web_m2x_options_manager.model_ir_ui_view +msgid "View" +msgstr "" diff --git a/web_m2x_options_manager/models/__init__.py b/web_m2x_options_manager/models/__init__.py new file mode 100644 index 000000000000..5e0ca203bf12 --- /dev/null +++ b/web_m2x_options_manager/models/__init__.py @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000000..9900c25d5418 --- /dev/null +++ b/web_m2x_options_manager/models/ir_model.py @@ -0,0 +1,64 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrModel(models.Model): + _inherit = "ir.model" + + 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) + + :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 new file mode 100644 index 000000000000..0874e2bbd7b8 --- /dev/null +++ b/web_m2x_options_manager/models/ir_ui_view.py @@ -0,0 +1,21 @@ +# 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_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 new file mode 100644 index 000000000000..194dce9a0a9a --- /dev/null +++ b/web_m2x_options_manager/models/m2x_create_edit_option.py @@ -0,0 +1,241 @@ +# 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): + """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 = "Field 'Create & Edit' Options" + + name = fields.Char(compute="_compute_name", store=True) + field_id = fields.Many2one( + "ir.model.fields", + domain=[("can_have_options", "=", True)], + ondelete="cascade", + required=True, + index=True, + string="Field", + ) + field_name = fields.Char( + related="field_id.name", + store=True, + ) + model_id = fields.Many2one( + "ir.model", + related="field_id.model_id", + store=True, + string="Model", + ) + model_name = fields.Char( + 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"), + ("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 = [ + ( + "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 methods + self._clear_caches() + return super().create(vals_list) + + def write(self, vals): + # 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 methods + self._clear_caches() + return super().unlink() + + def _clear_caches(self, *cache_names): + """Clear registry caches + + By default, clears caches to avoid misbehavior from cached methods: + - ``m2x.create.edit.option._get_id()`` + - ``ir.ui.view._get_view_cache()`` + """ + self.env.registry.clear_cache(*self._clear_caches_get_names(*cache_names)) + + def _clear_caches_get_names(self, *cache_names) -> list[str]: + """Retrieves registry caches names for clearance + + By default, we want to clear caches: + - "default": where ``m2x.create.edit.option._get_id()`` results get stored + - "templates": where ``ir.ui.view._get_view_cache()`` results get stored + """ + return list(cache_names) + ["default", "templates"] + + @api.depends("field_id") + def _compute_name(self): + for opt in self: + try: + opt.name = str(self.env[opt.field_id.model]._fields[opt.field_id.name]) + except KeyError: + opt.name = "Invalid field" + + @api.constrains("field_id") + def _check_field_can_have_options(self): + for opt in self: + if opt.field_id and not opt.field_id.can_have_options: + raise ValidationError( + self.env._( + "Field %(field)s cannot have M2X options", + field=opt.field_id.display_name, + ) + ) + + def _apply_options(self, 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, 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"] + + @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: a ``m2x.create.edit.option`` record + :rtype: M2xCreateEditOption + """ + return self.browse(self._get_id(model_name, field_name)) + + @api.model + @ormcache("model_name", "field_name", cache="default") + 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 + """ + 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/pyproject.toml b/web_m2x_options_manager/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_m2x_options_manager/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_m2x_options_manager/readme/CONTRIBUTORS.md b/web_m2x_options_manager/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..40a5fa301d66 --- /dev/null +++ b/web_m2x_options_manager/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Camptocamp](https://www.camptocamp.com): + - Silvio Gregorini diff --git a/web_m2x_options_manager/readme/DESCRIPTION.md b/web_m2x_options_manager/readme/DESCRIPTION.md new file mode 100644 index 000000000000..8a8a770d38d6 --- /dev/null +++ b/web_m2x_options_manager/readme/DESCRIPTION.md @@ -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.md b/web_m2x_options_manager/readme/USAGE.md new file mode 100644 index 000000000000..19605ccd25c7 --- /dev/null +++ b/web_m2x_options_manager/readme/USAGE.md @@ -0,0 +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 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 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 000000000000..796d5b9220d3 --- /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 000000000000..3a0328b516c4 Binary files /dev/null and b/web_m2x_options_manager/static/description/icon.png differ diff --git a/web_m2x_options_manager/static/description/index.html b/web_m2x_options_manager/static/description/index.html new file mode 100644 index 000000000000..75cfa981a44d --- /dev/null +++ b/web_m2x_options_manager/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +Web M2X Options Manager + + + +
+

Web M2X Options Manager

+ + +

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

+ +
+

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

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

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

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_m2x_options_manager/tests/__init__.py b/web_m2x_options_manager/tests/__init__.py new file mode 100644 index 000000000000..d5026ae9a5f4 --- /dev/null +++ b/web_m2x_options_manager/tests/__init__.py @@ -0,0 +1,3 @@ +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..3b0caed254b6 --- /dev/null +++ b/web_m2x_options_manager/tests/common.py @@ -0,0 +1,49 @@ +# 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 + +from odoo.addons.base.tests.common import BaseCommon + + +class Common(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env["base"].with_context(**BaseCommon.default_env_context()).env + + @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"].get_view(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 new file mode 100644 index 000000000000..fd84e376cbcb --- /dev/null +++ b/web_m2x_options_manager/tests/test_m2x_create_edit_option.py @@ -0,0 +1,95 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from .common import Common + + +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}, + ) + self.assertEqual( + self._eval_node_options(form_doc.xpath("//field[@name='category_id']")[0]), + {"create": False, "create_edit": False}, + ) + + # Create options, check view has been updated + self._create_opt( + "res.partner", + "title", + { + "option_create": "set_true", + "option_create_edit": "set_true", + }, + ) + self._create_opt( + "res.partner", + "parent_id", + { + "option_create": "set_true", + "option_create_edit": "set_true", + }, + ) + self._create_opt( + "res.partner", + "category_id", + { + "option_create": "force_true", + "option_create_edit": "force_true", + }, + ) + form_doc = self._get_test_view_parsed() + self.assertEqual( + self._eval_node_options(form_doc.xpath("//field[@name='title']")[0]), + {"create": True, "create_edit": True}, + ) + self.assertEqual( + 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}, + ) + self.assertEqual( + 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}, + ) + + # 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" + form_doc = self._get_test_view_parsed() + self.assertEqual( + self._eval_node_options(form_doc.xpath("//field[@name='parent_id']")[0]), + {"create": True, "create_edit": 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", + }, + ) + 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 new file mode 100644 index 000000000000..c94bec98743c --- /dev/null +++ b/web_m2x_options_manager/views/ir_model.xml @@ -0,0 +1,62 @@ + + + + view.model.form.inherit + ir.model + + + + + + + + + + + + + + + + + + + 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..b17c39191e22 --- /dev/null +++ b/web_m2x_options_manager/views/m2x_create_edit_option.xml @@ -0,0 +1,156 @@ + + + + m2x.create.edit.option search view + m2x.create.edit.option + + + + + + + + + + + + + + + + + m2x.create.edit.option form view + m2x.create.edit.option + +
+ + + + + + + + + + + + + +
+
+
+ + + m2x.create.edit.option list view - base + m2x.create.edit.option + + + + + + + + + + + + + + + + m2x.create.edit.option list 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 list view - filter by model + m2x.create.edit.option + primary + + + + { + 'o2m_list_view_m2x_domain': [('model_id', '=', parent.id)], + } + + + + + + m2x.create.edit.option list 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 + list,form + m2x-create-edit-option + + + + Fields' Create & Edit Options + + + +