mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 07:31:09 -05:00
- 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.
770 lines
25 KiB
Python
770 lines
25 KiB
Python
"""
|
|
Django admin configuration for the Moderation application.
|
|
|
|
This module provides comprehensive admin interfaces for content moderation
|
|
including edit submissions, photo submissions, and state transition logs.
|
|
Includes a custom moderation admin site for dedicated moderation workflows.
|
|
|
|
Performance targets:
|
|
- List views: < 12 queries
|
|
- Change views: < 15 queries
|
|
- Page load time: < 500ms for 100 records
|
|
"""
|
|
|
|
from django.contrib import admin, messages
|
|
from django.contrib.admin import AdminSite
|
|
from django.db.models import Count
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.utils.html import format_html
|
|
from django.utils.safestring import mark_safe
|
|
from django_fsm_log.models import StateLog
|
|
|
|
from .models import EditSubmission, PhotoSubmission
|
|
|
|
|
|
class ModerationAdminSite(AdminSite):
|
|
"""
|
|
Custom admin site for moderation workflows.
|
|
|
|
Provides a dedicated admin interface for moderators with:
|
|
- Dashboard with pending counts
|
|
- Quick action buttons
|
|
- Moderation statistics
|
|
- Activity feed
|
|
|
|
Access is restricted to users with MODERATOR, ADMIN, or SUPERUSER roles.
|
|
"""
|
|
|
|
site_header = "ThrillWiki Moderation"
|
|
site_title = "ThrillWiki Moderation"
|
|
index_title = "Moderation Dashboard"
|
|
|
|
def has_permission(self, request):
|
|
"""Only allow moderators and above to access this admin site."""
|
|
return request.user.is_authenticated and request.user.role in [
|
|
"MODERATOR",
|
|
"ADMIN",
|
|
"SUPERUSER",
|
|
]
|
|
|
|
def index(self, request, extra_context=None):
|
|
"""Add dashboard statistics to the index page."""
|
|
extra_context = extra_context or {}
|
|
|
|
# Get pending counts
|
|
extra_context["pending_edits"] = EditSubmission.objects.filter(
|
|
status="PENDING"
|
|
).count()
|
|
extra_context["pending_photos"] = PhotoSubmission.objects.filter(
|
|
status="PENDING"
|
|
).count()
|
|
|
|
# Get recent activity
|
|
extra_context["recent_edits"] = EditSubmission.objects.select_related(
|
|
"user", "handled_by"
|
|
).order_by("-created_at")[:5]
|
|
extra_context["recent_photos"] = PhotoSubmission.objects.select_related(
|
|
"user", "handled_by"
|
|
).order_by("-created_at")[:5]
|
|
|
|
return super().index(request, extra_context)
|
|
|
|
|
|
moderation_site = ModerationAdminSite(name="moderation")
|
|
|
|
|
|
class EditSubmissionAdmin(admin.ModelAdmin):
|
|
"""
|
|
Admin interface for edit submission moderation.
|
|
|
|
Provides edit submission management with:
|
|
- Bulk approve/reject/escalate actions
|
|
- FSM-aware status handling
|
|
- User and content linking
|
|
- Change preview
|
|
|
|
Query optimizations:
|
|
- select_related: user, content_type, handled_by
|
|
"""
|
|
|
|
list_display = (
|
|
"id",
|
|
"user_link",
|
|
"content_type_display",
|
|
"content_link",
|
|
"status_badge",
|
|
"created_at",
|
|
"handled_by_link",
|
|
)
|
|
list_filter = ("status", "content_type", "created_at")
|
|
list_select_related = ["user", "content_type", "handled_by"]
|
|
search_fields = ("user__username", "reason", "source", "notes", "object_id")
|
|
readonly_fields = (
|
|
"user",
|
|
"content_type",
|
|
"object_id",
|
|
"changes",
|
|
"created_at",
|
|
"changes_preview",
|
|
)
|
|
list_per_page = 50
|
|
show_full_result_count = False
|
|
ordering = ("-created_at",)
|
|
date_hierarchy = "created_at"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Submission Details",
|
|
{
|
|
"fields": ("user", "content_type", "object_id"),
|
|
"description": "Who submitted what.",
|
|
},
|
|
),
|
|
(
|
|
"Proposed Changes",
|
|
{
|
|
"fields": ("changes", "changes_preview"),
|
|
"description": "The changes being proposed.",
|
|
},
|
|
),
|
|
(
|
|
"Submission Info",
|
|
{
|
|
"fields": ("reason", "source"),
|
|
"classes": ("collapse",),
|
|
"description": "Reason and source for the submission.",
|
|
},
|
|
),
|
|
(
|
|
"Status",
|
|
{
|
|
"fields": ("status", "handled_by", "notes"),
|
|
"description": "Current status and moderation notes.",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at",),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.user:
|
|
try:
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.id])
|
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
|
except Exception:
|
|
return obj.user.username
|
|
return "-"
|
|
|
|
@admin.display(description="Type")
|
|
def content_type_display(self, obj):
|
|
"""Display content type in a readable format."""
|
|
if obj.content_type:
|
|
return f"{obj.content_type.app_label}.{obj.content_type.model}"
|
|
return "-"
|
|
|
|
@admin.display(description="Content")
|
|
def content_link(self, obj):
|
|
"""Display content object as clickable link."""
|
|
try:
|
|
content_obj = obj.content_object
|
|
if content_obj:
|
|
if hasattr(content_obj, "get_absolute_url"):
|
|
url = content_obj.get_absolute_url()
|
|
return format_html('<a href="{}">{}</a>', url, str(content_obj)[:30])
|
|
return str(content_obj)[:30]
|
|
except Exception:
|
|
pass
|
|
return format_html('<span style="color: red;">Not found</span>')
|
|
|
|
@admin.display(description="Status")
|
|
def status_badge(self, obj):
|
|
"""Display status with color-coded badge."""
|
|
colors = {
|
|
"PENDING": "orange",
|
|
"APPROVED": "green",
|
|
"REJECTED": "red",
|
|
"ESCALATED": "purple",
|
|
}
|
|
color = colors.get(obj.status, "gray")
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
obj.status,
|
|
)
|
|
|
|
@admin.display(description="Handled By")
|
|
def handled_by_link(self, obj):
|
|
"""Display handler as clickable link."""
|
|
if obj.handled_by:
|
|
try:
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.handled_by.id])
|
|
return format_html('<a href="{}">{}</a>', url, obj.handled_by.username)
|
|
except Exception:
|
|
return obj.handled_by.username
|
|
return "-"
|
|
|
|
@admin.display(description="Changes Preview")
|
|
def changes_preview(self, obj):
|
|
"""Display changes in a formatted preview."""
|
|
if obj.changes:
|
|
html = ['<table style="border-collapse: collapse;">']
|
|
html.append("<tr><th>Field</th><th>Old</th><th>New</th></tr>")
|
|
for field, values in obj.changes.items():
|
|
if isinstance(values, dict):
|
|
old = values.get("old", "-")
|
|
new = values.get("new", "-")
|
|
else:
|
|
old = "-"
|
|
new = str(values)
|
|
html.append(
|
|
f'<tr><td style="padding: 4px; border: 1px solid #ddd;">{field}</td>'
|
|
f'<td style="padding: 4px; border: 1px solid #ddd;">{old}</td>'
|
|
f'<td style="padding: 4px; border: 1px solid #ddd; color: green;">{new}</td></tr>'
|
|
)
|
|
html.append("</table>")
|
|
return mark_safe("".join(html))
|
|
return "-"
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
"""Handle FSM transitions on status change."""
|
|
if "status" in form.changed_data:
|
|
try:
|
|
if obj.status == "APPROVED":
|
|
obj.approve(request.user)
|
|
elif obj.status == "REJECTED":
|
|
obj.reject(request.user)
|
|
elif obj.status == "ESCALATED":
|
|
obj.escalate(request.user)
|
|
except Exception as e:
|
|
messages.error(request, f"Status transition failed: {str(e)}")
|
|
return
|
|
super().save_model(request, obj, form, change)
|
|
|
|
@admin.action(description="Approve selected submissions")
|
|
def bulk_approve(self, request, queryset):
|
|
"""Approve all selected pending submissions."""
|
|
count = 0
|
|
errors = 0
|
|
for submission in queryset.filter(status="PENDING"):
|
|
try:
|
|
submission.approve(request.user)
|
|
count += 1
|
|
except Exception:
|
|
errors += 1
|
|
self.message_user(request, f"Approved {count} submissions.")
|
|
if errors:
|
|
self.message_user(
|
|
request,
|
|
f"Failed to approve {errors} submissions.",
|
|
level=messages.WARNING,
|
|
)
|
|
|
|
@admin.action(description="Reject selected submissions")
|
|
def bulk_reject(self, request, queryset):
|
|
"""Reject all selected pending submissions."""
|
|
count = 0
|
|
for submission in queryset.filter(status="PENDING"):
|
|
try:
|
|
submission.reject(request.user)
|
|
count += 1
|
|
except Exception:
|
|
pass
|
|
self.message_user(request, f"Rejected {count} submissions.")
|
|
|
|
@admin.action(description="Escalate selected submissions")
|
|
def bulk_escalate(self, request, queryset):
|
|
"""Escalate all selected pending submissions."""
|
|
count = 0
|
|
for submission in queryset.filter(status="PENDING"):
|
|
try:
|
|
submission.escalate(request.user)
|
|
count += 1
|
|
except Exception:
|
|
pass
|
|
self.message_user(request, f"Escalated {count} submissions.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add moderation actions."""
|
|
actions = super().get_actions(request)
|
|
actions["bulk_approve"] = (
|
|
self.bulk_approve,
|
|
"bulk_approve",
|
|
"Approve selected submissions",
|
|
)
|
|
actions["bulk_reject"] = (
|
|
self.bulk_reject,
|
|
"bulk_reject",
|
|
"Reject selected submissions",
|
|
)
|
|
actions["bulk_escalate"] = (
|
|
self.bulk_escalate,
|
|
"bulk_escalate",
|
|
"Escalate selected submissions",
|
|
)
|
|
return actions
|
|
|
|
|
|
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
|
"""
|
|
Admin interface for photo submission moderation.
|
|
|
|
Provides photo submission management with:
|
|
- Image preview in list view
|
|
- Bulk approve/reject actions
|
|
- FSM-aware status handling
|
|
- User and content linking
|
|
|
|
Query optimizations:
|
|
- select_related: user, content_type, handled_by
|
|
"""
|
|
|
|
list_display = (
|
|
"id",
|
|
"user_link",
|
|
"content_type_display",
|
|
"content_link",
|
|
"photo_preview",
|
|
"status_badge",
|
|
"created_at",
|
|
"handled_by_link",
|
|
)
|
|
list_filter = ("status", "content_type", "created_at")
|
|
list_select_related = ["user", "content_type", "handled_by"]
|
|
search_fields = ("user__username", "caption", "notes", "object_id")
|
|
readonly_fields = (
|
|
"user",
|
|
"content_type",
|
|
"object_id",
|
|
"photo_preview",
|
|
"created_at",
|
|
)
|
|
list_per_page = 50
|
|
show_full_result_count = False
|
|
ordering = ("-created_at",)
|
|
date_hierarchy = "created_at"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Submission Details",
|
|
{
|
|
"fields": ("user", "content_type", "object_id"),
|
|
"description": "Who submitted what.",
|
|
},
|
|
),
|
|
(
|
|
"Photo",
|
|
{
|
|
"fields": ("photo", "photo_preview", "caption"),
|
|
"description": "The submitted photo.",
|
|
},
|
|
),
|
|
(
|
|
"Status",
|
|
{
|
|
"fields": ("status", "handled_by", "notes"),
|
|
"description": "Current status and moderation notes.",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at",),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.user:
|
|
try:
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.id])
|
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
|
except Exception:
|
|
return obj.user.username
|
|
return "-"
|
|
|
|
@admin.display(description="Type")
|
|
def content_type_display(self, obj):
|
|
"""Display content type in a readable format."""
|
|
if obj.content_type:
|
|
return f"{obj.content_type.app_label}.{obj.content_type.model}"
|
|
return "-"
|
|
|
|
@admin.display(description="Content")
|
|
def content_link(self, obj):
|
|
"""Display content object as clickable link."""
|
|
try:
|
|
content_obj = obj.content_object
|
|
if content_obj:
|
|
if hasattr(content_obj, "get_absolute_url"):
|
|
url = content_obj.get_absolute_url()
|
|
return format_html('<a href="{}">{}</a>', url, str(content_obj)[:30])
|
|
return str(content_obj)[:30]
|
|
except Exception:
|
|
pass
|
|
return format_html('<span style="color: red;">Not found</span>')
|
|
|
|
@admin.display(description="Preview")
|
|
def photo_preview(self, obj):
|
|
"""Display photo preview thumbnail."""
|
|
if obj.photo:
|
|
return format_html(
|
|
'<img src="{}" style="max-height: 80px; max-width: 150px; '
|
|
'border-radius: 4px; object-fit: cover;" loading="lazy" />',
|
|
obj.photo.url,
|
|
)
|
|
return format_html('<span style="color: gray;">No photo</span>')
|
|
|
|
@admin.display(description="Status")
|
|
def status_badge(self, obj):
|
|
"""Display status with color-coded badge."""
|
|
colors = {
|
|
"PENDING": "orange",
|
|
"APPROVED": "green",
|
|
"REJECTED": "red",
|
|
}
|
|
color = colors.get(obj.status, "gray")
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
obj.status,
|
|
)
|
|
|
|
@admin.display(description="Handled By")
|
|
def handled_by_link(self, obj):
|
|
"""Display handler as clickable link."""
|
|
if obj.handled_by:
|
|
try:
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.handled_by.id])
|
|
return format_html('<a href="{}">{}</a>', url, obj.handled_by.username)
|
|
except Exception:
|
|
return obj.handled_by.username
|
|
return "-"
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
"""Handle FSM transitions on status change."""
|
|
if "status" in form.changed_data:
|
|
try:
|
|
if obj.status == "APPROVED":
|
|
obj.approve(request.user, obj.notes)
|
|
elif obj.status == "REJECTED":
|
|
obj.reject(request.user, obj.notes)
|
|
except Exception as e:
|
|
messages.error(request, f"Status transition failed: {str(e)}")
|
|
return
|
|
super().save_model(request, obj, form, change)
|
|
|
|
@admin.action(description="Approve selected photos")
|
|
def bulk_approve(self, request, queryset):
|
|
"""Approve all selected pending photo submissions."""
|
|
count = 0
|
|
for submission in queryset.filter(status="PENDING"):
|
|
try:
|
|
submission.approve(request.user, "Bulk approved")
|
|
count += 1
|
|
except Exception:
|
|
pass
|
|
self.message_user(request, f"Approved {count} photo submissions.")
|
|
|
|
@admin.action(description="Reject selected photos")
|
|
def bulk_reject(self, request, queryset):
|
|
"""Reject all selected pending photo submissions."""
|
|
count = 0
|
|
for submission in queryset.filter(status="PENDING"):
|
|
try:
|
|
submission.reject(request.user, "Bulk rejected")
|
|
count += 1
|
|
except Exception:
|
|
pass
|
|
self.message_user(request, f"Rejected {count} photo submissions.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add moderation actions."""
|
|
actions = super().get_actions(request)
|
|
actions["bulk_approve"] = (
|
|
self.bulk_approve,
|
|
"bulk_approve",
|
|
"Approve selected photos",
|
|
)
|
|
actions["bulk_reject"] = (
|
|
self.bulk_reject,
|
|
"bulk_reject",
|
|
"Reject selected photos",
|
|
)
|
|
return actions
|
|
|
|
|
|
class StateLogAdmin(admin.ModelAdmin):
|
|
"""
|
|
Admin interface for FSM state transition logs.
|
|
|
|
Read-only admin for viewing state machine transition history.
|
|
Logs are automatically created and should not be modified.
|
|
|
|
Query optimizations:
|
|
- select_related: content_type, by
|
|
"""
|
|
|
|
list_display = (
|
|
"id",
|
|
"timestamp",
|
|
"model_name",
|
|
"object_link",
|
|
"state_badge",
|
|
"transition",
|
|
"user_link",
|
|
)
|
|
list_filter = ("content_type", "state", "transition", "timestamp")
|
|
list_select_related = ["content_type", "by"]
|
|
search_fields = ("state", "transition", "description", "by__username", "object_id")
|
|
readonly_fields = (
|
|
"timestamp",
|
|
"content_type",
|
|
"object_id",
|
|
"state",
|
|
"transition",
|
|
"by",
|
|
"description",
|
|
)
|
|
date_hierarchy = "timestamp"
|
|
ordering = ("-timestamp",)
|
|
list_per_page = 50
|
|
show_full_result_count = False
|
|
|
|
fieldsets = (
|
|
(
|
|
"Transition Details",
|
|
{
|
|
"fields": ("state", "transition", "description"),
|
|
"description": "The state transition that occurred.",
|
|
},
|
|
),
|
|
(
|
|
"Related Object",
|
|
{
|
|
"fields": ("content_type", "object_id"),
|
|
"description": "The object that was transitioned.",
|
|
},
|
|
),
|
|
(
|
|
"Audit",
|
|
{
|
|
"fields": ("by", "timestamp"),
|
|
"description": "Who performed the transition and when.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Model")
|
|
def model_name(self, obj):
|
|
"""Display the model name from content type."""
|
|
if obj.content_type:
|
|
return obj.content_type.model
|
|
return "-"
|
|
|
|
@admin.display(description="Object")
|
|
def object_link(self, obj):
|
|
"""Display object as clickable link."""
|
|
try:
|
|
content_obj = obj.content_object
|
|
if content_obj:
|
|
if hasattr(content_obj, "get_absolute_url"):
|
|
url = content_obj.get_absolute_url()
|
|
return format_html('<a href="{}">{}</a>', url, str(content_obj)[:30])
|
|
return str(content_obj)[:30]
|
|
except Exception:
|
|
pass
|
|
return f"ID: {obj.object_id}"
|
|
|
|
@admin.display(description="State")
|
|
def state_badge(self, obj):
|
|
"""Display state with color-coded badge."""
|
|
colors = {
|
|
"PENDING": "orange",
|
|
"APPROVED": "green",
|
|
"REJECTED": "red",
|
|
"ESCALATED": "purple",
|
|
"operating": "green",
|
|
"closed": "red",
|
|
"sbno": "orange",
|
|
}
|
|
color = colors.get(obj.state, "gray")
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
obj.state,
|
|
)
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.by:
|
|
try:
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.by.id])
|
|
return format_html('<a href="{}">{}</a>', url, obj.by.username)
|
|
except Exception:
|
|
return obj.by.username
|
|
return "-"
|
|
|
|
def has_add_permission(self, request):
|
|
"""Disable manual creation of state logs."""
|
|
return False
|
|
|
|
def has_change_permission(self, request, obj=None):
|
|
"""Disable editing of state logs."""
|
|
return False
|
|
|
|
def has_delete_permission(self, request, obj=None):
|
|
"""Only superusers can delete logs."""
|
|
return request.user.is_superuser
|
|
|
|
@admin.action(description="Export audit trail to CSV")
|
|
def export_audit_trail(self, request, queryset):
|
|
"""Export selected state logs for audit reporting."""
|
|
import csv
|
|
from io import StringIO
|
|
|
|
from django.http import HttpResponse
|
|
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(
|
|
["ID", "Timestamp", "Model", "Object ID", "State", "Transition", "User"]
|
|
)
|
|
|
|
for log in queryset:
|
|
writer.writerow(
|
|
[
|
|
log.id,
|
|
log.timestamp.isoformat(),
|
|
log.content_type.model if log.content_type else "",
|
|
log.object_id,
|
|
log.state,
|
|
log.transition,
|
|
log.by.username if log.by else "",
|
|
]
|
|
)
|
|
|
|
response = HttpResponse(output.getvalue(), content_type="text/csv")
|
|
response["Content-Disposition"] = 'attachment; filename="state_log_audit.csv"'
|
|
self.message_user(request, f"Exported {queryset.count()} log entries.")
|
|
return response
|
|
|
|
def get_actions(self, request):
|
|
"""Add export action."""
|
|
actions = super().get_actions(request)
|
|
actions["export_audit_trail"] = (
|
|
self.export_audit_trail,
|
|
"export_audit_trail",
|
|
"Export audit trail to CSV",
|
|
)
|
|
return actions
|
|
|
|
|
|
class HistoryEventAdmin(admin.ModelAdmin):
|
|
"""
|
|
Admin interface for viewing model history events (pghistory).
|
|
|
|
Read-only admin for viewing detailed change history.
|
|
Events are automatically created and should not be modified.
|
|
"""
|
|
|
|
list_display = (
|
|
"pgh_label",
|
|
"pgh_created_at",
|
|
"object_link",
|
|
"context_preview",
|
|
)
|
|
list_filter = ("pgh_label", "pgh_created_at")
|
|
readonly_fields = (
|
|
"pgh_label",
|
|
"pgh_obj_id",
|
|
"pgh_data",
|
|
"pgh_context",
|
|
"pgh_created_at",
|
|
)
|
|
date_hierarchy = "pgh_created_at"
|
|
ordering = ("-pgh_created_at",)
|
|
list_per_page = 50
|
|
show_full_result_count = False
|
|
|
|
fieldsets = (
|
|
(
|
|
"Event Information",
|
|
{
|
|
"fields": ("pgh_label", "pgh_created_at"),
|
|
"description": "Event type and timing.",
|
|
},
|
|
),
|
|
(
|
|
"Related Object",
|
|
{
|
|
"fields": ("pgh_obj_id",),
|
|
"description": "The object this event belongs to.",
|
|
},
|
|
),
|
|
(
|
|
"Data",
|
|
{
|
|
"fields": ("pgh_data", "pgh_context"),
|
|
"classes": ("collapse",),
|
|
"description": "Detailed data and context at time of event.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Object")
|
|
def object_link(self, obj):
|
|
"""Display link to the related object."""
|
|
if obj.pgh_obj and hasattr(obj.pgh_obj, "get_absolute_url"):
|
|
url = obj.pgh_obj.get_absolute_url()
|
|
return format_html('<a href="{}">{}</a>', url, str(obj.pgh_obj)[:30])
|
|
return str(obj.pgh_obj or f"ID: {obj.pgh_obj_id}")[:30]
|
|
|
|
@admin.display(description="Context")
|
|
def context_preview(self, obj):
|
|
"""Display formatted context preview."""
|
|
if not obj.pgh_context:
|
|
return "-"
|
|
html = ['<table style="font-size: 11px;">']
|
|
for key, value in list(obj.pgh_context.items())[:3]:
|
|
html.append(f"<tr><th>{key}</th><td>{value}</td></tr>")
|
|
if len(obj.pgh_context) > 3:
|
|
html.append("<tr><td colspan='2'>...</td></tr>")
|
|
html.append("</table>")
|
|
return mark_safe("".join(html))
|
|
|
|
def has_add_permission(self, request):
|
|
"""Disable manual creation of history events."""
|
|
return False
|
|
|
|
def has_change_permission(self, request, obj=None):
|
|
"""Disable editing of history events."""
|
|
return False
|
|
|
|
def has_delete_permission(self, request, obj=None):
|
|
"""Only superusers can delete events."""
|
|
return request.user.is_superuser
|
|
|
|
|
|
# Register with moderation site only
|
|
moderation_site.register(EditSubmission, EditSubmissionAdmin)
|
|
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
|
|
moderation_site.register(StateLog, StateLogAdmin)
|
|
|
|
# Note: Concrete pghistory event models would be registered as they are created
|
|
# Example: moderation_site.register(DesignerEvent, HistoryEventAdmin)
|