mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-25 10:11:08 -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)
|
||||
|
||||
Reference in New Issue
Block a user