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:
pacnpal
2026-01-13 19:34:41 -05:00
parent d631f3183c
commit 4140a0d8e7
18 changed files with 526 additions and 692 deletions

View File

@@ -55,3 +55,45 @@ def get_direct_upload_url(user_id=None):
raise e raise e
return result.get("result", {}) 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

View File

@@ -2,7 +2,7 @@
Django admin configuration for the Moderation application. Django admin configuration for the Moderation application.
This module provides comprehensive admin interfaces for content moderation 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. Includes a custom moderation admin site for dedicated moderation workflows.
Performance targets: Performance targets:
@@ -18,7 +18,7 @@ from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_fsm_log.models import StateLog from django_fsm_log.models import StateLog
from .models import EditSubmission, PhotoSubmission from .models import EditSubmission
class ModerationAdminSite(AdminSite): class ModerationAdminSite(AdminSite):
@@ -52,13 +52,13 @@ class ModerationAdminSite(AdminSite):
# Get pending counts # Get pending counts
extra_context["pending_edits"] = EditSubmission.objects.filter(status="PENDING").count() 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 # Get recent activity
extra_context["recent_edits"] = EditSubmission.objects.select_related("user", "handled_by").order_by( extra_context["recent_edits"] = EditSubmission.objects.select_related("user", "handled_by").order_by(
"-created_at" "-created_at"
)[:5] )[: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" "-created_at"
)[:5] )[:5]
@@ -307,198 +307,6 @@ class EditSubmissionAdmin(admin.ModelAdmin):
return actions 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): class StateLogAdmin(admin.ModelAdmin):
""" """
@@ -754,7 +562,6 @@ class HistoryEventAdmin(admin.ModelAdmin):
# Register with moderation site only # Register with moderation site only
moderation_site.register(EditSubmission, EditSubmissionAdmin) moderation_site.register(EditSubmission, EditSubmissionAdmin)
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
moderation_site.register(StateLog, StateLogAdmin) moderation_site.register(StateLog, StateLogAdmin)
# Note: Concrete pghistory event models would be registered as they are created # Note: Concrete pghistory event models would be registered as they are created

View File

@@ -25,7 +25,6 @@ class ModerationConfig(AppConfig):
EditSubmission, EditSubmission,
ModerationQueue, ModerationQueue,
ModerationReport, ModerationReport,
PhotoSubmission,
) )
# Apply FSM to all models with their respective choice groups # Apply FSM to all models with their respective choice groups
@@ -53,12 +52,6 @@ class ModerationConfig(AppConfig):
choice_group="bulk_operation_statuses", choice_group="bulk_operation_statuses",
domain="moderation", domain="moderation",
) )
apply_state_machine(
PhotoSubmission,
field_name="status",
choice_group="photo_submission_statuses",
domain="moderation",
)
def _register_callbacks(self): def _register_callbacks(self):
"""Register FSM transition callbacks for moderation models.""" """Register FSM transition callbacks for moderation models."""
@@ -78,7 +71,6 @@ class ModerationConfig(AppConfig):
EditSubmission, EditSubmission,
ModerationQueue, ModerationQueue,
ModerationReport, ModerationReport,
PhotoSubmission,
) )
# EditSubmission callbacks (transitions from CLAIMED state) # 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", "REJECTED", ModerationCacheInvalidation())
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification()) register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", ModerationCacheInvalidation()) 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 # ModerationReport callbacks
register_callback(ModerationReport, "status", "*", "*", ModerationNotificationCallback()) register_callback(ModerationReport, "status", "*", "*", ModerationNotificationCallback())
register_callback(ModerationReport, "status", "*", "*", ModerationCacheInvalidation()) register_callback(ModerationReport, "status", "*", "*", ModerationCacheInvalidation())

View File

