From 4140a0d8e78a2ae21c05c886ce888c59cb84f99a Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Tue, 13 Jan 2026 19:34:41 -0500
Subject: [PATCH] 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
---
backend/apps/core/utils/cloudflare.py | 42 +++
backend/apps/moderation/admin.py | 201 +------------
backend/apps/moderation/apps.py | 16 --
.../commands/expire_stale_claims.py | 13 +-
.../management/commands/seed_submissions.py | 22 +-
.../commands/validate_state_machines.py | 10 +-
.../migrations/0013_remove_photosubmission.py | 47 ++++
backend/apps/moderation/mixins.py | 14 +-
backend/apps/moderation/models.py | 264 ++----------------
backend/apps/moderation/serializers.py | 249 +++++++++++------
backend/apps/moderation/services.py | 11 +-
backend/apps/moderation/signals.py | 26 +-
backend/apps/moderation/tasks.py | 94 ++++---
backend/apps/moderation/tests/test_admin.py | 34 +--
.../moderation/tests/test_comprehensive.py | 18 +-
.../apps/moderation/tests/test_workflows.py | 15 +-
backend/apps/moderation/urls.py | 31 +-
backend/apps/moderation/views.py | 111 +++++++-
18 files changed, 526 insertions(+), 692 deletions(-)
create mode 100644 backend/apps/moderation/migrations/0013_remove_photosubmission.py
diff --git a/backend/apps/core/utils/cloudflare.py b/backend/apps/core/utils/cloudflare.py
index f96650f3..916ede2a 100644
--- a/backend/apps/core/utils/cloudflare.py
+++ b/backend/apps/core/utils/cloudflare.py
@@ -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
diff --git a/backend/apps/moderation/admin.py b/backend/apps/moderation/admin.py
index d0577989..659e7fab 100644
--- a/backend/apps/moderation/admin.py
+++ b/backend/apps/moderation/admin.py
@@ -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('{}', 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('{}', url, str(content_obj)[:30])
- return str(content_obj)[:30]
- except Exception:
- pass
- return format_html('Not found')
-
- @admin.display(description="Preview")
- def photo_preview(self, obj):
- """Display photo preview thumbnail."""
- if obj.photo:
- return format_html(
- '
',
- obj.photo.url,
- )
- return format_html('No photo')
-
- @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(
- '{}',
- 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('{}', 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
diff --git a/backend/apps/moderation/apps.py b/backend/apps/moderation/apps.py
index ad4511b3..fcb7f406 100644
--- a/backend/apps/moderation/apps.py
+++ b/backend/apps/moderation/apps.py
@@ -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())
diff --git a/backend/apps/moderation/management/commands/expire_stale_claims.py b/backend/apps/moderation/management/commands/expire_stale_claims.py
index 50664145..ba191654 100644
--- a/backend/apps/moderation/management/commands/expire_stale_claims.py
+++ b/backend/apps/moderation/management/commands/expire_stale_claims.py
@@ -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:"))
diff --git a/backend/apps/moderation/management/commands/seed_submissions.py b/backend/apps/moderation/management/commands/seed_submissions.py
index 246cd3fc..7be52d6a 100644
--- a/backend/apps/moderation/management/commands/seed_submissions.py
+++ b/backend/apps/moderation/management/commands/seed_submissions.py
@@ -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"))
diff --git a/backend/apps/moderation/management/commands/validate_state_machines.py b/backend/apps/moderation/management/commands/validate_state_machines.py
index 1f6188a6..0489186d 100644
--- a/backend/apps/moderation/management/commands/validate_state_machines.py
+++ b/backend/apps/moderation/management/commands/validate_state_machines.py
@@ -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
diff --git a/backend/apps/moderation/migrations/0013_remove_photosubmission.py b/backend/apps/moderation/migrations/0013_remove_photosubmission.py
new file mode 100644
index 00000000..5888c129
--- /dev/null
+++ b/backend/apps/moderation/migrations/0013_remove_photosubmission.py
@@ -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",
+ ),
+ ]
diff --git a/backend/apps/moderation/mixins.py b/backend/apps/moderation/mixins.py
index 80a92d79..f06a21f8 100644
--- a/backend/apps/moderation/mixins.py
+++ b/backend/apps/moderation/mixins.py
@@ -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",
diff --git a/backend/apps/moderation/models.py b/backend/apps/moderation/models.py
index 10f641e8..78d994f4 100644
--- a/backend/apps/moderation/models.py
+++ b/backend/apps/moderation/models.py
@@ -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):
diff --git a/backend/apps/moderation/serializers.py b/backend/apps/moderation/serializers.py
index f4432687..6d8dbe34 100644
--- a/backend/apps/moderation/serializers.py
+++ b/backend/apps/moderation/serializers.py
@@ -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
diff --git a/backend/apps/moderation/services.py b/backend/apps/moderation/services.py
index 38dfe641..1226d329 100644
--- a/backend/apps/moderation/services.py
+++ b/backend/apps/moderation/services.py
@@ -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,
diff --git a/backend/apps/moderation/signals.py b/backend/apps/moderation/signals.py
index 5e8bb60b..be943a03 100644
--- a/backend/apps/moderation/signals.py
+++ b/backend/apps/moderation/signals.py
@@ -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")
diff --git a/backend/apps/moderation/tasks.py b/backend/apps/moderation/tasks.py
index 01181a57..588e0b79 100644
--- a/backend/apps/moderation/tasks.py
+++ b/backend/apps/moderation/tasks.py
@@ -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)}",
+ }
+
diff --git a/backend/apps/moderation/tests/test_admin.py b/backend/apps/moderation/tests/test_admin.py
index 944aba6b..3479c767 100644
--- a/backend/apps/moderation/tests/test_admin.py
+++ b/backend/apps/moderation/tests/test_admin.py
@@ -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."""
diff --git a/backend/apps/moderation/tests/test_comprehensive.py b/backend/apps/moderation/tests/test_comprehensive.py
index 2eb12c8b..a028de4d 100644
--- a/backend/apps/moderation/tests/test_comprehensive.py
+++ b/backend/apps/moderation/tests/test_comprehensive.py
@@ -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
)
diff --git a/backend/apps/moderation/tests/test_workflows.py b/backend/apps/moderation/tests/test_workflows.py
index e532b876..cab53bb0 100644
--- a/backend/apps/moderation/tests/test_workflows.py
+++ b/backend/apps/moderation/tests/test_workflows.py
@@ -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)
diff --git a/backend/apps/moderation/urls.py b/backend/apps/moderation/urls.py
index 02f39080..869bab9a 100644
--- a/backend/apps/moderation/urls.py
+++ b/backend/apps/moderation/urls.py
@@ -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//transition//
path(
"photos//transition//",
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//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//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//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",
),
]
diff --git a/backend/apps/moderation/views.py b/backend/apps/moderation/views.py
index 2eee2b26..c33bff4a 100644
--- a/backend/apps/moderation/views.py
+++ b/backend/apps/moderation/views.py
@@ -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,
},