mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 09:25:18 -05:00
2628 lines
105 KiB
Python
2628 lines
105 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, status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
|
from rest_framework.response import Response
|
|
|
|
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,
|
|
PhotoSubmission,
|
|
)
|
|
from .permissions import (
|
|
CanViewModerationData,
|
|
IsAdminOrSuperuser,
|
|
IsModeratorOrAdmin,
|
|
)
|
|
from .serializers import (
|
|
AssignQueueItemSerializer,
|
|
BulkOperationSerializer,
|
|
CompleteQueueItemSerializer,
|
|
CreateBulkOperationSerializer,
|
|
CreateEditSubmissionSerializer,
|
|
CreateModerationActionSerializer,
|
|
CreateModerationReportSerializer,
|
|
EditSubmissionListSerializer,
|
|
EditSubmissionSerializer,
|
|
ModerationActionSerializer,
|
|
ModerationQueueSerializer,
|
|
ModerationReportSerializer,
|
|
PhotoSubmissionSerializer,
|
|
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]})
|
|
|
|
@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)
|
|
|
|
@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)
|
|
|
|
@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)
|
|
|
|
@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)
|
|
|
|
@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=["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.
|
|
|
|
Includes claim/unclaim endpoints with concurrency protection using
|
|
database row locking (select_for_update) to prevent race conditions.
|
|
"""
|
|
|
|
queryset = PhotoSubmission.objects.all()
|
|
serializer_class = PhotoSubmissionSerializer
|
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
|
search_fields = ["caption", "notes"]
|
|
ordering_fields = ["created_at", "status"]
|
|
ordering = ["-created_at"]
|
|
permission_classes = [CanViewModerationData]
|
|
|
|
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=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:
|
|
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
|
|
except PhotoSubmission.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"PhotoSubmission {submission.id} claimed by {request.user.username}",
|
|
context={
|
|
"model": "PhotoSubmission",
|
|
"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": "PhotoSubmission",
|
|
"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)
|