@@ -33,7 +33,7 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from apps.moderation.models import EditSubmission, PhotoSubmission from apps.moderation.models import EditSubmission
minutes = options["minutes"] minutes = options["minutes"]
dry_run = options["dry_run"] dry_run = options["dry_run"]
@@ -47,8 +47,9 @@ class Command(BaseCommand):
status="CLAIMED", status="CLAIMED",
claimed_at__lt=cutoff_time, claimed_at__lt=cutoff_time,
).select_related("claimed_by") ).select_related("claimed_by")
# Also find PHOTO type EditSubmissions
stale_photo = PhotoSubmission.objects.filter( stale_photo = EditSubmission.objects.filter(
submission_type="PHOTO",
status="CLAIMED", status="CLAIMED",
claimed_at__lt=cutoff_time, claimed_at__lt=cutoff_time,
).select_related("claimed_by") ).select_related("claimed_by")
@@ -66,7 +67,7 @@ class Command(BaseCommand):
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}" 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: for sub in stale_photo:
self.stdout.write( self.stdout.write(
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}" 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" EditSubmissions: {result['edit_submissions']['released']} released, "
f"{result['edit_submissions']['failed']} failed" 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"]: if result["failures"]:
self.stdout.write(self.style.ERROR("\nFailures:")) self.stdout.write(self.style.ERROR("\nFailures:"))

View File

@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management.base import BaseCommand 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.parks.models import Park
from apps.rides.models import Ride from apps.rides.models import Ride
@@ -218,40 +218,38 @@ class Command(BaseCommand):
status="PENDING", status="PENDING",
) )
# Create PhotoSubmissions with detailed captions # Create PHOTO submissions using EditSubmission with submission_type=PHOTO
# Park photo submission # Park photo submission
image_data = ( EditSubmission.objects.create(
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(
user=user, user=user,
content_type=park_ct, content_type=park_ct,
object_id=test_park.id, object_id=test_park.id,
photo=dummy_image, submission_type="PHOTO",
changes={}, # No field changes for photos
caption=( caption=(
"Main entrance plaza of Test Park showing the newly installed digital display board " "Main entrance plaza of Test Park showing the newly installed digital display board "
"and renovated ticketing area. Photo taken during morning park opening." "and renovated ticketing area. Photo taken during morning park opening."
), ),
date_taken=date(2024, 1, 15), date_taken=date(2024, 1, 15),
status="PENDING", status="PENDING",
reason="Photo of park entrance",
) )
# Ride photo submission # Ride photo submission
dummy_image2 = SimpleUploadedFile("coaster_track.gif", image_data, content_type="image/gif") EditSubmission.objects.create(
PhotoSubmission.objects.create(
user=user, user=user,
content_type=ride_ct, content_type=ride_ct,
object_id=test_ride.id, object_id=test_ride.id,
photo=dummy_image2, submission_type="PHOTO",
changes={}, # No field changes for photos
caption=( caption=(
"Test Coaster's first drop and loop element showing the new paint scheme. " "Test Coaster's first drop and loop element showing the new paint scheme. "
"Photo taken from the guest pathway near Station Alpha." "Photo taken from the guest pathway near Station Alpha."
), ),
date_taken=date(2024, 1, 20), date_taken=date(2024, 1, 20),
status="PENDING", status="PENDING",
reason="Photo of ride",
) )
self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions")) self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions"))

View File

@@ -9,7 +9,6 @@ from apps.moderation.models import (
EditSubmission, EditSubmission,
ModerationQueue, ModerationQueue,
ModerationReport, ModerationReport,
PhotoSubmission,
) )
@@ -28,8 +27,7 @@ class Command(BaseCommand):
type=str, type=str,
help=( help=(
"Validate only specific model " "Validate only specific model "
"(editsubmission, moderationreport, moderationqueue, " "(editsubmission, moderationreport, moderationqueue, bulkoperation)"
"bulkoperation, photosubmission)"
), ),
) )
parser.add_argument( parser.add_argument(
@@ -65,11 +63,7 @@ class Command(BaseCommand):
"bulk_operation_statuses", "bulk_operation_statuses",
"moderation", "moderation",
), ),
"photosubmission": ( # Note: PhotoSubmission removed - photos now handled via EditSubmission
PhotoSubmission,
"photo_submission_statuses",
"moderation",
),
} }
# Filter by model name if specified # Filter by model name if specified

View File

@@ -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",
),
]

View File

