Files
thrillwiki_django_no_react/backend/apps/moderation/views.py
pacnpal 2e35f8c5d9 feat: Refactor rides app with unique constraints, mixins, and enhanced documentation
- Added migration to convert unique_together constraints to UniqueConstraint for RideModel.
- Introduced RideFormMixin for handling entity suggestions in ride forms.
- Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements.
- Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling.
- Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples.
- Implemented a benchmarking script for query performance analysis and optimization.
- Developed security documentation detailing measures, configurations, and a security checklist.
- Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields.
2025-12-22 11:17:31 -05:00

1283 lines
47 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.
"""
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.db.models import Q, Count
from django.shortcuts import render
from django.core.paginator import Paginator
from datetime import timedelta
from django_fsm import can_proceed, TransitionNotAllowed
from apps.core.state_machine.exceptions import (
TransitionPermissionDenied,
TransitionValidationError,
format_transition_error,
)
from .models import (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
)
from .serializers import (
ModerationReportSerializer,
CreateModerationReportSerializer,
UpdateModerationReportSerializer,
ModerationQueueSerializer,
AssignQueueItemSerializer,
CompleteQueueItemSerializer,
ModerationActionSerializer,
CreateModerationActionSerializer,
BulkOperationSerializer,
CreateBulkOperationSerializer,
UserModerationProfileSerializer,
)
from .filters import (
ModerationReportFilter,
ModerationQueueFilter,
ModerationActionFilter,
BulkOperationFilter,
)
from .permissions import (
IsModeratorOrAdmin,
IsAdminOrSuperuser,
CanViewModerationData,
)
User = get_user_model()
# ============================================================================
# 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
try:
transition_method(user=moderator)
report.save()
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,
)
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()
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_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
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_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
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()
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()
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
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()
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,
)
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,
)
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_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
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."""
if self.action == "create":
permission_classes = [IsModeratorOrAdmin]
else:
permission_classes = [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_fsm_log.models import StateLog
from django.contrib.contenttypes.models import ContentType
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 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)