mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 15:31:09 -05:00
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:
@@ -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)
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
220
backend/apps/moderation/tests/test_admin.py
Normal file
220
backend/apps/moderation/tests/test_admin.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user