@@ -13,7 +13,7 @@ from django.http import (
) )
from django.views.generic import DetailView from django.views.generic import DetailView
from .models import EditSubmission, PhotoSubmission, UserType from .models import EditSubmission, UserType
User = get_user_model() User = get_user_model()
@@ -146,6 +146,8 @@ class EditSubmissionMixin(DetailView):
class PhotoSubmissionMixin(DetailView): class PhotoSubmissionMixin(DetailView):
""" """
Mixin for handling photo submissions with proper moderation. Mixin for handling photo submissions with proper moderation.
Photos are now handled via EditSubmission with submission_type='PHOTO'.
""" """
model: type[models.Model] | None = None model: type[models.Model] | None = None
@@ -177,19 +179,25 @@ class PhotoSubmissionMixin(DetailView):
content_type = ContentType.objects.get_for_model(obj) content_type = ContentType.objects.get_for_model(obj)
submission = PhotoSubmission( # Create EditSubmission with PHOTO type
submission = EditSubmission(
user=request.user, user=request.user,
content_type=content_type, content_type=content_type,
object_id=getattr(obj, "id", None), object_id=getattr(obj, "id", None),
submission_type="PHOTO",
changes={}, # No field changes for photos
photo=request.FILES["photo"], photo=request.FILES["photo"],
caption=request.POST.get("caption", ""), caption=request.POST.get("caption", ""),
date_taken=request.POST.get("date_taken"), date_taken=request.POST.get("date_taken"),
reason="Photo submission",
) )
# Auto-approve for moderators and above # Auto-approve for moderators and above
user_role = getattr(request.user, "role", None) user_role = getattr(request.user, "role", None)
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]: 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( return JsonResponse(
{ {
"status": "success", "status": "success",

View File

@@ -427,13 +427,35 @@ class EditSubmission(StateMachineMixin, TrackedModel):
resolved_changes = self._resolve_foreign_keys(final_changes) resolved_changes = self._resolve_foreign_keys(final_changes)
try: 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 # Create new object
obj = model_class(**resolved_changes) obj = model_class(**resolved_changes)
obj.full_clean() obj.full_clean()
obj.save() obj.save()
else: else:
# Update existing object # Update existing object (EDIT type)
if not self.content_object: if not self.content_object:
raise ValueError("Cannot update: content object not found") 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) return round((self.processed_items / self.total_items) * 100, 2)
@pghistory.track() # Track all changes by default # NOTE: PhotoSubmission model removed - photos are now handled via
class PhotoSubmission(StateMachineMixin, TrackedModel): # EditSubmission with submission_type="PHOTO". See migration for details.
"""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()
class ModerationAuditLog(models.Model): class ModerationAuditLog(models.Model):

View File

@@ -23,7 +23,6 @@ from .models import (
ModerationAction, ModerationAction,
ModerationQueue, ModerationQueue,
ModerationReport, ModerationReport,
PhotoSubmission,
) )
User = get_user_model() User = get_user_model()
@@ -76,6 +75,10 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
status_icon = serializers.SerializerMethodField() status_icon = serializers.SerializerMethodField()
status_display = serializers.CharField(source="get_status_display", read_only=True) status_display = serializers.CharField(source="get_status_display", read_only=True)
time_since_created = serializers.SerializerMethodField() time_since_created = serializers.SerializerMethodField()
# Photo URL for frontend compatibility (Cloudflare Images)
photo_url = serializers.SerializerMethodField()
cloudflare_image_id = serializers.SerializerMethodField()
class Meta: class Meta:
model = EditSubmission model = EditSubmission
@@ -102,6 +105,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"time_since_created", "time_since_created",
# Photo fields (used when submission_type="PHOTO") # Photo fields (used when submission_type="PHOTO")
"photo", "photo",
"photo_url", # Cloudflare image URL for frontend
"cloudflare_image_id",
"caption", "caption",
"date_taken", "date_taken",
] ]
@@ -117,6 +122,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"status_display", "status_display",
"content_type_name", "content_type_name",
"time_since_created", "time_since_created",
"photo_url",
"cloudflare_image_id",
] ]
def get_status_color(self, obj) -> str: def get_status_color(self, obj) -> str:
@@ -155,6 +162,16 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
minutes = diff.seconds // 60 minutes = diff.seconds // 60
return f"{minutes} minutes ago" 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): class EditSubmissionListSerializer(serializers.ModelSerializer):
"""Optimized serializer for EditSubmission lists.""" """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") 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: class Meta:
model = EditSubmission model = EditSubmission
@@ -220,10 +239,25 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
"object_id", "object_id",
"submission_type", "submission_type",
"changes", "changes",
"photo",
"caption",
"date_taken",
"reason", "reason",
"source", "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): def validate_entity_type(self, value):
"""Convert entity_type string to ContentType.""" """Convert entity_type string to ContentType."""
entity_type_map = { entity_type_map = {
@@ -246,16 +280,17 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
def validate_changes(self, value): def validate_changes(self, value):
"""Validate changes is a proper JSON object.""" """Validate changes is a proper JSON object."""
if value is None:
return {}
if not isinstance(value, dict): if not isinstance(value, dict):
raise serializers.ValidationError("Changes must be a JSON object") raise serializers.ValidationError("Changes must be a JSON object")
if not value:
raise serializers.ValidationError("Changes cannot be empty")
return value return value
def validate(self, attrs): def validate(self, attrs):
"""Cross-field validation.""" """Cross-field validation."""
submission_type = attrs.get("submission_type", "EDIT") submission_type = attrs.get("submission_type", "EDIT")
object_id = attrs.get("object_id") object_id = attrs.get("object_id")
changes = attrs.get("changes") or {}
# For EDIT submissions, object_id is required # For EDIT submissions, object_id is required
if submission_type == "EDIT" and not object_id: if submission_type == "EDIT" and not object_id:
@@ -268,6 +303,16 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
raise serializers.ValidationError( raise serializers.ValidationError(
{"object_id": "object_id must be null for CREATE submissions"} {"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 return attrs
@@ -298,6 +343,120 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
return super().create(validated_data) 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 # Moderation Report Serializers
# ============================================================================ # ============================================================================
@@ -983,90 +1142,6 @@ class StateLogSerializer(serializers.ModelSerializer):
read_only_fields = fields 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 # Moderation Audit Log Serializers

View File

@@ -13,7 +13,7 @@ from django_fsm import TransitionNotAllowed
from apps.accounts.models import User from apps.accounts.models import User
from .models import EditSubmission, ModerationQueue, PhotoSubmission from .models import EditSubmission, ModerationQueue
class ModerationService: class ModerationService:
@@ -444,9 +444,9 @@ class ModerationService:
return queue_item return queue_item
@staticmethod @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: Args:
submission: The photo submission submission: The photo submission
@@ -587,8 +587,9 @@ class ModerationService:
raise ValueError(f"Unknown action: {action}") raise ValueError(f"Unknown action: {action}")
elif "photo_submission" in queue_item.tags: elif "photo_submission" in queue_item.tags:
# Find PhotoSubmission # Find PHOTO EditSubmission
submissions = PhotoSubmission.objects.filter( submissions = EditSubmission.objects.filter(
submission_type="PHOTO",
user=queue_item.flagged_by, user=queue_item.flagged_by,
content_type=queue_item.content_type, content_type=queue_item.content_type,
object_id=queue_item.entity_id, object_id=queue_item.entity_id,

View File

@@ -2,7 +2,7 @@
Signal handlers for moderation-related FSM state transitions. Signal handlers for moderation-related FSM state transitions.
This module provides signal handlers that execute when moderation This module provides signal handlers that execute when moderation
models (EditSubmission, PhotoSubmission, ModerationReport, etc.) models (EditSubmission, ModerationReport, etc.)
undergo state transitions. undergo state transitions.
Includes: Includes:
@@ -114,6 +114,7 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
Handle submission rejection transitions. Handle submission rejection transitions.
Called when an EditSubmission or PhotoSubmission is rejected. Called when an EditSubmission or PhotoSubmission is rejected.
For photo submissions, queues Cloudflare image cleanup to prevent orphaned assets.
Args: Args:
instance: The submission instance. 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 ''}" 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): def handle_submission_escalated(instance, source, target, user, context=None, **kwargs):
""" """
@@ -377,18 +391,13 @@ def register_moderation_signal_handlers():
EditSubmission, EditSubmission,
ModerationQueue, ModerationQueue,
ModerationReport, 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, "*", "APPROVED", handle_submission_approved, stage="post")
register_transition_handler(EditSubmission, "*", "REJECTED", handle_submission_rejected, stage="post") register_transition_handler(EditSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
register_transition_handler(EditSubmission, "*", "ESCALATED", handle_submission_escalated, 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 # ModerationReport handlers
register_transition_handler(ModerationReport, "*", "RESOLVED", handle_report_resolved, stage="post") 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, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
register_transition_handler(EditSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, 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") logger.info("Registered moderation signal handlers")

View File

@@ -40,7 +40,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
Returns: Returns:
dict: Summary with counts of processed, succeeded, and failed releases 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: if lock_duration_minutes is None:
lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES
@@ -52,7 +52,6 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
result = { result = {
"edit_submissions": {"processed": 0, "released": 0, "failed": 0}, "edit_submissions": {"processed": 0, "released": 0, "failed": 0},
"photo_submissions": {"processed": 0, "released": 0, "failed": 0},
"failures": [], "failures": [],
"cutoff_time": cutoff_time.isoformat(), "cutoff_time": cutoff_time.isoformat(),
} }
@@ -95,44 +94,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
source="task", source="task",
) )
# Process PhotoSubmissions with stale claims (legacy model - until removed) # Process EditSubmission with PHOTO type (unified model)
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)
stale_photo_edit_ids = list( stale_photo_edit_ids = list(
EditSubmission.objects.filter( EditSubmission.objects.filter(
submission_type="PHOTO", submission_type="PHOTO",
@@ -169,8 +131,8 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
source="task", source="task",
) )
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"] total_released = result["edit_submissions"]["released"]
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"] total_failed = result["edit_submissions"]["failed"]
logger.info( logger.info(
"Completed stale claims expiration: %s released, %s failed", "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. and clear the claimed_by and claimed_at fields.
Args: Args:
submission: EditSubmission or PhotoSubmission instance submission: EditSubmission instance
""" """
# Store info for logging before clearing # Store info for logging before clearing
claimed_by = submission.claimed_by claimed_by = submission.claimed_by
@@ -205,3 +167,49 @@ def _release_claim(submission):
claimed_by, claimed_by,
claimed_at, 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)}",
}

View File

@@ -13,11 +13,10 @@ from django.test import RequestFactory, TestCase
from apps.moderation.admin import ( from apps.moderation.admin import (
EditSubmissionAdmin, EditSubmissionAdmin,
HistoryEventAdmin, HistoryEventAdmin,
PhotoSubmissionAdmin,
StateLogAdmin, StateLogAdmin,
moderation_site, moderation_site,
) )
from apps.moderation.models import EditSubmission, PhotoSubmission from apps.moderation.models import EditSubmission
User = get_user_model() User = get_user_model()
@@ -101,32 +100,7 @@ class TestEditSubmissionAdmin(TestCase):
assert "bulk_escalate" in actions assert "bulk_escalate" in actions
class TestPhotoSubmissionAdmin(TestCase): # PhotoSubmissionAdmin tests removed - model consolidated into EditSubmission
"""Tests for PhotoSubmissionAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = PhotoSubmissionAdmin(model=PhotoSubmission, admin_site=self.site)
def test_list_display_includes_preview(self):
"""Verify photo preview is in list_display."""
assert "photo_preview" in self.admin.list_display
def test_list_select_related(self):
"""Verify select_related is configured."""
assert "user" in self.admin.list_select_related
assert "content_type" in self.admin.list_select_related
assert "handled_by" in self.admin.list_select_related
def test_moderation_actions_registered(self):
"""Verify moderation actions are registered."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=True)
actions = self.admin.get_actions(request)
assert "bulk_approve" in actions
assert "bulk_reject" in actions
class TestStateLogAdmin(TestCase): class TestStateLogAdmin(TestCase):
@@ -200,9 +174,7 @@ class TestRegisteredModels(TestCase):
"""Verify EditSubmission is registered with moderation site.""" """Verify EditSubmission is registered with moderation site."""
assert EditSubmission in moderation_site._registry assert EditSubmission in moderation_site._registry
def test_photo_submission_registered(self): # PhotoSubmission registration test removed - model consolidated into EditSubmission
"""Verify PhotoSubmission is registered with moderation site."""
assert PhotoSubmission in moderation_site._registry
def test_state_log_registered(self): def test_state_log_registered(self):
"""Verify StateLog is registered with moderation site.""" """Verify StateLog is registered with moderation site."""

View File

@@ -3,7 +3,7 @@ Comprehensive tests for the moderation app.
This module contains tests for: This module contains tests for:
- EditSubmission state machine transitions - EditSubmission state machine transitions
- PhotoSubmission state machine transitions - EditSubmission with submission_type="PHOTO" (photo submissions)
- ModerationReport state machine transitions - ModerationReport state machine transitions
- ModerationQueue state machine transitions - ModerationQueue state machine transitions
- BulkOperation state machine transitions - BulkOperation state machine transitions
@@ -39,7 +39,6 @@ from ..models import (
ModerationAction, ModerationAction,
ModerationQueue, ModerationQueue,
ModerationReport, ModerationReport,
PhotoSubmission,
) )
User = get_user_model() User = get_user_model()
@@ -1132,14 +1131,17 @@ class ModerationActionTests(TestCase):
# ============================================================================ # ============================================================================
# PhotoSubmission FSM Transition Tests # EditSubmission PHOTO Type FSM Transition Tests
# ============================================================================ # ============================================================================
class PhotoSubmissionTransitionTests(TestCase): class PhotoEditSubmissionTransitionTests(TestCase):
"""Comprehensive tests for PhotoSubmission FSM transitions. """Comprehensive tests for EditSubmission with submission_type='PHOTO' FSM transitions.
Note: All approve/reject/escalate transitions require CLAIMED state first. 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): def setUp(self):
@@ -1169,13 +1171,15 @@ class PhotoSubmissionTransitionTests(TestCase):
) )
def _create_submission(self, status="PENDING"): def _create_submission(self, status="PENDING"):
"""Helper to create a PhotoSubmission with proper CloudflareImage.""" """Helper to create an EditSubmission with submission_type='PHOTO' and proper CloudflareImage."""
submission = PhotoSubmission.objects.create( submission = EditSubmission.objects.create(
user=self.user, user=self.user,
content_type=self.content_type, content_type=self.content_type,
object_id=self.operator.id, object_id=self.operator.id,
submission_type="PHOTO", # Unified model
photo=self.mock_image, photo=self.mock_image,
caption="Test Photo", caption="Test Photo",
changes={}, # Photos use empty changes
status="PENDING", # Always create as PENDING first status="PENDING", # Always create as PENDING first
) )

View File

@@ -83,13 +83,17 @@ class SubmissionApprovalWorkflowTests(TestCase):
def test_photo_submission_approval_workflow(self): 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 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 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 from apps.parks.models import Company, Park
# Create target park # Create target park
@@ -105,18 +109,21 @@ class SubmissionApprovalWorkflowTests(TestCase):
expires_at=timezone.now() + timedelta(days=365), 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) content_type = ContentType.objects.get_for_model(park)
submission = PhotoSubmission.objects.create( submission = EditSubmission.objects.create(
user=self.regular_user, user=self.regular_user,
content_type=content_type, content_type=content_type,
object_id=park.id, object_id=park.id,
submission_type="PHOTO", # Unified model with PHOTO type
status="PENDING", status="PENDING",
photo=mock_image, photo=mock_image,
caption="Beautiful park entrance", caption="Beautiful park entrance",
changes={}, # Photos use empty changes dict
) )
self.assertEqual(submission.status, "PENDING") self.assertEqual(submission.status, "PENDING")
self.assertEqual(submission.submission_type, "PHOTO")
# Moderator claims the submission first (required FSM step) # Moderator claims the submission first (required FSM step)
submission.claim(user=self.moderator) submission.claim(user=self.moderator)

View File

@@ -45,23 +45,16 @@ class SubmissionListView(TemplateView):
template_name = "moderation/partials/dashboard_content.html" template_name = "moderation/partials/dashboard_content.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
from itertools import chain from .models import EditSubmission
from .models import EditSubmission, PhotoSubmission
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
status = self.request.GET.get("status", "PENDING") 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") edit_submissions = EditSubmission.objects.filter(status=status).select_related("user")
photo_submissions = PhotoSubmission.objects.filter(status=status).select_related("user")
# Combine and sort # Sort by created_at descending
context["submissions"] = sorted( context["submissions"] = edit_submissions.order_by("-created_at")
chain(edit_submissions, photo_submissions),
key=lambda x: x.created_at,
reverse=True,
)
return context 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"actions", ModerationActionViewSet, basename="moderation-actions")
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations") router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
router.register(r"users", UserModerationViewSet, basename="user-moderation") 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"submissions", EditSubmissionViewSet, basename="submissions")
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-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"photos", PhotoSubmissionViewSet, basename="photos")
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions") router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
@@ -98,12 +91,12 @@ fsm_transition_patterns = [
{"app_label": "moderation", "model_name": "editsubmission"}, {"app_label": "moderation", "model_name": "editsubmission"},
name="submission_transition", 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>/ # URL: /api/moderation/photos/<pk>/transition/<transition_name>/
path( path(
"photos/<int:pk>/transition/<str:transition_name>/", "photos/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(), FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission"}, {"app_label": "moderation", "model_name": "editsubmission"},
name="photo_transition", name="photo_transition",
), ),
# ModerationReport transitions # ModerationReport transitions
@@ -150,23 +143,23 @@ fsm_transition_patterns = [
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"}, {"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
name="escalate_submission", name="escalate_submission",
), ),
# Backward compatibility aliases for PhotoSubmission actions # Photo transition aliases (use editsubmission model since photos are EditSubmission with type=PHOTO)
path( path(
"photos/<int:pk>/approve/", "photos/<int:pk>/approve/",
FSMTransitionView.as_view(), 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", name="approve_photo",
), ),
path( path(
"photos/<int:pk>/reject/", "photos/<int:pk>/reject/",
FSMTransitionView.as_view(), 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", name="reject_photo",
), ),
path( path(
"photos/<int:pk>/escalate/", "photos/<int:pk>/escalate/",
FSMTransitionView.as_view(), 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", name="escalate_photo",
), ),
] ]

