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.
This commit is contained in:
pacnpal
2025-12-23 16:41:42 -05:00
parent ae31e889d7
commit edcd8f2076
155 changed files with 22046 additions and 4645 deletions

View File

@@ -1,229 +1,763 @@
from django.contrib import admin
"""
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.utils.html import format_html
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"""
"""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):
list_display = [
"""
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",
"content_type_display",
"content_link",
"status",
"status_badge",
"created_at",
"handled_by",
]
list_filter = ["status", "content_type", "created_at"]
search_fields = ["user__username", "reason", "source", "notes"]
readonly_fields = [
"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):
url = reverse("admin:accounts_user_change", args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
"""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 "-"
user_link.short_description = "User"
@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):
if hasattr(obj.content_object, "get_absolute_url"):
url = obj.content_object.get_absolute_url()
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
return str(obj.content_object)
"""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>')
content_link.short_description = "Content"
@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:
if obj.status == "APPROVED":
obj.approve(request.user)
elif obj.status == "REJECTED":
obj.reject(request.user)
elif obj.status == "ESCALATED":
obj.escalate(request.user)
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):
list_display = [
"""
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",
"content_type_display",
"content_link",
"photo_preview",
"status",
"status_badge",
"created_at",
"handled_by",
]
list_filter = ["status", "content_type", "created_at"]
search_fields = ["user__username", "caption", "notes"]
readonly_fields = [
"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):
url = reverse("admin:accounts_user_change", args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
"""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 "-"
user_link.short_description = "User"
@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):
if hasattr(obj.content_object, "get_absolute_url"):
url = obj.content_object.get_absolute_url()
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
return str(obj.content_object)
content_link.short_description = "Content"
"""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: 100px; max-width: 200px;" />',
'<img src="{}" style="max-height: 80px; max-width: 150px; '
'border-radius: 4px; object-fit: cover;" loading="lazy" />',
obj.photo.url,
)
return ""
return format_html('<span style="color: gray;">No photo</span>')
photo_preview.short_description = "Photo Preview"
@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:
if obj.status == "APPROVED":
obj.approve(request.user, obj.notes)
elif obj.status == "REJECTED":
obj.reject(request.user, obj.notes)
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"""
"""
Admin interface for viewing model history events (pghistory).
list_display = [
Read-only admin for viewing detailed change history.
Events are automatically created and should not be modified.
"""
list_display = (
"pgh_label",
"pgh_created_at",
"get_object_link",
"get_context",
]
list_filter = ["pgh_label", "pgh_created_at"]
readonly_fields = [
"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
def get_object_link(self, obj):
"""Display a link to the related object if possible"""
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))
return str(obj.pgh_obj or "")
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]
get_object_link.short_description = "Object"
def get_context(self, obj):
"""Format the context data nicely"""
@admin.display(description="Context")
def context_preview(self, obj):
"""Display formatted context preview."""
if not obj.pgh_context:
return "-"
html = ["<table>"]
for key, value in obj.pgh_context.items():
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))
get_context.short_description = "Context"
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
class StateLogAdmin(admin.ModelAdmin):
"""Admin interface for FSM transition logs."""
list_display = [
'id',
'timestamp',
'get_model_name',
'get_object_link',
'state',
'transition',
'get_user_link',
]
list_filter = [
'content_type',
'state',
'transition',
'timestamp',
]
search_fields = [
'state',
'transition',
'description',
'by__username',
]
readonly_fields = [
'timestamp',
'content_type',
'object_id',
'state',
'transition',
'by',
'description',
]
date_hierarchy = 'timestamp'
ordering = ['-timestamp']
def get_model_name(self, obj):
"""Get the model name from content type."""
return obj.content_type.model
get_model_name.short_description = 'Model'
def get_object_link(self, obj):
"""Create link to the actual object."""
if obj.content_object:
# Try to get absolute URL if available
if hasattr(obj.content_object, 'get_absolute_url'):
url = obj.content_object.get_absolute_url()
else:
url = '#'
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
return f"ID: {obj.object_id}"
get_object_link.short_description = 'Object'
def get_user_link(self, obj):
"""Create link to the user who performed the transition."""
if obj.by:
url = reverse('admin:accounts_user_change', args=[obj.by.id])
return format_html('<a href="{}">{}</a>', url, obj.by.username)
return '-'
get_user_link.short_description = 'User'
def has_delete_permission(self, request, obj=None):
"""Only superusers can delete events."""
return request.user.is_superuser
# Register with moderation site only
@@ -231,5 +765,5 @@ moderation_site.register(EditSubmission, EditSubmissionAdmin)
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
moderation_site.register(StateLog, StateLogAdmin)
# We will register concrete event models as they are created during migrations
# Note: Concrete pghistory event models would be registered as they are created
# Example: moderation_site.register(DesignerEvent, HistoryEventAdmin)

