mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 11:05:17 -05:00
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
2826 lines
112 KiB
Python
2826 lines
112 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=["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_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
|
|
|
|
@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": "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)
|
|
|
|
|
|
# ============================================================================
|
|
# 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
|