Browse Source

feat(account): introduce interactive Role & Permissions Matrix and Group Pill chips for UserAdmin

main
sina_sajjadi 4 days ago
parent
commit
5dad0df2a9
  1. 43
      apps/account/admin/user.py
  2. 414
      apps/account/admin/widgets.py

43
apps/account/admin/user.py

@ -40,7 +40,23 @@ class UserAdminCreationForm(UserCreationForm):
return email return email
import json
from apps.account.admin.widgets import GroupPillSelectMultipleWidget, RolePermissionsMatrixWidget
class UserAdminChangeForm(UserChangeForm): class UserAdminChangeForm(UserChangeForm):
groups = forms.ModelMultipleChoiceField(
queryset=Group.objects.all(),
widget=GroupPillSelectMultipleWidget,
required=False,
label=_("Assigned Groups")
)
custom_permissions = forms.CharField(
widget=RolePermissionsMatrixWidget,
required=False,
label=_("Application Permissions Matrix")
)
class Meta(UserChangeForm.Meta): class Meta(UserChangeForm.Meta):
model = User model = User
@ -49,6 +65,17 @@ class UserAdminChangeForm(UserChangeForm):
if 'email' in self.fields: if 'email' in self.fields:
self.fields['email'].required = True self.fields['email'].required = True
def clean_custom_permissions(self):
val = self.cleaned_data.get('custom_permissions')
if isinstance(val, str):
try:
return json.loads(val) if val.strip() else {}
except Exception:
return {}
elif isinstance(val, dict):
return val
return {}
class UserAdmin(ModelAdmin, BaseUserAdmin): class UserAdmin(ModelAdmin, BaseUserAdmin):
form = UserAdminChangeForm form = UserAdminChangeForm
@ -59,7 +86,7 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
list_display_links = ('first_name', 'last_name', 'fullname', 'email') list_display_links = ('first_name', 'last_name', 'fullname', 'email')
ordering = ("-id",) ordering = ("-id",)
search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',) search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',)
filter_horizontal = ('groups', 'user_permissions')
filter_horizontal = ('user_permissions',)
list_filter = [ list_filter = [
"role", "role",
"user_type", "user_type",
@ -117,22 +144,16 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
("role", "user_type"), ("role", "user_type"),
("is_active", "is_staff", "is_superuser"), ("is_active", "is_staff", "is_superuser"),
"groups", "groups",
"custom_permissions",
), ),
}), }),
(_("Granular Permissions"), {
"classes": ["tab"],
"description": _("Low-level Django database permissions assigned directly to this user. Recommended to manage permissions via Roles or Groups instead."),
(_("Raw Database Permissions"), {
"classes": ["tab", "collapse"],
"description": _("Low-level Django database permissions for internal operations. Application features are managed in the Permissions Matrix above."),
"fields": ( "fields": (
"user_permissions", "user_permissions",
), ),
}), }),
(_("Custom Permissions"), {
"classes": ["tab"],
"description": _("Override specific permissions for this individual user (JSON dictionary of {permission_name: True/False}). Overrides take precedence over the role defaults."),
"fields": (
"custom_permissions",
),
}),
(_("Timestamps & Metadata"), { (_("Timestamps & Metadata"), {
"classes": ["tab"], "classes": ["tab"],
"fields": ( "fields": (

414
apps/account/admin/widgets.py

@ -0,0 +1,414 @@
import json
from django import forms
from django.utils.html import escape
from django.utils.safestring import mark_safe
from apps.account.models.role import Role, PERMISSION_CATEGORIES, ALL_PERMISSION_FIELDS
CATEGORY_ICONS = {
"Identity & Profiles": "badge",
"Chat & Communications": "forum",
"CMS & Articles": "article",
"LMS & Academics": "school",
"Meetings": "video_camera_front",
"Events": "event",
"Community Projects": "task_alt",
"Donations & Charity": "volunteer_activism",
"Support & Tickets": "confirmation_number",
"Dynamic Forms": "dynamic_form",
"Diplomacy & Institutional": "public",
"Administration & Security": "admin_panel_settings",
}
class GroupPillSelectMultipleWidget(forms.CheckboxSelectMultiple):
"""
Renders Django auth Groups as modern, interactive clickable pill cards.
Replaces the cumbersome dual-listbox with instant one-click toggle chips.
"""
def render(self, name, value, attrs=None, renderer=None):
if value is None:
value = []
elif isinstance(value, str):
value = [value]
else:
value = [str(v) for v in value]
attrs = attrs or {}
output = [
'<div class="group-pills-wrapper my-2">',
' <div class="flex flex-wrap gap-2.5 items-center">'
]
has_choices = False
for option_value, option_label in self.choices:
if not option_value:
continue
has_choices = True
is_checked = str(option_value) in value
checked_attr = 'checked' if is_checked else ''
card_class = (
'group-pill-card cursor-pointer select-none inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border text-sm transition-all duration-150 '
+ ('bg-indigo-50 border-indigo-400 text-indigo-700 shadow-xs dark:bg-indigo-950/60 dark:border-indigo-600 dark:text-indigo-300 font-semibold'
if is_checked else 'bg-white border-slate-200 text-slate-700 hover:bg-slate-50 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800')
)
icon_name = 'check_circle' if is_checked else 'add_circle'
icon_class = 'material-symbols-outlined text-base pill-icon ' + ('text-indigo-600 dark:text-indigo-400' if is_checked else 'text-slate-400')
output.append(
f'<label class="{card_class}">'
f'<input type="checkbox" name="{escape(name)}" value="{escape(option_value)}" {checked_attr} class="hidden group-pill-input">'
f'<span class="{icon_class}">{icon_name}</span>'
f'<span class="pill-title">{escape(option_label)}</span>'
f'</label>'
)
if not has_choices:
output.append('<span class="text-xs text-slate-400 italic">No groups available in system.</span>')
output.append('''
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-2">
Click any group chip to instantly assign or revoke membership for this user.
</div>
</div>
<script>
(function() {
document.addEventListener('change', function(e) {
if (e.target && e.target.classList.contains('group-pill-input')) {
const label = e.target.closest('.group-pill-card');
if (!label) return;
const icon = label.querySelector('.pill-icon');
if (e.target.checked) {
label.className = 'group-pill-card cursor-pointer select-none inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border text-sm transition-all duration-150 bg-indigo-50 border-indigo-400 text-indigo-700 shadow-xs dark:bg-indigo-950/60 dark:border-indigo-600 dark:text-indigo-300 font-semibold';
if (icon) {
icon.textContent = 'check_circle';
icon.className = 'material-symbols-outlined text-base text-indigo-600 dark:text-indigo-400 pill-icon';
}
} else {
label.className = 'group-pill-card cursor-pointer select-none inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border text-sm transition-all duration-150 bg-white border-slate-200 text-slate-700 hover:bg-slate-50 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800';
if (icon) {
icon.textContent = 'add_circle';
icon.className = 'material-symbols-outlined text-base text-slate-400 pill-icon';
}
}
}
});
})();
</script>
''')
return mark_safe('\n'.join(output))
class RolePermissionsMatrixWidget(forms.Widget):
"""
Renders an interactive, categorized visual permissions matrix.
Allows point-and-click permission overriding (Inherit / Allow / Deny)
with real-time inheritance tracking from the user's Assigned Role.
"""
def render(self, name, value, attrs=None, renderer=None):
if isinstance(value, str):
try:
overrides = json.loads(value) if value.strip() else {}
except Exception:
overrides = {}
elif isinstance(value, dict):
overrides = value
else:
overrides = {}
# Fetch all roles and their permissions dictionary
roles_data = {}
for r in Role.objects.all():
roles_data[str(r.id)] = r.get_permissions_dict()
categories_data = []
for cat_label, field_names in PERMISSION_CATEGORIES.items():
cat_name_str = str(cat_label)
icon = CATEGORY_ICONS.get(cat_name_str, "admin_panel_settings")
perms_list = []
for field_name in field_names:
try:
f = Role._meta.get_field(field_name)
vname = str(f.verbose_name)
except Exception:
vname = field_name.replace("_", " ").title()
perms_list.append({
"name": field_name,
"label": vname,
})
categories_data.append({
"title": cat_name_str,
"icon": icon,
"perms": perms_list,
})
roles_json_str = json.dumps(roles_data)
overrides_json_str = json.dumps(overrides)
html_parts = [
'<div id="role-perm-matrix-root" class="w-full my-3 font-sans">',
f'<textarea name="{escape(name)}" id="id_{escape(name)}" class="hidden">{escape(overrides_json_str)}</textarea>',
f'<script id="matrix-roles-data" type="application/json">{roles_json_str}</script>',
f'<script id="matrix-overrides-data" type="application/json">{overrides_json_str}</script>',
'''
<!-- Header Info & Search Toolbar -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-3 p-4 bg-slate-50 dark:bg-slate-900/60 rounded-xl border border-slate-200 dark:border-slate-800 mb-4 shadow-xs">
<div>
<h3 class="font-bold text-base text-slate-800 dark:text-slate-100 flex items-center gap-2">
<span class="material-symbols-outlined text-indigo-600 dark:text-indigo-400">tune</span>
<span>Application Permissions Matrix</span>
</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
Permissions are automatically inherited from the user's <strong>Assigned Role</strong>. You can selectively override any permission below.
</p>
</div>
<div class="flex items-center gap-2">
<div class="relative w-full md:w-64">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">search</span>
<input type="text" id="matrix-search-input" placeholder="Filter permissions..." class="w-full pl-9 pr-3 py-1.5 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-200 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500">
</div>
<button type="button" id="matrix-collapse-all-btn" class="px-2.5 py-1.5 text-xs font-medium rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors whitespace-nowrap">
Toggle All
</button>
</div>
</div>
<!-- Categories Container -->
<div class="flex flex-col gap-3" id="matrix-categories-list">
'''
]
for cat in categories_data:
cat_title = cat["title"]
cat_icon = cat["icon"]
cat_perms = cat["perms"]
html_parts.append(f'''
<div class="matrix-category-card border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden bg-white dark:bg-slate-900/50 transition-all shadow-xs" data-category="{escape(cat_title)}">
<!-- Category Header -->
<div class="matrix-cat-header flex items-center justify-between px-4 py-3 bg-slate-50/80 dark:bg-slate-800/40 cursor-pointer hover:bg-slate-100/70 dark:hover:bg-slate-800/80 select-none transition-colors border-b border-slate-100 dark:border-slate-800/60">
<div class="flex items-center gap-2.5">
<span class="material-symbols-outlined text-indigo-600 dark:text-indigo-400 text-lg">{cat_icon}</span>
<span class="font-semibold text-sm text-slate-800 dark:text-slate-200">{escape(cat_title)}</span>
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 cat-count-badge">
{len(cat_perms)} permissions
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-indigo-600 dark:text-indigo-400 font-medium cat-override-badge hidden">0 custom</span>
<span class="material-symbols-outlined text-slate-400 text-base transition-transform cat-chevron rotate-180">expand_more</span>
</div>
</div>
<!-- Category Items Grid -->
<div class="matrix-cat-body p-3.5 grid grid-cols-1 md:grid-cols-2 gap-2.5">
''')
for p in cat_perms:
p_name = p["name"]
p_label = p["label"]
html_parts.append(f'''
<div class="matrix-perm-row flex items-center justify-between p-2.5 rounded-lg border border-slate-100 dark:border-slate-800/80 bg-slate-50/40 dark:bg-slate-800/20 hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors" data-perm-name="{escape(p_name)}">
<div class="flex flex-col pr-2 min-w-0">
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200 truncate" title="{escape(p_label)}">
{escape(p_label)}
</span>
<span class="text-[11px] text-slate-400 mt-0.5 perm-status-label">
Checking role...
</span>
</div>
<!-- 3-Way Segmented Control -->
<div class="inline-flex rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 p-0.5 shrink-0 text-xs perm-btn-group">
<button type="button" data-val="inherit" class="px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn perm-btn-inherit bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-xs">
Inherit
</button>
<button type="button" data-val="allow" class="px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn perm-btn-allow text-slate-600 dark:text-slate-400 hover:text-green-600">
Allow
</button>
<button type="button" data-val="deny" class="px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn perm-btn-deny text-slate-600 dark:text-slate-400 hover:text-red-600">
Deny
</button>
</div>
</div>
''')
html_parts.append('''
</div>
</div>
''')
html_parts.append(f'''
</div>
</div>
<script>
(function() {{
const rolesEl = document.getElementById('matrix-roles-data');
const overridesEl = document.getElementById('matrix-overrides-data');
const rolesData = rolesEl ? JSON.parse(rolesEl.textContent || '{{}}') : {{}};
let currentOverrides = overridesEl ? JSON.parse(overridesEl.textContent || '{{}}') : {{}};
function getSelectedRoleId() {{
const roleSelect = document.querySelector('select[name="role"]');
return roleSelect ? roleSelect.value : null;
}}
function updateUI() {{
const roleId = getSelectedRoleId();
const activeRolePerms = (roleId && rolesData[roleId]) ? rolesData[roleId] : {{}};
const hiddenInput = document.getElementById('id_{escape(name)}');
document.querySelectorAll('.matrix-perm-row').forEach(row => {{
const permName = row.dataset.permName;
const statusLabel = row.querySelector('.perm-status-label');
const btnGroup = row.querySelector('.perm-btn-group');
if (!btnGroup) return;
const inheritBtn = btnGroup.querySelector('.perm-btn-inherit');
const allowBtn = btnGroup.querySelector('.perm-btn-allow');
const denyBtn = btnGroup.querySelector('.perm-btn-deny');
[inheritBtn, allowBtn, denyBtn].forEach(b => {{
if (b) b.className = 'px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn text-slate-600 dark:text-slate-400';
}});
const roleDefault = !!activeRolePerms[permName];
const hasOverride = currentOverrides.hasOwnProperty(permName);
const overrideVal = hasOverride ? currentOverrides[permName] : null;
if (overrideVal === true) {{
if (allowBtn) allowBtn.className = 'px-2 py-1 rounded-md text-[11px] font-bold transition-all perm-btn bg-green-600 text-white shadow-xs';
if (statusLabel) statusLabel.innerHTML = '<span class="text-green-600 dark:text-green-400 font-medium">✓ Force Allowed (Override)</span>';
}} else if (overrideVal === false) {{
if (denyBtn) denyBtn.className = 'px-2 py-1 rounded-md text-[11px] font-bold transition-all perm-btn bg-red-600 text-white shadow-xs';
if (statusLabel) statusLabel.innerHTML = '<span class="text-red-600 dark:text-red-400 font-medium">✕ Force Denied (Override)</span>';
}} else {{
if (inheritBtn) inheritBtn.className = 'px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-xs';
if (statusLabel) {{
if (roleDefault) {{
statusLabel.innerHTML = '<span class="text-indigo-600 dark:text-indigo-400 font-medium">● Inherited: Granted by Role</span>';
}} else {{
statusLabel.innerHTML = '<span class="text-slate-400">○ Inherited: Denied by Role</span>';
}}
}}
}}
}});
// Update category custom override counts
document.querySelectorAll('.matrix-category-card').forEach(card => {{
let catOverrides = 0;
card.querySelectorAll('.matrix-perm-row').forEach(r => {{
const p = r.dataset.permName;
if (currentOverrides.hasOwnProperty(p) && currentOverrides[p] !== null) {{
catOverrides++;
}}
}});
const badge = card.querySelector('.cat-override-badge');
if (badge) {{
if (catOverrides > 0) {{
badge.textContent = catOverrides + ' custom';
badge.classList.remove('hidden');
}} else {{
badge.classList.add('hidden');
}}
}}
}});
if (hiddenInput) {{
hiddenInput.value = JSON.stringify(currentOverrides);
}}
}}
document.addEventListener('click', function(e) {{
const btn = e.target.closest('.perm-btn');
if (btn) {{
const row = btn.closest('.matrix-perm-row');
if (!row) return;
const permName = row.dataset.permName;
const val = btn.dataset.val;
if (val === 'inherit') {{
delete currentOverrides[permName];
}} else if (val === 'allow') {{
currentOverrides[permName] = true;
}} else if (val === 'deny') {{
currentOverrides[permName] = false;
}}
updateUI();
return;
}}
const catHeader = e.target.closest('.matrix-cat-header');
if (catHeader) {{
const card = catHeader.closest('.matrix-category-card');
if (!card) return;
const body = card.querySelector('.matrix-cat-body');
const chevron = catHeader.querySelector('.cat-chevron');
if (body) {{
const isHidden = body.classList.toggle('hidden');
if (chevron) {{
chevron.style.transform = isHidden ? 'rotate(0deg)' : 'rotate(180deg)';
}}
}}
return;
}}
if (e.target && e.target.id === 'matrix-collapse-all-btn') {{
const bodies = document.querySelectorAll('.matrix-cat-body');
const chevrons = document.querySelectorAll('.cat-chevron');
const firstHidden = bodies.length > 0 && bodies[0].classList.contains('hidden');
bodies.forEach(b => {{
if (firstHidden) {{
b.classList.remove('hidden');
}} else {{
b.classList.add('hidden');
}}
}});
chevrons.forEach(ch => {{
ch.style.transform = firstHidden ? 'rotate(180deg)' : 'rotate(0deg)';
}});
return;
}}
}});
const searchInput = document.getElementById('matrix-search-input');
if (searchInput) {{
searchInput.addEventListener('input', function() {{
const q = this.value.toLowerCase().trim();
document.querySelectorAll('.matrix-perm-row').forEach(row => {{
const text = row.textContent.toLowerCase();
row.style.display = text.includes(q) ? 'flex' : 'none';
}});
document.querySelectorAll('.matrix-category-card').forEach(card => {{
const visibleRows = card.querySelectorAll('.matrix-perm-row:not([style*="display: none"])');
card.style.display = visibleRows.length > 0 ? 'block' : 'none';
const body = card.querySelector('.matrix-cat-body');
if (q && body) {{
body.classList.remove('hidden');
}}
}});
}});
}}
const roleSelect = document.querySelector('select[name="role"]');
if (roleSelect) {{
roleSelect.addEventListener('change', updateUI);
}}
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', updateUI);
}} else {{
updateUI();
}}
window.addEventListener('load', function() {{
setTimeout(updateUI, 100);
}});
}})();
</script>
''')
return mark_safe('\n'.join(html_parts))
Loading…
Cancel
Save