From e018d6d0b52aefeb1e538ea9ccd757aa9373c442 Mon Sep 17 00:00:00 2001 From: sina_sajjadi Date: Wed, 7 Oct 2026 10:16:40 +0330 Subject: [PATCH] feat: implement account role system with custom models, admin integration, and settings --- apps/account/admin/__init__.py | 1 + apps/account/admin/role.py | 142 +++++++ apps/account/admin/user.py | 24 +- .../account/management/commands/seed_roles.py | 387 ++++++++++++++++++ ..._role_user_custom_permissions_user_role.py | 88 ++++ apps/account/models/__init__.py | 4 +- apps/account/models/role.py | 188 +++++++++ apps/account/models/user.py | 55 ++- config/settings/base.py | 15 +- 9 files changed, 893 insertions(+), 11 deletions(-) create mode 100644 apps/account/admin/role.py create mode 100644 apps/account/management/commands/seed_roles.py create mode 100644 apps/account/migrations/0005_role_user_custom_permissions_user_role.py create mode 100644 apps/account/models/role.py diff --git a/apps/account/admin/__init__.py b/apps/account/admin/__init__.py index bbb9a9e..c0067bc 100644 --- a/apps/account/admin/__init__.py +++ b/apps/account/admin/__init__.py @@ -6,6 +6,7 @@ from .location import * from .login_history import * from .notification import * from .verification import * +from .role import * @register_component diff --git a/apps/account/admin/role.py b/apps/account/admin/role.py new file mode 100644 index 0000000..401c729 --- /dev/null +++ b/apps/account/admin/role.py @@ -0,0 +1,142 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin +from unfold.decorators import display + +from apps.account.models.role import Role, PERMISSION_CATEGORIES +from utils.admin import project_admin_site + + +class RoleAdmin(ModelAdmin): + list_display = ('name', 'slug', 'is_default', 'is_system', 'users_count', 'updated_at') + list_filter = ('is_default', 'is_system') + search_fields = ('name', 'slug', 'description') + ordering = ('name',) + readonly_fields = ('created_at', 'updated_at') + + fieldsets = ( + (None, { + "fields": ( + ("name", "slug"), + ("is_default", "is_system"), + "description", + ), + }), + (_("Identity & Profiles"), { + "classes": ["tab"], + "fields": ( + ("is_searchable", "can_submit_verification"), + ("can_view_full_profiles", "can_manage_institutions"), + ), + }), + (_("Chat & Communications"), { + "classes": ["tab"], + "fields": ( + ("can_send_direct_messages", "can_create_group_chats"), + ("can_start_video_calls", "can_moderate_chat"), + ("is_chat_muted",), + ), + }), + (_("CMS & Articles"), { + "classes": ["tab"], + "fields": ( + ("can_create_posts", "can_publish_posts_directly"), + ("can_edit_own_posts", "can_delete_own_posts"), + ("can_comment",), + ), + }), + (_("LMS & Academics"), { + "classes": ["tab"], + "fields": ( + ("can_enroll_courses", "can_create_courses"), + ("can_publish_courses", "can_issue_certificates"), + ("can_grade_submissions", "has_premium_lms_access"), + ), + }), + (_("Meetings"), { + "classes": ["tab"], + "fields": ( + ("can_request_meetings", "can_accept_meetings"), + ), + }), + (_("Events"), { + "classes": ["tab"], + "fields": ( + ("can_register_events", "can_create_events"), + ("can_publish_events", "can_checkin_attendees"), + ("can_export_attendee_list",), + ), + }), + (_("Community Projects"), { + "classes": ["tab"], + "fields": ( + ("can_volunteer", "can_create_projects"), + ("can_manage_project_tasks", "can_approve_projects"), + ), + }), + (_("Donations & Charity"), { + "classes": ["tab"], + "fields": ( + ("can_make_donations", "can_create_donation_campaigns"), + ("can_view_donation_reports",), + ), + }), + (_("Support & Tickets"), { + "classes": ["tab"], + "fields": ( + ("can_create_tickets", "can_reply_tickets"), + ("can_manage_tickets",), + ), + }), + (_("Dynamic Forms"), { + "classes": ["tab"], + "fields": ( + ("can_submit_forms", "can_create_forms"), + ("can_export_form_data",), + ), + }), + (_("Diplomacy & Institutional"), { + "classes": ["tab"], + "fields": ( + ("can_view_diplomatic_reports", "can_create_diplomatic_reports"), + ), + }), + (_("Administration & Security"), { + "classes": ["tab"], + "fields": ( + ("can_access_admin_panel", "can_manage_users"), + ("can_ban_users", "can_view_analytics"), + ), + }), + (_("Metadata"), { + "classes": ["tab"], + "fields": ( + ("created_at", "updated_at"), + ), + }), + ) + + @display(description=_("Assigned Users")) + def users_count(self, obj): + count = obj.users.count() + return f"{count} users" + + def has_add_permission(self, request): + return bool(request.user and request.user.is_authenticated and (request.user.is_staff or request.user.is_superuser or request.user.can_access_admin_panel())) + + def has_change_permission(self, request, obj=None): + return bool(request.user and request.user.is_authenticated and (request.user.is_staff or request.user.is_superuser or request.user.can_access_admin_panel())) + + def has_delete_permission(self, request, obj=None): + if obj and getattr(obj, 'is_system', False): + return False # Protect core default roles from accidental deletion + return bool(request.user and request.user.is_authenticated and (request.user.is_staff or request.user.is_superuser or request.user.can_access_admin_panel())) + + +# Register in both default and custom admin sites +try: + admin.site.register(Role, RoleAdmin) +except admin.sites.AlreadyRegistered: + pass + +project_admin_site.register(Role, RoleAdmin) diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py index 18fb6ac..510dfc8 100644 --- a/apps/account/admin/user.py +++ b/apps/account/admin/user.py @@ -55,10 +55,11 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): add_form = UserAdminCreationForm change_password_form = AdminPasswordChangeForm compressed_fields = False - list_display = ('first_name', 'last_name', 'fullname', 'email', 'user_type', 'is_staff', 'is_active', 'display_date_joined',) + list_display = ('first_name', 'last_name', 'fullname', 'email', 'role', 'user_type', 'is_staff', 'is_active', 'display_date_joined',) ordering = ("-id",) search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',) list_filter = [ + "role", "user_type", "is_active", "is_staff", @@ -73,6 +74,7 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): 'fields': ( ('first_name', 'last_name'), ('fullname', 'email'), + 'role', 'phone_number', ('title', 'rank'), ('institution_name', 'website'), @@ -107,14 +109,21 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): ("info",), ), }), - (_("Access & Status"), { + (_("Access & Role"), { "classes": ["tab"], "fields": ( - ("user_type",), + ("role", "user_type"), ("is_active", "is_staff", "is_superuser"), ("groups", "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": ( @@ -147,6 +156,15 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin): count = obj.permissions.count() return ngettext("{count} permission", "{count} permissions", count).format(count=count) if count > 0 else "-" + def has_add_permission(self, request): + return bool(request.user and request.user.is_authenticated and (request.user.is_staff or request.user.is_superuser or request.user.can_access_admin_panel())) + + def has_change_permission(self, request, obj=None): + return bool(request.user and request.user.is_authenticated and (request.user.is_staff or request.user.is_superuser or request.user.can_access_admin_panel())) + + def has_delete_permission(self, request, obj=None): + return bool(request.user and request.user.is_authenticated and (request.user.is_staff or request.user.is_superuser or request.user.can_access_admin_panel())) + # ========================================================= # 2. Registrations diff --git a/apps/account/management/commands/seed_roles.py b/apps/account/management/commands/seed_roles.py new file mode 100644 index 0000000..2e52250 --- /dev/null +++ b/apps/account/management/commands/seed_roles.py @@ -0,0 +1,387 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.account.models.role import Role, ALL_PERMISSION_FIELDS + + +DEFAULT_ROLES = [ + { + "name": "User", + "slug": "user", + "description": "Standard community member with basic profile, messaging, learning, and participation privileges.", + "is_default": True, + "is_system": True, + "permissions": { + "is_searchable": True, + "can_submit_verification": True, + "can_view_full_profiles": True, + "can_manage_institutions": False, + "can_send_direct_messages": True, + "can_create_group_chats": True, + "can_start_video_calls": False, + "can_moderate_chat": False, + "is_chat_muted": False, + "can_create_posts": True, + "can_publish_posts_directly": False, + "can_edit_own_posts": True, + "can_delete_own_posts": True, + "can_comment": True, + "can_enroll_courses": True, + "can_create_courses": False, + "can_publish_courses": False, + "can_issue_certificates": False, + "can_grade_submissions": False, + "has_premium_lms_access": False, + "can_request_meetings": True, + "can_accept_meetings": False, + "can_register_events": True, + "can_create_events": False, + "can_publish_events": False, + "can_checkin_attendees": False, + "can_export_attendee_list": False, + "can_volunteer": True, + "can_create_projects": False, + "can_manage_project_tasks": False, + "can_approve_projects": False, + "can_make_donations": True, + "can_create_donation_campaigns": False, + "can_view_donation_reports": False, + "can_create_tickets": True, + "can_reply_tickets": True, + "can_manage_tickets": False, + "can_submit_forms": True, + "can_create_forms": False, + "can_export_form_data": False, + "can_view_diplomatic_reports": False, + "can_create_diplomatic_reports": False, + "can_access_admin_panel": False, + "can_manage_users": False, + "can_ban_users": False, + "can_view_analytics": False, + } + }, + { + "name": "Verified User", + "slug": "verified_user", + "description": "Verified community member with authenticated identity, video calling, and direct content publishing.", + "is_default": False, + "is_system": True, + "permissions": { + "is_searchable": True, + "can_submit_verification": True, + "can_view_full_profiles": True, + "can_manage_institutions": False, + "can_send_direct_messages": True, + "can_create_group_chats": True, + "can_start_video_calls": True, + "can_moderate_chat": False, + "is_chat_muted": False, + "can_create_posts": True, + "can_publish_posts_directly": True, + "can_edit_own_posts": True, + "can_delete_own_posts": True, + "can_comment": True, + "can_enroll_courses": True, + "can_create_courses": False, + "can_publish_courses": False, + "can_issue_certificates": False, + "can_grade_submissions": False, + "has_premium_lms_access": True, + "can_request_meetings": True, + "can_accept_meetings": False, + "can_register_events": True, + "can_create_events": True, + "can_publish_events": False, + "can_checkin_attendees": False, + "can_export_attendee_list": False, + "can_volunteer": True, + "can_create_projects": False, + "can_manage_project_tasks": False, + "can_approve_projects": False, + "can_make_donations": True, + "can_create_donation_campaigns": False, + "can_view_donation_reports": False, + "can_create_tickets": True, + "can_reply_tickets": True, + "can_manage_tickets": False, + "can_submit_forms": True, + "can_create_forms": False, + "can_export_form_data": False, + "can_view_diplomatic_reports": False, + "can_create_diplomatic_reports": False, + "can_access_admin_panel": False, + "can_manage_users": False, + "can_ban_users": False, + "can_view_analytics": False, + } + }, + { + "name": "Institution Admin", + "slug": "institution_admin", + "description": "Administrator of an Islamic center, university, or institution with management over courses, events, meetings, and campaigns.", + "is_default": False, + "is_system": True, + "permissions": { + "is_searchable": True, + "can_submit_verification": True, + "can_view_full_profiles": True, + "can_manage_institutions": True, + "can_send_direct_messages": True, + "can_create_group_chats": True, + "can_start_video_calls": True, + "can_moderate_chat": False, + "is_chat_muted": False, + "can_create_posts": True, + "can_publish_posts_directly": True, + "can_edit_own_posts": True, + "can_delete_own_posts": True, + "can_comment": True, + "can_enroll_courses": True, + "can_create_courses": True, + "can_publish_courses": True, + "can_issue_certificates": True, + "can_grade_submissions": True, + "has_premium_lms_access": True, + "can_request_meetings": True, + "can_accept_meetings": True, + "can_register_events": True, + "can_create_events": True, + "can_publish_events": True, + "can_checkin_attendees": True, + "can_export_attendee_list": True, + "can_volunteer": True, + "can_create_projects": True, + "can_manage_project_tasks": True, + "can_approve_projects": False, + "can_make_donations": True, + "can_create_donation_campaigns": True, + "can_view_donation_reports": True, + "can_create_tickets": True, + "can_reply_tickets": True, + "can_manage_tickets": False, + "can_submit_forms": True, + "can_create_forms": True, + "can_export_form_data": True, + "can_view_diplomatic_reports": True, + "can_create_diplomatic_reports": False, + "can_access_admin_panel": False, + "can_manage_users": False, + "can_ban_users": False, + "can_view_analytics": False, + } + }, + { + "name": "Institution Owner", + "slug": "institution_owner", + "description": "Owner / Primary Representative of an institution with full oversight over institutional projects, campaigns, and diplomacy.", + "is_default": False, + "is_system": True, + "permissions": { + "is_searchable": True, + "can_submit_verification": True, + "can_view_full_profiles": True, + "can_manage_institutions": True, + "can_send_direct_messages": True, + "can_create_group_chats": True, + "can_start_video_calls": True, + "can_moderate_chat": False, + "is_chat_muted": False, + "can_create_posts": True, + "can_publish_posts_directly": True, + "can_edit_own_posts": True, + "can_delete_own_posts": True, + "can_comment": True, + "can_enroll_courses": True, + "can_create_courses": True, + "can_publish_courses": True, + "can_issue_certificates": True, + "can_grade_submissions": True, + "has_premium_lms_access": True, + "can_request_meetings": True, + "can_accept_meetings": True, + "can_register_events": True, + "can_create_events": True, + "can_publish_events": True, + "can_checkin_attendees": True, + "can_export_attendee_list": True, + "can_volunteer": True, + "can_create_projects": True, + "can_manage_project_tasks": True, + "can_approve_projects": True, + "can_make_donations": True, + "can_create_donation_campaigns": True, + "can_view_donation_reports": True, + "can_create_tickets": True, + "can_reply_tickets": True, + "can_manage_tickets": False, + "can_submit_forms": True, + "can_create_forms": True, + "can_export_form_data": True, + "can_view_diplomatic_reports": True, + "can_create_diplomatic_reports": True, + "can_access_admin_panel": False, + "can_manage_users": False, + "can_ban_users": False, + "can_view_analytics": False, + } + }, + { + "name": "Content Moderator", + "slug": "content_moderator", + "description": "Community moderator responsible for chat safety, content review, and ticket management.", + "is_default": False, + "is_system": True, + "permissions": { + "is_searchable": True, + "can_submit_verification": True, + "can_view_full_profiles": True, + "can_manage_institutions": False, + "can_send_direct_messages": True, + "can_create_group_chats": True, + "can_start_video_calls": False, + "can_moderate_chat": True, + "is_chat_muted": False, + "can_create_posts": True, + "can_publish_posts_directly": True, + "can_edit_own_posts": True, + "can_delete_own_posts": True, + "can_comment": True, + "can_enroll_courses": True, + "can_create_courses": False, + "can_publish_courses": False, + "can_issue_certificates": False, + "can_grade_submissions": False, + "has_premium_lms_access": False, + "can_request_meetings": True, + "can_accept_meetings": False, + "can_register_events": True, + "can_create_events": False, + "can_publish_events": False, + "can_checkin_attendees": False, + "can_export_attendee_list": False, + "can_volunteer": True, + "can_create_projects": False, + "can_manage_project_tasks": False, + "can_approve_projects": False, + "can_make_donations": True, + "can_create_donation_campaigns": False, + "can_view_donation_reports": False, + "can_create_tickets": True, + "can_reply_tickets": True, + "can_manage_tickets": True, + "can_submit_forms": True, + "can_create_forms": False, + "can_export_form_data": False, + "can_view_diplomatic_reports": False, + "can_create_diplomatic_reports": False, + "can_access_admin_panel": True, + "can_manage_users": False, + "can_ban_users": False, + "can_view_analytics": False, + } + }, + { + "name": "Regional Admin", + "slug": "regional_admin", + "description": "Regional supervisor with authority over regional institutions, user accounts, and community analytics.", + "is_default": False, + "is_system": True, + "permissions": { + "is_searchable": True, + "can_submit_verification": True, + "can_view_full_profiles": True, + "can_manage_institutions": True, + "can_send_direct_messages": True, + "can_create_group_chats": True, + "can_start_video_calls": True, + "can_moderate_chat": True, + "is_chat_muted": False, + "can_create_posts": True, + "can_publish_posts_directly": True, + "can_edit_own_posts": True, + "can_delete_own_posts": True, + "can_comment": True, + "can_enroll_courses": True, + "can_create_courses": True, + "can_publish_courses": True, + "can_issue_certificates": True, + "can_grade_submissions": True, + "has_premium_lms_access": True, + "can_request_meetings": True, + "can_accept_meetings": True, + "can_register_events": True, + "can_create_events": True, + "can_publish_events": True, + "can_checkin_attendees": True, + "can_export_attendee_list": True, + "can_volunteer": True, + "can_create_projects": True, + "can_manage_project_tasks": True, + "can_approve_projects": True, + "can_make_donations": True, + "can_create_donation_campaigns": True, + "can_view_donation_reports": True, + "can_create_tickets": True, + "can_reply_tickets": True, + "can_manage_tickets": True, + "can_submit_forms": True, + "can_create_forms": True, + "can_export_form_data": True, + "can_view_diplomatic_reports": True, + "can_create_diplomatic_reports": True, + "can_access_admin_panel": True, + "can_manage_users": True, + "can_ban_users": True, + "can_view_analytics": True, + } + }, + { + "name": "Super Administrator", + "slug": "super_admin", + "description": "Master administrative role with all permissions, total access control, and full platform authority.", + "is_default": False, + "is_system": True, + "permissions": {perm: (perm != "is_chat_muted") for perm in ALL_PERMISSION_FIELDS} + }, +] + + +class Command(BaseCommand): + help = "Seed the 7 default configurable system roles with their exact permission matrix" + + @transaction.atomic + def handle(self, *args, **options): + self.stdout.write("Seeding default system roles...") + created_count = 0 + updated_count = 0 + + for role_data in DEFAULT_ROLES: + perms = role_data.pop("permissions") + role, created = Role.objects.get_or_create( + slug=role_data["slug"], + defaults={ + "name": role_data["name"], + "description": role_data["description"], + "is_default": role_data["is_default"], + "is_system": role_data["is_system"], + **perms + } + ) + + if not created: + # Update role configuration if existing + role.name = role_data["name"] + role.description = role_data["description"] + role.is_default = role_data["is_default"] + role.is_system = role_data["is_system"] + for perm, val in perms.items(): + setattr(role, perm, val) + role.save() + updated_count += 1 + self.stdout.write(self.style.SUCCESS(f" [✓] Updated role: {role.name} ({role.slug})")) + else: + created_count += 1 + self.stdout.write(self.style.SUCCESS(f" [+] Created role: {role.name} ({role.slug})")) + + self.stdout.write(self.style.SUCCESS( + f"Successfully finished seeding roles: {created_count} created, {updated_count} updated." + )) diff --git a/apps/account/migrations/0005_role_user_custom_permissions_user_role.py b/apps/account/migrations/0005_role_user_custom_permissions_user_role.py new file mode 100644 index 0000000..bf7b718 --- /dev/null +++ b/apps/account/migrations/0005_role_user_custom_permissions_user_role.py @@ -0,0 +1,88 @@ +# Generated by Django 4.2.30 on 2026-10-06 12:37 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0004_user_address_user_institution_name_user_rank_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='Role Name')), + ('slug', models.SlugField(blank=True, max_length=100, unique=True, verbose_name='Slug / Identifier')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('is_default', models.BooleanField(default=False, help_text='Automatically assigned to new registrations if no other role is selected.', verbose_name='Default Role for New Users')), + ('is_system', models.BooleanField(default=False, help_text='Marks default system roles. Admins can configure permissions, but deletion is restricted.', verbose_name='System Core Role')), + ('is_searchable', models.BooleanField(default=True, verbose_name='Appear in User Searches / Directory')), + ('can_submit_verification', models.BooleanField(default=True, verbose_name='Submit Verification Documents')), + ('can_view_full_profiles', models.BooleanField(default=True, verbose_name='View Full Profiles')), + ('can_manage_institutions', models.BooleanField(default=False, verbose_name='Manage Institutions')), + ('can_send_direct_messages', models.BooleanField(default=True, verbose_name='Send Direct Messages')), + ('can_create_group_chats', models.BooleanField(default=True, verbose_name='Create Group Chats')), + ('can_start_video_calls', models.BooleanField(default=False, verbose_name='Start Video Calls')), + ('can_moderate_chat', models.BooleanField(default=False, verbose_name='Moderate Chat Rooms')), + ('is_chat_muted', models.BooleanField(default=False, verbose_name='Chat Muted (Kill-switch)')), + ('can_create_posts', models.BooleanField(default=True, verbose_name='Create Posts')), + ('can_publish_posts_directly', models.BooleanField(default=False, verbose_name='Publish Posts Directly (Bypass Moderation)')), + ('can_edit_own_posts', models.BooleanField(default=True, verbose_name='Edit Own Posts')), + ('can_delete_own_posts', models.BooleanField(default=True, verbose_name='Delete Own Posts')), + ('can_comment', models.BooleanField(default=True, verbose_name='Comment on Posts')), + ('can_enroll_courses', models.BooleanField(default=True, verbose_name='Enroll in Courses')), + ('can_create_courses', models.BooleanField(default=False, verbose_name='Create Courses')), + ('can_publish_courses', models.BooleanField(default=False, verbose_name='Publish Courses Directly')), + ('can_issue_certificates', models.BooleanField(default=False, verbose_name='Issue Certificates')), + ('can_grade_submissions', models.BooleanField(default=False, verbose_name='Grade Submissions')), + ('has_premium_lms_access', models.BooleanField(default=False, verbose_name='Premium LMS Access')), + ('can_request_meetings', models.BooleanField(default=True, verbose_name='Send Meeting Requests')), + ('can_accept_meetings', models.BooleanField(default=False, verbose_name='Accept / Host Meetings')), + ('can_register_events', models.BooleanField(default=True, verbose_name='Register for Events')), + ('can_create_events', models.BooleanField(default=False, verbose_name='Create Events')), + ('can_publish_events', models.BooleanField(default=False, verbose_name='Publish Events Directly')), + ('can_checkin_attendees', models.BooleanField(default=False, verbose_name='Check-in Attendees (Scan Tickets)')), + ('can_export_attendee_list', models.BooleanField(default=False, verbose_name='Export Attendee List')), + ('can_volunteer', models.BooleanField(default=True, verbose_name='Volunteer for Projects')), + ('can_create_projects', models.BooleanField(default=False, verbose_name='Create Projects')), + ('can_manage_project_tasks', models.BooleanField(default=False, verbose_name='Manage Project Tasks')), + ('can_approve_projects', models.BooleanField(default=False, verbose_name='Approve Projects')), + ('can_make_donations', models.BooleanField(default=True, verbose_name='Make Donations')), + ('can_create_donation_campaigns', models.BooleanField(default=False, verbose_name='Create Donation Campaigns')), + ('can_view_donation_reports', models.BooleanField(default=False, verbose_name='View Donation Reports')), + ('can_create_tickets', models.BooleanField(default=True, verbose_name='Create Support Tickets')), + ('can_reply_tickets', models.BooleanField(default=True, verbose_name='Reply to Support Tickets')), + ('can_manage_tickets', models.BooleanField(default=False, verbose_name='Manage Support Tickets')), + ('can_submit_forms', models.BooleanField(default=True, verbose_name='Submit Dynamic Forms')), + ('can_create_forms', models.BooleanField(default=False, verbose_name='Create Dynamic Forms')), + ('can_export_form_data', models.BooleanField(default=False, verbose_name='Export Form Responses')), + ('can_view_diplomatic_reports', models.BooleanField(default=False, verbose_name='View Diplomatic Reports')), + ('can_create_diplomatic_reports', models.BooleanField(default=False, verbose_name='Create Diplomatic Reports')), + ('can_access_admin_panel', models.BooleanField(default=False, verbose_name='Access Admin Panel')), + ('can_manage_users', models.BooleanField(default=False, verbose_name='Manage Users')), + ('can_ban_users', models.BooleanField(default=False, verbose_name='Ban / Suspend Users')), + ('can_view_analytics', models.BooleanField(default=False, verbose_name='View Analytics Dashboard')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ], + options={ + 'verbose_name': 'Role', + 'verbose_name_plural': 'Roles', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='user', + name='custom_permissions', + field=models.JSONField(blank=True, default=dict, help_text='Dictionary of permission overrides: {permission_name: True/False}', verbose_name='Custom Permission Overrides'), + ), + migrations.AddField( + model_name='user', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='account.role', verbose_name='Assigned Role'), + ), + ] diff --git a/apps/account/models/__init__.py b/apps/account/models/__init__.py index 8adaa4a..3a1e484 100644 --- a/apps/account/models/__init__.py +++ b/apps/account/models/__init__.py @@ -1,4 +1,6 @@ from .user import * from .groups import * from .notification import * -from .verification import * \ No newline at end of file +from .verification import * +from .role import * + \ No newline at end of file diff --git a/apps/account/models/role.py b/apps/account/models/role.py new file mode 100644 index 0000000..e508bbf --- /dev/null +++ b/apps/account/models/role.py @@ -0,0 +1,188 @@ +from django.db import models +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ + + +PERMISSION_CATEGORIES = { + _("Identity & Profiles"): [ + "is_searchable", + "can_submit_verification", + "can_view_full_profiles", + "can_manage_institutions", + ], + _("Chat & Communications"): [ + "can_send_direct_messages", + "can_create_group_chats", + "can_start_video_calls", + "can_moderate_chat", + "is_chat_muted", + ], + _("CMS & Articles"): [ + "can_create_posts", + "can_publish_posts_directly", + "can_edit_own_posts", + "can_delete_own_posts", + "can_comment", + ], + _("LMS & Academics"): [ + "can_enroll_courses", + "can_create_courses", + "can_publish_courses", + "can_issue_certificates", + "can_grade_submissions", + "has_premium_lms_access", + ], + _("Meetings"): [ + "can_request_meetings", + "can_accept_meetings", + ], + _("Events"): [ + "can_register_events", + "can_create_events", + "can_publish_events", + "can_checkin_attendees", + "can_export_attendee_list", + ], + _("Community Projects"): [ + "can_volunteer", + "can_create_projects", + "can_manage_project_tasks", + "can_approve_projects", + ], + _("Donations & Charity"): [ + "can_make_donations", + "can_create_donation_campaigns", + "can_view_donation_reports", + ], + _("Support & Tickets"): [ + "can_create_tickets", + "can_reply_tickets", + "can_manage_tickets", + ], + _("Dynamic Forms"): [ + "can_submit_forms", + "can_create_forms", + "can_export_form_data", + ], + _("Diplomacy & Institutional"): [ + "can_view_diplomatic_reports", + "can_create_diplomatic_reports", + ], + _("Administration & Security"): [ + "can_access_admin_panel", + "can_manage_users", + "can_ban_users", + "can_view_analytics", + ], +} + +ALL_PERMISSION_FIELDS = [perm for perms in PERMISSION_CATEGORIES.values() for perm in perms] + + +class Role(models.Model): + name = models.CharField(max_length=100, unique=True, verbose_name=_("Role Name")) + slug = models.SlugField(max_length=100, unique=True, blank=True, verbose_name=_("Slug / Identifier")) + description = models.TextField(blank=True, verbose_name=_("Description")) + is_default = models.BooleanField( + default=False, + verbose_name=_("Default Role for New Users"), + help_text=_("Automatically assigned to new registrations if no other role is selected.") + ) + is_system = models.BooleanField( + default=False, + verbose_name=_("System Core Role"), + help_text=_("Marks default system roles. Admins can configure permissions, but deletion is restricted.") + ) + + # 1. Identity & Profiles + is_searchable = models.BooleanField(default=True, verbose_name=_("Appear in User Searches / Directory")) + can_submit_verification = models.BooleanField(default=True, verbose_name=_("Submit Verification Documents")) + can_view_full_profiles = models.BooleanField(default=True, verbose_name=_("View Full Profiles")) + can_manage_institutions = models.BooleanField(default=False, verbose_name=_("Manage Institutions")) + + # 2. Chat & Communications + can_send_direct_messages = models.BooleanField(default=True, verbose_name=_("Send Direct Messages")) + can_create_group_chats = models.BooleanField(default=True, verbose_name=_("Create Group Chats")) + can_start_video_calls = models.BooleanField(default=False, verbose_name=_("Start Video Calls")) + can_moderate_chat = models.BooleanField(default=False, verbose_name=_("Moderate Chat Rooms")) + is_chat_muted = models.BooleanField(default=False, verbose_name=_("Chat Muted (Kill-switch)")) + + # 3. CMS & Articles + can_create_posts = models.BooleanField(default=True, verbose_name=_("Create Posts")) + can_publish_posts_directly = models.BooleanField(default=False, verbose_name=_("Publish Posts Directly (Bypass Moderation)")) + can_edit_own_posts = models.BooleanField(default=True, verbose_name=_("Edit Own Posts")) + can_delete_own_posts = models.BooleanField(default=True, verbose_name=_("Delete Own Posts")) + can_comment = models.BooleanField(default=True, verbose_name=_("Comment on Posts")) + + # 4. LMS & Academics + can_enroll_courses = models.BooleanField(default=True, verbose_name=_("Enroll in Courses")) + can_create_courses = models.BooleanField(default=False, verbose_name=_("Create Courses")) + can_publish_courses = models.BooleanField(default=False, verbose_name=_("Publish Courses Directly")) + can_issue_certificates = models.BooleanField(default=False, verbose_name=_("Issue Certificates")) + can_grade_submissions = models.BooleanField(default=False, verbose_name=_("Grade Submissions")) + has_premium_lms_access = models.BooleanField(default=False, verbose_name=_("Premium LMS Access")) + + # 5. Meetings + can_request_meetings = models.BooleanField(default=True, verbose_name=_("Send Meeting Requests")) + can_accept_meetings = models.BooleanField(default=False, verbose_name=_("Accept / Host Meetings")) + + # 6. Events + can_register_events = models.BooleanField(default=True, verbose_name=_("Register for Events")) + can_create_events = models.BooleanField(default=False, verbose_name=_("Create Events")) + can_publish_events = models.BooleanField(default=False, verbose_name=_("Publish Events Directly")) + can_checkin_attendees = models.BooleanField(default=False, verbose_name=_("Check-in Attendees (Scan Tickets)")) + can_export_attendee_list = models.BooleanField(default=False, verbose_name=_("Export Attendee List")) + + # 7. Community Projects + can_volunteer = models.BooleanField(default=True, verbose_name=_("Volunteer for Projects")) + can_create_projects = models.BooleanField(default=False, verbose_name=_("Create Projects")) + can_manage_project_tasks = models.BooleanField(default=False, verbose_name=_("Manage Project Tasks")) + can_approve_projects = models.BooleanField(default=False, verbose_name=_("Approve Projects")) + + # 8. Donations & Charity + can_make_donations = models.BooleanField(default=True, verbose_name=_("Make Donations")) + can_create_donation_campaigns = models.BooleanField(default=False, verbose_name=_("Create Donation Campaigns")) + can_view_donation_reports = models.BooleanField(default=False, verbose_name=_("View Donation Reports")) + + # 9. Support & Tickets + can_create_tickets = models.BooleanField(default=True, verbose_name=_("Create Support Tickets")) + can_reply_tickets = models.BooleanField(default=True, verbose_name=_("Reply to Support Tickets")) + can_manage_tickets = models.BooleanField(default=False, verbose_name=_("Manage Support Tickets")) + + # 10. Dynamic Forms + can_submit_forms = models.BooleanField(default=True, verbose_name=_("Submit Dynamic Forms")) + can_create_forms = models.BooleanField(default=False, verbose_name=_("Create Dynamic Forms")) + can_export_form_data = models.BooleanField(default=False, verbose_name=_("Export Form Responses")) + + # 11. Diplomacy & Institutional + can_view_diplomatic_reports = models.BooleanField(default=False, verbose_name=_("View Diplomatic Reports")) + can_create_diplomatic_reports = models.BooleanField(default=False, verbose_name=_("Create Diplomatic Reports")) + + # 12. Administration & Security + can_access_admin_panel = models.BooleanField(default=False, verbose_name=_("Access Admin Panel")) + can_manage_users = models.BooleanField(default=False, verbose_name=_("Manage Users")) + can_ban_users = models.BooleanField(default=False, verbose_name=_("Ban / Suspend Users")) + can_view_analytics = models.BooleanField(default=False, verbose_name=_("View Analytics Dashboard")) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + class Meta: + ordering = ('name',) + verbose_name = _("Role") + verbose_name_plural = _("Roles") + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + if self.is_default: + # Ensure only one default role exists + Role.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False) + super().save(*args, **kwargs) + + def get_permissions_dict(self): + """Returns a dict of all permission flags and their boolean values.""" + return {perm: getattr(self, perm, False) for perm in ALL_PERMISSION_FIELDS} diff --git a/apps/account/models/user.py b/apps/account/models/user.py index 8f75f76..d7ba04c 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -146,6 +146,21 @@ class User(AbstractUser): help_text=_("Encrypted copy of password for displaying in the admin panel if required.") ) + role = models.ForeignKey( + 'account.Role', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='users', + verbose_name=_("Assigned Role") + ) + custom_permissions = models.JSONField( + default=dict, + blank=True, + verbose_name=_("Custom Permission Overrides"), + help_text=_("Dictionary of permission overrides: {permission_name: True/False}") + ) + objects = UserManager() EMAIL_FIELD = "email" @@ -186,6 +201,15 @@ class User(AbstractUser): self.first_name = parts[0] self.last_name = "" + if not self.role_id: + try: + from apps.account.models.role import Role + default_role = Role.objects.filter(is_default=True).first() + if default_role: + self.role = default_role + except Exception: + pass + return super().save(*args, **kwargs) def get_full_name(self): @@ -202,27 +226,47 @@ class User(AbstractUser): def has_role(self, role_name): return self.groups.filter(name__iexact=f"{role_name} Group").exists() or self.user_type == role_name + def has_perm_flag(self, perm_name: str) -> bool: + """ + Check effective boolean permission flag: + 1. Superuser always returns True. + 2. Custom permission override on the user takes precedence if set. + 3. Falls back to assigned Role value if user has a role. + 4. Otherwise returns False. + """ + if not self.is_active: + return False + if self.is_superuser: + return True + if self.custom_permissions and perm_name in self.custom_permissions: + val = self.custom_permissions[perm_name] + if val is not None: + return bool(val) + if self.role and hasattr(self.role, perm_name): + return getattr(self.role, perm_name) + return False + @property def is_super_admin(self): - return self.is_superuser or self.user_type == self.UserType.SUPER_ADMIN + return self.is_superuser or self.user_type == self.UserType.SUPER_ADMIN or (self.role and self.role.slug == 'super_admin') @property def is_regional_admin(self): - return self.is_super_admin or self.user_type == self.UserType.REGIONAL_ADMIN + return self.is_super_admin or self.user_type == self.UserType.REGIONAL_ADMIN or (self.role and self.role.slug == 'regional_admin') @property def is_institution_admin(self): - return self.is_regional_admin or self.user_type == self.UserType.INSTITUTION_ADMIN + return self.is_regional_admin or self.user_type == self.UserType.INSTITUTION_ADMIN or (self.role and self.role.slug in ['institution_admin', 'institution_owner']) @property def is_editor(self): - return self.is_institution_admin or self.user_type == self.UserType.EDITOR + return self.is_institution_admin or self.user_type == self.UserType.EDITOR or (self.role and self.role.slug == 'content_moderator') def is_super_admin_panel_user(self): return self.is_superuser or self.user_type == self.UserType.SUPER_ADMIN def is_admin_panel_user(self): - return self.is_staff or self.is_superuser or self.user_type in [ + return self.is_staff or self.is_superuser or self.has_perm_flag('can_access_admin_panel') or self.user_type in [ self.UserType.SUPER_ADMIN, self.UserType.ADMIN, self.UserType.REGIONAL_ADMIN, @@ -236,6 +280,7 @@ class User(AbstractUser): return ( self.is_staff or self.is_superuser or + self.has_perm_flag('can_access_admin_panel') or self.user_type in [ self.UserType.SUPER_ADMIN, self.UserType.ADMIN, diff --git a/config/settings/base.py b/config/settings/base.py index a2b0d56..6901afa 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -351,13 +351,18 @@ UNFOLD = { "TABS": [ { "page": "accounts", - "models": ["account.user", "auth.group"], + "models": ["account.user", "account.role", "auth.group"], "items": [ { "title": _("Users"), "icon": "people", "link": lambda request: admin_url_generator(request, "account_user_changelist"), }, + { + "title": _("Roles & Permissions"), + "icon": "admin_panel_settings", + "link": lambda request: admin_url_generator(request, "account_role_changelist"), + }, { "title": _("Groups"), "icon": "shield", @@ -391,8 +396,14 @@ UNFOLD = { "link": lambda request: admin_url_generator(request, "account_user_changelist"), }, { - "title": _("Groups & Roles"), + "title": _("Roles & Permissions"), "icon": "admin_panel_settings", + "link": lambda request: admin_url_generator(request, "account_role_changelist"), + "permission": lambda request: request.user.can_access_admin_panel(), + }, + { + "title": _("Django Groups"), + "icon": "shield", "link": lambda request: admin_url_generator(request, "auth_group_changelist"), "permission": lambda request: request.user.is_staff, },