diff --git a/base_user_role_menu/README.rst b/base_user_role_menu/README.rst new file mode 100644 index 000000000..4dcb9e055 --- /dev/null +++ b/base_user_role_menu/README.rst @@ -0,0 +1,84 @@ +=================== +Base User Role Menu +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:22bad68d1ff4d67af1c3a3944b67fe6354602b370e7a3a26b1d02a210e947cbf + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fserver--backend-lightgray.png?logo=github + :target: https://github.com/OCA/server-backend/tree/14.0/base_user_role_menu + :alt: OCA/server-backend +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-backend-14-0/server-backend-14-0-base_user_role_menu + :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/server-backend&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Lists all menus with roles, and adds a smart button with accessible menu list directly on each role form. + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Kévin Roche + +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. + +.. |maintainer-Kev-Roche| image:: https://github.com/Kev-Roche.png?size=40px + :target: https://github.com/Kev-Roche + :alt: Kev-Roche + +Current `maintainer `__: + +|maintainer-Kev-Roche| + +This module is part of the `OCA/server-backend `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_user_role_menu/__init__.py b/base_user_role_menu/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/base_user_role_menu/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_user_role_menu/__manifest__.py b/base_user_role_menu/__manifest__.py new file mode 100644 index 000000000..741a042d3 --- /dev/null +++ b/base_user_role_menu/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Base User Role Menu", + "summary": "List roles required to access each menu item", + "version": "14.0.1.0.0", + "category": "security", + "website": "https://github.com/OCA/server-backend", + "author": "Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", + "maintainers": ["Kev-Roche"], + "application": False, + "installable": True, + "depends": [ + "base_user_role", + ], + "data": [ + "views/ir_ui_menu.xml", + "views/res_users_role.xml", + ], +} diff --git a/base_user_role_menu/models/__init__.py b/base_user_role_menu/models/__init__.py new file mode 100644 index 000000000..e99bbc8bc --- /dev/null +++ b/base_user_role_menu/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_ui_menu +from . import res_users_role diff --git a/base_user_role_menu/models/ir_ui_menu.py b/base_user_role_menu/models/ir_ui_menu.py new file mode 100644 index 000000000..2d0678d4d --- /dev/null +++ b/base_user_role_menu/models/ir_ui_menu.py @@ -0,0 +1,72 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrUiMenu(models.Model): + _inherit = "ir.ui.menu" + + role_ids = fields.Many2many( + comodel_name="res.users.role", + string="Roles with Access", + compute="_compute_role_ids", + search="_search_role_ids", + ) + has_no_role = fields.Boolean( + string="No Role Restriction", + compute="_compute_role_ids", + search="_search_has_no_role", + ) + + def _compute_role_ids(self): + all_roles = self.env["res.users.role"].search([]) + group_to_roles = {} + for role in all_roles: + implied_groups = role.group_id | role.group_id.trans_implied_ids + for grp in implied_groups: + group_to_roles.setdefault(grp.id, self.env["res.users.role"]) + group_to_roles[grp.id] |= role + + for menu in self: + if not menu.groups_id: + menu.role_ids = self.env["res.users.role"] + menu.has_no_role = True + continue + + roles = self.env["res.users.role"] + for grp in menu.groups_id: + roles |= group_to_roles.get(grp.id, self.env["res.users.role"]) + + menu.role_ids = roles + menu.has_no_role = not bool(roles) + + def _search_role_ids(self, operator, value): + if operator not in ("in", "not in", "=", "!="): + return [] + + roles = self.env["res.users.role"] + if isinstance(value, (list, tuple)): + roles = roles.browse(value) + elif isinstance(value, int): + roles = roles.browse([value]) + + group_ids = [] + for role in roles: + implied = role.group_id | role.group_id.trans_implied_ids + group_ids.extend(implied.ids) + menus = self.env["ir.ui.menu"].search([("groups_id", "in", group_ids)]) + if operator in ("in", "="): + return [("id", "in", menus.ids)] + else: + return [("id", "not in", menus.ids)] + + def _search_has_no_role(self, operator, value): + if operator not in ("=", "!="): + return [] + menus_with_roles = self.env["ir.ui.menu"].search([("groups_id", "!=", False)]) + if (operator == "=" and value) or (operator == "!=" and not value): + return [("id", "not in", menus_with_roles.ids)] + else: + return [("id", "in", menus_with_roles.ids)] diff --git a/base_user_role_menu/models/res_users_role.py b/base_user_role_menu/models/res_users_role.py new file mode 100644 index 000000000..c9666c82c --- /dev/null +++ b/base_user_role_menu/models/res_users_role.py @@ -0,0 +1,41 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResUsersRole(models.Model): + _inherit = "res.users.role" + + menu_ids = fields.Many2many( + comodel_name="ir.ui.menu", + string="Accessible Menus", + compute="_compute_menu_ids", + help="All menus accessible through this role.", + ) + menu_count = fields.Integer( + string="Menu Count", + compute="_compute_menu_ids", + ) + + def _compute_menu_ids(self): + for role in self: + implied_groups = role.group_id | role.group_id.trans_implied_ids + menus = self.env["ir.ui.menu"].search([]) + filtered_menus = menus.filtered( + lambda menu: any(group in menu.groups_id for group in implied_groups) + ) + role.menu_ids = filtered_menus + role.menu_count = len(filtered_menus) + + def action_view_menus(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "Menus — %s" % self.name, + "res_model": "ir.ui.menu", + "view_mode": "tree,form", + "domain": [("id", "in", self.menu_ids.ids)], + "context": {"default_role_id": self.id}, + } diff --git a/base_user_role_menu/readme/CONTRIBUTORS.rst b/base_user_role_menu/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..5ef178f72 --- /dev/null +++ b/base_user_role_menu/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Kévin Roche \ No newline at end of file diff --git a/base_user_role_menu/readme/DESCRIPTION.rst b/base_user_role_menu/readme/DESCRIPTION.rst new file mode 100644 index 000000000..16043e71e --- /dev/null +++ b/base_user_role_menu/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Lists all menus with roles, and adds a smart button with accessible menu list directly on each role form. diff --git a/base_user_role_menu/static/description/index.html b/base_user_role_menu/static/description/index.html new file mode 100644 index 000000000..aabd3d55a --- /dev/null +++ b/base_user_role_menu/static/description/index.html @@ -0,0 +1,425 @@ + + + + + +Base User Role Menu + + + +
+

