mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 11:25: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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user