diff --git a/base_duplicate_manager/README.rst b/base_duplicate_manager/README.rst new file mode 100644 index 0000000..6f3b814 --- /dev/null +++ b/base_duplicate_manager/README.rst @@ -0,0 +1,3 @@ +======================== +Base Duplicate Manager +======================== \ No newline at end of file diff --git a/base_duplicate_manager/__init__.py b/base_duplicate_manager/__init__.py new file mode 100644 index 0000000..723d3ee --- /dev/null +++ b/base_duplicate_manager/__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_duplicate_manager/__manifest__.py b/base_duplicate_manager/__manifest__.py new file mode 100644 index 0000000..c01de18 --- /dev/null +++ b/base_duplicate_manager/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Base Duplicate Manager", + "version": "18.0.1.0.0", + "category": "Tools", + "summary": "Restricts the Duplicate action per model based on Group access rights", + "author": "CIT Services, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-backend", + "license": "LGPL-3", + "depends": ["base"], + "data": ["views/ir_model_access.xml", "views/res_groups.xml"], + "installable": True, +} diff --git a/base_duplicate_manager/models/__init__.py b/base_duplicate_manager/models/__init__.py new file mode 100644 index 0000000..9e2f3d1 --- /dev/null +++ b/base_duplicate_manager/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 ir_ui_view +from . import base diff --git a/base_duplicate_manager/models/base.py b/base_duplicate_manager/models/base.py new file mode 100644 index 0000000..5b4c7c5 --- /dev/null +++ b/base_duplicate_manager/models/base.py @@ -0,0 +1,12 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class Base(models.AbstractModel): + _inherit = "base" + + def copy(self, default=None): + self.env["ir.model.access"].check_duplicate_access(self._name) + return super().copy(default=default) diff --git a/base_duplicate_manager/models/ir_model_access.py b/base_duplicate_manager/models/ir_model_access.py new file mode 100644 index 0000000..1579deb --- /dev/null +++ b/base_duplicate_manager/models/ir_model_access.py @@ -0,0 +1,37 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from odoo import _, api, fields, models, tools +from odoo.exceptions import AccessError + + +class IrModelAccess(models.Model): + _inherit = "ir.model.access" + + perm_duplicate = fields.Boolean("Duplicate Access", default=True) + + @api.model + @tools.ormcache("self.env.uid", "model_name") + def _check_duplicate_access_cached(self, model_name): + user = self.env.user.sudo() + group_ids = 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.su: + return True + has_duplicate = self._check_duplicate_access_cached(model_name) + if not has_duplicate and raise_exception: + raise AccessError( + _("You are not allowed to duplicate records of model %s.") % model_name + ) + return has_duplicate diff --git a/base_duplicate_manager/models/ir_ui_view.py b/base_duplicate_manager/models/ir_ui_view.py new file mode 100644 index 0000000..31077c4 --- /dev/null +++ b/base_duplicate_manager/models/ir_ui_view.py @@ -0,0 +1,21 @@ +# 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.""" + target_model = arch.get("model_access_rights") + arch = super()._postprocess_access_rights(arch) + if not target_model or arch.tag not in ("list", "form") or self.env.su: + return arch + + if not self.env["ir.model.access"].check_duplicate_access( + target_model, raise_exception=False + ): + arch.set("duplicate", "0") + return arch diff --git a/base_duplicate_manager/readme/CONTRIBUTORS.md b/base_duplicate_manager/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..2ef6993 --- /dev/null +++ b/base_duplicate_manager/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* CIT Services diff --git a/base_duplicate_manager/readme/DESCRIPTION.md b/base_duplicate_manager/readme/DESCRIPTION.md new file mode 100644 index 0000000..01dfe23 --- /dev/null +++ b/base_duplicate_manager/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +It adds a new field `perm_duplicate` (Duplicate Access) to `ir.model.access` to control +whether 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_duplicate_manager/readme/USAGE.md b/base_duplicate_manager/readme/USAGE.md new file mode 100644 index 0000000..300ad82 --- /dev/null +++ b/base_duplicate_manager/readme/USAGE.md @@ -0,0 +1,6 @@ +To use this module, you need to: + +1. Go to **Settings > Users & Companies > 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 groups opens form or list views of the configured model, the "Duplicate" option will be hidden from the action menu. diff --git a/base_duplicate_manager/tests/__init__.py b/base_duplicate_manager/tests/__init__.py new file mode 100644 index 0000000..cb45d2b --- /dev/null +++ b/base_duplicate_manager/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_duplicate_manager/tests/test_duplicate.py b/base_duplicate_manager/tests/test_duplicate.py new file mode 100644 index 0000000..a40e873 --- /dev/null +++ b/base_duplicate_manager/tests/test_duplicate.py @@ -0,0 +1,118 @@ +# 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.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_user = cls.user_model.create( + { + "name": "Test Duplication User", + "login": "test_dup_user", + "groups_id": [(6, 0, [cls.group_user.id, cls.test_group.id])], + } + ) + + def test_duplicate_access_controls(self): + 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") + + self.partner_access.write({"perm_duplicate": True}) + 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") + + def test_duplicate_access_superuser(self): + """Test that the superuser can duplicate records regardless of restrictions.""" + partner = self.env["res.partner"].create({"name": "Original Partner"}) + # As su, copying should succeed even if perm_duplicate is False for all other groups + duplicated_partner = partner.sudo().copy() + self.assertTrue(duplicated_partner) + self.assertEqual(duplicated_partner.name, "Original Partner (copy)") + + def test_duplicate_access_additive_permissions(self): + """Test that if a user has multiple groups, and at least one grants duplicate permission, they can duplicate.""" + # Create a second group that will grant duplicate access + group_grant = self.group_model.create( + { + "name": "Granting Group for Duplication", + } + ) + self.access_model.create( + { + "name": "Grant Partner Access", + "model_id": self.partner_model.id, + "group_id": group_grant.id, + "perm_read": True, + "perm_write": True, + "perm_create": True, + "perm_unlink": True, + "perm_duplicate": True, + } + ) + + # User is in both groups: test_group (perm_duplicate=False) and group_grant (perm_duplicate=True) + self.test_user.write( + { + "groups_id": [(4, group_grant.id)], + } + ) + + partner = self.env["res.partner"].create({"name": "Original Partner"}) + + # Copy should succeed because group_grant has perm_duplicate=True + duplicated_partner = partner.with_user(self.test_user).copy() + self.assertTrue(duplicated_partner) + self.assertEqual(duplicated_partner.name, "Original Partner (copy)") diff --git a/base_duplicate_manager/views/ir_model_access.xml b/base_duplicate_manager/views/ir_model_access.xml new file mode 100644 index 0000000..305c53f --- /dev/null +++ b/base_duplicate_manager/views/ir_model_access.xml @@ -0,0 +1,33 @@ + + + + ir.model.access.list.base_user_role_duplicate + ir.model.access + + + + + + + + + ir.model.access.list.edition.base_user_role_duplicate + ir.model.access + + + + + + + + + ir.model.access.form.base_user_role_duplicate + ir.model.access + + + + + + + + diff --git a/base_duplicate_manager/views/res_groups.xml b/base_duplicate_manager/views/res_groups.xml new file mode 100644 index 0000000..f43d60d --- /dev/null +++ b/base_duplicate_manager/views/res_groups.xml @@ -0,0 +1,16 @@ + + + + res.groups.form.base_user_role_duplicate + res.groups + + + + + + + +