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_duplicate_manager/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
========================
Base Duplicate Manager
========================
4 changes: 4 additions & 0 deletions base_duplicate_manager/__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_duplicate_manager/__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": "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,
}
6 changes: 6 additions & 0 deletions base_duplicate_manager/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 ir_ui_view
from . import base
12 changes: 12 additions & 0 deletions base_duplicate_manager/models/base.py
Original file line number Diff line number Diff line change
@@ -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)
37 changes: 37 additions & 0 deletions base_duplicate_manager/models/ir_model_access.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions base_duplicate_manager/models/ir_ui_view.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions base_duplicate_manager/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* CIT Services <info@cit-services.com>
4 changes: 4 additions & 0 deletions base_duplicate_manager/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions base_duplicate_manager/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 > 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.
4 changes: 4 additions & 0 deletions base_duplicate_manager/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
118 changes: 118 additions & 0 deletions base_duplicate_manager/tests/test_duplicate.py
Original file line number Diff line number Diff line change
@@ -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)")
33 changes: 33 additions & 0 deletions base_duplicate_manager/views/ir_model_access.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" ?>
<odoo>
<record id="ir_model_access_view_tree" model="ir.ui.view">
<field name="name">ir.model.access.list.base_user_role_duplicate</field>
<field name="model">ir.model.access</field>
<field name="inherit_id" ref="base.ir_access_view_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='perm_unlink']" position="after">
<field name="perm_duplicate" />
</xpath>
</field>
</record>
<record id="ir_model_access_view_tree_edition" model="ir.ui.view">
<field name="name">ir.model.access.list.edition.base_user_role_duplicate</field>
<field name="model">ir.model.access</field>
<field name="inherit_id" ref="base.ir_access_view_tree_edition" />
<field name="arch" type="xml">
<xpath expr="//field[@name='perm_unlink']" position="after">
<field name="perm_duplicate" />
</xpath>
</field>
</record>
<record id="ir_model_access_view_form" model="ir.ui.view">
<field name="name">ir.model.access.form.base_user_role_duplicate</field>
<field name="model">ir.model.access</field>
<field name="inherit_id" ref="base.ir_access_view_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='perm_unlink']" position="after">
<field name="perm_duplicate" />
</xpath>
</field>
</record>
</odoo>
16 changes: 16 additions & 0 deletions base_duplicate_manager/views/res_groups.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" ?>
<odoo>
<record id="res_groups_view_form" model="ir.ui.view">
<field name="name">res.groups.form.base_user_role_duplicate</field>
<field name="model">res.groups</field>
<field name="inherit_id" ref="base.view_groups_form" />
<field name="arch" type="xml">
<xpath
expr="//field[@name='model_access']//list//field[@name='perm_unlink']"
position="after"
>
<field name="perm_duplicate" />
</xpath>
</field>
</record>
</odoo>