Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions base_user_role_duplicate/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
========================
Base User Role Duplicate
========================
4 changes: 4 additions & 0 deletions base_user_role_duplicate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 CIT Services
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

from . import models
15 changes: 15 additions & 0 deletions base_user_role_duplicate/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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,
}
6 changes: 6 additions & 0 deletions base_user_role_duplicate/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions base_user_role_duplicate/models/ir_model_access.py
Original file line number Diff line number Diff line change
@@ -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
)
14 changes: 14 additions & 0 deletions base_user_role_duplicate/models/ir_ui_view.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions base_user_role_duplicate/models/res_users_role.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions base_user_role_duplicate/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* CIT Services <info@cit-services.com>
7 changes: 7 additions & 0 deletions base_user_role_duplicate/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions base_user_role_duplicate/readme/USAGE.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions base_user_role_duplicate/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 CIT Services
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

from . import test_duplicate
99 changes: 99 additions & 0 deletions base_user_role_duplicate/tests/test_duplicate.py
Original file line number Diff line number Diff line change
@@ -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")