feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -10,63 +10,62 @@ This module contains DRF viewsets for the moderation system, including:
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
import logging
from datetime import timedelta
from django_fsm import can_proceed, TransitionNotAllowed
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 (
ModerationReport,
ModerationQueue,
ModerationAction,
BulkOperation,
EditSubmission,
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
from .serializers import (
ModerationReportSerializer,
CreateModerationReportSerializer,
UpdateModerationReportSerializer,
ModerationQueueSerializer,
AssignQueueItemSerializer,
CompleteQueueItemSerializer,
ModerationActionSerializer,
CreateModerationActionSerializer,
BulkOperationSerializer,
CreateBulkOperationSerializer,
UserModerationProfileSerializer,
EditSubmissionSerializer,
EditSubmissionListSerializer,
PhotoSubmissionSerializer,
)
from .filters import (
ModerationReportFilter,
ModerationQueueFilter,
ModerationActionFilter,
BulkOperationFilter,
)
import logging
from apps.core.logging import log_exception, log_business_event
from .permissions import (
IsModeratorOrAdmin,
IsAdminOrSuperuser,
CanViewModerationData,
IsAdminOrSuperuser,
IsModeratorOrAdmin,
)
from .serializers import (
AssignQueueItemSerializer,
BulkOperationSerializer,
CompleteQueueItemSerializer,
CreateBulkOperationSerializer,
CreateModerationActionSerializer,
CreateModerationReportSerializer,
EditSubmissionListSerializer,
EditSubmissionSerializer,
ModerationActionSerializer,
ModerationQueueSerializer,
ModerationReportSerializer,
PhotoSubmissionSerializer,
UpdateModerationReportSerializer,
UserModerationProfileSerializer,
)
User = get_user_model()
@@ -352,8 +351,8 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
@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
from django_fsm_log.models import StateLog
report = self.get_object()
content_type = ContentType.objects.get_for_model(report)
@@ -387,8 +386,8 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
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
from django_fsm_log.models import StateLog
queryset = StateLog.objects.select_related("by", "content_type").all()
@@ -829,8 +828,8 @@ class ModerationQueueViewSet(viewsets.ModelViewSet):
@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
from django_fsm_log.models import StateLog
queue_item = self.get_object()
content_type = ContentType.objects.get_for_model(queue_item)
@@ -890,10 +889,7 @@ class ModerationActionViewSet(viewsets.ModelViewSet):
def get_permissions(self):
"""Return appropriate permissions based on action."""
if self.action == "create":
permission_classes = [IsModeratorOrAdmin]
else:
permission_classes = [CanViewModerationData]
permission_classes = [IsModeratorOrAdmin] if self.action == "create" else [CanViewModerationData]
return [permission() for permission in permission_classes]
@@ -1125,8 +1121,8 @@ class BulkOperationViewSet(viewsets.ModelViewSet):
@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
from django_fsm_log.models import StateLog
operation = self.get_object()
content_type = ContentType.objects.get_for_model(operation)
@@ -1404,7 +1400,7 @@ class UserModerationViewSet(viewsets.ViewSet):
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.
"""
@@ -1425,7 +1421,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
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:
@@ -1437,20 +1433,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
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.db import transaction, DatabaseError
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
@@ -1466,7 +1462,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
{"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(
@@ -1477,14 +1473,14 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
},
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(
@@ -1506,26 +1502,26 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
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(
@@ -1547,7 +1543,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
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)
@@ -1559,19 +1555,19 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
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)
@@ -1582,7 +1578,7 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing photo submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
@@ -1599,24 +1595,24 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
Claim a photo submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock.
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
with transaction.atomic():
try:
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
@@ -1630,7 +1626,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
if submission.status == "CLAIMED":
return Response(
{
@@ -1640,13 +1636,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
},
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(
@@ -1668,21 +1664,21 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
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(
@@ -1705,7 +1701,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
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)
@@ -1717,7 +1713,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
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)
@@ -1729,7 +1725,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
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)