mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 05:45:17 -05:00
feat: Add analytics, incident, and alert models and APIs, along with user permissions and bulk profile lookups.
This commit is contained in:
@@ -206,7 +206,9 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
if self.status != "PENDING":
|
||||
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
|
||||
|
||||
self.transition_to_claimed(user=user)
|
||||
# Set status directly (similar to unclaim method)
|
||||
# The transition_to_claimed FSM method was never defined
|
||||
self.status = "CLAIMED"
|
||||
self.claimed_by = user
|
||||
self.claimed_at = timezone.now()
|
||||
self.save()
|
||||
@@ -754,7 +756,9 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
if self.status != "PENDING":
|
||||
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
|
||||
|
||||
self.transition_to_claimed(user=user)
|
||||
# Set status directly (similar to unclaim method)
|
||||
# The transition_to_claimed FSM method was never defined
|
||||
self.status = "CLAIMED"
|
||||
self.claimed_by = user
|
||||
self.claimed_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
@@ -67,6 +67,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for EditSubmission with UI metadata for Nuxt frontend."""
|
||||
|
||||
submitted_by = UserBasicSerializer(source="user", read_only=True)
|
||||
handled_by = UserBasicSerializer(read_only=True)
|
||||
claimed_by = UserBasicSerializer(read_only=True)
|
||||
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
|
||||
|
||||
@@ -87,22 +88,24 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"content_type",
|
||||
"content_type_name",
|
||||
"object_id",
|
||||
"submission_type",
|
||||
"changes",
|
||||
"moderator_changes",
|
||||
"rejection_reason",
|
||||
"reason",
|
||||
"source",
|
||||
"notes",
|
||||
"submitted_by",
|
||||
"reviewed_by",
|
||||
"handled_by",
|
||||
"claimed_by",
|
||||
"claimed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"time_since_created",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"submitted_by",
|
||||
"handled_by",
|
||||
"claimed_by",
|
||||
"claimed_at",
|
||||
"status_color",
|
||||
@@ -163,6 +166,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"status",
|
||||
"submission_type", # Added for frontend compatibility
|
||||
"content_type_name",
|
||||
"object_id",
|
||||
"submitted_by_username",
|
||||
@@ -195,6 +199,101 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
return icons.get(obj.status, "heroicons:question-mark-circle")
|
||||
|
||||
|
||||
class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for creating edit submissions.
|
||||
|
||||
This replaces the Supabase RPC 'create_submission_with_items' function.
|
||||
Accepts entity type as a string and resolves it to ContentType.
|
||||
"""
|
||||
|
||||
entity_type = serializers.CharField(write_only=True, help_text="Entity type: park, ride, company, ride_model")
|
||||
|
||||
class Meta:
|
||||
model = EditSubmission
|
||||
fields = [
|
||||
"entity_type",
|
||||
"object_id",
|
||||
"submission_type",
|
||||
"changes",
|
||||
"reason",
|
||||
"source",
|
||||
]
|
||||
|
||||
def validate_entity_type(self, value):
|
||||
"""Convert entity_type string to ContentType."""
|
||||
entity_type_map = {
|
||||
"park": ("parks", "park"),
|
||||
"ride": ("rides", "ride"),
|
||||
"company": ("parks", "company"),
|
||||
"ride_model": ("rides", "ridemodel"),
|
||||
"manufacturer": ("parks", "company"),
|
||||
"designer": ("parks", "company"),
|
||||
"operator": ("parks", "company"),
|
||||
"property_owner": ("parks", "company"),
|
||||
}
|
||||
|
||||
if value.lower() not in entity_type_map:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid entity_type. Must be one of: {', '.join(entity_type_map.keys())}"
|
||||
)
|
||||
|
||||
return value.lower()
|
||||
|
||||
def validate_changes(self, value):
|
||||
"""Validate changes is a proper JSON object."""
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Changes must be a JSON object")
|
||||
if not value:
|
||||
raise serializers.ValidationError("Changes cannot be empty")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
submission_type = attrs.get("submission_type", "EDIT")
|
||||
object_id = attrs.get("object_id")
|
||||
|
||||
# For EDIT submissions, object_id is required
|
||||
if submission_type == "EDIT" and not object_id:
|
||||
raise serializers.ValidationError(
|
||||
{"object_id": "object_id is required for EDIT submissions"}
|
||||
)
|
||||
|
||||
# For CREATE submissions, object_id should be null
|
||||
if submission_type == "CREATE" and object_id:
|
||||
raise serializers.ValidationError(
|
||||
{"object_id": "object_id must be null for CREATE submissions"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new submission."""
|
||||
entity_type = validated_data.pop("entity_type")
|
||||
|
||||
# Map entity_type to ContentType
|
||||
entity_type_map = {
|
||||
"park": ("parks", "park"),
|
||||
"ride": ("rides", "ride"),
|
||||
"company": ("parks", "company"),
|
||||
"ride_model": ("rides", "ridemodel"),
|
||||
"manufacturer": ("parks", "company"),
|
||||
"designer": ("parks", "company"),
|
||||
"operator": ("parks", "company"),
|
||||
"property_owner": ("parks", "company"),
|
||||
}
|
||||
|
||||
app_label, model_name = entity_type_map[entity_type]
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
|
||||
|
||||
# Set automatic fields
|
||||
validated_data["user"] = self.context["request"].user
|
||||
validated_data["content_type"] = content_type
|
||||
validated_data["status"] = "PENDING"
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Report Serializers
|
||||
# ============================================================================
|
||||
|
||||
@@ -9,6 +9,8 @@ This module tests end-to-end moderation workflows including:
|
||||
- Bulk operation workflow
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
@@ -37,7 +39,7 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
"""
|
||||
Test complete edit submission approval workflow.
|
||||
|
||||
Flow: User submits → Moderator reviews → Moderator approves → Changes applied
|
||||
Flow: User submits → Moderator claims → Moderator approves → Changes applied
|
||||
"""
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.parks.models import Company
|
||||
@@ -61,6 +63,13 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
self.assertIsNone(submission.handled_by)
|
||||
self.assertIsNone(submission.handled_at)
|
||||
|
||||
# Moderator claims the submission first
|
||||
submission.transition_to_claimed(user=self.moderator)
|
||||
submission.save()
|
||||
|
||||
submission.refresh_from_db()
|
||||
self.assertEqual(submission.status, "CLAIMED")
|
||||
|
||||
# Moderator approves
|
||||
submission.transition_to_approved(user=self.moderator)
|
||||
submission.handled_by = self.moderator
|
||||
@@ -78,6 +87,8 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
|
||||
Flow: User submits photo → Moderator reviews → Moderator approves → Photo created
|
||||
"""
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
from apps.moderation.models import PhotoSubmission
|
||||
from apps.parks.models import Company, Park
|
||||
|
||||
@@ -87,6 +98,13 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
name="Test Park", slug="test-park", operator=operator, status="OPERATING", timezone="America/New_York"
|
||||
)
|
||||
|
||||
# Create mock CloudflareImage for the photo submission
|
||||
mock_image = CloudflareImage.objects.create(
|
||||
cloudflare_id="test-cf-image-id-12345",
|
||||
user=self.regular_user,
|
||||
expires_at=timezone.now() + timedelta(days=365),
|
||||
)
|
||||
|
||||
# User submits a photo
|
||||
content_type = ContentType.objects.get_for_model(park)
|
||||
submission = PhotoSubmission.objects.create(
|
||||
@@ -94,12 +112,18 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
content_type=content_type,
|
||||
object_id=park.id,
|
||||
status="PENDING",
|
||||
photo_type="GENERAL",
|
||||
description="Beautiful park entrance",
|
||||
photo=mock_image,
|
||||
caption="Beautiful park entrance",
|
||||
)
|
||||
|
||||
self.assertEqual(submission.status, "PENDING")
|
||||
|
||||
# Moderator claims the submission first (required FSM step)
|
||||
submission.claim(user=self.moderator)
|
||||
|
||||
submission.refresh_from_db()
|
||||
self.assertEqual(submission.status, "CLAIMED")
|
||||
|
||||
# Moderator approves
|
||||
submission.transition_to_approved(user=self.moderator)
|
||||
submission.handled_by = self.moderator
|
||||
@@ -144,7 +168,13 @@ class SubmissionRejectionWorkflowTests(TestCase):
|
||||
reason="Name change request",
|
||||
)
|
||||
|
||||
# Moderator rejects
|
||||
# Moderator claims and then rejects
|
||||
submission.transition_to_claimed(user=self.moderator)
|
||||
submission.save()
|
||||
|
||||
submission.refresh_from_db()
|
||||
self.assertEqual(submission.status, "CLAIMED")
|
||||
|
||||
submission.transition_to_rejected(user=self.moderator)
|
||||
submission.handled_by = self.moderator
|
||||
submission.handled_at = timezone.now()
|
||||
@@ -193,7 +223,13 @@ class SubmissionEscalationWorkflowTests(TestCase):
|
||||
reason="Major name change",
|
||||
)
|
||||
|
||||
# Moderator escalates
|
||||
# Moderator claims and then escalates
|
||||
submission.transition_to_claimed(user=self.moderator)
|
||||
submission.save()
|
||||
|
||||
submission.refresh_from_db()
|
||||
self.assertEqual(submission.status, "CLAIMED")
|
||||
|
||||
submission.transition_to_escalated(user=self.moderator)
|
||||
submission.notes = "Escalated: Major change needs admin review"
|
||||
submission.save()
|
||||
@@ -447,11 +483,13 @@ class ModerationQueueWorkflowTests(TestCase):
|
||||
from apps.moderation.models import ModerationQueue
|
||||
|
||||
queue_item = ModerationQueue.objects.create(
|
||||
queue_type="SUBMISSION_REVIEW",
|
||||
item_type="SUBMISSION_REVIEW",
|
||||
status="PENDING",
|
||||
priority="MEDIUM",
|
||||
item_type="edit_submission",
|
||||
item_id=123,
|
||||
title="Review edit submission #123",
|
||||
description="Review and process edit submission",
|
||||
entity_type="edit_submission",
|
||||
entity_id=123,
|
||||
)
|
||||
|
||||
self.assertEqual(queue_item.status, "PENDING")
|
||||
|
||||
@@ -20,6 +20,7 @@ from .views import (
|
||||
ModerationActionViewSet,
|
||||
ModerationQueueViewSet,
|
||||
ModerationReportViewSet,
|
||||
ModerationStatsView,
|
||||
PhotoSubmissionViewSet,
|
||||
UserModerationViewSet,
|
||||
)
|
||||
@@ -175,6 +176,9 @@ html_patterns = [
|
||||
path("", ModerationDashboardView.as_view(), name="dashboard"),
|
||||
path("submissions/", SubmissionListView.as_view(), name="submission_list"),
|
||||
path("history/", HistoryPageView.as_view(), name="history"),
|
||||
# Edit submission detail for HTMX form posts
|
||||
path("submissions/<int:pk>/edit/", EditSubmissionViewSet.as_view({'post': 'partial_update'}), name="edit_submission"),
|
||||
path("edit-submissions/", TemplateView.as_view(template_name="moderation/edit_submissions.html"), name="edit_submissions"),
|
||||
]
|
||||
|
||||
# SSE endpoints for real-time updates
|
||||
@@ -188,6 +192,8 @@ urlpatterns = [
|
||||
*html_patterns,
|
||||
# SSE endpoints
|
||||
*sse_patterns,
|
||||
# Top-level stats endpoint (must be before router.urls to take precedence)
|
||||
path("stats/", ModerationStatsView.as_view(), name="moderation-stats"),
|
||||
# Include all router URLs (API endpoints)
|
||||
path("api/", include(router.urls)),
|
||||
# Standalone convert-to-edit endpoint (frontend calls /moderation/api/edit-submissions/ POST)
|
||||
|
||||
@@ -56,6 +56,7 @@ from .serializers import (
|
||||
BulkOperationSerializer,
|
||||
CompleteQueueItemSerializer,
|
||||
CreateBulkOperationSerializer,
|
||||
CreateEditSubmissionSerializer,
|
||||
CreateModerationActionSerializer,
|
||||
CreateModerationReportSerializer,
|
||||
EditSubmissionListSerializer,
|
||||
@@ -1363,6 +1364,8 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return EditSubmissionListSerializer
|
||||
if self.action == "create":
|
||||
return CreateEditSubmissionSerializer
|
||||
return EditSubmissionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -1378,6 +1381,191 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -1440,9 +1628,23 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
# 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({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
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):
|
||||
@@ -1516,6 +1718,162 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@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):
|
||||
"""
|
||||
@@ -1706,9 +2064,23 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return Response(self.get_serializer(submission).data)
|
||||
# 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({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
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):
|
||||
@@ -2139,3 +2511,117 @@ class ConvertSubmissionToEditView(APIView):
|
||||
{"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)
|
||||
|
||||
Reference in New Issue
Block a user