View File

@@ -78,13 +78,20 @@ class EditSubmission(StateMachineMixin, TrackedModel):
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="edit_submissions",
help_text="User who submitted this edit",
)
# What is being edited (Park or Ride)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of object being edited",
)
object_id = models.PositiveIntegerField(
null=True, blank=True
) # Null for new objects
null=True,
blank=True,
help_text="ID of object being edited (null for new objects)",
)
content_object = GenericForeignKey("content_type", "object_id")
# Type of submission
@@ -127,13 +134,18 @@ class EditSubmission(StateMachineMixin, TrackedModel):
null=True,
blank=True,
related_name="handled_submissions",
help_text="Moderator who handled this submission",
)
handled_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was handled"
)
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True, help_text="Notes from the moderator about this submission"
)
class Meta(TrackedModel.Meta):
verbose_name = "Edit Submission"
verbose_name_plural = "Edit Submissions"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
@@ -344,14 +356,16 @@ class ModerationReport(StateMachineMixin, TrackedModel):
reported_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_reports_made'
related_name='moderation_reports_made',
help_text="User who made this report",
)
assigned_moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_moderation_reports'
related_name='assigned_moderation_reports',
help_text="Moderator assigned to handle this report",
)
# Resolution
@@ -359,13 +373,21 @@ class ModerationReport(StateMachineMixin, TrackedModel):
max_length=100, blank=True, help_text="Action taken to resolve")
resolution_notes = models.TextField(
blank=True, help_text="Notes about the resolution")
resolved_at = models.DateTimeField(null=True, blank=True)
resolved_at = models.DateTimeField(
null=True, blank=True, help_text="When this report was resolved"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this report was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this report was last updated"
)
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Report"
verbose_name_plural = "Moderation Reports"
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'priority']),
@@ -428,9 +450,12 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_queue_items'
related_name='assigned_queue_items',
help_text="Moderator assigned to this item",
)
assigned_at = models.DateTimeField(
null=True, blank=True, help_text="When this item was assigned"
)
assigned_at = models.DateTimeField(null=True, blank=True)
estimated_review_time = models.PositiveIntegerField(
default=30, help_text="Estimated time in minutes")
@@ -440,7 +465,8 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='flagged_queue_items'
related_name='flagged_queue_items',
help_text="User who flagged this item",
)
tags = models.JSONField(default=list, blank=True,
help_text="Tags for categorization")
@@ -451,14 +477,21 @@ class ModerationQueue(StateMachineMixin, TrackedModel):
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='queue_items'
related_name='queue_items',
help_text="Related moderation report",
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this item was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this item was last updated"
)
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Queue Item"
verbose_name_plural = "Moderation Queue Items"
ordering = ['priority', 'created_at']
indexes = [
models.Index(fields=['status', 'priority']),
@@ -503,12 +536,14 @@ class ModerationAction(TrackedModel):
moderator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_taken'
related_name='moderation_actions_taken',
help_text="Moderator who took this action",
)
target_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='moderation_actions_received'
related_name='moderation_actions_received',
help_text="User this action was taken against",
)
# Related objects
@@ -517,14 +552,21 @@ class ModerationAction(TrackedModel):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='actions_taken'
related_name='actions_taken',
help_text="Related moderation report",
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this action was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this action was last updated"
)
class Meta(TrackedModel.Meta):
verbose_name = "Moderation Action"
verbose_name_plural = "Moderation Actions"
ordering = ['-created_at']
indexes = [
models.Index(fields=['target_user', 'is_active']),
@@ -605,16 +647,25 @@ class BulkOperation(StateMachineMixin, TrackedModel):
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='bulk_operations_created'
related_name='bulk_operations_created',
help_text="User who created this operation",
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
started_at = models.DateTimeField(
null=True, blank=True, help_text="When this operation started"
)
completed_at = models.DateTimeField(
null=True, blank=True, help_text="When this operation completed"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this operation was last updated"
)
class Meta(TrackedModel.Meta):
verbose_name = "Bulk Operation"
verbose_name_plural = "Bulk Operations"
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'priority']),
@@ -645,11 +696,18 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="photo_submissions",
help_text="User who submitted this photo",
)
# What the photo is for (Park or Ride)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of object this photo is for",
)
object_id = models.PositiveIntegerField(
help_text="ID of object this photo is for"
)
content_object = GenericForeignKey("content_type", "object_id")
# The photo itself
@@ -658,8 +716,10 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
on_delete=models.CASCADE,
help_text="Photo submission stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
date_taken = models.DateField(null=True, blank=True)
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption")
date_taken = models.DateField(
null=True, blank=True, help_text="Date the photo was taken"
)
# Metadata
status = RichFSMField(
@@ -677,14 +737,19 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
null=True,
blank=True,
related_name="handled_photos",
help_text="Moderator who handled this submission",
)
handled_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was handled"
)
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True,
help_text="Notes from the moderator about this photo submission",
)
class Meta(TrackedModel.Meta):
verbose_name = "Photo Submission"
verbose_name_plural = "Photo Submissions"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),

