"""
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("| Field | Old | New |
")
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'| {field} | '
f'{old} | '
f'{new} |
'
)
html.append("
")
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"| {key} | {value} |
")
if len(obj.pgh_context) > 3:
html.append("| ... |
")
html.append("
")
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)