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

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