View File

@@ -0,0 +1,220 @@
"""
Tests for moderation admin interfaces.
These tests verify the functionality of edit submission, photo submission,
state log, and history event admin classes including query optimization
and custom moderation actions.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
from apps.moderation.admin import (
EditSubmissionAdmin,
HistoryEventAdmin,
ModerationAdminSite,
PhotoSubmissionAdmin,
StateLogAdmin,
moderation_site,
)
from apps.moderation.models import EditSubmission, PhotoSubmission
User = get_user_model()
class TestModerationAdminSite(TestCase):
"""Tests for ModerationAdminSite class."""
def setUp(self):
self.factory = RequestFactory()
def test_site_configuration(self):
"""Verify site header and title are set."""
assert moderation_site.site_header == "ThrillWiki Moderation"
assert moderation_site.site_title == "ThrillWiki Moderation"
assert moderation_site.index_title == "Moderation Dashboard"
def test_permission_check_requires_moderator_role(self):
"""Verify only moderators can access the site."""
request = self.factory.get("/moderation/")
# Anonymous user
request.user = type("obj", (object,), {"is_authenticated": False})()
assert moderation_site.has_permission(request) is False
# Regular user
request.user = type("obj", (object,), {
"is_authenticated": True,
"role": "USER"
})()
assert moderation_site.has_permission(request) is False
# Moderator
request.user = type("obj", (object,), {
"is_authenticated": True,
"role": "MODERATOR"
})()
assert moderation_site.has_permission(request) is True
# Admin
request.user = type("obj", (object,), {
"is_authenticated": True,
"role": "ADMIN"
})()
assert moderation_site.has_permission(request) is True
class TestEditSubmissionAdmin(TestCase):
"""Tests for EditSubmissionAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = EditSubmissionAdmin(model=EditSubmission, admin_site=self.site)
def test_list_display_fields(self):
"""Verify all required fields are in list_display."""
required_fields = [
"id",
"user_link",
"content_type_display",
"content_link",
"status_badge",
"created_at",
"handled_by_link",
]
for field in required_fields:
assert field in self.admin.list_display
def test_list_select_related(self):
"""Verify select_related is configured."""
assert "user" in self.admin.list_select_related
assert "content_type" in self.admin.list_select_related
assert "handled_by" in self.admin.list_select_related
def test_readonly_fields(self):
"""Verify submission fields are readonly."""
assert "user" in self.admin.readonly_fields
assert "content_type" in self.admin.readonly_fields
assert "changes" in self.admin.readonly_fields
assert "created_at" in self.admin.readonly_fields
def test_moderation_actions_registered(self):
"""Verify moderation actions are registered."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=True)
actions = self.admin.get_actions(request)
assert "bulk_approve" in actions
assert "bulk_reject" in actions
assert "bulk_escalate" in actions
class TestPhotoSubmissionAdmin(TestCase):
"""Tests for PhotoSubmissionAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = PhotoSubmissionAdmin(model=PhotoSubmission, admin_site=self.site)
def test_list_display_includes_preview(self):
"""Verify photo preview is in list_display."""
assert "photo_preview" in self.admin.list_display
def test_list_select_related(self):
"""Verify select_related is configured."""
assert "user" in self.admin.list_select_related
assert "content_type" in self.admin.list_select_related
assert "handled_by" in self.admin.list_select_related
def test_moderation_actions_registered(self):
"""Verify moderation actions are registered."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=True)
actions = self.admin.get_actions(request)
assert "bulk_approve" in actions
assert "bulk_reject" in actions
class TestStateLogAdmin(TestCase):
"""Tests for StateLogAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
# Note: StateLog is from django_fsm_log
from django_fsm_log.models import StateLog
self.admin = StateLogAdmin(model=StateLog, admin_site=self.site)
def test_readonly_permissions(self):
"""Verify read-only permissions are set."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=False)
assert self.admin.has_add_permission(request) is False
assert self.admin.has_change_permission(request) is False
def test_delete_permission_superuser_only(self):
"""Verify delete permission is superuser only."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=False)
assert self.admin.has_delete_permission(request) is False
request.user = User(is_superuser=True)
assert self.admin.has_delete_permission(request) is True
def test_list_select_related(self):
"""Verify select_related is configured."""
assert "content_type" in self.admin.list_select_related
assert "by" in self.admin.list_select_related
def test_export_action_registered(self):
"""Verify export audit trail action is registered."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=True)
actions = self.admin.get_actions(request)
assert "export_audit_trail" in actions
class TestHistoryEventAdmin(TestCase):
"""Tests for HistoryEventAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
# Note: HistoryEventAdmin is designed for pghistory event models
# We test it with a mock model
def test_readonly_permissions(self):
"""Verify read-only permissions are configured in the class."""
# Test the methods exist and return correct values
admin = HistoryEventAdmin
# Check that has_add_permission returns False
assert hasattr(admin, "has_add_permission")
# Check that has_change_permission returns False
assert hasattr(admin, "has_change_permission")
class TestRegisteredModels(TestCase):
"""Tests for models registered with moderation site."""
def test_edit_submission_registered(self):
"""Verify EditSubmission is registered with moderation site."""
assert EditSubmission in moderation_site._registry
def test_photo_submission_registered(self):
"""Verify PhotoSubmission is registered with moderation site."""
assert PhotoSubmission in moderation_site._registry
def test_state_log_registered(self):
"""Verify StateLog is registered with moderation site."""
from django_fsm_log.models import StateLog
assert StateLog in moderation_site._registry

