feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature.

This commit is contained in:
pacnpal
2025-12-26 15:15:28 -05:00
parent cd8868a591
commit 00699d53b4
77 changed files with 7274 additions and 538 deletions

View File

@@ -34,6 +34,8 @@ from .models import (
ModerationQueue,
ModerationAction,
BulkOperation,
EditSubmission,
PhotoSubmission,
)
from .serializers import (
ModerationReportSerializer,
@@ -47,6 +49,9 @@ from .serializers import (
BulkOperationSerializer,
CreateBulkOperationSerializer,
UserModerationProfileSerializer,
EditSubmissionSerializer,
EditSubmissionListSerializer,
PhotoSubmissionSerializer,
)
from .filters import (
ModerationReportFilter,
@@ -1166,6 +1171,28 @@ class UserModerationViewSet(viewsets.ViewSet):
# 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:
@@ -1367,3 +1394,345 @@ class UserModerationViewSet(viewsets.ViewSet):
}
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
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=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.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
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(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 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)
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing photo submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = PhotoSubmission.objects.all()
serializer_class = PhotoSubmissionSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["caption", "notes"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_queryset(self):
queryset = super().get_queryset()
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
Claim a photo submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock.
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
with transaction.atomic():
try:
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except PhotoSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND
)
except DatabaseError:
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
if submission.status == "CLAIMED":
return Response(
{
"error": "Submission already claimed",
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT
)
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.claim(user=request.user)
log_business_event(
logger,
event_type="submission_claimed",
message=f"PhotoSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"object_id": submission.id,
"claimed_by": request.user.username,
},
request=request,
)
return Response(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 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)