""" Django admin configuration for the Moderation application. This module provides comprehensive admin interfaces for content moderation including edit submissions, photo submissions, and state transition logs. Includes a custom moderation admin site for dedicated moderation workflows. Performance targets: - List views: < 12 queries - Change views: < 15 queries - Page load time: < 500ms for 100 records """ from django.contrib import admin, messages from django.contrib.admin import AdminSite from django.db.models import Count from django.urls import reverse from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe from django_fsm_log.models import StateLog from .models import EditSubmission, PhotoSubmission class ModerationAdminSite(AdminSite): """ Custom admin site for moderation workflows. Provides a dedicated admin interface for moderators with: - Dashboard with pending counts - Quick action buttons - Moderation statistics - Activity feed Access is restricted to users with MODERATOR, ADMIN, or SUPERUSER roles. """ site_header = "ThrillWiki Moderation" site_title = "ThrillWiki Moderation" index_title = "Moderation Dashboard" def has_permission(self, request): """Only allow moderators and above to access this admin site.""" return request.user.is_authenticated and request.user.role in [ "MODERATOR", "ADMIN", "SUPERUSER", ] def index(self, request, extra_context=None): """Add dashboard statistics to the index page.""" extra_context = extra_context or {} # Get pending counts extra_context["pending_edits"] = EditSubmission.objects.filter( status="PENDING" ).count() extra_context["pending_photos"] = PhotoSubmission.objects.filter( status="PENDING" ).count() # Get recent activity extra_context["recent_edits"] = EditSubmission.objects.select_related( "user", "handled_by" ).order_by("-created_at")[:5] extra_context["recent_photos"] = PhotoSubmission.objects.select_related( "user", "handled_by" ).order_by("-created_at")[:5] return super().index(request, extra_context) moderation_site = ModerationAdminSite(name="moderation") class EditSubmissionAdmin(admin.ModelAdmin): """ Admin interface for edit submission moderation. Provides edit submission management with: - Bulk approve/reject/escalate actions - FSM-aware status handling - User and content linking - Change preview Query optimizations: - select_related: user, content_type, handled_by """ list_display = ( "id", "user_link", "content_type_display", "content_link", "status_badge", "created_at", "handled_by_link", ) list_filter = ("status", "content_type", "created_at") list_select_related = ["user", "content_type", "handled_by"] search_fields = ("user__username", "reason", "source", "notes", "object_id") readonly_fields = ( "user", "content_type", "object_id", "changes", "created_at", "changes_preview", ) list_per_page = 50 show_full_result_count = False ordering = ("-created_at",) date_hierarchy = "created_at" fieldsets = ( ( "Submission Details", { "fields": ("user", "content_type", "object_id"), "description": "Who submitted what.", }, ), ( "Proposed Changes", { "fields": ("changes", "changes_preview"), "description": "The changes being proposed.", }, ), ( "Submission Info", { "fields": ("reason", "source"), "classes": ("collapse",), "description": "Reason and source for the submission.", }, ), ( "Status", { "fields": ("status", "handled_by", "notes"), "description": "Current status and moderation notes.", }, ), ( "Metadata", { "fields": ("created_at",), "classes": ("collapse",), }, ), ) @admin.display(description="User") def user_link(self, obj): """Display user as clickable link.""" if obj.user: try: url = reverse("admin:accounts_customuser_change", args=[obj.user.id]) return format_html('{}', url, obj.user.username) except Exception: return obj.user.username return "-" @admin.display(description="Type") def content_type_display(self, obj): """Display content type in a readable format.""" if obj.content_type: return f"{obj.content_type.app_label}.{obj.content_type.model}" return "-" @admin.display(description="Content") def content_link(self, obj): """Display content object as clickable link.""" try: content_obj = obj.content_object if content_obj: if hasattr(content_obj, "get_absolute_url"): url = content_obj.get_absolute_url() return format_html('{}', url, str(content_obj)[:30]) return str(content_obj)[:30] except Exception: pass return format_html('Not found') @admin.display(description="Status") def status_badge(self, obj): """Display status with color-coded badge.""" colors = { "PENDING": "orange", "APPROVED": "green", "REJECTED": "red", "ESCALATED": "purple", } color = colors.get(obj.status, "gray") return format_html( '{}', color, obj.status, ) @admin.display(description="Handled By") def handled_by_link(self, obj): """Display handler as clickable link.""" if obj.handled_by: try: url = reverse("admin:accounts_customuser_change", args=[obj.handled_by.id]) return format_html('{}', url, obj.handled_by.username) except Exception: return obj.handled_by.username return "-" @admin.display(description="Changes Preview") def changes_preview(self, obj): """Display changes in a formatted preview.""" if obj.changes: html = [''] html.append("") for field, values in obj.changes.items(): if isinstance(values, dict): old = values.get("old", "-") new = values.get("new", "-") else: old = "-" new = str(values) html.append( f'' f'' f'' ) html.append("
FieldOldNew
{field}{old}{new}
") return mark_safe("".join(html)) return "-" def save_model(self, request, obj, form, change): """Handle FSM transitions on status change.""" if "status" in form.changed_data: try: if obj.status == "APPROVED": obj.approve(request.user) elif obj.status == "REJECTED": obj.reject(request.user) elif obj.status == "ESCALATED": obj.escalate(request.user) except Exception as e: messages.error(request, f"Status transition failed: {str(e)}") return super().save_model(request, obj, form, change) @admin.action(description="Approve selected submissions") def bulk_approve(self, request, queryset): """Approve all selected pending submissions.""" count = 0 errors = 0 for submission in queryset.filter(status="PENDING"): try: submission.approve(request.user) count += 1 except Exception: errors += 1 self.message_user(request, f"Approved {count} submissions.") if errors: self.message_user( request, f"Failed to approve {errors} submissions.", level=messages.WARNING, ) @admin.action(description="Reject selected submissions") def bulk_reject(self, request, queryset): """Reject all selected pending submissions.""" count = 0 for submission in queryset.filter(status="PENDING"): try: submission.reject(request.user) count += 1 except Exception: pass self.message_user(request, f"Rejected {count} submissions.") @admin.action(description="Escalate selected submissions") def bulk_escalate(self, request, queryset): """Escalate all selected pending submissions.""" count = 0 for submission in queryset.filter(status="PENDING"): try: submission.escalate(request.user) count += 1 except Exception: pass self.message_user(request, f"Escalated {count} submissions.") def get_actions(self, request): """Add moderation actions.""" actions = super().get_actions(request) actions["bulk_approve"] = ( self.bulk_approve, "bulk_approve", "Approve selected submissions", ) actions["bulk_reject"] = ( self.bulk_reject, "bulk_reject", "Reject selected submissions", ) actions["bulk_escalate"] = ( self.bulk_escalate, "bulk_escalate", "Escalate selected submissions", ) return actions class PhotoSubmissionAdmin(admin.ModelAdmin): """ Admin interface for photo submission moderation. Provides photo submission management with: - Image preview in list view - Bulk approve/reject actions - FSM-aware status handling - User and content linking Query optimizations: - select_related: user, content_type, handled_by """ list_display = ( "id", "user_link", "content_type_display", "content_link", "photo_preview", "status_badge", "created_at", "handled_by_link", ) list_filter = ("status", "content_type", "created_at") list_select_related = ["user", "content_type", "handled_by"] search_fields = ("user__username", "caption", "notes", "object_id") readonly_fields = ( "user", "content_type", "object_id", "photo_preview", "created_at", ) list_per_page = 50 show_full_result_count = False ordering = ("-created_at",) date_hierarchy = "created_at" fieldsets = ( ( "Submission Details", { "fields": ("user", "content_type", "object_id"), "description": "Who submitted what.", }, ), ( "Photo", { "fields": ("photo", "photo_preview", "caption"), "description": "The submitted photo.", }, ), ( "Status", { "fields": ("status", "handled_by", "notes"), "description": "Current status and moderation notes.", }, ), ( "Metadata", { "fields": ("created_at",), "classes": ("collapse",), }, ), ) @admin.display(description="User") def user_link(self, obj): """Display user as clickable link.""" if obj.user: try: url = reverse("admin:accounts_customuser_change", args=[obj.user.id]) return format_html('{}', url, obj.user.username) except Exception: return obj.user.username return "-" @admin.display(description="Type") def content_type_display(self, obj): """Display content type in a readable format.""" if obj.content_type: return f"{obj.content_type.app_label}.{obj.content_type.model}" return "-" @admin.display(description="Content") def content_link(self, obj): """Display content object as clickable link.""" try: content_obj = obj.content_object if content_obj: if hasattr(content_obj, "get_absolute_url"): url = content_obj.get_absolute_url() return format_html('{}', url, str(content_obj)[:30]) return str(content_obj)[:30] except Exception: pass return format_html('Not found') @admin.display(description="Preview") def photo_preview(self, obj): """Display photo preview thumbnail.""" if obj.photo: return format_html( '', obj.photo.url, ) return format_html('No photo') @admin.display(description="Status") def status_badge(self, obj): """Display status with color-coded badge.""" colors = { "PENDING": "orange", "APPROVED": "green", "REJECTED": "red", } color = colors.get(obj.status, "gray") return format_html( '{}', color, obj.status, ) @admin.display(description="Handled By") def handled_by_link(self, obj): """Display handler as clickable link.""" if obj.handled_by: try: url = reverse("admin:accounts_customuser_change", args=[obj.handled_by.id]) return format_html('{}', url, obj.handled_by.username) except Exception: return obj.handled_by.username return "-" def save_model(self, request, obj, form, change): """Handle FSM transitions on status change.""" if "status" in form.changed_data: try: if obj.status == "APPROVED": obj.approve(request.user, obj.notes) elif obj.status == "REJECTED": obj.reject(request.user, obj.notes) except Exception as e: messages.error(request, f"Status transition failed: {str(e)}") return super().save_model(request, obj, form, change) @admin.action(description="Approve selected photos") def bulk_approve(self, request, queryset): """Approve all selected pending photo submissions.""" count = 0 for submission in queryset.filter(status="PENDING"): try: submission.approve(request.user, "Bulk approved") count += 1 except Exception: pass self.message_user(request, f"Approved {count} photo submissions.") @admin.action(description="Reject selected photos") def bulk_reject(self, request, queryset): """Reject all selected pending photo submissions.""" count = 0 for submission in queryset.filter(status="PENDING"): try: submission.reject(request.user, "Bulk rejected") count += 1 except Exception: pass self.message_user(request, f"Rejected {count} photo submissions.") def get_actions(self, request): """Add moderation actions.""" actions = super().get_actions(request) actions["bulk_approve"] = ( self.bulk_approve, "bulk_approve", "Approve selected photos", ) actions["bulk_reject"] = ( self.bulk_reject, "bulk_reject", "Reject selected photos", ) return actions class StateLogAdmin(admin.ModelAdmin): """ Admin interface for FSM state transition logs. Read-only admin for viewing state machine transition history. Logs are automatically created and should not be modified. Query optimizations: - select_related: content_type, by """ list_display = ( "id", "timestamp", "model_name", "object_link", "state_badge", "transition", "user_link", ) list_filter = ("content_type", "state", "transition", "timestamp") list_select_related = ["content_type", "by"] search_fields = ("state", "transition", "description", "by__username", "object_id") readonly_fields = ( "timestamp", "content_type", "object_id", "state", "transition", "by", "description", ) date_hierarchy = "timestamp" ordering = ("-timestamp",) list_per_page = 50 show_full_result_count = False fieldsets = ( ( "Transition Details", { "fields": ("state", "transition", "description"), "description": "The state transition that occurred.", }, ), ( "Related Object", { "fields": ("content_type", "object_id"), "description": "The object that was transitioned.", }, ), ( "Audit", { "fields": ("by", "timestamp"), "description": "Who performed the transition and when.", }, ), ) @admin.display(description="Model") def model_name(self, obj): """Display the model name from content type.""" if obj.content_type: return obj.content_type.model return "-" @admin.display(description="Object") def object_link(self, obj): """Display object as clickable link.""" try: content_obj = obj.content_object if content_obj: if hasattr(content_obj, "get_absolute_url"): url = content_obj.get_absolute_url() return format_html('{}', url, str(content_obj)[:30]) return str(content_obj)[:30] except Exception: pass return f"ID: {obj.object_id}" @admin.display(description="State") def state_badge(self, obj): """Display state with color-coded badge.""" colors = { "PENDING": "orange", "APPROVED": "green", "REJECTED": "red", "ESCALATED": "purple", "operating": "green", "closed": "red", "sbno": "orange", } color = colors.get(obj.state, "gray") return format_html( '{}', color, obj.state, ) @admin.display(description="User") def user_link(self, obj): """Display user as clickable link.""" if obj.by: try: url = reverse("admin:accounts_customuser_change", args=[obj.by.id]) return format_html('{}', url, obj.by.username) except Exception: return obj.by.username return "-" def has_add_permission(self, request): """Disable manual creation of state logs.""" return False def has_change_permission(self, request, obj=None): """Disable editing of state logs.""" return False def has_delete_permission(self, request, obj=None): """Only superusers can delete logs.""" return request.user.is_superuser @admin.action(description="Export audit trail to CSV") def export_audit_trail(self, request, queryset): """Export selected state logs for audit reporting.""" import csv from io import StringIO from django.http import HttpResponse output = StringIO() writer = csv.writer(output) writer.writerow( ["ID", "Timestamp", "Model", "Object ID", "State", "Transition", "User"] ) for log in queryset: writer.writerow( [ log.id, log.timestamp.isoformat(), log.content_type.model if log.content_type else "", log.object_id, log.state, log.transition, log.by.username if log.by else "", ] ) response = HttpResponse(output.getvalue(), content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="state_log_audit.csv"' self.message_user(request, f"Exported {queryset.count()} log entries.") return response def get_actions(self, request): """Add export action.""" actions = super().get_actions(request) actions["export_audit_trail"] = ( self.export_audit_trail, "export_audit_trail", "Export audit trail to CSV", ) return actions class HistoryEventAdmin(admin.ModelAdmin): """ Admin interface for viewing model history events (pghistory). Read-only admin for viewing detailed change history. Events are automatically created and should not be modified. """ list_display = ( "pgh_label", "pgh_created_at", "object_link", "context_preview", ) list_filter = ("pgh_label", "pgh_created_at") readonly_fields = ( "pgh_label", "pgh_obj_id", "pgh_data", "pgh_context", "pgh_created_at", ) date_hierarchy = "pgh_created_at" ordering = ("-pgh_created_at",) list_per_page = 50 show_full_result_count = False fieldsets = ( ( "Event Information", { "fields": ("pgh_label", "pgh_created_at"), "description": "Event type and timing.", }, ), ( "Related Object", { "fields": ("pgh_obj_id",), "description": "The object this event belongs to.", }, ), ( "Data", { "fields": ("pgh_data", "pgh_context"), "classes": ("collapse",), "description": "Detailed data and context at time of event.", }, ), ) @admin.display(description="Object") def object_link(self, obj): """Display link to the related object.""" if obj.pgh_obj and hasattr(obj.pgh_obj, "get_absolute_url"): url = obj.pgh_obj.get_absolute_url() return format_html('{}', url, str(obj.pgh_obj)[:30]) return str(obj.pgh_obj or f"ID: {obj.pgh_obj_id}")[:30] @admin.display(description="Context") def context_preview(self, obj): """Display formatted context preview.""" if not obj.pgh_context: return "-" html = [''] for key, value in list(obj.pgh_context.items())[:3]: html.append(f"") if len(obj.pgh_context) > 3: html.append("") html.append("
{key}{value}
...
") return mark_safe("".join(html)) def has_add_permission(self, request): """Disable manual creation of history events.""" return False def has_change_permission(self, request, obj=None): """Disable editing of history events.""" return False def has_delete_permission(self, request, obj=None): """Only superusers can delete events.""" return request.user.is_superuser # Register with moderation site only moderation_site.register(EditSubmission, EditSubmissionAdmin) moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin) moderation_site.register(StateLog, StateLogAdmin) # Note: Concrete pghistory event models would be registered as they are created # Example: moderation_site.register(DesignerEvent, HistoryEventAdmin)