feat: Add analytics, incident, and alert models and APIs, along with user permissions and bulk profile lookups.

This commit is contained in:
pacnpal
2026-01-07 11:07:36 -05:00
parent 4da7e52fb0
commit 3ec5a4857d
46 changed files with 4012 additions and 199 deletions

View File

@@ -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()

View File

@@ -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
# ============================================================================

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)