mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 13:35:19 -05:00
Add @extend_schema decorators to moderation ViewSet actions
- Add drf_spectacular imports (extend_schema, OpenApiResponse, inline_serializer) - Annotate claim action with response schemas for 200/404/409/400 - Annotate unclaim action with response schemas for 200/403/400 - Annotate approve action with request=None and response schemas - Annotate reject action with reason request body schema - Annotate escalate action with reason request body schema - All actions tagged with 'Moderation' for API docs grouping
This commit is contained in:
@@ -55,3 +55,45 @@ def get_direct_upload_url(user_id=None):
|
|||||||
raise e
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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:"))
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-01-13 01:46
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("moderation", "0012_migrate_photo_submissions"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="photosubmissionevent",
|
||||||
|
name="pgh_obj",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="photosubmissionevent",
|
||||||
|
name="claimed_by",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="photosubmissionevent",
|
||||||
|
name="content_type",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="photosubmissionevent",
|
||||||
|
name="handled_by",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="photosubmissionevent",
|
||||||
|
name="pgh_context",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="photosubmissionevent",
|
||||||
|
name="photo",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="photosubmissionevent",
|
||||||
|
name="user",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="PhotoSubmission",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="PhotoSubmissionEvent",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -13,7 +13,7 @@ from django.http import (
|
|||||||
)
|
)
|
||||||
from django.views.generic import DetailView
|
from 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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user