diff --git a/base_user_role_duplicate/README.rst b/base_user_role_duplicate/README.rst new file mode 100644 index 0000000..3dc5841 --- /dev/null +++ b/base_user_role_duplicate/README.rst @@ -0,0 +1,3 @@ +======================== +Base User Role Duplicate +======================== \ No newline at end of file diff --git a/base_user_role_duplicate/__init__.py b/base_user_role_duplicate/__init__.py new file mode 100644 index 0000000..723d3ee --- /dev/null +++ b/base_user_role_duplicate/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import models diff --git a/base_user_role_duplicate/__manifest__.py b/base_user_role_duplicate/__manifest__.py new file mode 100644 index 0000000..5e62640 --- /dev/null +++ b/base_user_role_duplicate/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Restrict Duplicate by Group/Role", + "version": "18.0.1.0.0", + "category": "Tools", + "summary": "Restricts the Duplicate action per model based on role access rights", + "author": "CIT Services, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-backend", + "license": "LGPL-3", + "depends": ["base_user_role_extended", "base_duplicate_manager"], + "data": [], + "installable": True, +} diff --git a/base_user_role_duplicate/models/__init__.py b/base_user_role_duplicate/models/__init__.py new file mode 100644 index 0000000..0ab5e9f --- /dev/null +++ b/base_user_role_duplicate/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import ir_model_access +from . import res_users_role +from . import ir_ui_view diff --git a/base_user_role_duplicate/models/ir_model_access.py b/base_user_role_duplicate/models/ir_model_access.py new file mode 100644 index 0000000..8972faa --- /dev/null +++ b/base_user_role_duplicate/models/ir_model_access.py @@ -0,0 +1,34 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from odoo import api, models, tools + + +class IrModelAccess(models.Model): + _inherit = "ir.model.access" + + @api.model + @tools.ormcache("self.env.uid", "model_name") + def _check_duplicate_access_cached(self, model_name): + user = self.env.user.sudo() + roles = user.role_line_ids.filtered(lambda line: line.is_enabled).mapped( + "role_id" + ) + group_ids = roles.mapped("group_id").ids if roles else user.groups_id.ids + domain = [ + ("model_id.model", "=", model_name), + ("active", "=", True), + ("perm_duplicate", "=", True), + "|", + ("group_id", "=", False), + ("group_id", "in", group_ids), + ] + return self.sudo().search_count(domain) > 0 + + @api.model + def check_duplicate_access(self, model_name, raise_exception=True): + """Check if the current user has permission to duplicate (perm_duplicate) for model_name.""" + if self.env.user.bypass_role_policy: + return True + return super().check_duplicate_access( + model_name, raise_exception=raise_exception + ) diff --git a/base_user_role_duplicate/models/ir_ui_view.py b/base_user_role_duplicate/models/ir_ui_view.py new file mode 100644 index 0000000..9d4acb6 --- /dev/null +++ b/base_user_role_duplicate/models/ir_ui_view.py @@ -0,0 +1,14 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class IrUiView(models.Model): + _inherit = "ir.ui.view" + + def _postprocess_access_rights(self, arch): + """Restrict the 'Duplicate' action based on the user's model access rights.""" + if self.env.user.bypass_role_policy: + return arch + return super()._postprocess_access_rights(arch) diff --git a/base_user_role_duplicate/models/res_users_role.py b/base_user_role_duplicate/models/res_users_role.py new file mode 100644 index 0000000..3559e09 --- /dev/null +++ b/base_user_role_duplicate/models/res_users_role.py @@ -0,0 +1,13 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class ResUsersRole(models.Model): + _inherit = "res.users.role" + + def collect_all_perm_fields(self, perm_fields=None): + res = super().collect_all_perm_fields(perm_fields) + res.setdefault("perm_duplicate", False) + return res diff --git a/base_user_role_duplicate/readme/CONTRIBUTORS.md b/base_user_role_duplicate/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..2ef6993 --- /dev/null +++ b/base_user_role_duplicate/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* CIT Services diff --git a/base_user_role_duplicate/readme/DESCRIPTION.md b/base_user_role_duplicate/readme/DESCRIPTION.md new file mode 100644 index 0000000..7df2b27 --- /dev/null +++ b/base_user_role_duplicate/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module extends the `base_user_role_extended` functionality to restrict +the "Duplicate" action for Odoo models. + +It adds a new field `perm_duplicate` (Duplicate Access) to `ir.model.access` to control +whether roles/groups have duplicate rights. In the frontend, the module post-processes views +to dynamically inject `duplicate="0"` on form and list views when a user lacks duplication +rights, hiding the duplicate button. diff --git a/base_user_role_duplicate/readme/USAGE.md b/base_user_role_duplicate/readme/USAGE.md new file mode 100644 index 0000000..6c9a140 --- /dev/null +++ b/base_user_role_duplicate/readme/USAGE.md @@ -0,0 +1,6 @@ +To use this module, you need to: + +1. Go to **Settings > Users & Companies > Roles** or **Groups**. +2. Under the Access Rights tab of group, you will find a new column **Duplicate Access** (`perm_duplicate`). +3. Toggle the checkbox to grant or restrict duplicate permissions for the selected models. +4. When a user with restricted roles/groups opens form or list views of the configured model, the "Duplicate" option will be hidden from the action menu. diff --git a/base_user_role_duplicate/tests/__init__.py b/base_user_role_duplicate/tests/__init__.py new file mode 100644 index 0000000..cb45d2b --- /dev/null +++ b/base_user_role_duplicate/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import test_duplicate diff --git a/base_user_role_duplicate/tests/test_duplicate.py b/base_user_role_duplicate/tests/test_duplicate.py new file mode 100644 index 0000000..8eea02f --- /dev/null +++ b/base_user_role_duplicate/tests/test_duplicate.py @@ -0,0 +1,99 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from lxml import etree + +from odoo.exceptions import AccessError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestDuplicateAccess(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_model = cls.env["res.users"] + cls.role_model = cls.env["res.users.role"] + cls.group_model = cls.env["res.groups"] + cls.access_model = cls.env["ir.model.access"] + cls.test_group = cls.group_model.create( + { + "name": "Test Group for Duplication", + } + ) + cls.group_user = cls.env.ref("base.group_user") + + cls.partner_model = cls.env["ir.model"].search( + [("model", "=", "res.partner")], limit=1 + ) + cls.partner_access = cls.access_model.create( + { + "name": "Test Partner Access", + "model_id": cls.partner_model.id, + "group_id": cls.test_group.id, + "perm_read": True, + "perm_write": True, + "perm_create": True, + "perm_unlink": True, + "perm_duplicate": False, + } + ) + + cls.access_model.search( + [ + ("model_id.model", "=", "res.partner"), + ("group_id", "in", [cls.group_user.id, cls.test_group.id]), + ] + ).write({"perm_duplicate": False}) + cls.test_role = cls.role_model.create( + { + "name": "Test Role for Duplication", + "implied_ids": [(6, 0, [cls.group_user.id, cls.test_group.id])], + } + ) + + cls.test_user = cls.user_model.create( + { + "name": "Test Duplication User", + "login": "test_dup_user", + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + } + ) + + def test_duplicate_access_controls(self): + self.test_user.write( + { + "role_line_ids": [(0, 0, {"role_id": self.test_role.id})], + } + ) + partner = self.env["res.partner"].create({"name": "Original Partner"}) + with self.assertRaises(AccessError): + partner.with_user(self.test_user).copy() + result = ( + self.env["res.partner"].with_user(self.test_user).get_view(view_type="form") + ) + arch = etree.fromstring(result["arch"]) + self.assertEqual(arch.get("duplicate"), "0") + # Test bypass role policy + self.test_user.write({"bypass_role_policy": True}) + # Under bypass role policy, they should be able to copy even if perm_duplicate is False + duplicated_partner_bypass = partner.with_user(self.test_user).copy() + self.assertTrue(duplicated_partner_bypass) + result_bypass = ( + self.env["res.partner"].with_user(self.test_user).get_view(view_type="form") + ) + arch_bypass = etree.fromstring(result_bypass["arch"]) + self.assertNotEqual(arch_bypass.get("duplicate"), "0") + # Disable bypass role policy back to False + self.test_user.write({"bypass_role_policy": False}) + self.partner_access.write({"perm_duplicate": True}) + self.test_role._update_role_model_access() + duplicated_partner = partner.with_user(self.test_user).copy() + self.assertTrue(duplicated_partner) + self.assertEqual(duplicated_partner.name, "Original Partner (copy)") + result = ( + self.env["res.partner"].with_user(self.test_user).get_view(view_type="form") + ) + arch = etree.fromstring(result["arch"]) + self.assertNotEqual(arch.get("duplicate"), "0")