View File

@@ -20,11 +20,13 @@ from django.shortcuts import render
from django.utils import timezone from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_fsm import TransitionNotAllowed, can_proceed 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.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response 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.logging import log_business_event
from apps.core.state_machine.exceptions import ( from apps.core.state_machine.exceptions import (
TransitionPermissionDenied, TransitionPermissionDenied,
@@ -44,7 +46,6 @@ from .models import (
ModerationAction, ModerationAction,
ModerationQueue, ModerationQueue,
ModerationReport, ModerationReport,
PhotoSubmission,
) )
from .permissions import ( from .permissions import (
CanViewModerationData, CanViewModerationData,
@@ -59,12 +60,12 @@ from .serializers import (
CreateEditSubmissionSerializer, CreateEditSubmissionSerializer,
CreateModerationActionSerializer, CreateModerationActionSerializer,
CreateModerationReportSerializer, CreateModerationReportSerializer,
CreatePhotoSubmissionSerializer,
EditSubmissionListSerializer, EditSubmissionListSerializer,
EditSubmissionSerializer, EditSubmissionSerializer,
ModerationActionSerializer, ModerationActionSerializer,
ModerationQueueSerializer, ModerationQueueSerializer,
ModerationReportSerializer, ModerationReportSerializer,
PhotoSubmissionSerializer,
UpdateModerationReportSerializer, UpdateModerationReportSerializer,
UserModerationProfileSerializer, UserModerationProfileSerializer,
) )
@@ -1566,6 +1567,30 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
return Response({"items": [item]}) 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]) @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None): def claim(self, request, pk=None):
""" """
@@ -1646,6 +1671,18 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except ValidationError as e: except ValidationError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST) 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]) @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None): def unclaim(self, request, pk=None):
""" """
@@ -1683,6 +1720,17 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except ValidationError as e: except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) 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]) @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None): def approve(self, request, pk=None):
submission = self.get_object() submission = self.get_object()
@@ -1694,6 +1742,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except Exception as e: except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) 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]) @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None): def reject(self, request, pk=None):
submission = self.get_object() submission = self.get_object()
@@ -1706,6 +1768,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except Exception as e: except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) 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]) @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None): def escalate(self, request, pk=None):
submission = self.get_object() submission = self.get_object()
@@ -2145,6 +2221,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
ordering = ["-created_at"] ordering = ["-created_at"]
permission_classes = [CanViewModerationData] 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): def get_queryset(self):
queryset = EditSubmission.objects.filter(submission_type="PHOTO") queryset = EditSubmission.objects.filter(submission_type="PHOTO")
status_param = self.request.query_params.get("status") status_param = self.request.query_params.get("status")
@@ -2158,6 +2241,26 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
return queryset 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]) @action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None): def claim(self, request, pk=None):
""" """
@@ -2250,7 +2353,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
event_type="submission_unclaimed", event_type="submission_unclaimed",
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}", message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
context={ context={
"model": "PhotoSubmission", "model": "EditSubmission",
"object_id": submission.id, "object_id": submission.id,
"unclaimed_by": request.user.username, "unclaimed_by": request.user.username,
}, },