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

@@ -20,11 +20,13 @@ from django.shortcuts import render
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from django_fsm import TransitionNotAllowed, can_proceed
from rest_framework import permissions, status, viewsets
from rest_framework import permissions, serializers as drf_serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from apps.core.logging import log_business_event
from apps.core.state_machine.exceptions import (
TransitionPermissionDenied,
@@ -44,7 +46,6 @@ from .models import (
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
from .permissions import (
CanViewModerationData,
@@ -59,12 +60,12 @@ from .serializers import (
CreateEditSubmissionSerializer,
CreateModerationActionSerializer,
CreateModerationReportSerializer,
CreatePhotoSubmissionSerializer,
EditSubmissionListSerializer,
EditSubmissionSerializer,
ModerationActionSerializer,
ModerationQueueSerializer,
ModerationReportSerializer,
PhotoSubmissionSerializer,
UpdateModerationReportSerializer,
UserModerationProfileSerializer,
)
@@ -1566,6 +1567,30 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
return Response({"items": [item]})
@extend_schema(
summary="Claim a submission for review",
description="Claim a submission for review with concurrency protection using database row locking. "
"Prevents race conditions when multiple moderators try to claim the same submission.",
request=None,
responses={
200: inline_serializer(
name="ClaimSuccessResponse",
fields={
"success": drf_serializers.BooleanField(),
"locked_until": drf_serializers.DateTimeField(),
"submission_id": drf_serializers.CharField(),
"claimed_by": drf_serializers.CharField(),
"claimed_at": drf_serializers.DateTimeField(allow_null=True),
"status": drf_serializers.CharField(),
"lock_duration_minutes": drf_serializers.IntegerField(),
},
),
404: OpenApiResponse(description="Submission not found"),
409: OpenApiResponse(description="Submission already claimed or being claimed by another moderator"),
400: OpenApiResponse(description="Invalid state for claiming (not PENDING)"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
@@ -1646,6 +1671,18 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except ValidationError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Release claim on a submission",
description="Release the current user's claim on a submission. "
"Only the claiming moderator or an admin can unclaim.",
request=None,
responses={
200: EditSubmissionSerializer,
403: OpenApiResponse(description="Only the claiming moderator or admin can unclaim"),
400: OpenApiResponse(description="Submission is not claimed"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None):
"""
@@ -1683,6 +1720,17 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Approve a submission",
description="Approve an edit submission and apply the proposed changes. "
"Only moderators and admins can approve submissions.",
request=None,
responses={
200: EditSubmissionSerializer,
400: OpenApiResponse(description="Approval failed due to validation error"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None):
submission = self.get_object()
@@ -1694,6 +1742,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Reject a submission",
description="Reject an edit submission with an optional reason. "
"The submitter will be notified of the rejection.",
request=inline_serializer(
name="RejectSubmissionRequest",
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
),
responses={
200: EditSubmissionSerializer,
400: OpenApiResponse(description="Rejection failed due to validation error"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None):
submission = self.get_object()
@@ -1706,6 +1768,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Escalate a submission",
description="Escalate an edit submission to senior moderators or admins with a reason. "
"Used for complex or controversial submissions requiring higher-level review.",
request=inline_serializer(
name="EscalateSubmissionRequest",
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
),
responses={
200: EditSubmissionSerializer,
400: OpenApiResponse(description="Escalation failed due to validation error"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
@@ -2145,6 +2221,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_serializer_class(self):
if self.action == "list":
return EditSubmissionListSerializer
if self.action == "create":
return CreatePhotoSubmissionSerializer # Use photo-specific serializer
return EditSubmissionSerializer
def get_queryset(self):
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
status_param = self.request.query_params.get("status")
@@ -2158,6 +2241,26 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
return queryset
def create(self, request, *args, **kwargs):
"""
Create a photo submission.
Backward-compatible: Uses CreatePhotoSubmissionSerializer for input
validation which supports both new format (entity_type) and legacy
format (content_type_id). Returns full submission data via EditSubmissionSerializer.
"""
# Use CreatePhotoSubmissionSerializer for input validation
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# Return the created instance using EditSubmissionSerializer for full output
# This includes id, status, timestamps, etc. that clients need
instance = serializer.instance
response_serializer = EditSubmissionSerializer(instance, context={"request": request})
headers = self.get_success_headers(response_serializer.data)
return Response(response_serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
@@ -2250,7 +2353,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
event_type="submission_unclaimed",
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"model": "EditSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},