Files
thrillwiki_django_no_react/backend/apps/moderation/admin.py
pacnpal edcd8f2076 Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols.
- Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage.
- Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
2025-12-23 16:41:42 -05:00

770 lines
25 KiB
Python

"""
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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', url, str(content_obj)[:30])
return str(content_obj)[:30]
except Exception:
pass
return format_html('<span style="color: red;">Not found</span>')
@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(
'<span style="background-color: {}; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">{}</span>',
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('<a href="{}">{}</a>', 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 = ['<table style="border-collapse: collapse;">']
html.append("<tr><th>Field</th><th>Old</th><th>New</th></tr>")
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'<tr><td style="padding: 4px; border: 1px solid #ddd;">{field}</td>'
f'<td style="padding: 4px; border: 1px solid #ddd;">{old}</td>'
f'<td style="padding: 4px; border: 1px solid #ddd; color: green;">{new}</td></tr>'
)
html.append("</table>")
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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', url, str(content_obj)[:30])
return str(content_obj)[:30]
except Exception:
pass
return format_html('<span style="color: red;">Not found</span>')
@admin.display(description="Preview")
def photo_preview(self, obj):
"""Display photo preview thumbnail."""
if obj.photo:
return format_html(
'<img src="{}" style="max-height: 80px; max-width: 150px; '
'border-radius: 4px; object-fit: cover;" loading="lazy" />',
obj.photo.url,
)
return format_html('<span style="color: gray;">No photo</span>')
@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(
'<span style="background-color: {}; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">{}</span>',
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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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(
'<span style="background-color: {}; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">{}</span>',
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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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 = ['<table style="font-size: 11px;">']
for key, value in list(obj.pgh_context.items())[:3]:
html.append(f"<tr><th>{key}</th><td>{value}</td></tr>")
if len(obj.pgh_context) > 3:
html.append("<tr><td colspan='2'>...</td></tr>")
html.append("</table>")
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)