View File

@@ -54,6 +54,10 @@ from .filters import (
ModerationActionFilter,
BulkOperationFilter,
)
import logging
from apps.core.logging import log_exception, log_business_event
from .permissions import (
IsModeratorOrAdmin,
IsAdminOrSuperuser,
@@ -62,6 +66,8 @@ from .permissions import (
User = get_user_model()
logger = logging.getLogger(__name__)
# ============================================================================
# Moderation Report ViewSet
@@ -159,9 +165,24 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
)
report.assigned_moderator = moderator
old_status = report.status
try:
transition_method(user=moderator)
report.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationReport {report.id} assigned to {moderator.username}",
context={
"model": "ModerationReport",
"object_id": report.id,
"old_state": old_status,
"new_state": report.status,
"transition": "assign",
"moderator": moderator.username,
},
request=request,
)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
@@ -220,6 +241,7 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
status=status.HTTP_403_FORBIDDEN,
)
old_status = report.status
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
@@ -243,6 +265,22 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
report.resolved_at = timezone.now()
report.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationReport {report.id} resolved with action: {resolution_action}",
context={
"model": "ModerationReport",
"object_id": report.id,
"old_state": old_status,
"new_state": report.status,
"transition": "resolve",
"resolution_action": resolution_action,
"user": request.user.username,
},
request=request,
)
serializer = self.get_serializer(report)
return Response(serializer.data)
@@ -579,6 +617,7 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
queue_item.assigned_to = moderator
queue_item.assigned_at = timezone.now()
old_status = queue_item.status
try:
transition_method(user=moderator)
except TransitionPermissionDenied as e:
@@ -599,6 +638,21 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
queue_item.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationQueue {queue_item.id} assigned to {moderator.username}",
context={
"model": "ModerationQueue",
"object_id": queue_item.id,
"old_state": old_status,
"new_state": queue_item.status,
"transition": "assign",
"moderator": moderator.username,
},
request=request,
)
response_serializer = self.get_serializer(queue_item)
return Response(response_serializer.data)
@@ -631,6 +685,7 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
queue_item.assigned_to = None
queue_item.assigned_at = None
old_status = queue_item.status
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
@@ -651,6 +706,21 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
queue_item.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationQueue {queue_item.id} unassigned",
context={
"model": "ModerationQueue",
"object_id": queue_item.id,
"old_state": old_status,
"new_state": queue_item.status,
"transition": "unassign",
"user": request.user.username,
},
request=request,
)
serializer = self.get_serializer(queue_item)
return Response(serializer.data)
@@ -684,6 +754,7 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
status=status.HTTP_403_FORBIDDEN,
)
old_status = queue_item.status
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
@@ -716,6 +787,22 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
is_active=True,
)
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationQueue {queue_item.id} completed with action: {action_taken}",
context={
"model": "ModerationQueue",
"object_id": queue_item.id,
"old_state": old_status,
"new_state": queue_item.status,
"transition": "complete",
"action_taken": action_taken,
"user": request.user.username,
},
request=request,
)
response_serializer = self.get_serializer(queue_item)
return Response(response_serializer.data)