Role Based Group Management
This commit is contained in:
parent
d0ed9e4e9d
commit
b44c591082
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import res_users
|
||||||
|
from . import user_role
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_users_form_role_management" model="ir.ui.view">
|
||||||
|
<field name="name">res.users.form.role.management</field>
|
||||||
|
<field name="model">res.users</field>
|
||||||
|
<field name="inherit_id" ref="base.view_users_form"/>
|
||||||
|
<field name="priority">15</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<!-- Insert before Odoo's generated groups block so the role selector
|
||||||
|
appears at the top of Access Rights, just above User Type. -->
|
||||||
|
<xpath expr="//page[@name='access_rights']/field[@name='groups_id']" position="before">
|
||||||
|
<group string="Role Access" groups="base.group_system">
|
||||||
|
<field name="role_id"
|
||||||
|
string="Assigned Role"
|
||||||
|
options="{'no_create': True, 'no_create_edit': True}"/>
|
||||||
|
<button name="action_fetch_role_groups"
|
||||||
|
string="Fetch Groups"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
invisible="not role_id"/>
|
||||||
|
</group>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_res_users_role"
|
||||||
|
name="Roles"
|
||||||
|
parent="base.menu_users"
|
||||||
|
action="action_res_users_role"
|
||||||
|
sequence="2"
|
||||||
|
groups="base.group_system"/>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_res_users_role_list" model="ir.ui.view">
|
||||||
|
<field name="name">res.users.role.list</field>
|
||||||
|
<field name="model">res.users.role</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Roles">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="group_ids" widget="many2many_tags" options="{'no_create': True}"/>
|
||||||
|
<field name="user_count" string="Users"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_res_users_role_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.users.role.form</field>
|
||||||
|
<field name="model">res.users.role</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Role">
|
||||||
|
<header>
|
||||||
|
<button name="action_update_users"
|
||||||
|
string="Update Users"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
groups="base.group_system"
|
||||||
|
invisible="not id"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="e.g. Developer"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<notebook>
|
||||||
|
<page string="Users" name="users">
|
||||||
|
<field name="assigned_user_ids"
|
||||||
|
string="Assigned Users"
|
||||||
|
context="{'active_test': False}"
|
||||||
|
options="{'no_create': True, 'no_create_edit': True}">
|
||||||
|
<list string="Assigned Users" editable="bottom" create="true" delete="true">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="login"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
<field name="active" column_invisible="True"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Groups" name="groups">
|
||||||
|
<field name="group_ids"
|
||||||
|
string="Groups"
|
||||||
|
options="{'no_create': True, 'no_create_edit': True}"
|
||||||
|
placeholder="Select security groups...">
|
||||||
|
<list string="Groups" editable="bottom" create="true" delete="true">
|
||||||
|
<field name="full_name" string="Group"/>
|
||||||
|
<field name="category_id" string="Application"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_res_users_role" model="ir.actions.act_window">
|
||||||
|
<field name="name">Roles</field>
|
||||||
|
<field name="res_model">res.users.role</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">Create a reusable user role</p>
|
||||||
|
<p>Choose the security groups that administrators can synchronize to users.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Loading…
Reference in New Issue