diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py index ea41791..a3ef984 100644 --- a/apps/account/admin/user.py +++ b/apps/account/admin/user.py @@ -40,7 +40,23 @@ class UserAdminCreationForm(UserCreationForm): return email +import json +from apps.account.admin.widgets import GroupPillSelectMultipleWidget, RolePermissionsMatrixWidget + + 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): model = User @@ -49,6 +65,17 @@ class UserAdminChangeForm(UserChangeForm): if 'email' in self.fields: 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): form = UserAdminChangeForm @@ -59,7 +86,7 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): list_display_links = ('first_name', 'last_name', 'fullname', 'email') ordering = ("-id",) search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',) - filter_horizontal = ('groups', 'user_permissions') + filter_horizontal = ('user_permissions',) list_filter = [ "role", "user_type", @@ -117,22 +144,16 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): ("role", "user_type"), ("is_active", "is_staff", "is_superuser"), "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": ( "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"), { "classes": ["tab"], "fields": ( diff --git a/apps/account/admin/widgets.py b/apps/account/admin/widgets.py new file mode 100644 index 0000000..243c6b9 --- /dev/null +++ b/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 = [ + '
', + '
' + ] + + 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'' + ) + + if not has_choices: + output.append('No groups available in system.') + + output.append(''' +
+
+ Click any group chip to instantly assign or revoke membership for this user. +
+
+ + ''') + 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 = [ + '
', + f'', + f'', + f'', + ''' + +
+
+

+ tune + Application Permissions Matrix +

+

+ Permissions are automatically inherited from the user's Assigned Role. You can selectively override any permission below. +

+
+
+
+ search + +
+ +
+
+ + +
+ ''' + ] + + for cat in categories_data: + cat_title = cat["title"] + cat_icon = cat["icon"] + cat_perms = cat["perms"] + + html_parts.append(f''' +
+ +
+
+ {cat_icon} + {escape(cat_title)} + + {len(cat_perms)} permissions + +
+
+ + expand_more +
+
+ + +
+ ''') + + for p in cat_perms: + p_name = p["name"] + p_label = p["label"] + + html_parts.append(f''' +
+
+ + {escape(p_label)} + + + Checking role... + +
+ +
+ + + +
+
+ ''') + + html_parts.append(''' +
+
+ ''') + + html_parts.append(f''' +
+
+ + + ''') + + return mark_safe('\n'.join(html_parts))