Base User Role Menu

+ + +

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

+

Lists all menus with roles, and adds a smart button with accessible menu list directly on each role form.

+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+
+

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.

+

Current maintainer:

+

Kev-Roche

+

This module is part of the OCA/server-backend project on GitHub.

+

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

+
+
+
+ + diff --git a/base_user_role_menu/tests/__init__.py b/base_user_role_menu/tests/__init__.py new file mode 100644 index 000000000..700b5d094 --- /dev/null +++ b/base_user_role_menu/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_user_role_menu diff --git a/base_user_role_menu/tests/test_base_user_role_menu.py b/base_user_role_menu/tests/test_base_user_role_menu.py new file mode 100644 index 000000000..068090f00 --- /dev/null +++ b/base_user_role_menu/tests/test_base_user_role_menu.py @@ -0,0 +1,64 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import SavepointCase + + +class TestBaseUserRoleMenu(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.group_a = cls.env["res.groups"].create({"name": "Test Group A"}) + cls.group_b = cls.env["res.groups"].create({"name": "Test Group B"}) + + cls.group_parent = cls.env["res.groups"].create( + { + "name": "Test Group Parent", + } + ) + + cls.role_a = cls.env["res.users.role"].create({"name": "Role A"}) + cls.role_b = cls.env["res.users.role"].create({"name": "Role B"}) + cls.role_parent = cls.env["res.users.role"].create({"name": "Role Parent"}) + + cls.role_a.write({"implied_ids": [(4, cls.group_a.id)]}) + cls.role_b.write({"implied_ids": [(4, cls.group_b.id)]}) + cls.role_parent.write({"implied_ids": [(4, cls.role_a.group_id.id)]}) + + cls.menu_a = cls.env["ir.ui.menu"].create( + { + "name": "Test Menu A", + "groups_id": [(4, cls.role_a.group_id.id)], + } + ) + cls.menu_b = cls.env["ir.ui.menu"].create( + { + "name": "Test Menu B", + "groups_id": [(4, cls.role_b.group_id.id)], + } + ) + cls.menu_both = cls.env["ir.ui.menu"].create( + { + "name": "Test Menu Both", + "groups_id": [(4, cls.role_a.group_id.id), (4, cls.role_b.group_id.id)], + } + ) + cls.menu_free = cls.env["ir.ui.menu"].create({"name": "Test Menu Free"}) + + def test_menu_with_single_group_returns_matching_role(self): + self.assertIn(self.role_a, self.menu_a.role_ids) + self.assertNotIn(self.role_b, self.menu_a.role_ids) + + def test_menu_with_two_groups_returns_both_roles(self): + self.assertIn(self.role_a, self.menu_both.role_ids) + self.assertIn(self.role_b, self.menu_both.role_ids) + + def test_menu_without_groups_has_no_role(self): + self.assertTrue(self.menu_free.has_no_role) + self.assertFalse(self.menu_free.role_ids) + + def test_implied_group_propagates_role(self): + self.menu_a._compute_role_ids() + self.assertIn(self.role_parent, self.menu_a.role_ids) diff --git a/base_user_role_menu/views/ir_ui_menu.xml b/base_user_role_menu/views/ir_ui_menu.xml new file mode 100644 index 000000000..f9136f453 --- /dev/null +++ b/base_user_role_menu/views/ir_ui_menu.xml @@ -0,0 +1,92 @@ + + + + + ir.ui.menu + + + + + + + + + + + ir.ui.menu + + + + + + + + + + + + + + + + + + + ir.ui.menu + + + + + + + + + + + + + + + + Menus & Role Access + ir.ui.menu + tree,form + {'search_default_main_menus': 1} + + + + + + diff --git a/base_user_role_menu/views/res_users_role.xml b/base_user_role_menu/views/res_users_role.xml new file mode 100644 index 000000000..b30bc5540 --- /dev/null +++ b/base_user_role_menu/views/res_users_role.xml @@ -0,0 +1,23 @@ + + + + + res.users.role.form.menu_access + res.users.role + + + + + + + + diff --git a/setup/base_user_role_menu/odoo/addons/base_user_role_menu b/setup/base_user_role_menu/odoo/addons/base_user_role_menu new file mode 120000 index 000000000..c711a4022 --- /dev/null +++ b/setup/base_user_role_menu/odoo/addons/base_user_role_menu @@ -0,0 +1 @@ +../../../../base_user_role_menu \ No newline at end of file diff --git a/setup/base_user_role_menu/setup.py b/setup/base_user_role_menu/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/base_user_role_menu/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)