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, },