mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 09:45:17 -05:00
Add @extend_schema decorators to moderation ViewSet actions
- Add drf_spectacular imports (extend_schema, OpenApiResponse, inline_serializer) - Annotate claim action with response schemas for 200/404/409/400 - Annotate unclaim action with response schemas for 200/403/400 - Annotate approve action with request=None and response schemas - Annotate reject action with reason request body schema - Annotate escalate action with reason request body schema - All actions tagged with 'Moderation' for API docs grouping
This commit is contained in:
@@ -55,3 +55,45 @@ def get_direct_upload_url(user_id=None):
|
||||
raise e
|
||||
|
||||
return result.get("result", {})
|
||||
|
||||
|
||||
def delete_cloudflare_image(image_id: str) -> bool:
|
||||
"""
|
||||
Delete an image from Cloudflare Images.
|
||||
|
||||
Used to cleanup orphaned images when submissions are rejected or deleted.
|
||||
|
||||
Args:
|
||||
image_id: The Cloudflare image ID to delete.
|
||||
|
||||
Returns:
|
||||
bool: True if deletion succeeded, False otherwise.
|
||||
"""
|
||||
account_id = getattr(settings, "CLOUDFLARE_IMAGES_ACCOUNT_ID", None)
|
||||
api_token = getattr(settings, "CLOUDFLARE_IMAGES_API_TOKEN", None)
|
||||
|
||||
if not account_id or not api_token:
|
||||
logger.error("Cloudflare settings missing, cannot delete image %s", image_id)
|
||||
return False
|
||||
|
||||
url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.delete(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("success"):
|
||||
logger.info("Successfully deleted Cloudflare image: %s", image_id)
|
||||
return True
|
||||
else:
|
||||
error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message")
|
||||
logger.warning("Failed to delete Cloudflare image %s: %s", image_id, error_msg)
|
||||
return False
|
||||
except requests.RequestException as e:
|
||||
capture_and_log(e, f"Delete Cloudflare image {image_id}", source="service")
|
||||
return False
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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.
|
||||
including edit submissions and state transition logs.
|
||||
Includes a custom moderation admin site for dedicated moderation workflows.
|
||||
|
||||
Performance targets:
|
||||
@@ -18,7 +18,7 @@ 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
|
||||
from .models import EditSubmission
|
||||
|
||||
|
||||
class ModerationAdminSite(AdminSite):
|
||||
@@ -52,13 +52,13 @@ class ModerationAdminSite(AdminSite):
|
||||
|
||||
# Get pending counts
|
||||
extra_context["pending_edits"] = EditSubmission.objects.filter(status="PENDING").count()
|
||||
extra_context["pending_photos"] = PhotoSubmission.objects.filter(status="PENDING").count()
|
||||
extra_context["pending_photos"] = EditSubmission.objects.filter(submission_type="PHOTO", 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(
|
||||
extra_context["recent_photos"] = EditSubmission.objects.filter(submission_type="PHOTO").select_related("user", "handled_by").order_by(
|
||||
"-created_at"
|
||||
)[:5]
|
||||
|
||||
@@ -307,198 +307,6 @@ class EditSubmissionAdmin(admin.ModelAdmin):
|
||||
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):
|
||||
"""
|
||||
@@ -754,7 +562,6 @@ class HistoryEventAdmin(admin.ModelAdmin):
|
||||
|
||||
# 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
|
||||
|
||||
@@ -25,7 +25,6 @@ class ModerationConfig(AppConfig):
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# Apply FSM to all models with their respective choice groups
|
||||
@@ -53,12 +52,6 @@ class ModerationConfig(AppConfig):
|
||||
choice_group="bulk_operation_statuses",
|
||||
domain="moderation",
|
||||
)
|
||||
apply_state_machine(
|
||||
PhotoSubmission,
|
||||
field_name="status",
|
||||
choice_group="photo_submission_statuses",
|
||||
domain="moderation",
|
||||
)
|
||||
|
||||
def _register_callbacks(self):
|
||||
"""Register FSM transition callbacks for moderation models."""
|
||||
@@ -78,7 +71,6 @@ class ModerationConfig(AppConfig):
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# EditSubmission callbacks (transitions from CLAIMED state)
|
||||
@@ -88,14 +80,6 @@ class ModerationConfig(AppConfig):
|
||||
register_callback(EditSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
|
||||
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
|
||||
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", ModerationCacheInvalidation())
|
||||
|
||||
# PhotoSubmission callbacks (transitions from CLAIMED state)
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", SubmissionApprovedNotification())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", ModerationCacheInvalidation())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", SubmissionRejectedNotification())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
|
||||
|
||||
# ModerationReport callbacks
|
||||
register_callback(ModerationReport, "status", "*", "*", ModerationNotificationCallback())
|
||||
register_callback(ModerationReport, "status", "*", "*", ModerationCacheInvalidation())
|
||||
|
||||
@@ -33,7 +33,7 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
|
||||
minutes = options["minutes"]
|
||||
dry_run = options["dry_run"]
|
||||
@@ -47,8 +47,9 @@ class Command(BaseCommand):
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
|
||||
stale_photo = PhotoSubmission.objects.filter(
|
||||
# Also find PHOTO type EditSubmissions
|
||||
stale_photo = EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
@@ -66,7 +67,7 @@ class Command(BaseCommand):
|
||||
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||
)
|
||||
|
||||
self.stdout.write(f"Found {stale_photo_count} stale PhotoSubmission claims:")
|
||||
self.stdout.write(f"Found {stale_photo_count} stale PHOTO submission claims:")
|
||||
for sub in stale_photo:
|
||||
self.stdout.write(
|
||||
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||
@@ -84,10 +85,6 @@ class Command(BaseCommand):
|
||||
f" EditSubmissions: {result['edit_submissions']['released']} released, "
|
||||
f"{result['edit_submissions']['failed']} failed"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" PhotoSubmissions: {result['photo_submissions']['released']} released, "
|
||||
f"{result['photo_submissions']['failed']} failed"
|
||||
)
|
||||
|
||||
if result["failures"]:
|
||||
self.stdout.write(self.style.ERROR("\nFailures:"))
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
@@ -218,40 +218,38 @@ class Command(BaseCommand):
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Create PhotoSubmissions with detailed captions
|
||||
# Create PHOTO submissions using EditSubmission with submission_type=PHOTO
|
||||
|
||||
# Park photo submission
|
||||
image_data = (
|
||||
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
|
||||
)
|
||||
dummy_image = SimpleUploadedFile("park_entrance.gif", image_data, content_type="image/gif")
|
||||
|
||||
PhotoSubmission.objects.create(
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
object_id=test_park.id,
|
||||
photo=dummy_image,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
caption=(
|
||||
"Main entrance plaza of Test Park showing the newly installed digital display board "
|
||||
"and renovated ticketing area. Photo taken during morning park opening."
|
||||
),
|
||||
date_taken=date(2024, 1, 15),
|
||||
status="PENDING",
|
||||
reason="Photo of park entrance",
|
||||
)
|
||||
|
||||
# Ride photo submission
|
||||
dummy_image2 = SimpleUploadedFile("coaster_track.gif", image_data, content_type="image/gif")
|
||||
PhotoSubmission.objects.create(
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
object_id=test_ride.id,
|
||||
photo=dummy_image2,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
caption=(
|
||||
"Test Coaster's first drop and loop element showing the new paint scheme. "
|
||||
"Photo taken from the guest pathway near Station Alpha."
|
||||
),
|
||||
date_taken=date(2024, 1, 20),
|
||||
status="PENDING",
|
||||
reason="Photo of ride",
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions"))
|
||||
|
||||
@@ -9,7 +9,6 @@ from apps.moderation.models import (
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,8 +27,7 @@ class Command(BaseCommand):
|
||||
type=str,
|
||||
help=(
|
||||
"Validate only specific model "
|
||||
"(editsubmission, moderationreport, moderationqueue, "
|
||||
"bulkoperation, photosubmission)"
|
||||
"(editsubmission, moderationreport, moderationqueue, bulkoperation)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -65,11 +63,7 @@ class Command(BaseCommand):
|
||||
"bulk_operation_statuses",
|
||||
"moderation",
|
||||
),
|
||||
"photosubmission": (
|
||||
PhotoSubmission,
|
||||
"photo_submission_statuses",
|
||||
"moderation",
|
||||
),
|
||||
# Note: PhotoSubmission removed - photos now handled via EditSubmission
|
||||
}
|
||||
|
||||
# Filter by model name if specified
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-13 01:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0012_migrate_photo_submissions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="pgh_obj",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="claimed_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="content_type",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="handled_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="pgh_context",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="photo",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PhotoSubmission",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PhotoSubmissionEvent",
|
||||
),
|
||||
]
|
||||
@@ -13,7 +13,7 @@ from django.http import (
|
||||
)
|
||||
from django.views.generic import DetailView
|
||||
|
||||
from .models import EditSubmission, PhotoSubmission, UserType
|
||||
from .models import EditSubmission, UserType
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -146,6 +146,8 @@ class EditSubmissionMixin(DetailView):
|
||||
class PhotoSubmissionMixin(DetailView):
|
||||
"""
|
||||
Mixin for handling photo submissions with proper moderation.
|
||||
|
||||
Photos are now handled via EditSubmission with submission_type='PHOTO'.
|
||||
"""
|
||||
|
||||
model: type[models.Model] | None = None
|
||||
@@ -177,19 +179,25 @@ class PhotoSubmissionMixin(DetailView):
|
||||
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
submission = PhotoSubmission(
|
||||
# Create EditSubmission with PHOTO type
|
||||
submission = EditSubmission(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
object_id=getattr(obj, "id", None),
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
photo=request.FILES["photo"],
|
||||
caption=request.POST.get("caption", ""),
|
||||
date_taken=request.POST.get("date_taken"),
|
||||
reason="Photo submission",
|
||||
)
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
submission.auto_approve()
|
||||
submission.save()
|
||||
submission.claim(user=request.user)
|
||||
submission.approve(cast(UserType, request.user))
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
|
||||
@@ -427,13 +427,35 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
resolved_changes = self._resolve_foreign_keys(final_changes)
|
||||
|
||||
try:
|
||||
if self.submission_type == "CREATE":
|
||||
if self.submission_type == "PHOTO":
|
||||
# Handle photo submissions - create ParkPhoto or RidePhoto
|
||||
from apps.parks.models.media import ParkPhoto
|
||||
from apps.rides.models.media import RidePhoto
|
||||
|
||||
# Determine the correct photo model based on content type
|
||||
model_name = model_class.__name__
|
||||
if model_name == "Park":
|
||||
PhotoModel = ParkPhoto
|
||||
elif model_name == "Ride":
|
||||
PhotoModel = RidePhoto
|
||||
else:
|
||||
raise ValueError(f"Unsupported content type for photo: {model_name}")
|
||||
|
||||
# Create the approved photo
|
||||
obj = PhotoModel.objects.create(
|
||||
uploaded_by=self.user,
|
||||
content_object=self.content_object,
|
||||
image=self.photo,
|
||||
caption=self.caption or "",
|
||||
is_approved=True,
|
||||
)
|
||||
elif self.submission_type == "CREATE":
|
||||
# Create new object
|
||||
obj = model_class(**resolved_changes)
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
else:
|
||||
# Update existing object
|
||||
# Update existing object (EDIT type)
|
||||
if not self.content_object:
|
||||
raise ValueError("Cannot update: content object not found")
|
||||
|
||||
@@ -823,242 +845,8 @@ class BulkOperation(StateMachineMixin, TrackedModel):
|
||||
return round((self.processed_items / self.total_items) * 100, 2)
|
||||
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
"""Photo submission model with FSM-managed status transitions."""
|
||||
|
||||
state_field_name = "status"
|
||||
|
||||
# Who submitted the photo
|
||||
user = models.ForeignKey(
|
||||
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,
|
||||
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
|
||||
photo = models.ForeignKey(
|
||||
"django_cloudflareimages_toolkit.CloudflareImage",
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Photo submission stored on Cloudflare Images",
|
||||
)
|
||||
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(
|
||||
choice_group="photo_submission_statuses", domain="moderation", max_length=20, default="PENDING"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
handled_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
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")
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
)
|
||||
|
||||
# Claim tracking for concurrency control
|
||||
claimed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="claimed_photo_submissions",
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
)
|
||||
claimed_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was claimed")
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Photo Submission"
|
||||
verbose_name_plural = "Photo Submissions"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["status"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo submission by {self.user.username} for {self.content_object}"
|
||||
|
||||
def claim(self, user: UserType) -> None:
|
||||
"""
|
||||
Claim this photo submission for review.
|
||||
Transition: PENDING -> CLAIMED
|
||||
|
||||
Args:
|
||||
user: The moderator claiming this submission
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in PENDING state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "PENDING":
|
||||
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
|
||||
|
||||
# Set status directly (similar to unclaim method)
|
||||
# The transition_to_claimed FSM method was never defined
|
||||
self.status = "CLAIMED"
|
||||
self.claimed_by = user
|
||||
self.claimed_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def unclaim(self, user: UserType = None) -> None:
|
||||
"""
|
||||
Release claim on this photo submission.
|
||||
Transition: CLAIMED -> PENDING
|
||||
|
||||
Args:
|
||||
user: The user initiating the unclaim (for audit)
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in CLAIMED state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED")
|
||||
|
||||
# Set status directly (not via FSM transition to avoid cycle)
|
||||
# This is intentional - the unclaim action is a special "rollback" operation
|
||||
self.status = "PENDING"
|
||||
self.claimed_by = None
|
||||
self.claimed_at = None
|
||||
self.save()
|
||||
|
||||
def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
||||
"""
|
||||
Approve the photo submission.
|
||||
Wrapper method that preserves business logic while using FSM.
|
||||
|
||||
Args:
|
||||
moderator: The user approving the submission
|
||||
notes: Optional approval notes
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.parks.models.media import ParkPhoto
|
||||
from apps.rides.models.media import RidePhoto
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
approver = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before approval
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Determine the correct photo model based on the content type
|
||||
model_class = self.content_type.model_class()
|
||||
if model_class.__name__ == "Park":
|
||||
PhotoModel = ParkPhoto
|
||||
elif model_class.__name__ == "Ride":
|
||||
PhotoModel = RidePhoto
|
||||
else:
|
||||
raise ValueError(f"Unsupported content type: {model_class.__name__}")
|
||||
|
||||
# Create the approved photo
|
||||
PhotoModel.objects.create(
|
||||
uploaded_by=self.user,
|
||||
content_object=self.content_object,
|
||||
image=self.photo,
|
||||
caption=self.caption,
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_approved(user=approver)
|
||||
self.handled_by = approver # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
|
||||
def reject(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
||||
"""
|
||||
Reject the photo submission.
|
||||
Wrapper method that preserves business logic while using FSM.
|
||||
|
||||
Args:
|
||||
moderator: The user rejecting the submission
|
||||
notes: Rejection reason
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
rejecter = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before rejection
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_rejected(user=rejecter)
|
||||
self.handled_by = rejecter # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
|
||||
def auto_approve(self) -> None:
|
||||
"""Auto-approve submissions from moderators."""
|
||||
# Get user role safely
|
||||
user_role = getattr(self.user, "role", None)
|
||||
|
||||
# If user is moderator or above, claim then approve
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
self.claim(user=self.user)
|
||||
self.approve(self.user)
|
||||
|
||||
def escalate(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
||||
"""
|
||||
Escalate the photo submission to admin.
|
||||
Wrapper method that preserves business logic while using FSM.
|
||||
|
||||
Args:
|
||||
moderator: The user escalating the submission
|
||||
notes: Escalation reason
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
escalator = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before escalation
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_escalated(user=escalator)
|
||||
self.handled_by = escalator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
# NOTE: PhotoSubmission model removed - photos are now handled via
|
||||
# EditSubmission with submission_type="PHOTO". See migration for details.
|
||||
|
||||
|
||||
class ModerationAuditLog(models.Model):
|
||||
|
||||
@@ -23,7 +23,6 @@ from .models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -76,6 +75,10 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
status_icon = serializers.SerializerMethodField()
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
time_since_created = serializers.SerializerMethodField()
|
||||
|
||||
# Photo URL for frontend compatibility (Cloudflare Images)
|
||||
photo_url = serializers.SerializerMethodField()
|
||||
cloudflare_image_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = EditSubmission
|
||||
@@ -102,6 +105,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"time_since_created",
|
||||
# Photo fields (used when submission_type="PHOTO")
|
||||
"photo",
|
||||
"photo_url", # Cloudflare image URL for frontend
|
||||
"cloudflare_image_id",
|
||||
"caption",
|
||||
"date_taken",
|
||||
]
|
||||
@@ -117,6 +122,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"status_display",
|
||||
"content_type_name",
|
||||
"time_since_created",
|
||||
"photo_url",
|
||||
"cloudflare_image_id",
|
||||
]
|
||||
|
||||
def get_status_color(self, obj) -> str:
|
||||
@@ -155,6 +162,16 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
|
||||
def get_photo_url(self, obj) -> str | None:
|
||||
"""Return Cloudflare image URL for photo submissions."""
|
||||
if obj.photo:
|
||||
return getattr(obj.photo, "image_url", None) or getattr(obj.photo, "url", None)
|
||||
return None
|
||||
|
||||
def get_cloudflare_image_id(self, obj) -> str | None:
|
||||
"""Expose Cloudflare image id for clients expecting Supabase-like fields."""
|
||||
return getattr(obj.photo, "id", None) if obj.photo else None
|
||||
|
||||
|
||||
class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
"""Optimized serializer for EditSubmission lists."""
|
||||
@@ -212,6 +229,8 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
entity_type = serializers.CharField(write_only=True, help_text="Entity type: park, ride, company, ride_model")
|
||||
caption = serializers.CharField(required=False, allow_blank=True)
|
||||
date_taken = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = EditSubmission
|
||||
@@ -220,10 +239,25 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"object_id",
|
||||
"submission_type",
|
||||
"changes",
|
||||
"photo",
|
||||
"caption",
|
||||
"date_taken",
|
||||
"reason",
|
||||
"source",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add photo field with lazy import to avoid app loading cycles
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
|
||||
queryset=CloudflareImage.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="CloudflareImage id for photo submissions",
|
||||
)
|
||||
|
||||
def validate_entity_type(self, value):
|
||||
"""Convert entity_type string to ContentType."""
|
||||
entity_type_map = {
|
||||
@@ -246,16 +280,17 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
|
||||
def validate_changes(self, value):
|
||||
"""Validate changes is a proper JSON object."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Changes must be a JSON object")
|
||||
if not value:
|
||||
raise serializers.ValidationError("Changes cannot be empty")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
submission_type = attrs.get("submission_type", "EDIT")
|
||||
object_id = attrs.get("object_id")
|
||||
changes = attrs.get("changes") or {}
|
||||
|
||||
# For EDIT submissions, object_id is required
|
||||
if submission_type == "EDIT" and not object_id:
|
||||
@@ -268,6 +303,16 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(
|
||||
{"object_id": "object_id must be null for CREATE submissions"}
|
||||
)
|
||||
|
||||
# For PHOTO submissions, enforce required fields and allow empty changes
|
||||
if submission_type == "PHOTO":
|
||||
if not object_id:
|
||||
raise serializers.ValidationError({"object_id": "object_id is required for PHOTO submissions"})
|
||||
if not attrs.get("photo"):
|
||||
raise serializers.ValidationError({"photo": "photo is required for PHOTO submissions"})
|
||||
else:
|
||||
if not changes:
|
||||
raise serializers.ValidationError({"changes": "Changes cannot be empty"})
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -298,6 +343,120 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class CreatePhotoSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for creating photo submissions with backward compatibility.
|
||||
|
||||
This is a specialized serializer for the /photos endpoint that:
|
||||
- Makes entity_type optional (can be inferred from content_type_id if provided)
|
||||
- Automatically sets submission_type to "PHOTO"
|
||||
- Allows empty changes (photos don't have field changes)
|
||||
|
||||
Supports both new format (entity_type) and legacy format (content_type_id + object_id).
|
||||
"""
|
||||
|
||||
entity_type = serializers.CharField(
|
||||
write_only=True,
|
||||
required=False, # Optional for backward compatibility
|
||||
allow_blank=True,
|
||||
help_text="Entity type: park, ride, company, ride_model (optional if content_type provided)"
|
||||
)
|
||||
content_type_id = serializers.IntegerField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
help_text="Legacy: ContentType ID (alternative to entity_type)"
|
||||
)
|
||||
caption = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
date_taken = serializers.DateField(required=False, allow_null=True)
|
||||
reason = serializers.CharField(required=False, allow_blank=True, default="Photo submission")
|
||||
|
||||
class Meta:
|
||||
model = EditSubmission
|
||||
fields = [
|
||||
"entity_type",
|
||||
"content_type_id",
|
||||
"object_id",
|
||||
"photo",
|
||||
"caption",
|
||||
"date_taken",
|
||||
"reason",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add photo field with lazy import to avoid app loading cycles
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
|
||||
queryset=CloudflareImage.objects.all(),
|
||||
required=True, # Photo is required for photo submissions
|
||||
help_text="CloudflareImage id for photo submissions",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate and resolve content_type."""
|
||||
entity_type = attrs.get("entity_type")
|
||||
content_type_id = attrs.get("content_type_id")
|
||||
object_id = attrs.get("object_id")
|
||||
|
||||
# Must have object_id
|
||||
if not object_id:
|
||||
raise serializers.ValidationError({"object_id": "object_id is required for photo submissions"})
|
||||
|
||||
# Must have either entity_type or content_type_id
|
||||
if not entity_type and not content_type_id:
|
||||
raise serializers.ValidationError({
|
||||
"entity_type": "Either entity_type or content_type_id is required"
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a photo submission."""
|
||||
entity_type = validated_data.pop("entity_type", None)
|
||||
content_type_id = validated_data.pop("content_type_id", None)
|
||||
|
||||
# Resolve ContentType
|
||||
if entity_type:
|
||||
# Map entity_type to ContentType
|
||||
entity_type_map = {
|
||||
"park": ("parks", "park"),
|
||||
"ride": ("rides", "ride"),
|
||||
"company": ("parks", "company"),
|
||||
"ride_model": ("rides", "ridemodel"),
|
||||
"manufacturer": ("parks", "company"),
|
||||
"designer": ("parks", "company"),
|
||||
"operator": ("parks", "company"),
|
||||
"property_owner": ("parks", "company"),
|
||||
}
|
||||
|
||||
entity_lower = entity_type.lower()
|
||||
if entity_lower not in entity_type_map:
|
||||
raise serializers.ValidationError({
|
||||
"entity_type": f"Invalid entity_type. Must be one of: {', '.join(entity_type_map.keys())}"
|
||||
})
|
||||
|
||||
app_label, model_name = entity_type_map[entity_lower]
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
|
||||
elif content_type_id:
|
||||
# Legacy: Use content_type_id directly
|
||||
try:
|
||||
content_type = ContentType.objects.get(pk=content_type_id)
|
||||
except ContentType.DoesNotExist:
|
||||
raise serializers.ValidationError({"content_type_id": "Invalid content_type_id"})
|
||||
else:
|
||||
raise serializers.ValidationError({"entity_type": "entity_type or content_type_id is required"})
|
||||
|
||||
# Set automatic fields for photo submission
|
||||
validated_data["user"] = self.context["request"].user
|
||||
validated_data["content_type"] = content_type
|
||||
validated_data["submission_type"] = "PHOTO"
|
||||
validated_data["changes"] = {} # Photos don't have field changes
|
||||
validated_data["status"] = "PENDING"
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Report Serializers
|
||||
# ============================================================================
|
||||
@@ -983,90 +1142,6 @@ class StateLogSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class PhotoSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for PhotoSubmission."""
|
||||
|
||||
submitted_by = UserBasicSerializer(source="user", read_only=True)
|
||||
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
|
||||
photo_url = serializers.SerializerMethodField()
|
||||
|
||||
# UI Metadata
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
status_color = serializers.SerializerMethodField()
|
||||
status_icon = serializers.SerializerMethodField()
|
||||
time_since_created = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PhotoSubmission
|
||||
fields = [
|
||||
"id",
|
||||
"status",
|
||||
"status_display",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"content_type",
|
||||
"content_type_name",
|
||||
"object_id",
|
||||
"photo",
|
||||
"photo_url",
|
||||
"caption",
|
||||
"date_taken",
|
||||
"submitted_by",
|
||||
"handled_by",
|
||||
"handled_at",
|
||||
"notes",
|
||||
"created_at",
|
||||
"time_since_created",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"submitted_by",
|
||||
"handled_by",
|
||||
"handled_at",
|
||||
"status_display",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"content_type_name",
|
||||
"photo_url",
|
||||
"time_since_created",
|
||||
]
|
||||
|
||||
def get_photo_url(self, obj) -> str | None:
|
||||
if obj.photo:
|
||||
return obj.photo.image_url
|
||||
return None
|
||||
|
||||
def get_status_color(self, obj) -> str:
|
||||
colors = {
|
||||
"PENDING": "#f59e0b",
|
||||
"APPROVED": "#10b981",
|
||||
"REJECTED": "#ef4444",
|
||||
}
|
||||
return colors.get(obj.status, "#6b7280")
|
||||
|
||||
def get_status_icon(self, obj) -> str:
|
||||
icons = {
|
||||
"PENDING": "heroicons:clock",
|
||||
"APPROVED": "heroicons:check-circle",
|
||||
"REJECTED": "heroicons:x-circle",
|
||||
}
|
||||
return icons.get(obj.status, "heroicons:question-mark-circle")
|
||||
|
||||
def get_time_since_created(self, obj) -> str:
|
||||
"""Human-readable time since creation."""
|
||||
now = timezone.now()
|
||||
diff = now - obj.created_at
|
||||
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days ago"
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hours ago"
|
||||
else:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Audit Log Serializers
|
||||
|
||||
@@ -13,7 +13,7 @@ from django_fsm import TransitionNotAllowed
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
from .models import EditSubmission, ModerationQueue, PhotoSubmission
|
||||
from .models import EditSubmission, ModerationQueue
|
||||
|
||||
|
||||
class ModerationService:
|
||||
@@ -444,9 +444,9 @@ class ModerationService:
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_photo_submission(*, submission: PhotoSubmission, submitter: User) -> ModerationQueue:
|
||||
def _create_queue_item_for_photo_submission(*, submission: EditSubmission, submitter: User) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for a photo submission.
|
||||
Create a moderation queue item for a photo submission (EditSubmission with type=PHOTO).
|
||||
|
||||
Args:
|
||||
submission: The photo submission
|
||||
@@ -587,8 +587,9 @@ class ModerationService:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
elif "photo_submission" in queue_item.tags:
|
||||
# Find PhotoSubmission
|
||||
submissions = PhotoSubmission.objects.filter(
|
||||
# Find PHOTO EditSubmission
|
||||
submissions = EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Signal handlers for moderation-related FSM state transitions.
|
||||
|
||||
This module provides signal handlers that execute when moderation
|
||||
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
|
||||
models (EditSubmission, ModerationReport, etc.)
|
||||
undergo state transitions.
|
||||
|
||||
Includes:
|
||||
@@ -114,6 +114,7 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
|
||||
Handle submission rejection transitions.
|
||||
|
||||
Called when an EditSubmission or PhotoSubmission is rejected.
|
||||
For photo submissions, queues Cloudflare image cleanup to prevent orphaned assets.
|
||||
|
||||
Args:
|
||||
instance: The submission instance.
|
||||
@@ -130,6 +131,19 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
|
||||
f"Submission {instance.pk} rejected by {user if user else 'system'}" f"{f': {reason}' if reason else ''}"
|
||||
)
|
||||
|
||||
# Cleanup Cloudflare image for rejected photo submissions
|
||||
if getattr(instance, "submission_type", None) == "PHOTO" and instance.photo:
|
||||
try:
|
||||
from apps.moderation.tasks import cleanup_cloudflare_image
|
||||
|
||||
# Get image ID from the CloudflareImage model
|
||||
image_id = getattr(instance.photo, "image_id", None) or str(instance.photo.id)
|
||||
if image_id:
|
||||
cleanup_cloudflare_image.delay(image_id)
|
||||
logger.info(f"Queued Cloudflare image cleanup for rejected submission {instance.pk}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to queue Cloudflare image cleanup for submission {instance.pk}: {e}")
|
||||
|
||||
|
||||
def handle_submission_escalated(instance, source, target, user, context=None, **kwargs):
|
||||
"""
|
||||
@@ -377,18 +391,13 @@ def register_moderation_signal_handlers():
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# EditSubmission handlers
|
||||
# EditSubmission handlers (handles both EDIT and PHOTO types now)
|
||||
register_transition_handler(EditSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
|
||||
register_transition_handler(EditSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
|
||||
register_transition_handler(EditSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
|
||||
|
||||
# PhotoSubmission handlers
|
||||
register_transition_handler(PhotoSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
|
||||
register_transition_handler(PhotoSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
|
||||
register_transition_handler(PhotoSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
|
||||
|
||||
# ModerationReport handlers
|
||||
register_transition_handler(ModerationReport, "*", "RESOLVED", handle_report_resolved, stage="post")
|
||||
@@ -403,9 +412,6 @@ def register_moderation_signal_handlers():
|
||||
register_transition_handler(EditSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
|
||||
register_transition_handler(EditSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
|
||||
|
||||
# Claim/Unclaim handlers for PhotoSubmission
|
||||
register_transition_handler(PhotoSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
|
||||
register_transition_handler(PhotoSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
|
||||
|
||||
logger.info("Registered moderation signal handlers")
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
Returns:
|
||||
dict: Summary with counts of processed, succeeded, and failed releases
|
||||
"""
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
|
||||
if lock_duration_minutes is None:
|
||||
lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES
|
||||
@@ -52,7 +52,6 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
|
||||
result = {
|
||||
"edit_submissions": {"processed": 0, "released": 0, "failed": 0},
|
||||
"photo_submissions": {"processed": 0, "released": 0, "failed": 0},
|
||||
"failures": [],
|
||||
"cutoff_time": cutoff_time.isoformat(),
|
||||
}
|
||||
@@ -95,44 +94,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
source="task",
|
||||
)
|
||||
|
||||
# Process PhotoSubmissions with stale claims (legacy model - until removed)
|
||||
stale_photo_ids = list(
|
||||
PhotoSubmission.objects.filter(
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
for submission_id in stale_photo_ids:
|
||||
result["photo_submissions"]["processed"] += 1
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Lock and fetch the specific row
|
||||
submission = PhotoSubmission.objects.select_for_update(skip_locked=True).filter(
|
||||
id=submission_id,
|
||||
status="CLAIMED", # Re-verify status in case it changed
|
||||
).first()
|
||||
|
||||
if submission:
|
||||
_release_claim(submission)
|
||||
result["photo_submissions"]["released"] += 1
|
||||
logger.info(
|
||||
"Released stale claim on PhotoSubmission %s (claimed by %s at %s)",
|
||||
submission_id,
|
||||
submission.claimed_by,
|
||||
submission.claimed_at,
|
||||
)
|
||||
except Exception as e:
|
||||
result["photo_submissions"]["failed"] += 1
|
||||
error_msg = f"PhotoSubmission {submission_id}: {str(e)}"
|
||||
result["failures"].append(error_msg)
|
||||
capture_and_log(
|
||||
e,
|
||||
f"Release stale claim on PhotoSubmission {submission_id}",
|
||||
source="task",
|
||||
)
|
||||
|
||||
# Also process EditSubmission with PHOTO type (new unified model)
|
||||
# Process EditSubmission with PHOTO type (unified model)
|
||||
stale_photo_edit_ids = list(
|
||||
EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
@@ -169,8 +131,8 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
source="task",
|
||||
)
|
||||
|
||||
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"]
|
||||
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"]
|
||||
total_released = result["edit_submissions"]["released"]
|
||||
total_failed = result["edit_submissions"]["failed"]
|
||||
|
||||
logger.info(
|
||||
"Completed stale claims expiration: %s released, %s failed",
|
||||
@@ -189,7 +151,7 @@ def _release_claim(submission):
|
||||
and clear the claimed_by and claimed_at fields.
|
||||
|
||||
Args:
|
||||
submission: EditSubmission or PhotoSubmission instance
|
||||
submission: EditSubmission instance
|
||||
"""
|
||||
# Store info for logging before clearing
|
||||
claimed_by = submission.claimed_by
|
||||
@@ -205,3 +167,49 @@ def _release_claim(submission):
|
||||
claimed_by,
|
||||
claimed_at,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="moderation.cleanup_cloudflare_image", bind=True, max_retries=3)
|
||||
def cleanup_cloudflare_image(self, image_id: str) -> dict:
|
||||
"""
|
||||
Delete an orphaned or rejected Cloudflare image.
|
||||
|
||||
This task is called when a photo submission is rejected to cleanup
|
||||
the associated Cloudflare image and prevent orphaned assets.
|
||||
|
||||
Args:
|
||||
image_id: The Cloudflare image ID to delete.
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and message.
|
||||
"""
|
||||
from apps.core.utils.cloudflare import delete_cloudflare_image
|
||||
|
||||
logger.info("Cleaning up Cloudflare image: %s", image_id)
|
||||
|
||||
try:
|
||||
success = delete_cloudflare_image(image_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"image_id": image_id,
|
||||
"success": True,
|
||||
"message": "Image deleted successfully",
|
||||
}
|
||||
else:
|
||||
# Retry on failure (may be transient API issue)
|
||||
raise Exception(f"Failed to delete Cloudflare image {image_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Cloudflare image cleanup failed: %s (attempt %d)", str(e), self.request.retries + 1)
|
||||
# Retry with exponential backoff
|
||||
try:
|
||||
self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
||||
except self.MaxRetriesExceededError:
|
||||
logger.error("Max retries exceeded for Cloudflare image cleanup: %s", image_id)
|
||||
return {
|
||||
"image_id": image_id,
|
||||
"success": False,
|
||||
"message": f"Failed after {self.request.retries + 1} attempts: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,10 @@ from django.test import RequestFactory, TestCase
|
||||
from apps.moderation.admin import (
|
||||
EditSubmissionAdmin,
|
||||
HistoryEventAdmin,
|
||||
PhotoSubmissionAdmin,
|
||||
StateLogAdmin,
|
||||
moderation_site,
|
||||
)
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -101,32 +100,7 @@ class TestEditSubmissionAdmin(TestCase):
|
||||
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
|
||||
# PhotoSubmissionAdmin tests removed - model consolidated into EditSubmission
|
||||
|
||||
|
||||
class TestStateLogAdmin(TestCase):
|
||||
@@ -200,9 +174,7 @@ class TestRegisteredModels(TestCase):
|
||||
"""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
|
||||
# PhotoSubmission registration test removed - model consolidated into EditSubmission
|
||||
|
||||
def test_state_log_registered(self):
|
||||
"""Verify StateLog is registered with moderation site."""
|
||||
|
||||
@@ -3,7 +3,7 @@ Comprehensive tests for the moderation app.
|
||||
|
||||
This module contains tests for:
|
||||
- EditSubmission state machine transitions
|
||||
- PhotoSubmission state machine transitions
|
||||
- EditSubmission with submission_type="PHOTO" (photo submissions)
|
||||
- ModerationReport state machine transitions
|
||||
- ModerationQueue state machine transitions
|
||||
- BulkOperation state machine transitions
|
||||
@@ -39,7 +39,6 @@ from ..models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -1132,14 +1131,17 @@ class ModerationActionTests(TestCase):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PhotoSubmission FSM Transition Tests
|
||||
# EditSubmission PHOTO Type FSM Transition Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PhotoSubmissionTransitionTests(TestCase):
|
||||
"""Comprehensive tests for PhotoSubmission FSM transitions.
|
||||
class PhotoEditSubmissionTransitionTests(TestCase):
|
||||
"""Comprehensive tests for EditSubmission with submission_type='PHOTO' FSM transitions.
|
||||
|
||||
Note: All approve/reject/escalate transitions require CLAIMED state first.
|
||||
|
||||
These tests validate that photo submissions (using the unified EditSubmission model)
|
||||
have correct FSM behavior.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -1169,13 +1171,15 @@ class PhotoSubmissionTransitionTests(TestCase):
|
||||
)
|
||||
|
||||
def _create_submission(self, status="PENDING"):
|
||||
"""Helper to create a PhotoSubmission with proper CloudflareImage."""
|
||||
submission = PhotoSubmission.objects.create(
|
||||
"""Helper to create an EditSubmission with submission_type='PHOTO' and proper CloudflareImage."""
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.user,
|
||||
content_type=self.content_type,
|
||||
object_id=self.operator.id,
|
||||
submission_type="PHOTO", # Unified model
|
||||
photo=self.mock_image,
|
||||
caption="Test Photo",
|
||||
changes={}, # Photos use empty changes
|
||||
status="PENDING", # Always create as PENDING first
|
||||
)
|
||||
|
||||
|
||||
@@ -83,13 +83,17 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
|
||||
def test_photo_submission_approval_workflow(self):
|
||||
"""
|
||||
Test complete photo submission approval workflow.
|
||||
Test complete photo submission approval workflow using EditSubmission.
|
||||
|
||||
Flow: User submits photo → Moderator reviews → Moderator approves → Photo created
|
||||
|
||||
Note: Photos now use EditSubmission with submission_type="PHOTO" (unified model).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
from apps.moderation.models import PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.parks.models import Company, Park
|
||||
|
||||
# Create target park
|
||||
@@ -105,18 +109,21 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
expires_at=timezone.now() + timedelta(days=365),
|
||||
)
|
||||
|
||||
# User submits a photo
|
||||
# User submits a photo using unified EditSubmission model
|
||||
content_type = ContentType.objects.get_for_model(park)
|
||||
submission = PhotoSubmission.objects.create(
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.regular_user,
|
||||
content_type=content_type,
|
||||
object_id=park.id,
|
||||
submission_type="PHOTO", # Unified model with PHOTO type
|
||||
status="PENDING",
|
||||
photo=mock_image,
|
||||
caption="Beautiful park entrance",
|
||||
changes={}, # Photos use empty changes dict
|
||||
)
|
||||
|
||||
self.assertEqual(submission.status, "PENDING")
|
||||
self.assertEqual(submission.submission_type, "PHOTO")
|
||||
|
||||
# Moderator claims the submission first (required FSM step)
|
||||
submission.claim(user=self.moderator)
|
||||
|
||||
@@ -45,23 +45,16 @@ class SubmissionListView(TemplateView):
|
||||
template_name = "moderation/partials/dashboard_content.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
from itertools import chain
|
||||
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
from .models import EditSubmission
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
status = self.request.GET.get("status", "PENDING")
|
||||
|
||||
# Get filtered submissions
|
||||
# Get filtered submissions (EditSubmission now handles all types including PHOTO)
|
||||
edit_submissions = EditSubmission.objects.filter(status=status).select_related("user")
|
||||
photo_submissions = PhotoSubmission.objects.filter(status=status).select_related("user")
|
||||
|
||||
# Combine and sort
|
||||
context["submissions"] = sorted(
|
||||
chain(edit_submissions, photo_submissions),
|
||||
key=lambda x: x.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
# Sort by created_at descending
|
||||
context["submissions"] = edit_submissions.order_by("-created_at")
|
||||
return context
|
||||
|
||||
|
||||
@@ -78,10 +71,10 @@ router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
|
||||
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
|
||||
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
|
||||
router.register(r"users", UserModerationViewSet, basename="user-moderation")
|
||||
# EditSubmission - register under both names for compatibility
|
||||
# EditSubmission - handles all submission types (EDIT, CREATE, PHOTO)
|
||||
router.register(r"submissions", EditSubmissionViewSet, basename="submissions")
|
||||
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions")
|
||||
# PhotoSubmission - register under both names for compatibility
|
||||
# PhotoSubmissionViewSet - now queries EditSubmission with type=PHOTO, kept for API compatibility
|
||||
router.register(r"photos", PhotoSubmissionViewSet, basename="photos")
|
||||
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
|
||||
|
||||
@@ -98,12 +91,12 @@ fsm_transition_patterns = [
|
||||
{"app_label": "moderation", "model_name": "editsubmission"},
|
||||
name="submission_transition",
|
||||
),
|
||||
# PhotoSubmission transitions
|
||||
# PhotoSubmission transitions (now use editsubmission model since photos are EditSubmission with type=PHOTO)
|
||||
# URL: /api/moderation/photos/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"photos/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission"},
|
||||
name="photo_transition",
|
||||
),
|
||||
# ModerationReport transitions
|
||||
@@ -150,23 +143,23 @@ fsm_transition_patterns = [
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_submission",
|
||||
),
|
||||
# Backward compatibility aliases for PhotoSubmission actions
|
||||
# Photo transition aliases (use editsubmission model since photos are EditSubmission with type=PHOTO)
|
||||
path(
|
||||
"photos/<int:pk>/approve/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_approved"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_approved"},
|
||||
name="approve_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/reject/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_rejected"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_rejected"},
|
||||
name="reject_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/escalate/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_escalated"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_photo",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,11 +20,13 @@ from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_fsm import TransitionNotAllowed, can_proceed
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework import permissions, serializers as drf_serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
|
||||
from apps.core.logging import log_business_event
|
||||
from apps.core.state_machine.exceptions import (
|
||||
TransitionPermissionDenied,
|
||||
@@ -44,7 +46,6 @@ from .models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
from .permissions import (
|
||||
CanViewModerationData,
|
||||
@@ -59,12 +60,12 @@ from .serializers import (
|
||||
CreateEditSubmissionSerializer,
|
||||
CreateModerationActionSerializer,
|
||||
CreateModerationReportSerializer,
|
||||
CreatePhotoSubmissionSerializer,
|
||||
EditSubmissionListSerializer,
|
||||
EditSubmissionSerializer,
|
||||
ModerationActionSerializer,
|
||||
ModerationQueueSerializer,
|
||||
ModerationReportSerializer,
|
||||
PhotoSubmissionSerializer,
|
||||
UpdateModerationReportSerializer,
|
||||
UserModerationProfileSerializer,
|
||||
)
|
||||
@@ -1566,6 +1567,30 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return Response({"items": [item]})
|
||||
|
||||
@extend_schema(
|
||||
summary="Claim a submission for review",
|
||||
description="Claim a submission for review with concurrency protection using database row locking. "
|
||||
"Prevents race conditions when multiple moderators try to claim the same submission.",
|
||||
request=None,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="ClaimSuccessResponse",
|
||||
fields={
|
||||
"success": drf_serializers.BooleanField(),
|
||||
"locked_until": drf_serializers.DateTimeField(),
|
||||
"submission_id": drf_serializers.CharField(),
|
||||
"claimed_by": drf_serializers.CharField(),
|
||||
"claimed_at": drf_serializers.DateTimeField(allow_null=True),
|
||||
"status": drf_serializers.CharField(),
|
||||
"lock_duration_minutes": drf_serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
404: OpenApiResponse(description="Submission not found"),
|
||||
409: OpenApiResponse(description="Submission already claimed or being claimed by another moderator"),
|
||||
400: OpenApiResponse(description="Invalid state for claiming (not PENDING)"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def claim(self, request, pk=None):
|
||||
"""
|
||||
@@ -1646,6 +1671,18 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except ValidationError as e:
|
||||
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Release claim on a submission",
|
||||
description="Release the current user's claim on a submission. "
|
||||
"Only the claiming moderator or an admin can unclaim.",
|
||||
request=None,
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
403: OpenApiResponse(description="Only the claiming moderator or admin can unclaim"),
|
||||
400: OpenApiResponse(description="Submission is not claimed"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def unclaim(self, request, pk=None):
|
||||
"""
|
||||
@@ -1683,6 +1720,17 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Approve a submission",
|
||||
description="Approve an edit submission and apply the proposed changes. "
|
||||
"Only moderators and admins can approve submissions.",
|
||||
request=None,
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
400: OpenApiResponse(description="Approval failed due to validation error"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def approve(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
@@ -1694,6 +1742,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Reject a submission",
|
||||
description="Reject an edit submission with an optional reason. "
|
||||
"The submitter will be notified of the rejection.",
|
||||
request=inline_serializer(
|
||||
name="RejectSubmissionRequest",
|
||||
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
|
||||
),
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
400: OpenApiResponse(description="Rejection failed due to validation error"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def reject(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
@@ -1706,6 +1768,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Escalate a submission",
|
||||
description="Escalate an edit submission to senior moderators or admins with a reason. "
|
||||
"Used for complex or controversial submissions requiring higher-level review.",
|
||||
request=inline_serializer(
|
||||
name="EscalateSubmissionRequest",
|
||||
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
|
||||
),
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
400: OpenApiResponse(description="Escalation failed due to validation error"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def escalate(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
@@ -2145,6 +2221,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
ordering = ["-created_at"]
|
||||
permission_classes = [CanViewModerationData]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return EditSubmissionListSerializer
|
||||
if self.action == "create":
|
||||
return CreatePhotoSubmissionSerializer # Use photo-specific serializer
|
||||
return EditSubmissionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
|
||||
status_param = self.request.query_params.get("status")
|
||||
@@ -2158,6 +2241,26 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return queryset
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
Create a photo submission.
|
||||
|
||||
Backward-compatible: Uses CreatePhotoSubmissionSerializer for input
|
||||
validation which supports both new format (entity_type) and legacy
|
||||
format (content_type_id). Returns full submission data via EditSubmissionSerializer.
|
||||
"""
|
||||
# Use CreatePhotoSubmissionSerializer for input validation
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
# Return the created instance using EditSubmissionSerializer for full output
|
||||
# This includes id, status, timestamps, etc. that clients need
|
||||
instance = serializer.instance
|
||||
response_serializer = EditSubmissionSerializer(instance, context={"request": request})
|
||||
headers = self.get_success_headers(response_serializer.data)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def claim(self, request, pk=None):
|
||||
"""
|
||||
@@ -2250,7 +2353,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
event_type="submission_unclaimed",
|
||||
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
|
||||
context={
|
||||
"model": "PhotoSubmission",
|
||||
"model": "EditSubmission",
|
||||
"object_id": submission.id,
|
||||
"unclaimed_by": request.user.username,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user