Files
thrillwiki_django_no_react/backend/apps/moderation/views.py
pacnpal 4140a0d8e7 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
2026-01-13 19:34:41 -05:00

2929 lines
117 KiB
Python

"""
Moderation API Views
This module contains DRF viewsets for the moderation system, including:
- ModerationReport views for content reporting
- ModerationQueue views for moderation workflow
- ModerationAction views for tracking moderation actions
- BulkOperation views for administrative bulk operations
All views include comprehensive permissions, filtering, and pagination.
"""
import logging
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.paginator import Paginator
from django.db.models import Count, Q
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, 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,
TransitionValidationError,
format_transition_error,
)
from .filters import (
BulkOperationFilter,
ModerationActionFilter,
ModerationQueueFilter,
ModerationReportFilter,
)
from .models import (
BulkOperation,
EditSubmission,
ModerationAction,
ModerationQueue,
ModerationReport,
)
from .permissions import (
CanViewModerationData,
IsAdminOrSuperuser,
IsModeratorOrAdmin,
)
from .serializers import (
AssignQueueItemSerializer,
BulkOperationSerializer,
CompleteQueueItemSerializer,
CreateBulkOperationSerializer,
CreateEditSubmissionSerializer,
CreateModerationActionSerializer,
CreateModerationReportSerializer,
CreatePhotoSubmissionSerializer,
EditSubmissionListSerializer,
EditSubmissionSerializer,
ModerationActionSerializer,
ModerationQueueSerializer,
ModerationReportSerializer,
UpdateModerationReportSerializer,
UserModerationProfileSerializer,
)
User = get_user_model()
logger = logging.getLogger(__name__)
# ============================================================================
# Moderation Report ViewSet
# ============================================================================
class ModerationReportViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing moderation reports.
Provides CRUD operations for moderation reports with comprehensive
filtering, search, and permission controls.
"""
queryset = ModerationReport.objects.select_related("reported_by", "assigned_moderator", "content_type").all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationReportFilter
search_fields = ["reason", "description", "resolution_notes"]
ordering_fields = ["created_at", "updated_at", "priority", "status"]
ordering = ["-created_at"]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return CreateModerationReportSerializer
elif self.action in ["update", "partial_update"]:
return UpdateModerationReportSerializer
return ModerationReportSerializer
def get_permissions(self):
"""Return appropriate permissions based on action."""
if self.action == "create":
# Any authenticated user can create reports
permission_classes = [permissions.IsAuthenticated]
elif self.action in ["list", "retrieve"]:
# Moderators and above can view reports
permission_classes = [CanViewModerationData]
else:
# Only moderators and above can modify reports
permission_classes = [IsModeratorOrAdmin]
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Filter queryset based on user permissions."""
queryset = super().get_queryset()
# Regular users can only see their own reports
if not self.request.user.is_authenticated:
return queryset.none()
user_role = getattr(self.request.user, "role", "USER")
if user_role == "USER":
queryset = queryset.filter(reported_by=self.request.user)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def assign(self, request, pk=None):
"""Assign a report to a moderator."""
report = self.get_object()
moderator_id = request.data.get("moderator_id")
try:
moderator = User.objects.get(id=moderator_id)
moderator_role = getattr(moderator, "role", "USER")
if moderator_role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
return Response(
{"error": "User must be a moderator, admin, or superuser"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition method exists
transition_method = getattr(report, "transition_to_under_review", None)
if transition_method is None:
return Response(
{"error": "Transition method not available"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition can proceed before attempting
if not can_proceed(transition_method, moderator):
return Response(
format_transition_error(
TransitionPermissionDenied(
message="Cannot transition to UNDER_REVIEW",
user_message="You don't have permission to assign this report or it cannot be transitioned from the current state.",
)
),
status=status.HTTP_403_FORBIDDEN,
)
report.assigned_moderator = moderator
old_status = report.status
try:
transition_method(user=moderator)
report.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationReport {report.id} assigned to {moderator.username}",
context={
"model": "ModerationReport",
"object_id": report.id,
"old_state": old_status,
"new_state": report.status,
"transition": "assign",
"moderator": moderator.username,
},
request=request,
)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
status=status.HTTP_403_FORBIDDEN,
)
except TransitionValidationError as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
except TransitionNotAllowed as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
serializer = self.get_serializer(report)
return Response(serializer.data)
except User.DoesNotExist:
return Response({"error": "Moderator not found"}, status=status.HTTP_404_NOT_FOUND)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def resolve(self, request, pk=None):
"""Resolve a moderation report."""
report = self.get_object()
resolution_action = request.data.get("resolution_action")
resolution_notes = request.data.get("resolution_notes", "")
if not resolution_action:
return Response(
{"error": "resolution_action is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition method exists
transition_method = getattr(report, "transition_to_resolved", None)
if transition_method is None:
return Response(
{"error": "Transition method not available"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition can proceed before attempting
if not can_proceed(transition_method, request.user):
return Response(
format_transition_error(
TransitionPermissionDenied(
message="Cannot transition to RESOLVED",
user_message="You don't have permission to resolve this report or it cannot be resolved from the current state.",
)
),
status=status.HTTP_403_FORBIDDEN,
)
old_status = report.status
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
status=status.HTTP_403_FORBIDDEN,
)
except TransitionValidationError as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
except TransitionNotAllowed as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
report.resolution_action = resolution_action
report.resolution_notes = resolution_notes
report.resolved_at = timezone.now()
report.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationReport {report.id} resolved with action: {resolution_action}",
context={
"model": "ModerationReport",
"object_id": report.id,
"old_state": old_status,
"new_state": report.status,
"transition": "resolve",
"resolution_action": resolution_action,
"user": request.user.username,
},
request=request,
)
serializer = self.get_serializer(report)
return Response(serializer.data)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def stats(self, request):
"""Get moderation report statistics."""
queryset = self.get_queryset()
# Basic counts
total_reports = queryset.count()
pending_reports = queryset.filter(status="PENDING").count()
resolved_reports = queryset.filter(status="RESOLVED").count()
# Overdue reports (based on priority SLA)
now = timezone.now()
overdue_reports = 0
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
hours_since_created = (now - report.created_at).total_seconds() / 3600
if report.priority in sla_hours:
threshold = sla_hours[report.priority]
else:
raise ValueError(f"Unknown priority level: {report.priority}")
if hours_since_created > threshold:
overdue_reports += 1
# Reports by priority and type
reports_by_priority = dict(queryset.values_list("priority").annotate(count=Count("id")))
reports_by_type = dict(queryset.values_list("report_type").annotate(count=Count("id")))
# Average resolution time
resolved_queryset = queryset.filter(status="RESOLVED", resolved_at__isnull=False)
avg_resolution_time = 0
if resolved_queryset.exists():
total_time = sum(
[
(report.resolved_at - report.created_at).total_seconds() / 3600
for report in resolved_queryset
if report.resolved_at
]
)
avg_resolution_time = total_time / resolved_queryset.count()
stats_data = {
"total_reports": total_reports,
"pending_reports": pending_reports,
"resolved_reports": resolved_reports,
"overdue_reports": overdue_reports,
"reports_by_priority": reports_by_priority,
"reports_by_type": reports_by_type,
"average_resolution_time_hours": round(avg_resolution_time, 2),
}
return Response(stats_data)
@action(detail=True, methods=["get"], permission_classes=[CanViewModerationData])
def history(self, request, pk=None):
"""Get transition history for this report."""
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
report = self.get_object()
content_type = ContentType.objects.get_for_model(report)
logs = (
StateLog.objects.filter(content_type=content_type, object_id=report.id)
.select_related("by")
.order_by("-timestamp")
)
history_data = [
{
"id": log.id,
"timestamp": log.timestamp,
"state": log.state,
"from_state": log.source_state,
"to_state": log.state,
"transition": log.transition,
"user": log.by.username if log.by else None,
"description": log.description,
"reason": log.description,
}
for log in logs
]
return Response(history_data)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def all_history(self, request):
"""Get all transition history with filtering.
Supports both HTMX (returns HTML partials) and API (returns JSON) requests.
"""
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
queryset = StateLog.objects.select_related("by", "content_type").all()
# Filter by id (for detail view)
log_id = request.query_params.get("id")
if log_id:
try:
log = queryset.get(id=log_id)
# Check if HTMX request for detail view
if request.headers.get("HX-Request"):
return render(
request,
"moderation/partials/history_detail_content.html",
{
"log": log,
},
)
# Return JSON for API request
return Response(
{
"id": log.id,
"timestamp": log.timestamp,
"model": log.content_type.model,
"object_id": log.object_id,
"state": log.state,
"from_state": log.source_state,
"to_state": log.state,
"transition": log.transition,
"user": log.by.username if log.by else None,
"description": log.description,
"reason": log.description,
}
)
except StateLog.DoesNotExist:
if request.headers.get("HX-Request"):
return render(
request,
"moderation/partials/history_detail_content.html",
{
"log": None,
},
)
return Response({"error": "Log not found"}, status=status.HTTP_404_NOT_FOUND)
# Filter by model type with app_label support for correct ContentType resolution
model_type = request.query_params.get("model_type")
app_label = request.query_params.get("app_label")
if model_type:
try:
if app_label:
# Use both app_label and model for precise matching
content_type = ContentType.objects.get_by_natural_key(app_label, model_type)
else:
# Map common model names to their app_labels for correct resolution
model_app_mapping = {
"park": "parks",
"ride": "rides",
"editsubmission": "submissions",
"photosubmission": "submissions",
"moderationreport": "moderation",
"moderationqueue": "moderation",
"bulkoperation": "moderation",
}
mapped_app_label = model_app_mapping.get(model_type.lower())
if mapped_app_label:
content_type = ContentType.objects.get_by_natural_key(mapped_app_label, model_type.lower())
else:
# Fallback to model-only lookup
content_type = ContentType.objects.get(model=model_type)
queryset = queryset.filter(content_type=content_type)
except ContentType.DoesNotExist:
pass
# Filter by object_id (for object-level history)
object_id = request.query_params.get("object_id")
if object_id:
queryset = queryset.filter(object_id=object_id)
# Filter by user
user_id = request.query_params.get("user_id")
if user_id:
queryset = queryset.filter(by_id=user_id)
# Filter by date range
start_date = request.query_params.get("start_date")
end_date = request.query_params.get("end_date")
if start_date:
queryset = queryset.filter(timestamp__gte=start_date)
if end_date:
queryset = queryset.filter(timestamp__lte=end_date)
# Filter by state
state = request.query_params.get("state")
if state:
queryset = queryset.filter(state=state)
# Search filter (case-insensitive across relevant fields)
search_query = request.query_params.get("q")
if search_query:
queryset = queryset.filter(
Q(transition__icontains=search_query)
| Q(description__icontains=search_query)
| Q(state__icontains=search_query)
| Q(source_state__icontains=search_query)
| Q(object_id__icontains=search_query)
| Q(by__username__icontains=search_query)
)
# Order queryset
queryset = queryset.order_by("-timestamp")
# Check if HTMX request
if request.headers.get("HX-Request"):
# Use Django's Paginator for HTMX responses
paginator = Paginator(queryset, 20)
page_number = request.query_params.get("page", 1)
page_obj = paginator.get_page(page_number)
return render(
request,
"moderation/partials/history_table.html",
{
"history_logs": page_obj,
"page_obj": page_obj,
"request": request,
},
)
# Paginate for API response
page = self.paginate_queryset(queryset)
if page is not None:
history_data = [
{
"id": log.id,
"timestamp": log.timestamp,
"model": log.content_type.model,
"object_id": log.object_id,
"state": log.state,
"from_state": log.source_state,
"to_state": log.state,
"transition": log.transition,
"user": log.by.username if log.by else None,
"description": log.description,
"reason": log.description,
}
for log in page
]
return self.get_paginated_response(history_data)
# Return all history data when pagination is not triggered
history_data = [
{
"id": log.id,
"timestamp": log.timestamp,
"model": log.content_type.model,
"object_id": log.object_id,
"state": log.state,
"from_state": log.source_state,
"to_state": log.state,
"transition": log.transition,
"user": log.by.username if log.by else None,
"description": log.description,
"reason": log.description,
}
for log in queryset
]
return Response(history_data)
# ============================================================================
# Moderation Queue ViewSet
# ============================================================================
class ModerationQueueViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing moderation queue items.
Provides workflow management for moderation tasks with assignment,
completion, and progress tracking.
"""
queryset = ModerationQueue.objects.select_related("assigned_to", "related_report", "content_type").all()
serializer_class = ModerationQueueSerializer
permission_classes = [CanViewModerationData]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationQueueFilter
search_fields = ["title", "description"]
ordering_fields = ["created_at", "updated_at", "priority", "status"]
ordering = ["-created_at"]
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def assign(self, request, pk=None):
"""Assign a queue item to a moderator."""
queue_item = self.get_object()
serializer = AssignQueueItemSerializer(data=request.data)
if serializer.is_valid():
moderator_id = serializer.validated_data["moderator_id"]
moderator = User.objects.get(id=moderator_id)
# Check if transition method exists
transition_method = getattr(queue_item, "transition_to_in_progress", None)
if transition_method is None:
return Response(
{"error": "Transition method not available"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition can proceed before attempting
if not can_proceed(transition_method, moderator):
return Response(
format_transition_error(
TransitionPermissionDenied(
message="Cannot transition to IN_PROGRESS",
user_message="You don't have permission to assign this queue item or it cannot be transitioned from the current state.",
)
),
status=status.HTTP_403_FORBIDDEN,
)
queue_item.assigned_to = moderator
queue_item.assigned_at = timezone.now()
old_status = queue_item.status
try:
transition_method(user=moderator)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
status=status.HTTP_403_FORBIDDEN,
)
except TransitionValidationError as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
except TransitionNotAllowed as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
queue_item.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationQueue {queue_item.id} assigned to {moderator.username}",
context={
"model": "ModerationQueue",
"object_id": queue_item.id,
"old_state": old_status,
"new_state": queue_item.status,
"transition": "assign",
"moderator": moderator.username,
},
request=request,
)
response_serializer = self.get_serializer(queue_item)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unassign(self, request, pk=None):
"""Unassign a queue item."""
queue_item = self.get_object()
# Check if transition method exists
transition_method = getattr(queue_item, "transition_to_pending", None)
if transition_method is None:
return Response(
{"error": "Transition method not available"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition can proceed before attempting
if not can_proceed(transition_method, request.user):
return Response(
format_transition_error(
TransitionPermissionDenied(
message="Cannot transition to PENDING",
user_message="You don't have permission to unassign this queue item or it cannot be transitioned from the current state.",
)
),
status=status.HTTP_403_FORBIDDEN,
)
queue_item.assigned_to = None
queue_item.assigned_at = None
old_status = queue_item.status
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
status=status.HTTP_403_FORBIDDEN,
)
except TransitionValidationError as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
except TransitionNotAllowed as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
queue_item.save()
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationQueue {queue_item.id} unassigned",
context={
"model": "ModerationQueue",
"object_id": queue_item.id,
"old_state": old_status,
"new_state": queue_item.status,
"transition": "unassign",
"user": request.user.username,
},
request=request,
)
serializer = self.get_serializer(queue_item)
return Response(serializer.data)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def complete(self, request, pk=None):
"""Complete a queue item."""
queue_item = self.get_object()
serializer = CompleteQueueItemSerializer(data=request.data)
if serializer.is_valid():
action_taken = serializer.validated_data["action"]
notes = serializer.validated_data.get("notes", "")
# Check if transition method exists
transition_method = getattr(queue_item, "transition_to_completed", None)
if transition_method is None:
return Response(
{"error": "Transition method not available"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition can proceed before attempting
if not can_proceed(transition_method, request.user):
return Response(
format_transition_error(
TransitionPermissionDenied(
message="Cannot transition to COMPLETED",
user_message="You don't have permission to complete this queue item or it cannot be transitioned from the current state.",
)
),
status=status.HTTP_403_FORBIDDEN,
)
old_status = queue_item.status
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
status=status.HTTP_403_FORBIDDEN,
)
except TransitionValidationError as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
except TransitionNotAllowed as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
queue_item.save()
# Create moderation action if needed
if action_taken != "NO_ACTION" and queue_item.related_report:
ModerationAction.objects.create(
action_type=action_taken,
reason=f"Queue item completion: {action_taken}",
details=notes,
moderator=request.user,
target_user=queue_item.related_report.reported_by,
related_report=queue_item.related_report,
is_active=True,
)
log_business_event(
logger,
event_type="fsm_transition",
message=f"ModerationQueue {queue_item.id} completed with action: {action_taken}",
context={
"model": "ModerationQueue",
"object_id": queue_item.id,
"old_state": old_status,
"new_state": queue_item.status,
"transition": "complete",
"action_taken": action_taken,
"user": request.user.username,
},
request=request,
)
response_serializer = self.get_serializer(queue_item)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def my_queue(self, request):
"""Get queue items assigned to the current user."""
queryset = self.get_queryset().filter(assigned_to=request.user)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=["get"], permission_classes=[CanViewModerationData])
def history(self, request, pk=None):
"""Get transition history for this queue item."""
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
queue_item = self.get_object()
content_type = ContentType.objects.get_for_model(queue_item)
logs = (
StateLog.objects.filter(content_type=content_type, object_id=queue_item.id)
.select_related("by")
.order_by("-timestamp")
)
history_data = [
{
"id": log.id,
"timestamp": log.timestamp,
"state": log.state,
"from_state": log.source_state,
"to_state": log.state,
"transition": log.transition,
"user": log.by.username if log.by else None,
"description": log.description,
"reason": log.description,
}
for log in logs
]
return Response(history_data)
# ============================================================================
# Moderation Action ViewSet
# ============================================================================
class ModerationActionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing moderation actions.
Tracks actions taken against users and content with expiration
and status management.
"""
queryset = ModerationAction.objects.select_related("moderator", "target_user", "related_report").all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ModerationActionFilter
search_fields = ["reason", "details"]
ordering_fields = ["created_at", "expires_at", "action_type"]
ordering = ["-created_at"]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return CreateModerationActionSerializer
return ModerationActionSerializer
def get_permissions(self):
"""Return appropriate permissions based on action."""
permission_classes = [IsModeratorOrAdmin] if self.action == "create" else [CanViewModerationData]
return [permission() for permission in permission_classes]
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def deactivate(self, request, pk=None):
"""Deactivate a moderation action."""
action_obj = self.get_object()
action_obj.is_active = False
action_obj.save()
serializer = self.get_serializer(action_obj)
return Response(serializer.data)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def active(self, request):
"""Get all active moderation actions."""
queryset = self.get_queryset().filter(is_active=True, expires_at__gt=timezone.now())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], permission_classes=[CanViewModerationData])
def expired(self, request):
"""Get all expired moderation actions."""
queryset = self.get_queryset().filter(expires_at__lte=timezone.now(), is_active=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# ============================================================================
# Bulk Operation ViewSet
# ============================================================================
class BulkOperationViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing bulk operations.
Provides administrative bulk operations with progress tracking
and cancellation support.
"""
queryset = BulkOperation.objects.select_related("created_by").all()
permission_classes = [IsAdminOrSuperuser]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = BulkOperationFilter
search_fields = ["description"]
ordering_fields = ["created_at", "started_at", "completed_at", "priority"]
ordering = ["-created_at"]
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "create":
return CreateBulkOperationSerializer
return BulkOperationSerializer
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
"""Cancel a bulk operation."""
operation = self.get_object()
if operation.status not in ["PENDING", "RUNNING"]:
return Response(
{"error": "Operation cannot be cancelled"},
status=status.HTTP_400_BAD_REQUEST,
)
if not operation.can_cancel:
return Response(
{"error": "Operation is not cancellable"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition method exists
transition_method = getattr(operation, "transition_to_cancelled", None)
if transition_method is None:
return Response(
{"error": "Transition method not available"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition can proceed before attempting
if not can_proceed(transition_method, request.user):
return Response(
format_transition_error(
TransitionPermissionDenied(
message="Cannot transition to CANCELLED",
user_message="You don't have permission to cancel this operation or it cannot be cancelled from the current state.",
)
),
status=status.HTTP_403_FORBIDDEN,
)
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
status=status.HTTP_403_FORBIDDEN,
)
except TransitionValidationError as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
except TransitionNotAllowed as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
operation.completed_at = timezone.now()
operation.save()
serializer = self.get_serializer(operation)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def retry(self, request, pk=None):
"""Retry a failed bulk operation."""
operation = self.get_object()
if operation.status != "FAILED":
return Response(
{"error": "Only failed operations can be retried"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition method exists
transition_method = getattr(operation, "transition_to_pending", None)
if transition_method is None:
return Response(
{"error": "Transition method not available"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if transition can proceed before attempting
if not can_proceed(transition_method, request.user):
return Response(
format_transition_error(
TransitionPermissionDenied(
message="Cannot transition to PENDING",
user_message="You don't have permission to retry this operation or it cannot be retried from the current state.",
)
),
status=status.HTTP_403_FORBIDDEN,
)
# Reset operation status
try:
transition_method(user=request.user)
except TransitionPermissionDenied as e:
return Response(
format_transition_error(e),
status=status.HTTP_403_FORBIDDEN,
)
except TransitionValidationError as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
except TransitionNotAllowed as e:
return Response(
format_transition_error(e),
status=status.HTTP_400_BAD_REQUEST,
)
operation.started_at = None
operation.completed_at = None
operation.processed_items = 0
operation.failed_items = 0
operation.results = {}
operation.save()
serializer = self.get_serializer(operation)
return Response(serializer.data)
@action(detail=True, methods=["get"])
def logs(self, request, pk=None):
"""Get logs for a bulk operation."""
operation = self.get_object()
# This would typically fetch logs from a logging system
# For now, return a placeholder response
logs = {
"logs": [
{
"timestamp": operation.created_at.isoformat(),
"level": "INFO",
"message": f"Operation {operation.id} created",
"details": operation.parameters,
}
],
"count": 1,
}
return Response(logs)
@action(detail=False, methods=["get"])
def running(self, request):
"""Get all running bulk operations."""
queryset = self.get_queryset().filter(status="RUNNING")
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=["get"])
def history(self, request, pk=None):
"""Get transition history for this bulk operation."""
from django.contrib.contenttypes.models import ContentType
from django_fsm_log.models import StateLog
operation = self.get_object()
content_type = ContentType.objects.get_for_model(operation)
logs = (
StateLog.objects.filter(content_type=content_type, object_id=operation.id)
.select_related("by")
.order_by("-timestamp")
)
history_data = [
{
"id": log.id,
"timestamp": log.timestamp,
"state": log.state,
"from_state": log.source_state,
"to_state": log.state,
"transition": log.transition,
"user": log.by.username if log.by else None,
"description": log.description,
"reason": log.description,
}
for log in logs
]
return Response(history_data)
# ============================================================================
# User Moderation ViewSet
# ============================================================================
class UserModerationViewSet(viewsets.ViewSet):
"""
ViewSet for user moderation operations.
Provides user-specific moderation data, statistics, and actions.
"""
permission_classes = [IsModeratorOrAdmin]
# Default serializer for schema generation
serializer_class = UserModerationProfileSerializer
def list(self, request):
"""Search for users to moderate."""
query = request.query_params.get("q", "")
if not query:
return Response([])
queryset = User.objects.filter(Q(username__icontains=query) | Q(email__icontains=query))[:20]
users_data = [
{
"id": user.id,
"username": user.username,
"email": user.email,
"role": getattr(user, "role", "USER"),
"is_active": user.is_active,
}
for user in queryset
]
return Response(users_data)
def retrieve(self, request, pk=None):
"""Get moderation profile for a specific user."""
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
# Gather user moderation data
reports_made = ModerationReport.objects.filter(reported_by=user).count()
reports_against = ModerationReport.objects.filter(
reported_entity_type="user", reported_entity_id=user.id
).count()
actions_against = ModerationAction.objects.filter(target_user=user)
warnings_received = actions_against.filter(action_type="WARNING").count()
suspensions_received = actions_against.filter(action_type="USER_SUSPENSION").count()
active_restrictions = actions_against.filter(is_active=True, expires_at__gt=timezone.now()).count()
# Risk assessment (simplified)
risk_factors = []
risk_level = "LOW"
if reports_against > 5:
risk_factors.append("Multiple reports against user")
risk_level = "MEDIUM"
if suspensions_received > 0:
risk_factors.append("Previous suspensions")
risk_level = "HIGH"
if active_restrictions > 0:
risk_factors.append("Active restrictions")
risk_level = "HIGH"
# Recent activity
recent_reports = ModerationReport.objects.filter(reported_by=user).order_by("-created_at")[:5]
recent_actions = actions_against.order_by("-created_at")[:5]
# Account status
account_status = "ACTIVE"
if getattr(user, "is_banned", False):
account_status = "BANNED"
elif active_restrictions > 0:
account_status = "RESTRICTED"
last_violation = (
actions_against.filter(action_type__in=["WARNING", "USER_SUSPENSION", "USER_BAN"])
.order_by("-created_at")
.first()
)
profile_data = {
"user": {
"id": user.id,
"username": user.username,
"display_name": user.get_display_name(),
"email": user.email,
"role": getattr(user, "role", "USER"),
},
"reports_made": reports_made,
"reports_against": reports_against,
"warnings_received": warnings_received,
"suspensions_received": suspensions_received,
"active_restrictions": active_restrictions,
"risk_level": risk_level,
"risk_factors": risk_factors,
"recent_reports": ModerationReportSerializer(recent_reports, many=True).data,
"recent_actions": ModerationActionSerializer(recent_actions, many=True).data,
"account_status": account_status,
"last_violation_date": (last_violation.created_at if last_violation else None),
"next_review_date": None, # Would be calculated based on business rules
}
return Response(profile_data)
@action(detail=True, methods=["post"])
def moderate(self, request, pk=None):
"""Take moderation action against a user."""
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = CreateModerationActionSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
# Override target_user_id with the user from URL
validated_data = serializer.validated_data.copy()
validated_data["target_user_id"] = user.id
action = ModerationAction.objects.create(
action_type=validated_data["action_type"],
reason=validated_data["reason"],
details=validated_data["details"],
duration_hours=validated_data.get("duration_hours"),
moderator=request.user,
target_user=user,
related_report_id=validated_data.get("related_report_id"),
is_active=True,
expires_at=(
timezone.now() + timedelta(hours=validated_data["duration_hours"])
if validated_data.get("duration_hours")
else None
),
)
response_serializer = ModerationActionSerializer(action)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=["get"])
def search(self, request):
"""Search users for moderation purposes."""
query = request.query_params.get("query", "")
role = request.query_params.get("role")
has_restrictions = request.query_params.get("has_restrictions")
queryset = User.objects.all()
if query:
queryset = queryset.filter(Q(username__icontains=query) | Q(email__icontains=query))
if role:
queryset = queryset.filter(role=role)
if has_restrictions == "true":
active_action_users = ModerationAction.objects.filter(
is_active=True, expires_at__gt=timezone.now()
).values_list("target_user_id", flat=True)
queryset = queryset.filter(id__in=active_action_users)
# Paginate results
page = self.paginate_queryset(queryset)
if page is not None:
users_data = []
for user in page:
restriction_count = ModerationAction.objects.filter(
target_user=user, is_active=True, expires_at__gt=timezone.now()
).count()
users_data.append(
{
"id": user.id,
"username": user.username,
"display_name": user.get_display_name(),
"email": user.email,
"role": getattr(user, "role", "USER"),
"date_joined": user.date_joined,
"last_login": user.last_login,
"is_active": user.is_active,
"restriction_count": restriction_count,
"risk_level": "HIGH" if restriction_count > 0 else "LOW",
}
)
return self.get_paginated_response(users_data)
return Response([])
@action(detail=False, methods=["get"])
def stats(self, request):
"""Get overall user moderation statistics."""
total_actions = ModerationAction.objects.count()
active_actions = ModerationAction.objects.filter(is_active=True, expires_at__gt=timezone.now()).count()
expired_actions = ModerationAction.objects.filter(expires_at__lte=timezone.now()).count()
stats_data = {
"total_actions": total_actions,
"active_actions": active_actions,
"expired_actions": expired_actions,
}
return Response(stats_data)
# ============================================================================
# Submission ViewSets
# ============================================================================
class EditSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing edit submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = EditSubmission.objects.all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["reason", "changes"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_serializer_class(self):
if self.action == "list":
return EditSubmissionListSerializer
if self.action == "create":
return CreateEditSubmissionSerializer
return EditSubmissionSerializer
def get_queryset(self):
queryset = super().get_queryset()
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=False, methods=["post"], permission_classes=[CanViewModerationData], url_path="with-diffs")
def with_diffs(self, request):
"""
Fetch submission items with pre-calculated diffs.
POST /api/v1/moderation/api/submissions/with-diffs/
Request body:
submission_id: str - The EditSubmission ID to fetch
Returns:
items: list - List of submission items with diffs calculated
"""
from deepdiff import DeepDiff
submission_id = request.data.get("submission_id")
if not submission_id:
return Response(
{"error": "submission_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
submission = EditSubmission.objects.get(pk=submission_id)
except EditSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception:
return Response(
{"error": "Invalid submission_id format"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get submission changes
entity_data = submission.changes or {}
original_data = None
# Get entity type from content_type
entity_type = submission.content_type.model if submission.content_type else None
# If this is an EDIT submission, try to get the original entity data
if submission.object_id and entity_type:
try:
model_class = submission.content_type.model_class()
if model_class:
original_entity = model_class.objects.get(pk=submission.object_id)
from django.forms.models import model_to_dict
original_data = model_to_dict(original_entity)
except Exception as e:
logger.debug(f"Could not fetch original entity for diff: {e}")
# Calculate field-level diffs
field_changes = []
if original_data and entity_data:
# Check if entity_data already contains pre-computed diff objects {new, old}
# This happens when the changes dict stores diffs directly
has_precomputed_diffs = any(
isinstance(value, dict) and "new" in value and "old" in value and len(value) == 2
for value in entity_data.values()
if isinstance(value, dict)
)
if has_precomputed_diffs:
# Extract field changes directly from pre-computed diffs
for field, value in entity_data.items():
if field.startswith("_"):
continue
if (
isinstance(value, dict)
and "new" in value
and "old" in value
and len(value) == 2
):
field_changes.append({
"field": field,
"oldValue": value.get("old"),
"newValue": value.get("new"),
"changeType": "modified",
"category": "other",
"priority": "optional",
})
else:
# Use DeepDiff for regular data comparison
try:
diff = DeepDiff(original_data, entity_data, ignore_order=True)
for change_type, changes in diff.items():
if isinstance(changes, dict):
for field_path, change_value in changes.items():
field_name = field_path.replace("root['", "").replace("']", "").split("']['")[0]
if change_type == "values_changed":
field_changes.append({
"field": field_name,
"oldValue": change_value.get("old_value"),
"newValue": change_value.get("new_value"),
"changeType": "modified",
"category": "other",
"priority": "optional",
})
elif change_type == "dictionary_item_added":
field_changes.append({
"field": field_name,
"oldValue": None,
"newValue": change_value,
"changeType": "added",
"category": "other",
"priority": "optional",
})
elif change_type == "dictionary_item_removed":
field_changes.append({
"field": field_name,
"oldValue": change_value,
"newValue": None,
"changeType": "removed",
"category": "other",
"priority": "optional",
})
except Exception as e:
logger.debug(f"Error calculating diffs: {e}")
elif entity_data:
# Handle entity_data that may contain pre-computed diff objects {new, old}
for field, value in entity_data.items():
if field.startswith("_"):
continue
# Check if value is a diff object with {new, old} structure
if (
isinstance(value, dict)
and "new" in value
and "old" in value
and len(value) == 2
):
# This is a pre-computed diff, extract the values
field_changes.append({
"field": field,
"oldValue": value.get("old"),
"newValue": value.get("new"),
"changeType": "modified",
"category": "other",
"priority": "optional",
})
else:
# Regular value (for create submissions)
field_changes.append({
"field": field,
"oldValue": None,
"newValue": value,
"changeType": "added",
"category": "other",
"priority": "optional",
})
action_type = "edit" if submission.object_id else "create"
item = {
"id": str(submission.id),
"submission_id": str(submission.id),
"item_type": entity_type or "unknown",
"action_type": action_type,
"status": submission.status,
"order_index": 0,
"depends_on": None,
"entity_data": entity_data,
"original_entity_data": original_data,
"item_data": entity_data,
"original_data": original_data,
"diff": {
"action": action_type,
"fieldChanges": field_changes,
"unchangedFields": [],
"totalChanges": len(field_changes),
},
"created_at": submission.created_at.isoformat() if submission.created_at else None,
"updated_at": submission.updated_at.isoformat() if hasattr(submission, "updated_at") and submission.updated_at else None,
}
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):
"""
Claim a submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock,
preventing race conditions when multiple moderators try to
claim the same submission simultaneously.
Returns:
200: Submission successfully claimed
404: Submission not found
409: Submission already claimed or being claimed by another moderator
400: Invalid state for claiming
"""
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
with transaction.atomic():
try:
# Lock the row for update - other transactions will fail immediately
submission = EditSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except EditSubmission.DoesNotExist:
return Response({"error": "Submission not found"}, status=status.HTTP_404_NOT_FOUND)
except DatabaseError:
# Row is already locked by another transaction
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT,
)
# Check if already claimed
if submission.status == "CLAIMED":
return Response(
{
"error": "Submission already claimed",
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT,
)
# Check if in valid state for claiming
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
submission.claim(user=request.user)
log_business_event(
logger,
event_type="submission_claimed",
message=f"EditSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"claimed_by": request.user.username,
},
request=request,
)
# Return response in format expected by frontend useModerationQueue.ts
# Frontend expects: { locked_until: "...", submission_id: "..." } at top level
lock_duration_minutes = 15
locked_until = submission.claimed_at + timedelta(minutes=lock_duration_minutes)
return Response({
"success": True,
"locked_until": locked_until.isoformat(),
"lockedUntil": locked_until.isoformat(), # Both camelCase and snake_case for compatibility
"submission_id": str(submission.id),
"submissionId": str(submission.id),
"claimed_by": request.user.username,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
"status": submission.status,
"lock_duration_minutes": lock_duration_minutes,
})
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):
"""
Release claim on a submission.
Only the claiming moderator or an admin can unclaim a submission.
"""
from django.core.exceptions import ValidationError
submission = self.get_object()
# Only the claiming user or an admin can unclaim
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"}, status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response({"error": "Submission is not claimed"}, status=status.HTTP_400_BAD_REQUEST)
try:
submission.unclaim(user=request.user)
log_business_event(
logger,
event_type="submission_unclaimed",
message=f"EditSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
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()
user = request.user
try:
submission.approve(moderator=user)
return Response(self.get_serializer(submission).data)
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()
user = request.user
reason = request.data.get("reason", "")
try:
submission.reject(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
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()
user = request.user
reason = request.data.get("reason", "")
try:
submission.escalate(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=["post"], permission_classes=[IsModeratorOrAdmin], url_path="release-expired")
def release_expired_locks(self, request):
"""
Release all expired claim locks.
This is typically handled by a Celery task, but can be triggered manually.
Claims are expired after 30 minutes by default.
"""
from datetime import timedelta
expiry_threshold = timezone.now() - timedelta(minutes=30)
expired_claims = EditSubmission.objects.filter(
status="CLAIMED",
claimed_at__lt=expiry_threshold
)
released_count = 0
for submission in expired_claims:
submission.status = "PENDING"
submission.claimed_by = None
submission.claimed_at = None
submission.save(update_fields=["status", "claimed_by", "claimed_at"])
released_count += 1
return Response({
"released_count": released_count,
"message": f"Released {released_count} expired lock(s)"
})
@action(detail=True, methods=["post"], permission_classes=[IsAdminOrSuperuser], url_path="admin-release")
def admin_release(self, request, pk=None):
"""
Admin/superuser force release of a specific claim.
"""
submission = self.get_object()
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
submission.status = "PENDING"
submission.claimed_by = None
submission.claimed_at = None
submission.save(update_fields=["status", "claimed_by", "claimed_at"])
return Response({
"success": True,
"message": f"Lock released on submission {submission.id}"
})
@action(detail=False, methods=["post"], permission_classes=[IsAdminOrSuperuser], url_path="admin-release-all")
def admin_release_all(self, request):
"""
Admin/superuser force release of all active claims.
"""
claimed_submissions = EditSubmission.objects.filter(status="CLAIMED")
released_count = 0
for submission in claimed_submissions:
submission.status = "PENDING"
submission.claimed_by = None
submission.claimed_at = None
submission.save(update_fields=["status", "claimed_by", "claimed_at"])
released_count += 1
return Response({
"released_count": released_count,
"message": f"Released all {released_count} active lock(s)"
})
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin], url_path="reassign")
def reassign(self, request, pk=None):
"""
Reassign a submission to a different moderator.
Only admins can reassign submissions claimed by other moderators.
The submission must be in CLAIMED status.
"""
submission = self.get_object()
new_moderator_id = request.data.get("new_moderator_id")
if not new_moderator_id:
return Response(
{"error": "new_moderator_id is required"},
status=status.HTTP_400_BAD_REQUEST
)
try:
new_moderator = User.objects.get(pk=new_moderator_id)
except User.DoesNotExist:
return Response(
{"error": "Moderator not found"},
status=status.HTTP_404_NOT_FOUND
)
# Check moderator permissions
if new_moderator.role not in ["MODERATOR", "ADMIN", "SUPERUSER"]:
return Response(
{"error": "User is not a moderator"},
status=status.HTTP_400_BAD_REQUEST
)
# Update the claim
submission.claimed_by = new_moderator
submission.claimed_at = timezone.now()
submission.save(update_fields=["claimed_by", "claimed_at"])
return Response({
"success": True,
"message": f"Submission reassigned to {new_moderator.username}"
})
@action(detail=False, methods=["post"], permission_classes=[IsModeratorOrAdmin], url_path="audit-log")
def log_admin_action(self, request):
"""
Log an admin action for audit trail.
This creates an audit log entry for moderator actions.
"""
action_type = request.data.get("action_type", "")
action_details = request.data.get("action_details", {})
target_entity = request.data.get("target_entity", {})
# Create audit log entry
logger.info(
f"[AdminAction] User {request.user.username} - {action_type}",
extra={
"user_id": request.user.id,
"action_type": action_type,
"action_details": action_details,
"target_entity": target_entity,
}
)
return Response({
"success": True,
"message": "Action logged successfully"
})
@action(detail=False, methods=["get"], permission_classes=[IsModeratorOrAdmin], url_path="my-active-claim")
def my_active_claim(self, request):
"""
Get the current user's active claim on any submission.
Used by lock restoration to restore a moderator's active claim after
page refresh. Returns the most recent CLAIMED submission for this user.
Returns:
200: Active claim found with submission data
200: No active claim (empty data)
"""
user = request.user
# Find any submission claimed by this user
claimed_submission = (
EditSubmission.objects.filter(
claimed_by=user,
status="CLAIMED"
)
.order_by("-claimed_at")
.first()
)
if not claimed_submission:
return Response({
"active_claim": None,
"message": "No active claims found"
})
return Response({
"active_claim": {
"id": claimed_submission.id,
"status": claimed_submission.status,
"claimed_at": claimed_submission.claimed_at.isoformat() if claimed_submission.claimed_at else None,
# Include basic submission info for context
"content_type": claimed_submission.content_type.model if claimed_submission.content_type else None,
"object_id": claimed_submission.object_id,
},
"message": "Active claim found"
})
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def extend(self, request, pk=None):
"""
Extend the lock on a claimed submission.
Only the claiming moderator can extend the lock.
Extends the lock by the default duration (15 minutes).
Returns:
200: Lock extended with new expiration time
400: Submission not in claimed state
403: User is not the claiming moderator
404: Submission not found
"""
submission = self.get_object()
user = request.user
# Only the claiming user can extend
if submission.claimed_by != user:
return Response(
{"error": "Only the claiming moderator can extend the lock"},
status=status.HTTP_403_FORBIDDEN,
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST,
)
# Extend the claim time by 15 minutes
extension_minutes = request.data.get("extension_minutes", 15)
new_claimed_at = timezone.now()
submission.claimed_at = new_claimed_at
submission.save(update_fields=["claimed_at"])
new_expires_at = new_claimed_at + timedelta(minutes=extension_minutes)
log_business_event(
logger,
event_type="submission_lock_extended",
message=f"EditSubmission {submission.id} lock extended by {user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"extended_by": user.username,
"new_expires_at": new_expires_at.isoformat(),
},
request=request,
)
return Response({
"success": True,
"new_expiry": new_expires_at.isoformat(),
"newExpiresAt": new_expires_at.isoformat(), # CamelCase for compatibility
"submission_id": str(submission.id),
"extension_minutes": extension_minutes,
})
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def release(self, request, pk=None):
"""
Release the lock on a claimed submission (alias for unclaim).
This is a convenience endpoint that mirrors the unclaim behavior
but is named to match the frontend's lock terminology.
Returns:
200: Lock released successfully
400: Submission not in claimed state
403: User is not the claiming moderator or admin
404: Submission not found
"""
from django.core.exceptions import ValidationError
submission = self.get_object()
user = request.user
silent = request.data.get("silent", False)
# Only the claiming user or an admin can release
if submission.claimed_by != user and not user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can release the lock"},
status=status.HTTP_403_FORBIDDEN,
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
submission.unclaim(user=user)
log_business_event(
logger,
event_type="submission_lock_released",
message=f"EditSubmission {submission.id} lock released by {user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"released_by": user.username,
"silent": silent,
},
request=request,
)
return Response({
"success": True,
"message": "Lock released successfully",
"submission_id": str(submission.id),
})
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin], url_path="convert-to-edit")
def convert_to_edit(self, request, pk=None):
"""
Convert a pending entity submission to an edit suggestion.
This is used when a new entity submission should be merged with
an existing entity rather than creating a new one.
Request body:
target_entity_type: str - The type of entity to merge into (e.g., 'park', 'ride')
target_entity_id: int - The ID of the existing entity
merge_fields: list[str] - Optional list of fields to merge (defaults to all)
notes: str - Optional moderator notes
Returns:
200: Submission successfully converted
400: Invalid request or conversion not possible
404: Submission or target entity not found
"""
from django.contrib.contenttypes.models import ContentType
submission = self.get_object()
user = request.user
# Validate submission state
if submission.status not in ["PENDING", "CLAIMED"]:
return Response(
{"error": f"Cannot convert submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get request data
target_entity_type = request.data.get("target_entity_type")
target_entity_id = request.data.get("target_entity_id")
merge_fields = request.data.get("merge_fields", [])
notes = request.data.get("notes", "")
if not target_entity_type or not target_entity_id:
return Response(
{"error": "target_entity_type and target_entity_id are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Look up the target entity
try:
app_label = "parks" if target_entity_type in ["park"] else "rides"
if target_entity_type == "company":
app_label = "core"
content_type = ContentType.objects.get(app_label=app_label, model=target_entity_type)
model_class = content_type.model_class()
target_entity = model_class.objects.get(pk=target_entity_id)
except (ContentType.DoesNotExist, Exception) as e:
return Response(
{"error": f"Target entity not found: {target_entity_type}#{target_entity_id}"},
status=status.HTTP_404_NOT_FOUND,
)
# Store the conversion metadata
conversion_data = {
"converted_from": "new_entity_submission",
"target_entity_type": target_entity_type,
"target_entity_id": target_entity_id,
"target_entity_name": str(target_entity),
"merge_fields": merge_fields,
"converted_by": user.username,
"converted_at": timezone.now().isoformat(),
"notes": notes,
}
# Update the submission
if hasattr(submission, "changes") and isinstance(submission.changes, dict):
submission.changes["_conversion_metadata"] = conversion_data
else:
# Create changes dict if it doesn't exist
submission.changes = {"_conversion_metadata": conversion_data}
# Add moderator note
if hasattr(submission, "moderator_notes"):
existing_notes = submission.moderator_notes or ""
submission.moderator_notes = (
f"{existing_notes}\n\n[Converted to edit] {notes}".strip()
if notes
else f"{existing_notes}\n\n[Converted to edit for {target_entity_type} #{target_entity_id}]".strip()
)
submission.save()
# Log the conversion
log_business_event(
logger,
event_type="submission_converted_to_edit",
message=f"EditSubmission {submission.id} converted to edit for {target_entity_type}#{target_entity_id}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"target_entity_type": target_entity_type,
"target_entity_id": target_entity_id,
"converted_by": user.username,
},
request=request,
)
return Response({
"success": True,
"message": f"Submission converted to edit for {target_entity_type} #{target_entity_id}",
"submission": self.get_serializer(submission).data,
"conversion_metadata": conversion_data,
})
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing photo submissions.
Now queries EditSubmission with submission_type="PHOTO" for unified model.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
# Use EditSubmission filtered by PHOTO type instead of separate PhotoSubmission model
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
serializer_class = EditSubmissionSerializer # Use unified serializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["caption", "notes"]
ordering_fields = ["created_at", "status"]
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")
if status_param:
queryset = queryset.filter(status=status_param)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
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):
"""
Claim a photo submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock.
"""
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
with transaction.atomic():
try:
# Use EditSubmission filtered by PHOTO type
submission = EditSubmission.objects.filter(submission_type="PHOTO").select_for_update(nowait=True).get(pk=pk)
except EditSubmission.DoesNotExist:
return Response({"error": "Submission not found"}, status=status.HTTP_404_NOT_FOUND)
except DatabaseError:
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT,
)
if submission.status == "CLAIMED":
return Response(
{
"error": "Submission already claimed",
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT,
)
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
submission.claim(user=request.user)
log_business_event(
logger,
event_type="submission_claimed",
message=f"Photo EditSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "EditSubmission",
"submission_type": "PHOTO",
"object_id": submission.id,
"claimed_by": request.user.username,
},
request=request,
)
# Return response in format expected by frontend useModerationQueue.ts
# Frontend expects: { locked_until: "...", submission_id: "..." } at top level
lock_duration_minutes = 15
locked_until = submission.claimed_at + timedelta(minutes=lock_duration_minutes)
return Response({
"success": True,
"locked_until": locked_until.isoformat(),
"lockedUntil": locked_until.isoformat(), # Both camelCase and snake_case for compatibility
"submission_id": str(submission.id),
"submissionId": str(submission.id),
"claimed_by": request.user.username,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
"status": submission.status,
"lock_duration_minutes": lock_duration_minutes,
})
except ValidationError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None):
"""Release claim on a photo submission."""
from django.core.exceptions import ValidationError
submission = self.get_object()
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"}, status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response({"error": "Submission is not claimed"}, status=status.HTTP_400_BAD_REQUEST)
try:
submission.unclaim(user=request.user)
log_business_event(
logger,
event_type="submission_unclaimed",
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.approve(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.reject(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.escalate(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
# ============================================================================
# Standalone Convert Submission to Edit View
# ============================================================================
from rest_framework.views import APIView
class ConvertSubmissionToEditView(APIView):
"""
POST /api/moderation/api/convert-to-edit/
Convert a CREATE submission to an EDIT by linking it to an existing entity.
Full parity with Supabase Edge Function: convert-submission-to-edit
This endpoint:
1. Validates the submission is locked by the requesting moderator
2. Validates the submission is in a valid state (PENDING or CLAIMED)
3. Validates the submission_type is 'CREATE' (only CREATE can be converted)
4. Looks up the existing entity
5. Updates the submission_type to 'EDIT' and links to existing entity
6. Logs to audit trail
BULLETPROOFED: Transaction safety, UUID validation, comprehensive error handling.
Request body:
{
"submissionId": "...", # The EditSubmission ID
"itemId": "...", # The submission item ID (optional, for Supabase compat)
"existingEntityId": "...", # The existing entity to link to
"conversionType": "..." # Optional: 'automatic' or 'manual'
}
Returns:
{
"success": true/false,
"itemId": "...",
"submissionId": "...",
"existingEntityId": "...",
"existingEntityName": "...",
"message": "..."
}
"""
permission_classes = [IsModeratorOrAdmin]
# Validation constants
MAX_NOTE_LENGTH = 5000
ALLOWED_CONVERSION_TYPES = {"automatic", "manual", "duplicate_detected"}
VALID_STATES = {"PENDING", "CLAIMED", "pending", "partially_approved", "claimed"}
def post(self, request):
from django.db import transaction
from django.contrib.contenttypes.models import ContentType
import uuid
try:
# ================================================================
# STEP 0: Validate user is authenticated
# ================================================================
user = request.user
if not user or not user.is_authenticated:
return Response(
{"success": False, "message": "Authentication required"},
status=status.HTTP_401_UNAUTHORIZED,
)
# ================================================================
# STEP 1: Extract and validate request parameters
# ================================================================
submission_id = request.data.get("submissionId")
item_id = request.data.get("itemId") # For Supabase compatibility
existing_entity_id = request.data.get("existingEntityId")
conversion_type = request.data.get("conversionType", "automatic")
# Validate required parameters
if not submission_id:
return Response(
{"success": False, "message": "submissionId is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not existing_entity_id:
return Response(
{"success": False, "message": "existingEntityId is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate UUID formats
try:
if isinstance(submission_id, str):
submission_uuid = uuid.UUID(submission_id)
else:
submission_uuid = submission_id
except (ValueError, AttributeError):
return Response(
{"success": False, "message": "Invalid submissionId format"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
if isinstance(existing_entity_id, str):
entity_uuid = uuid.UUID(existing_entity_id)
else:
entity_uuid = existing_entity_id
except (ValueError, AttributeError):
return Response(
{"success": False, "message": "Invalid existingEntityId format"},
status=status.HTTP_400_BAD_REQUEST,
)
# Sanitize conversion_type
if not isinstance(conversion_type, str):
conversion_type = "automatic"
conversion_type = conversion_type.strip().lower()[:50]
if conversion_type not in self.ALLOWED_CONVERSION_TYPES:
conversion_type = "automatic"
# ================================================================
# STEP 2: Get the submission with select_for_update
# ================================================================
try:
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(pk=submission_uuid)
except EditSubmission.DoesNotExist:
return Response(
{"success": False, "message": "Submission not found"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
logger.warning(f"Failed to fetch submission {submission_id}: {e}")
return Response(
{"success": False, "message": "Failed to fetch submission"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# ================================================================
# STEP 3: Verify submission is locked by requesting moderator
# ================================================================
claimed_by_id = getattr(submission, 'claimed_by_id', None)
user_id = getattr(user, 'id', None)
if claimed_by_id != user_id:
# Additional check: allow admins to override
if not getattr(user, 'is_staff', False) and not getattr(user, 'is_superuser', False):
return Response(
{"success": False, "message": "You must claim this submission before converting it"},
status=status.HTTP_400_BAD_REQUEST,
)
logger.info(
f"Admin override: {user.username} converting submission claimed by user {claimed_by_id}",
extra={"submission_id": str(submission_uuid), "admin_user": user.username}
)
# ================================================================
# STEP 4: Validate submission state
# ================================================================
current_status = getattr(submission, 'status', 'unknown')
if current_status not in self.VALID_STATES:
return Response(
{"success": False, "message": f"Submission must be pending or claimed to convert (current: {current_status})"},
status=status.HTTP_400_BAD_REQUEST,
)
# ================================================================
# STEP 5: Validate submission_type is CREATE
# ================================================================
current_type = getattr(submission, 'submission_type', '')
if current_type != "CREATE":
return Response(
{
"success": False,
"message": f"Item is already set to '{current_type}', cannot convert"
},
status=status.HTTP_400_BAD_REQUEST,
)
# ================================================================
# STEP 6: Determine entity type from submission's content_type
# ================================================================
target_entity_type = None
target_entity_name = None
target_entity_slug = None
target_entity = None
if submission.content_type:
target_entity_type = submission.content_type.model
# Also try to get from changes if available
if not target_entity_type and isinstance(submission.changes, dict):
target_entity_type = submission.changes.get("entity_type")
# ================================================================
# STEP 7: Look up the existing entity
# ================================================================
app_label_map = {
"park": "parks",
"ride": "rides",
"company": "core",
"ridemodel": "rides",
"manufacturer": "core",
"operator": "core",
}
if target_entity_type:
try:
app_label = app_label_map.get(target_entity_type.lower(), "core")
content_type = ContentType.objects.get(app_label=app_label, model=target_entity_type.lower())
model_class = content_type.model_class()
if model_class is None:
raise ValueError(f"No model class for {target_entity_type}")
target_entity = model_class.objects.filter(pk=entity_uuid).first()
if not target_entity:
return Response(
{"success": False, "message": f"Existing {target_entity_type} not found with ID {existing_entity_id}"},
status=status.HTTP_404_NOT_FOUND,
)
target_entity_name = str(getattr(target_entity, 'name', target_entity))[:200]
target_entity_slug = getattr(target_entity, 'slug', None)
except ContentType.DoesNotExist:
return Response(
{"success": False, "message": f"Unknown entity type: {target_entity_type}"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.warning(f"Failed to look up entity {target_entity_type}/{existing_entity_id}: {e}")
return Response(
{"success": False, "message": "Existing entity not found"},
status=status.HTTP_404_NOT_FOUND,
)
else:
# Try to find entity across common models
for model_name, app_label in [("park", "parks"), ("ride", "rides"), ("company", "core")]:
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
model_class = content_type.model_class()
if model_class is None:
continue
target_entity = model_class.objects.filter(pk=entity_uuid).first()
if target_entity:
target_entity_type = model_name
target_entity_name = str(getattr(target_entity, 'name', target_entity))[:200]
target_entity_slug = getattr(target_entity, 'slug', None)
break
except Exception:
continue
if not target_entity:
return Response(
{"success": False, "message": "Existing entity not found in any known model"},
status=status.HTTP_404_NOT_FOUND,
)
# ================================================================
# STEP 8: Update submission with atomic transaction
# ================================================================
try:
with transaction.atomic():
# Re-fetch with lock to ensure no concurrent modifications
submission = EditSubmission.objects.select_for_update().get(pk=submission_uuid)
# Double-check state hasn't changed
if submission.submission_type != "CREATE":
return Response(
{"success": False, "message": "Submission was already converted"},
status=status.HTTP_409_CONFLICT,
)
# Update submission_type
submission.submission_type = "EDIT"
# Link to existing entity via object_id
submission.object_id = entity_uuid
# Store conversion metadata in changes
if not isinstance(submission.changes, dict):
submission.changes = {}
submission.changes["_conversion_metadata"] = {
"converted_from": "new_entity_submission",
"original_action_type": "create",
"target_entity_type": target_entity_type,
"target_entity_id": str(entity_uuid),
"target_entity_name": target_entity_name,
"target_entity_slug": target_entity_slug,
"conversion_type": conversion_type,
"converted_by": user.username,
"converted_by_id": str(getattr(user, 'user_id', user.id)),
"converted_at": timezone.now().isoformat(),
}
# Add moderator note (with length limit)
existing_notes = (submission.notes or "")[:self.MAX_NOTE_LENGTH]
conversion_note = f"[Converted CREATE to EDIT] for {target_entity_type}: {target_entity_name}"
if target_entity_slug:
conversion_note += f" ({target_entity_slug})"
conversion_note += f". Conversion type: {conversion_type}"
new_notes = f"{existing_notes}\n\n{conversion_note}".strip()
submission.notes = new_notes[:self.MAX_NOTE_LENGTH]
submission.save(update_fields=["submission_type", "object_id", "changes", "notes"])
except Exception as e:
logger.error(f"Failed to update submission {submission_uuid}: {e}")
return Response(
{"success": False, "message": "Failed to update submission"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# ================================================================
# STEP 9: Log to audit trail (outside transaction for reliability)
# ================================================================
try:
log_business_event(
logger,
event_type="submission_converted_to_edit",
message=f"EditSubmission {submission.id} converted from CREATE to EDIT for {target_entity_type}#{entity_uuid}",
context={
"model": "EditSubmission",
"object_id": str(submission.id),
"item_id": str(item_id) if item_id else None,
"target_entity_type": target_entity_type,
"target_entity_id": str(entity_uuid),
"target_entity_name": target_entity_name,
"converted_by": user.username,
"conversion_type": conversion_type,
},
request=request,
)
except Exception as log_error:
# Don't fail the request if logging fails
logger.warning(f"Failed to log conversion event: {log_error}")
# ================================================================
# STEP 10: Return success response matching original format
# ================================================================
return Response({
"success": True,
"itemId": str(item_id) if item_id else str(submission.id),
"submissionId": str(submission.id),
"existingEntityId": str(entity_uuid),
"existingEntityName": target_entity_name,
"message": f"Converted submission item to EDIT for existing {target_entity_type}: {target_entity_name}",
})
except Exception as e:
capture_and_log(e, "Convert submission to edit", source="moderation", request=request)
return Response(
{"success": False, "message": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# ============================================================================
# Aggregated Moderation Stats View
# ============================================================================
from rest_framework.views import APIView
class ModerationStatsView(APIView):
"""
View for aggregated moderation statistics.
Returns comprehensive stats from all moderation models including
reports, queue, actions, and bulk operations.
"""
permission_classes = [CanViewModerationData]
def get(self, request):
"""Get aggregated moderation statistics."""
now = timezone.now()
# Report stats
reports = ModerationReport.objects.all()
total_reports = reports.count()
pending_reports = reports.filter(status="PENDING").count()
resolved_reports = reports.filter(status="RESOLVED").count()
# Calculate overdue reports
overdue_reports = 0
for report in reports.filter(status__in=["PENDING", "UNDER_REVIEW"]):
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
hours_since_created = (now - report.created_at).total_seconds() / 3600
threshold = sla_hours.get(report.priority, 72)
if hours_since_created > threshold:
overdue_reports += 1
# Queue stats
queue = ModerationQueue.objects.all()
queue_size = queue.count()
assigned_items = queue.filter(assigned_to__isnull=False).count()
unassigned_items = queue.filter(assigned_to__isnull=True).count()
# Action stats
actions = ModerationAction.objects.all()
total_actions = actions.count()
active_actions = actions.filter(is_active=True).count()
expired_actions = actions.filter(
is_active=True,
expires_at__isnull=False,
expires_at__lt=now
).count()
# Bulk operation stats
bulk_ops = BulkOperation.objects.all()
running_operations = bulk_ops.filter(status="RUNNING").count()
completed_operations = bulk_ops.filter(status="COMPLETED").count()
failed_operations = bulk_ops.filter(status="FAILED").count()
# Average resolution time
resolved_queryset = reports.filter(
status="RESOLVED",
resolved_at__isnull=False
)
avg_resolution_time = 0
if resolved_queryset.exists():
total_time = sum([
(r.resolved_at - r.created_at).total_seconds() / 3600
for r in resolved_queryset if r.resolved_at
])
avg_resolution_time = total_time / resolved_queryset.count()
# Reports by priority and type
reports_by_priority = dict(
reports.values_list("priority").annotate(count=Count("id"))
)
reports_by_type = dict(
reports.values_list("report_type").annotate(count=Count("id"))
)
stats_data = {
# Report stats
"total_reports": total_reports,
"pending_reports": pending_reports,
"resolved_reports": resolved_reports,
"overdue_reports": overdue_reports,
# Queue stats
"queue_size": queue_size,
"assigned_items": assigned_items,
"unassigned_items": unassigned_items,
# Action stats
"total_actions": total_actions,
"active_actions": active_actions,
"expired_actions": expired_actions,
# Bulk operation stats
"running_operations": running_operations,
"completed_operations": completed_operations,
"failed_operations": failed_operations,
# Performance metrics
"average_resolution_time_hours": round(avg_resolution_time, 2),
"reports_by_priority": reports_by_priority,
"reports_by_type": reports_by_type,
# Empty metrics array for frontend compatibility
"metrics": [],
}
return Response(stats_data)
# ============================================================================
# Moderation Audit Log ViewSet
# ============================================================================
class ModerationAuditLogViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for viewing moderation audit logs.
Provides read-only access to moderation action history for auditing
and accountability purposes.
"""
from .models import ModerationAuditLog
from .serializers import ModerationAuditLogSerializer
queryset = ModerationAuditLog.objects.select_related(
"submission", "submission__content_type", "moderator"
).all()
serializer_class = ModerationAuditLogSerializer
permission_classes = [IsAdminOrSuperuser]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["action", "is_system_action", "is_test_data"]
search_fields = ["notes"]
ordering_fields = ["created_at", "action"]
ordering = ["-created_at"]
def get_queryset(self):
queryset = super().get_queryset()
# Filter by submission ID
submission_id = self.request.query_params.get("submission_id")
if submission_id:
queryset = queryset.filter(submission_id=submission_id)
# Filter by moderator ID
moderator_id = self.request.query_params.get("moderator_id")
if moderator_id:
queryset = queryset.filter(moderator_id=moderator_id)
# Date range filtering
start_date = self.request.query_params.get("start_date")
end_date = self.request.query_params.get("end_date")
if start_date:
queryset = queryset.filter(created_at__gte=start_date)
if end_date:
queryset = queryset.filter(created_at__lte=end_date)
return queryset