mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-27 10:07:05 -05:00
feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user