diff --git a/addons_extensions/role_based_group_management/__init__.py b/addons_extensions/role_based_group_management/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/addons_extensions/role_based_group_management/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons_extensions/role_based_group_management/__manifest__.py b/addons_extensions/role_based_group_management/__manifest__.py new file mode 100644 index 000000000..a447ce883 --- /dev/null +++ b/addons_extensions/role_based_group_management/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "Role-Based Group Management", + "summary": "Manage reusable security-group roles for users", + "version": "18.0.1.4.0", + "category": "Administration/Access Rights", + "license": "LGPL-3", + "author": "Custom", + "depends": ["base"], + "data": [ + "security/ir.model.access.csv", + "views/user_role_views.xml", + "views/res_users_views.xml", + "views/user_role_menus.xml", + ], + "installable": True, + "application": False, +} diff --git a/addons_extensions/role_based_group_management/models/__init__.py b/addons_extensions/role_based_group_management/models/__init__.py new file mode 100644 index 000000000..cccafcc0b --- /dev/null +++ b/addons_extensions/role_based_group_management/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_users +from . import user_role diff --git a/addons_extensions/role_based_group_management/models/res_users.py b/addons_extensions/role_based_group_management/models/res_users.py new file mode 100644 index 000000000..cf2c3926e --- /dev/null +++ b/addons_extensions/role_based_group_management/models/res_users.py @@ -0,0 +1,109 @@ +from odoo import _, api, Command, fields, models +from odoo.exceptions import AccessError, UserError + + +class ResUsers(models.Model): + _inherit = "res.users" + + role_id = fields.Many2one( + comodel_name="res.users.role", + string="Role", + groups="base.group_system", + ondelete="set null", + copy=False, + help="Reusable role whose groups can be synchronized to this user.", + ) + role_managed_group_ids = fields.Many2many( + comodel_name="res.groups", + relation="res_users_role_managed_group_rel", + column1="user_id", + column2="group_id", + string="Role-Managed Groups", + groups="base.group_system", + copy=False, + readonly=True, + help="Technical record of groups controlled by role synchronization.", + ) + + @api.model_create_multi + def create(self, vals_list): + users = super().create(vals_list) + users.filtered("role_id")._sync_groups_from_role() + return users + + def write(self, values): + role_changed = "role_id" in values + result = super().write(values) + if role_changed: + self._sync_groups_from_role() + return result + + def _get_protected_role_group_ids(self): + """Return user-type groups that role synchronization must never revoke.""" + user_type_category = self.env.ref( + "base.module_category_user_type", raise_if_not_found=False + ) + if not user_type_category: + return self.env["res.groups"] + return self.env["res.groups"].search( + [("category_id", "=", user_type_category.id)] + ) + + def _sync_groups_from_role(self): + """Synchronize access groups from the selected role. + + When a role is selected it becomes the source of truth for application + access groups: groups not present in the role are removed. The only + groups protected from role synchronization are user-type groups + (Internal User, Portal, Public), because changing them through a role + can lock users out or convert an internal user into a portal user. + """ + protected_groups = self._get_protected_role_group_ids() + + for user in self: + role_groups = user.role_id.group_ids + desired_groups = role_groups | role_groups.trans_implied_ids + desired_groups -= protected_groups + + current_groups = user.groups_id + previously_managed = user.role_managed_group_ids + + if user.role_id: + groups_to_remove = current_groups - desired_groups - protected_groups + direct_groups_to_add = role_groups - current_groups - protected_groups + managed_groups = desired_groups + else: + groups_to_remove = previously_managed - protected_groups + direct_groups_to_add = self.env["res.groups"] + managed_groups = self.env["res.groups"] + + commands = [Command.unlink(group.id) for group in groups_to_remove] + commands += [Command.link(group.id) for group in direct_groups_to_add] + if commands: + user.write({"groups_id": commands}) + + user.write({ + "role_managed_group_ids": [ + Command.set((managed_groups & user.groups_id).ids) + ], + }) + + return True + + def action_fetch_role_groups(self): + self.ensure_one() + if not self.env.user.has_group("base.group_system"): + raise AccessError(_("Only Settings administrators can synchronize roles.")) + if not self.role_id: + raise UserError(_("Select a role before fetching groups.")) + self._sync_groups_from_role() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Groups Fetched"), + "message": _("The role groups were synchronized successfully."), + "type": "success", + "sticky": False, + }, + } diff --git a/addons_extensions/role_based_group_management/models/user_role.py b/addons_extensions/role_based_group_management/models/user_role.py new file mode 100644 index 000000000..bc8f96293 --- /dev/null +++ b/addons_extensions/role_based_group_management/models/user_role.py @@ -0,0 +1,126 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ResUsersRole(models.Model): + _name = "res.users.role" + _description = "User Role" + _order = "name" + + name = fields.Char(required=True, index=True) + group_ids = fields.Many2many( + comodel_name="res.groups", + relation="res_users_role_group_rel", + column1="role_id", + column2="group_id", + string="Groups", + domain=lambda self: self._get_allowed_group_domain(), + help="Security groups granted by this role. User-type groups are intentionally excluded.", + ) + user_ids = fields.One2many( + comodel_name="res.users", + inverse_name="role_id", + string="Users", + readonly=True, + ) + assigned_user_ids = fields.Many2many( + comodel_name="res.users", + string="Assigned Users", + compute="_compute_assigned_user_ids", + inverse="_inverse_assigned_user_ids", + domain=[("share", "=", False)], + help="Select internal users to assign this role. Their groups are synchronized automatically.", + ) + user_count = fields.Integer(compute="_compute_user_count") + + _sql_constraints = [ + ("name_unique", "UNIQUE(name)", "The role name must be unique."), + ] + + @api.model + def _get_allowed_group_domain(self): + """Roles must not change whether an account is internal, portal, or public.""" + user_type_category = self.env.ref( + "base.module_category_user_type", raise_if_not_found=False + ) + return ( + [("category_id", "!=", user_type_category.id)] + if user_type_category + else [] + ) + + @api.depends("user_ids") + def _compute_user_count(self): + counts = self.env["res.users"]._read_group( + [("role_id", "in", self.ids)], ["role_id"], ["__count"] + ) + count_by_role = {role.id: count for role, count in counts} + for role in self: + role.user_count = count_by_role.get(role.id, 0) + + def _compute_assigned_user_ids(self): + users = self.env["res.users"].with_context(active_test=False).search( + [("role_id", "in", self.ids)] + ) + for role in self: + role.assigned_user_ids = users.filtered( + lambda user: user.role_id == role + ) + + def _inverse_assigned_user_ids(self): + """Apply additions/removals made through the role's user selector.""" + Users = self.env["res.users"].with_context(active_test=False) + for role in self.filtered("id"): + current_users = Users.search([("role_id", "=", role.id)]) + selected_users = role.assigned_user_ids + (current_users - selected_users).write({"role_id": False}) + (selected_users - current_users).write({"role_id": role.id}) + + @api.constrains("group_ids") + def _check_group_ids_do_not_include_user_types(self): + user_type_category = self.env.ref( + "base.module_category_user_type", raise_if_not_found=False + ) + if not user_type_category: + return + for role in self: + invalid_groups = role.group_ids.filtered( + lambda group: group.category_id == user_type_category + ) + if invalid_groups: + raise ValidationError( + _( + "User-type groups cannot be assigned through roles: %(groups)s. " + "Keep the Internal User, Portal, or Public user type managed directly on the user.", + groups=", ".join(invalid_groups.mapped("display_name")), + ) + ) + + def write(self, values): + groups_changed = "group_ids" in values + result = super().write(values) + if groups_changed: + users = self.env["res.users"].with_context(active_test=False).search( + [("role_id", "in", self.ids)] + ) + users._sync_groups_from_role() + return result + + def action_update_users(self): + self.ensure_one() + users = self.env["res.users"].with_context(active_test=False).search( + [("role_id", "=", self.id)] + ) + users._sync_groups_from_role() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Role Updated"), + "message": _( + "Groups were synchronized for %(count)s user(s).", count=len(users) + ), + "type": "success", + "sticky": False, + }, + } diff --git a/addons_extensions/role_based_group_management/security/ir.model.access.csv b/addons_extensions/role_based_group_management/security/ir.model.access.csv new file mode 100644 index 000000000..d7813b732 --- /dev/null +++ b/addons_extensions/role_based_group_management/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_res_users_role_system,res.users.role system,model_res_users_role,base.group_system,1,1,1,1 diff --git a/addons_extensions/role_based_group_management/views/res_users_views.xml b/addons_extensions/role_based_group_management/views/res_users_views.xml new file mode 100644 index 000000000..53be917c0 --- /dev/null +++ b/addons_extensions/role_based_group_management/views/res_users_views.xml @@ -0,0 +1,25 @@ + + + + res.users.form.role.management + res.users + + 15 + + + + + + + + + + + diff --git a/addons_extensions/role_based_group_management/views/user_role_menus.xml b/addons_extensions/role_based_group_management/views/user_role_menus.xml new file mode 100644 index 000000000..9c43cff4b --- /dev/null +++ b/addons_extensions/role_based_group_management/views/user_role_menus.xml @@ -0,0 +1,9 @@ + + + + diff --git a/addons_extensions/role_based_group_management/views/user_role_views.xml b/addons_extensions/role_based_group_management/views/user_role_views.xml new file mode 100644 index 000000000..fbecca927 --- /dev/null +++ b/addons_extensions/role_based_group_management/views/user_role_views.xml @@ -0,0 +1,75 @@ + + + + res.users.role.list + res.users.role + + + + + + + + + + + res.users.role.form + res.users.role + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Roles + res.users.role + list,form + + Create a reusable user role + Choose the security groups that administrators can synchronize to users. + + +
Create a reusable user role
Choose the security groups that administrators can synchronize to users.