""" 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