mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 07:45:18 -05:00
feat: Add analytics, incident, and alert models and APIs, along with user permissions and bulk profile lookups.
This commit is contained in:
@@ -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