Add @extend_schema decorators to moderation ViewSet actions

- Add drf_spectacular imports (extend_schema, OpenApiResponse, inline_serializer)
- Annotate claim action with response schemas for 200/404/409/400
- Annotate unclaim action with response schemas for 200/403/400
- Annotate approve action with request=None and response schemas
- Annotate reject action with reason request body schema
- Annotate escalate action with reason request body schema
- All actions tagged with 'Moderation' for API docs grouping
This commit is contained in:
pacnpal
2026-01-13 19:34:41 -05:00
parent d631f3183c
commit 4140a0d8e7
18 changed files with 526 additions and 692 deletions

View File

@@ -23,7 +23,6 @@ from .models import (
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
User = get_user_model()
@@ -76,6 +75,10 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
status_icon = serializers.SerializerMethodField()
status_display = serializers.CharField(source="get_status_display", read_only=True)
time_since_created = serializers.SerializerMethodField()
# Photo URL for frontend compatibility (Cloudflare Images)
photo_url = serializers.SerializerMethodField()
cloudflare_image_id = serializers.SerializerMethodField()
class Meta:
model = EditSubmission
@@ -102,6 +105,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"time_since_created",
# Photo fields (used when submission_type="PHOTO")
"photo",
"photo_url", # Cloudflare image URL for frontend
"cloudflare_image_id",
"caption",
"date_taken",
]
@@ -117,6 +122,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"status_display",
"content_type_name",
"time_since_created",
"photo_url",
"cloudflare_image_id",
]
def get_status_color(self, obj) -> str:
@@ -155,6 +162,16 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
minutes = diff.seconds // 60
return f"{minutes} minutes ago"
def get_photo_url(self, obj) -> str | None:
"""Return Cloudflare image URL for photo submissions."""
if obj.photo:
return getattr(obj.photo, "image_url", None) or getattr(obj.photo, "url", None)
return None
def get_cloudflare_image_id(self, obj) -> str | None:
"""Expose Cloudflare image id for clients expecting Supabase-like fields."""
return getattr(obj.photo, "id", None) if obj.photo else None
class EditSubmissionListSerializer(serializers.ModelSerializer):
"""Optimized serializer for EditSubmission lists."""
@@ -212,6 +229,8 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
"""
entity_type = serializers.CharField(write_only=True, help_text="Entity type: park, ride, company, ride_model")
caption = serializers.CharField(required=False, allow_blank=True)
date_taken = serializers.DateField(required=False, allow_null=True)
class Meta:
model = EditSubmission
@@ -220,10 +239,25 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
"object_id",
"submission_type",
"changes",
"photo",
"caption",
"date_taken",
"reason",
"source",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add photo field with lazy import to avoid app loading cycles
from django_cloudflareimages_toolkit.models import CloudflareImage
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
required=False,
allow_null=True,
help_text="CloudflareImage id for photo submissions",
)
def validate_entity_type(self, value):
"""Convert entity_type string to ContentType."""
entity_type_map = {
@@ -246,16 +280,17 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
def validate_changes(self, value):
"""Validate changes is a proper JSON object."""
if value is None:
return {}
if not isinstance(value, dict):
raise serializers.ValidationError("Changes must be a JSON object")
if not value:
raise serializers.ValidationError("Changes cannot be empty")
return value
def validate(self, attrs):
"""Cross-field validation."""
submission_type = attrs.get("submission_type", "EDIT")
object_id = attrs.get("object_id")
changes = attrs.get("changes") or {}
# For EDIT submissions, object_id is required
if submission_type == "EDIT" and not object_id:
@@ -268,6 +303,16 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"object_id": "object_id must be null for CREATE submissions"}
)
# For PHOTO submissions, enforce required fields and allow empty changes
if submission_type == "PHOTO":
if not object_id:
raise serializers.ValidationError({"object_id": "object_id is required for PHOTO submissions"})
if not attrs.get("photo"):
raise serializers.ValidationError({"photo": "photo is required for PHOTO submissions"})
else:
if not changes:
raise serializers.ValidationError({"changes": "Changes cannot be empty"})
return attrs
@@ -298,6 +343,120 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
return super().create(validated_data)
class CreatePhotoSubmissionSerializer(serializers.ModelSerializer):
"""
Serializer for creating photo submissions with backward compatibility.
This is a specialized serializer for the /photos endpoint that:
- Makes entity_type optional (can be inferred from content_type_id if provided)
- Automatically sets submission_type to "PHOTO"
- Allows empty changes (photos don't have field changes)
Supports both new format (entity_type) and legacy format (content_type_id + object_id).
"""
entity_type = serializers.CharField(
write_only=True,
required=False, # Optional for backward compatibility
allow_blank=True,
help_text="Entity type: park, ride, company, ride_model (optional if content_type provided)"
)
content_type_id = serializers.IntegerField(
write_only=True,
required=False,
help_text="Legacy: ContentType ID (alternative to entity_type)"
)
caption = serializers.CharField(required=False, allow_blank=True, default="")
date_taken = serializers.DateField(required=False, allow_null=True)
reason = serializers.CharField(required=False, allow_blank=True, default="Photo submission")
class Meta:
model = EditSubmission
fields = [
"entity_type",
"content_type_id",
"object_id",
"photo",
"caption",
"date_taken",
"reason",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add photo field with lazy import to avoid app loading cycles
from django_cloudflareimages_toolkit.models import CloudflareImage
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
required=True, # Photo is required for photo submissions
help_text="CloudflareImage id for photo submissions",
)
def validate(self, attrs):
"""Validate and resolve content_type."""
entity_type = attrs.get("entity_type")
content_type_id = attrs.get("content_type_id")
object_id = attrs.get("object_id")
# Must have object_id
if not object_id:
raise serializers.ValidationError({"object_id": "object_id is required for photo submissions"})
# Must have either entity_type or content_type_id
if not entity_type and not content_type_id:
raise serializers.ValidationError({
"entity_type": "Either entity_type or content_type_id is required"
})
return attrs
def create(self, validated_data):
"""Create a photo submission."""
entity_type = validated_data.pop("entity_type", None)
content_type_id = validated_data.pop("content_type_id", None)
# Resolve ContentType
if entity_type:
# Map entity_type to ContentType
entity_type_map = {
"park": ("parks", "park"),
"ride": ("rides", "ride"),
"company": ("parks", "company"),
"ride_model": ("rides", "ridemodel"),
"manufacturer": ("parks", "company"),
"designer": ("parks", "company"),
"operator": ("parks", "company"),
"property_owner": ("parks", "company"),
}
entity_lower = entity_type.lower()
if entity_lower not in entity_type_map:
raise serializers.ValidationError({
"entity_type": f"Invalid entity_type. Must be one of: {', '.join(entity_type_map.keys())}"
})
app_label, model_name = entity_type_map[entity_lower]
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
elif content_type_id:
# Legacy: Use content_type_id directly
try:
content_type = ContentType.objects.get(pk=content_type_id)
except ContentType.DoesNotExist:
raise serializers.ValidationError({"content_type_id": "Invalid content_type_id"})
else:
raise serializers.ValidationError({"entity_type": "entity_type or content_type_id is required"})
# Set automatic fields for photo submission
validated_data["user"] = self.context["request"].user
validated_data["content_type"] = content_type
validated_data["submission_type"] = "PHOTO"
validated_data["changes"] = {} # Photos don't have field changes
validated_data["status"] = "PENDING"
return super().create(validated_data)
# ============================================================================
# Moderation Report Serializers
# ============================================================================
@@ -983,90 +1142,6 @@ class StateLogSerializer(serializers.ModelSerializer):
read_only_fields = fields
class PhotoSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for PhotoSubmission."""
submitted_by = UserBasicSerializer(source="user", read_only=True)
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
photo_url = serializers.SerializerMethodField()
# UI Metadata
status_display = serializers.CharField(source="get_status_display", read_only=True)
status_color = serializers.SerializerMethodField()
status_icon = serializers.SerializerMethodField()
time_since_created = serializers.SerializerMethodField()
class Meta:
model = PhotoSubmission
fields = [
"id",
"status",
"status_display",
"status_color",
"status_icon",
"content_type",
"content_type_name",
"object_id",
"photo",
"photo_url",
"caption",
"date_taken",
"submitted_by",
"handled_by",
"handled_at",
"notes",
"created_at",
"time_since_created",
]
read_only_fields = [
"id",
"created_at",
"submitted_by",
"handled_by",
"handled_at",
"status_display",
"status_color",
"status_icon",
"content_type_name",
"photo_url",
"time_since_created",
]
def get_photo_url(self, obj) -> str | None:
if obj.photo:
return obj.photo.image_url
return None
def get_status_color(self, obj) -> str:
colors = {
"PENDING": "#f59e0b",
"APPROVED": "#10b981",
"REJECTED": "#ef4444",
}
return colors.get(obj.status, "#6b7280")
def get_status_icon(self, obj) -> str:
icons = {
"PENDING": "heroicons:clock",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
}
return icons.get(obj.status, "heroicons:question-mark-circle")
def get_time_since_created(self, obj) -> str:
"""Human-readable time since creation."""
now = timezone.now()
diff = now - obj.created_at
if diff.days > 0:
return f"{diff.days} days ago"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hours ago"
else:
minutes = diff.seconds // 60
return f"{minutes} minutes ago"
# ============================================================================
# Moderation Audit Log Serializers