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:
89
backend/apps/core/api/alert_serializers.py
Normal file
89
backend/apps/core/api/alert_serializers.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Serializers for admin alert API endpoints.
|
||||
|
||||
Provides serializers for SystemAlert, RateLimitAlert, and RateLimitAlertConfig models.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.models import RateLimitAlert, RateLimitAlertConfig, SystemAlert
|
||||
|
||||
|
||||
class SystemAlertSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for system alerts."""
|
||||
|
||||
is_resolved = serializers.BooleanField(read_only=True)
|
||||
resolved_by_username = serializers.CharField(source="resolved_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = SystemAlert
|
||||
fields = [
|
||||
"id",
|
||||
"alert_type",
|
||||
"severity",
|
||||
"message",
|
||||
"metadata",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"resolved_by_username",
|
||||
"created_at",
|
||||
"is_resolved",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "is_resolved", "resolved_by_username"]
|
||||
|
||||
|
||||
class SystemAlertResolveSerializer(serializers.Serializer):
|
||||
"""Serializer for resolving system alerts."""
|
||||
|
||||
notes = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class RateLimitAlertConfigSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for rate limit alert configurations."""
|
||||
|
||||
class Meta:
|
||||
model = RateLimitAlertConfig
|
||||
fields = [
|
||||
"id",
|
||||
"metric_type",
|
||||
"threshold_value",
|
||||
"time_window_ms",
|
||||
"function_name",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class RateLimitAlertSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for rate limit alerts."""
|
||||
|
||||
is_resolved = serializers.BooleanField(read_only=True)
|
||||
config_id = serializers.UUIDField(source="config.id", read_only=True)
|
||||
resolved_by_username = serializers.CharField(source="resolved_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = RateLimitAlert
|
||||
fields = [
|
||||
"id",
|
||||
"config_id",
|
||||
"metric_type",
|
||||
"metric_value",
|
||||
"threshold_value",
|
||||
"time_window_ms",
|
||||
"function_name",
|
||||
"alert_message",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"resolved_by_username",
|
||||
"created_at",
|
||||
"is_resolved",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "is_resolved", "config_id", "resolved_by_username"]
|
||||
|
||||
|
||||
class RateLimitAlertResolveSerializer(serializers.Serializer):
|
||||
"""Serializer for resolving rate limit alerts."""
|
||||
|
||||
notes = serializers.CharField(required=False, allow_blank=True)
|
||||
226
backend/apps/core/api/alert_views.py
Normal file
226
backend/apps/core/api/alert_views.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
ViewSets for admin alert API endpoints.
|
||||
|
||||
Provides CRUD operations for SystemAlert, RateLimitAlert, and RateLimitAlertConfig.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.core.models import RateLimitAlert, RateLimitAlertConfig, SystemAlert
|
||||
|
||||
from .alert_serializers import (
|
||||
RateLimitAlertConfigSerializer,
|
||||
RateLimitAlertResolveSerializer,
|
||||
RateLimitAlertSerializer,
|
||||
SystemAlertResolveSerializer,
|
||||
SystemAlertSerializer,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List system alerts",
|
||||
description="Get all system alerts, optionally filtered by severity or resolved status.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get system alert",
|
||||
description="Get details of a specific system alert.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create system alert",
|
||||
description="Create a new system alert.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update system alert",
|
||||
description="Update an existing system alert.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partial update system alert",
|
||||
description="Partially update an existing system alert.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete system alert",
|
||||
description="Delete a system alert.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
)
|
||||
class SystemAlertViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing system alerts.
|
||||
|
||||
Provides CRUD operations plus a resolve action for marking alerts as resolved.
|
||||
"""
|
||||
|
||||
queryset = SystemAlert.objects.all()
|
||||
serializer_class = SystemAlertSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["severity", "alert_type"]
|
||||
search_fields = ["message"]
|
||||
ordering_fields = ["created_at", "severity"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by resolved status
|
||||
resolved = self.request.query_params.get("resolved")
|
||||
if resolved is not None:
|
||||
if resolved.lower() == "true":
|
||||
queryset = queryset.exclude(resolved_at__isnull=True)
|
||||
elif resolved.lower() == "false":
|
||||
queryset = queryset.filter(resolved_at__isnull=True)
|
||||
|
||||
return queryset
|
||||
|
||||
@extend_schema(
|
||||
summary="Resolve system alert",
|
||||
description="Mark a system alert as resolved.",
|
||||
request=SystemAlertResolveSerializer,
|
||||
responses={200: SystemAlertSerializer},
|
||||
tags=["Admin - Alerts"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def resolve(self, request, pk=None):
|
||||
"""Mark an alert as resolved."""
|
||||
alert = self.get_object()
|
||||
|
||||
if alert.resolved_at:
|
||||
return Response(
|
||||
{"detail": "Alert is already resolved"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
alert.resolved_at = timezone.now()
|
||||
alert.resolved_by = request.user
|
||||
alert.save()
|
||||
|
||||
serializer = self.get_serializer(alert)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List rate limit alert configs",
|
||||
description="Get all rate limit alert configurations.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get rate limit alert config",
|
||||
description="Get details of a specific rate limit alert configuration.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create rate limit alert config",
|
||||
description="Create a new rate limit alert configuration.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update rate limit alert config",
|
||||
description="Update an existing rate limit alert configuration.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partial update rate limit alert config",
|
||||
description="Partially update an existing rate limit alert configuration.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete rate limit alert config",
|
||||
description="Delete a rate limit alert configuration.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
)
|
||||
class RateLimitAlertConfigViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing rate limit alert configurations.
|
||||
|
||||
Provides CRUD operations for alert thresholds.
|
||||
"""
|
||||
|
||||
queryset = RateLimitAlertConfig.objects.all()
|
||||
serializer_class = RateLimitAlertConfigSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||
filterset_fields = ["metric_type", "enabled"]
|
||||
ordering_fields = ["created_at", "metric_type", "threshold_value"]
|
||||
ordering = ["metric_type", "-created_at"]
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List rate limit alerts",
|
||||
description="Get all rate limit alerts, optionally filtered by resolved status.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get rate limit alert",
|
||||
description="Get details of a specific rate limit alert.",
|
||||
tags=["Admin - Alerts"],
|
||||
),
|
||||
)
|
||||
class RateLimitAlertViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing rate limit alerts.
|
||||
|
||||
Provides read-only access and a resolve action.
|
||||
"""
|
||||
|
||||
queryset = RateLimitAlert.objects.select_related("config").all()
|
||||
serializer_class = RateLimitAlertSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["metric_type"]
|
||||
search_fields = ["alert_message", "function_name"]
|
||||
ordering_fields = ["created_at", "metric_value"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by resolved status
|
||||
resolved = self.request.query_params.get("resolved")
|
||||
if resolved is not None:
|
||||
if resolved.lower() == "true":
|
||||
queryset = queryset.exclude(resolved_at__isnull=True)
|
||||
elif resolved.lower() == "false":
|
||||
queryset = queryset.filter(resolved_at__isnull=True)
|
||||
|
||||
return queryset
|
||||
|
||||
@extend_schema(
|
||||
summary="Resolve rate limit alert",
|
||||
description="Mark a rate limit alert as resolved.",
|
||||
request=RateLimitAlertResolveSerializer,
|
||||
responses={200: RateLimitAlertSerializer},
|
||||
tags=["Admin - Alerts"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def resolve(self, request, pk=None):
|
||||
"""Mark an alert as resolved."""
|
||||
alert = self.get_object()
|
||||
|
||||
if alert.resolved_at:
|
||||
return Response(
|
||||
{"detail": "Alert is already resolved"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
alert.resolved_at = timezone.now()
|
||||
alert.resolved_by = request.user
|
||||
alert.save()
|
||||
|
||||
serializer = self.get_serializer(alert)
|
||||
return Response(serializer.data)
|
||||
204
backend/apps/core/api/analytics_serializers.py
Normal file
204
backend/apps/core/api/analytics_serializers.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Serializers for admin analytics endpoints.
|
||||
|
||||
Provides serialization for RequestMetadata, RequestBreadcrumb,
|
||||
ApprovalTransactionMetric, and ErrorSummary aggregation.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.models import (
|
||||
ApprovalTransactionMetric,
|
||||
RequestBreadcrumb,
|
||||
RequestMetadata,
|
||||
)
|
||||
|
||||
|
||||
class RequestBreadcrumbSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for request breadcrumb data."""
|
||||
|
||||
class Meta:
|
||||
model = RequestBreadcrumb
|
||||
fields = [
|
||||
"timestamp",
|
||||
"category",
|
||||
"message",
|
||||
"level",
|
||||
"sequence_order",
|
||||
]
|
||||
|
||||
|
||||
class RequestMetadataSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for request metadata with nested breadcrumbs.
|
||||
|
||||
Supports the expand=request_breadcrumbs query parameter
|
||||
to include breadcrumb data in the response.
|
||||
"""
|
||||
|
||||
request_breadcrumbs = RequestBreadcrumbSerializer(many=True, read_only=True)
|
||||
user_id = serializers.CharField(source="user_id", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = RequestMetadata
|
||||
fields = [
|
||||
"id",
|
||||
"request_id",
|
||||
"trace_id",
|
||||
"session_id",
|
||||
"parent_request_id",
|
||||
"action",
|
||||
"method",
|
||||
"endpoint",
|
||||
"request_method",
|
||||
"request_path",
|
||||
"affected_route",
|
||||
"http_status",
|
||||
"status_code",
|
||||
"response_status",
|
||||
"success",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"duration_ms",
|
||||
"response_time_ms",
|
||||
"error_type",
|
||||
"error_message",
|
||||
"error_stack",
|
||||
"error_code",
|
||||
"error_origin",
|
||||
"component_stack",
|
||||
"severity",
|
||||
"is_resolved",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"resolution_notes",
|
||||
"retry_count",
|
||||
"retry_attempts",
|
||||
"user_id",
|
||||
"user_agent",
|
||||
"ip_address_hash",
|
||||
"client_version",
|
||||
"timezone",
|
||||
"referrer",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"created_at",
|
||||
"request_breadcrumbs",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Conditionally include breadcrumbs based on expand parameter."""
|
||||
data = super().to_representation(instance)
|
||||
request = self.context.get("request")
|
||||
|
||||
# Only include breadcrumbs if explicitly expanded
|
||||
if request:
|
||||
expand = request.query_params.get("expand", "")
|
||||
if "request_breadcrumbs" not in expand:
|
||||
data.pop("request_breadcrumbs", None)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RequestMetadataCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating request metadata (log_request_metadata RPC)."""
|
||||
|
||||
breadcrumbs = RequestBreadcrumbSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = RequestMetadata
|
||||
fields = [
|
||||
"request_id",
|
||||
"trace_id",
|
||||
"session_id",
|
||||
"parent_request_id",
|
||||
"action",
|
||||
"method",
|
||||
"endpoint",
|
||||
"request_method",
|
||||
"request_path",
|
||||
"affected_route",
|
||||
"http_status",
|
||||
"status_code",
|
||||
"response_status",
|
||||
"success",
|
||||
"completed_at",
|
||||
"duration_ms",
|
||||
"response_time_ms",
|
||||
"error_type",
|
||||
"error_message",
|
||||
"error_stack",
|
||||
"error_code",
|
||||
"error_origin",
|
||||
"component_stack",
|
||||
"severity",
|
||||
"retry_count",
|
||||
"retry_attempts",
|
||||
"user_agent",
|
||||
"ip_address_hash",
|
||||
"client_version",
|
||||
"timezone",
|
||||
"referrer",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"breadcrumbs",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
breadcrumbs_data = validated_data.pop("breadcrumbs", [])
|
||||
request_metadata = RequestMetadata.objects.create(**validated_data)
|
||||
|
||||
for i, breadcrumb_data in enumerate(breadcrumbs_data):
|
||||
RequestBreadcrumb.objects.create(
|
||||
request_metadata=request_metadata,
|
||||
sequence_order=breadcrumb_data.get("sequence_order", i),
|
||||
**{k: v for k, v in breadcrumb_data.items() if k != "sequence_order"}
|
||||
)
|
||||
|
||||
return request_metadata
|
||||
|
||||
|
||||
class RequestMetadataResolveSerializer(serializers.Serializer):
|
||||
"""Serializer for resolving request metadata errors."""
|
||||
|
||||
resolution_notes = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class ApprovalTransactionMetricSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for approval transaction metrics."""
|
||||
|
||||
class Meta:
|
||||
model = ApprovalTransactionMetric
|
||||
fields = [
|
||||
"id",
|
||||
"submission_id",
|
||||
"moderator_id",
|
||||
"submitter_id",
|
||||
"request_id",
|
||||
"success",
|
||||
"duration_ms",
|
||||
"items_count",
|
||||
"rollback_triggered",
|
||||
"error_code",
|
||||
"error_message",
|
||||
"error_details",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
|
||||
class ErrorSummarySerializer(serializers.Serializer):
|
||||
"""
|
||||
Read-only serializer for error summary aggregation.
|
||||
|
||||
Aggregates error data from RequestMetadata for dashboard display.
|
||||
"""
|
||||
|
||||
date = serializers.DateField(read_only=True)
|
||||
error_type = serializers.CharField(read_only=True)
|
||||
severity = serializers.CharField(read_only=True)
|
||||
error_count = serializers.IntegerField(read_only=True)
|
||||
resolved_count = serializers.IntegerField(read_only=True)
|
||||
affected_users = serializers.IntegerField(read_only=True)
|
||||
avg_resolution_minutes = serializers.FloatField(read_only=True, allow_null=True)
|
||||
184
backend/apps/core/api/analytics_views.py
Normal file
184
backend/apps/core/api/analytics_views.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
ViewSets for admin analytics endpoints.
|
||||
|
||||
Provides read/write access to RequestMetadata, ApprovalTransactionMetric,
|
||||
and a read-only aggregation endpoint for ErrorSummary.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Avg, Count, F, Q
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.utils import timezone
|
||||
from django_filters import rest_framework as filters
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.core.models import ApprovalTransactionMetric, RequestMetadata
|
||||
|
||||
from .analytics_serializers import (
|
||||
ApprovalTransactionMetricSerializer,
|
||||
ErrorSummarySerializer,
|
||||
RequestMetadataCreateSerializer,
|
||||
RequestMetadataResolveSerializer,
|
||||
RequestMetadataSerializer,
|
||||
)
|
||||
|
||||
|
||||
class RequestMetadataFilter(filters.FilterSet):
|
||||
"""Filter for RequestMetadata queries."""
|
||||
|
||||
error_type__ne = filters.CharFilter(field_name="error_type", method="filter_not_equal")
|
||||
created_at__gte = filters.IsoDateTimeFilter(field_name="created_at", lookup_expr="gte")
|
||||
created_at__lte = filters.IsoDateTimeFilter(field_name="created_at", lookup_expr="lte")
|
||||
|
||||
class Meta:
|
||||
model = RequestMetadata
|
||||
fields = {
|
||||
"error_type": ["exact", "isnull"],
|
||||
"severity": ["exact"],
|
||||
"is_resolved": ["exact"],
|
||||
"success": ["exact"],
|
||||
"http_status": ["exact", "gte", "lte"],
|
||||
"user": ["exact"],
|
||||
"endpoint": ["exact", "icontains"],
|
||||
}
|
||||
|
||||
def filter_not_equal(self, queryset, name, value):
|
||||
"""Handle the error_type__ne filter for non-null error types."""
|
||||
# The frontend sends a JSON object for 'not null' filter
|
||||
# We interpret this as 'error_type is not null'
|
||||
if value:
|
||||
return queryset.exclude(error_type__isnull=True)
|
||||
return queryset
|
||||
|
||||
|
||||
class RequestMetadataViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for request metadata CRUD operations.
|
||||
|
||||
Supports filtering by error_type, severity, date range, etc.
|
||||
Use the expand=request_breadcrumbs query parameter to include breadcrumbs.
|
||||
"""
|
||||
|
||||
queryset = RequestMetadata.objects.all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_class = RequestMetadataFilter
|
||||
ordering_fields = ["created_at", "severity", "error_type"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return RequestMetadataCreateSerializer
|
||||
return RequestMetadataSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optimize queryset with prefetch for breadcrumbs if expanded."""
|
||||
queryset = super().get_queryset()
|
||||
expand = self.request.query_params.get("expand", "")
|
||||
|
||||
if "request_breadcrumbs" in expand:
|
||||
queryset = queryset.prefetch_related("request_breadcrumbs")
|
||||
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Associate request metadata with current user if authenticated."""
|
||||
user = self.request.user if self.request.user.is_authenticated else None
|
||||
serializer.save(user=user)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
|
||||
def resolve(self, request, pk=None):
|
||||
"""Mark a request metadata entry as resolved."""
|
||||
instance = self.get_object()
|
||||
serializer = RequestMetadataResolveSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
instance.is_resolved = True
|
||||
instance.resolved_at = timezone.now()
|
||||
instance.resolved_by = request.user
|
||||
instance.resolution_notes = serializer.validated_data.get("resolution_notes", "")
|
||||
instance.save(update_fields=["is_resolved", "resolved_at", "resolved_by", "resolution_notes"])
|
||||
|
||||
return Response(RequestMetadataSerializer(instance).data)
|
||||
|
||||
|
||||
class ApprovalTransactionMetricFilter(filters.FilterSet):
|
||||
"""Filter for ApprovalTransactionMetric queries."""
|
||||
|
||||
created_at__gte = filters.IsoDateTimeFilter(field_name="created_at", lookup_expr="gte")
|
||||
created_at__lte = filters.IsoDateTimeFilter(field_name="created_at", lookup_expr="lte")
|
||||
|
||||
class Meta:
|
||||
model = ApprovalTransactionMetric
|
||||
fields = {
|
||||
"success": ["exact"],
|
||||
"moderator_id": ["exact"],
|
||||
"submitter_id": ["exact"],
|
||||
"submission_id": ["exact"],
|
||||
}
|
||||
|
||||
|
||||
class ApprovalTransactionMetricViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
Read-only ViewSet for approval transaction metrics.
|
||||
|
||||
Provides analytics data about moderation approval operations.
|
||||
"""
|
||||
|
||||
queryset = ApprovalTransactionMetric.objects.all()
|
||||
serializer_class = ApprovalTransactionMetricSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_class = ApprovalTransactionMetricFilter
|
||||
ordering_fields = ["created_at", "duration_ms", "success"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class ErrorSummaryView(APIView):
|
||||
"""
|
||||
Aggregation endpoint for error summary statistics.
|
||||
|
||||
Returns daily error counts grouped by error_type and severity,
|
||||
similar to the Supabase error_summary view.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
"""Get aggregated error summary data."""
|
||||
# Default to last 30 days
|
||||
days = int(request.query_params.get("days", 30))
|
||||
since = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Aggregate error data by date, error_type, and severity
|
||||
summary = (
|
||||
RequestMetadata.objects.filter(
|
||||
created_at__gte=since,
|
||||
error_type__isnull=False,
|
||||
)
|
||||
.annotate(date=TruncDate("created_at"))
|
||||
.values("date", "error_type", "severity")
|
||||
.annotate(
|
||||
error_count=Count("id"),
|
||||
resolved_count=Count("id", filter=Q(is_resolved=True)),
|
||||
affected_users=Count("user", distinct=True),
|
||||
avg_resolution_minutes=Avg(
|
||||
(F("resolved_at") - F("created_at")),
|
||||
filter=Q(is_resolved=True, resolved_at__isnull=False),
|
||||
),
|
||||
)
|
||||
.order_by("-date", "-error_count")
|
||||
)
|
||||
|
||||
# Convert timedelta to minutes for avg_resolution_minutes
|
||||
results = []
|
||||
for item in summary:
|
||||
if item["avg_resolution_minutes"]:
|
||||
item["avg_resolution_minutes"] = item["avg_resolution_minutes"].total_seconds() / 60
|
||||
results.append(item)
|
||||
|
||||
serializer = ErrorSummarySerializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
162
backend/apps/core/api/incident_serializers.py
Normal file
162
backend/apps/core/api/incident_serializers.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Serializers for Incident management API endpoints.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.models import Incident, IncidentAlert
|
||||
|
||||
|
||||
class IncidentAlertSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for linked alerts within an incident."""
|
||||
|
||||
class Meta:
|
||||
model = IncidentAlert
|
||||
fields = [
|
||||
"id",
|
||||
"alert_source",
|
||||
"alert_id",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
|
||||
class IncidentSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Incident model."""
|
||||
|
||||
acknowledged_by_username = serializers.CharField(
|
||||
source="acknowledged_by.username", read_only=True, allow_null=True
|
||||
)
|
||||
resolved_by_username = serializers.CharField(
|
||||
source="resolved_by.username", read_only=True, allow_null=True
|
||||
)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
severity_display = serializers.CharField(source="get_severity_display", read_only=True)
|
||||
linked_alerts = IncidentAlertSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Incident
|
||||
fields = [
|
||||
"id",
|
||||
"incident_number",
|
||||
"title",
|
||||
"description",
|
||||
"severity",
|
||||
"severity_display",
|
||||
"status",
|
||||
"status_display",
|
||||
"detected_at",
|
||||
"acknowledged_at",
|
||||
"acknowledged_by",
|
||||
"acknowledged_by_username",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"resolved_by_username",
|
||||
"resolution_notes",
|
||||
"alert_count",
|
||||
"linked_alerts",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"incident_number",
|
||||
"detected_at",
|
||||
"acknowledged_at",
|
||||
"acknowledged_by",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"alert_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IncidentCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating incidents with linked alerts."""
|
||||
|
||||
alert_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
help_text="List of alert IDs to link to this incident",
|
||||
)
|
||||
alert_sources = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=["system", "rate_limit"]),
|
||||
write_only=True,
|
||||
required=False,
|
||||
help_text="Source types for each alert (must match alert_ids length)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Incident
|
||||
fields = [
|
||||
"title",
|
||||
"description",
|
||||
"severity",
|
||||
"alert_ids",
|
||||
"alert_sources",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
alert_ids = data.get("alert_ids", [])
|
||||
alert_sources = data.get("alert_sources", [])
|
||||
|
||||
if alert_ids and len(alert_ids) != len(alert_sources):
|
||||
raise serializers.ValidationError(
|
||||
{"alert_sources": "Must provide one source per alert_id"}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
alert_ids = validated_data.pop("alert_ids", [])
|
||||
alert_sources = validated_data.pop("alert_sources", [])
|
||||
|
||||
incident = Incident.objects.create(**validated_data)
|
||||
|
||||
# Create linked alerts
|
||||
for alert_id, source in zip(alert_ids, alert_sources):
|
||||
IncidentAlert.objects.create(
|
||||
incident=incident,
|
||||
alert_id=alert_id,
|
||||
alert_source=source,
|
||||
)
|
||||
|
||||
return incident
|
||||
|
||||
|
||||
class IncidentAcknowledgeSerializer(serializers.Serializer):
|
||||
"""Serializer for acknowledging an incident."""
|
||||
|
||||
pass # No additional data needed
|
||||
|
||||
|
||||
class IncidentResolveSerializer(serializers.Serializer):
|
||||
"""Serializer for resolving an incident."""
|
||||
|
||||
resolution_notes = serializers.CharField(required=False, allow_blank=True)
|
||||
resolve_alerts = serializers.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether to also resolve all linked alerts",
|
||||
)
|
||||
|
||||
|
||||
class LinkAlertsSerializer(serializers.Serializer):
|
||||
"""Serializer for linking alerts to an incident."""
|
||||
|
||||
alert_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
help_text="List of alert IDs to link",
|
||||
)
|
||||
alert_sources = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=["system", "rate_limit"]),
|
||||
help_text="Source types for each alert",
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
if len(data["alert_ids"]) != len(data["alert_sources"]):
|
||||
raise serializers.ValidationError(
|
||||
{"alert_sources": "Must provide one source per alert_id"}
|
||||
)
|
||||
return data
|
||||
201
backend/apps/core/api/incident_views.py
Normal file
201
backend/apps/core/api/incident_views.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
ViewSets for Incident management API endpoints.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.core.models import Incident, IncidentAlert, RateLimitAlert, SystemAlert
|
||||
|
||||
from .incident_serializers import (
|
||||
IncidentAcknowledgeSerializer,
|
||||
IncidentAlertSerializer,
|
||||
IncidentCreateSerializer,
|
||||
IncidentResolveSerializer,
|
||||
IncidentSerializer,
|
||||
LinkAlertsSerializer,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List incidents",
|
||||
description="Get all incidents, optionally filtered by status or severity.",
|
||||
tags=["Admin - Incidents"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get incident",
|
||||
description="Get details of a specific incident including linked alerts.",
|
||||
tags=["Admin - Incidents"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create incident",
|
||||
description="Create a new incident and optionally link alerts.",
|
||||
tags=["Admin - Incidents"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update incident",
|
||||
description="Update an existing incident.",
|
||||
tags=["Admin - Incidents"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partial update incident",
|
||||
description="Partially update an existing incident.",
|
||||
tags=["Admin - Incidents"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete incident",
|
||||
description="Delete an incident.",
|
||||
tags=["Admin - Incidents"],
|
||||
),
|
||||
)
|
||||
class IncidentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing incidents.
|
||||
|
||||
Provides CRUD operations plus acknowledge, resolve, and alert linking actions.
|
||||
"""
|
||||
|
||||
queryset = Incident.objects.prefetch_related("linked_alerts").all()
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["status", "severity"]
|
||||
search_fields = ["title", "description", "incident_number"]
|
||||
ordering_fields = ["detected_at", "severity", "status", "alert_count"]
|
||||
ordering = ["-detected_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return IncidentCreateSerializer
|
||||
if self.action == "acknowledge":
|
||||
return IncidentAcknowledgeSerializer
|
||||
if self.action == "resolve":
|
||||
return IncidentResolveSerializer
|
||||
if self.action == "link_alerts":
|
||||
return LinkAlertsSerializer
|
||||
if self.action == "alerts":
|
||||
return IncidentAlertSerializer
|
||||
return IncidentSerializer
|
||||
|
||||
@extend_schema(
|
||||
summary="Acknowledge incident",
|
||||
description="Mark an incident as being investigated.",
|
||||
request=IncidentAcknowledgeSerializer,
|
||||
responses={200: IncidentSerializer},
|
||||
tags=["Admin - Incidents"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def acknowledge(self, request, pk=None):
|
||||
"""Mark an incident as being investigated."""
|
||||
incident = self.get_object()
|
||||
|
||||
if incident.status != Incident.Status.OPEN:
|
||||
return Response(
|
||||
{"detail": f"Cannot acknowledge incident in '{incident.status}' status"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
incident.status = Incident.Status.INVESTIGATING
|
||||
incident.acknowledged_at = timezone.now()
|
||||
incident.acknowledged_by = request.user
|
||||
incident.save()
|
||||
|
||||
return Response(IncidentSerializer(incident).data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Resolve incident",
|
||||
description="Mark an incident as resolved, optionally resolving all linked alerts.",
|
||||
request=IncidentResolveSerializer,
|
||||
responses={200: IncidentSerializer},
|
||||
tags=["Admin - Incidents"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def resolve(self, request, pk=None):
|
||||
"""Mark an incident as resolved."""
|
||||
incident = self.get_object()
|
||||
|
||||
if incident.status in (Incident.Status.RESOLVED, Incident.Status.CLOSED):
|
||||
return Response(
|
||||
{"detail": "Incident is already resolved or closed"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = IncidentResolveSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
incident.status = Incident.Status.RESOLVED
|
||||
incident.resolved_at = timezone.now()
|
||||
incident.resolved_by = request.user
|
||||
incident.resolution_notes = serializer.validated_data.get("resolution_notes", "")
|
||||
incident.save()
|
||||
|
||||
# Optionally resolve all linked alerts
|
||||
if serializer.validated_data.get("resolve_alerts", True):
|
||||
now = timezone.now()
|
||||
for link in incident.linked_alerts.all():
|
||||
if link.alert_source == "system":
|
||||
SystemAlert.objects.filter(
|
||||
id=link.alert_id, resolved_at__isnull=True
|
||||
).update(resolved_at=now, resolved_by=request.user)
|
||||
elif link.alert_source == "rate_limit":
|
||||
RateLimitAlert.objects.filter(
|
||||
id=link.alert_id, resolved_at__isnull=True
|
||||
).update(resolved_at=now, resolved_by=request.user)
|
||||
|
||||
return Response(IncidentSerializer(incident).data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get linked alerts",
|
||||
description="Get all alerts linked to this incident.",
|
||||
responses={200: IncidentAlertSerializer(many=True)},
|
||||
tags=["Admin - Incidents"],
|
||||
)
|
||||
@action(detail=True, methods=["get"])
|
||||
def alerts(self, request, pk=None):
|
||||
"""Get all alerts linked to this incident."""
|
||||
incident = self.get_object()
|
||||
alerts = incident.linked_alerts.all()
|
||||
serializer = IncidentAlertSerializer(alerts, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Link alerts to incident",
|
||||
description="Link additional alerts to an existing incident.",
|
||||
request=LinkAlertsSerializer,
|
||||
responses={200: IncidentSerializer},
|
||||
tags=["Admin - Incidents"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_path="link-alerts")
|
||||
def link_alerts(self, request, pk=None):
|
||||
"""Link additional alerts to an incident."""
|
||||
incident = self.get_object()
|
||||
|
||||
serializer = LinkAlertsSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
alert_ids = serializer.validated_data["alert_ids"]
|
||||
alert_sources = serializer.validated_data["alert_sources"]
|
||||
|
||||
created = 0
|
||||
for alert_id, source in zip(alert_ids, alert_sources):
|
||||
_, was_created = IncidentAlert.objects.get_or_create(
|
||||
incident=incident,
|
||||
alert_id=alert_id,
|
||||
alert_source=source,
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
|
||||
# Refresh to get updated alert_count
|
||||
incident.refresh_from_db()
|
||||
|
||||
return Response({
|
||||
"detail": f"Linked {created} new alerts to incident",
|
||||
"incident": IncidentSerializer(incident).data,
|
||||
})
|
||||
76
backend/apps/core/migrations/0006_add_alert_models.py
Normal file
76
backend/apps/core/migrations/0006_add_alert_models.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-06 17:00
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_add_application_error'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RateLimitAlertConfig',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('metric_type', models.CharField(choices=[('block_rate', 'Block Rate'), ('total_requests', 'Total Requests'), ('unique_ips', 'Unique IPs'), ('function_specific', 'Function Specific')], db_index=True, help_text='Type of metric to monitor', max_length=50)),
|
||||
('threshold_value', models.FloatField(help_text='Threshold value that triggers alert')),
|
||||
('time_window_ms', models.IntegerField(help_text='Time window in milliseconds for measurement')),
|
||||
('function_name', models.CharField(blank=True, help_text='Specific function to monitor (for function_specific metric type)', max_length=100, null=True)),
|
||||
('enabled', models.BooleanField(db_index=True, default=True, help_text='Whether this config is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Rate Limit Alert Config',
|
||||
'verbose_name_plural': 'Rate Limit Alert Configs',
|
||||
'ordering': ['metric_type', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RateLimitAlert',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('metric_type', models.CharField(help_text='Type of metric', max_length=50)),
|
||||
('metric_value', models.FloatField(help_text='Actual value that triggered the alert')),
|
||||
('threshold_value', models.FloatField(help_text='Threshold that was exceeded')),
|
||||
('time_window_ms', models.IntegerField(help_text='Time window of measurement')),
|
||||
('function_name', models.CharField(blank=True, help_text='Function name if applicable', max_length=100, null=True)),
|
||||
('alert_message', models.TextField(help_text='Descriptive alert message')),
|
||||
('resolved_at', models.DateTimeField(blank=True, db_index=True, help_text='When this alert was resolved', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('resolved_by', models.ForeignKey(blank=True, help_text='Admin who resolved this alert', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_rate_limit_alerts', to=settings.AUTH_USER_MODEL)),
|
||||
('config', models.ForeignKey(help_text='Configuration that triggered this alert', on_delete=django.db.models.deletion.CASCADE, related_name='alerts', to='core.ratelimitalertconfig')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Rate Limit Alert',
|
||||
'verbose_name_plural': 'Rate Limit Alerts',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['metric_type', 'created_at'], name='core_rateli_metric__6fd63e_idx'), models.Index(fields=['resolved_at', 'created_at'], name='core_rateli_resolve_98c143_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SystemAlert',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('alert_type', models.CharField(choices=[('orphaned_images', 'Orphaned Images'), ('stale_submissions', 'Stale Submissions'), ('circular_dependency', 'Circular Dependency'), ('validation_error', 'Validation Error'), ('ban_attempt', 'Ban Attempt'), ('upload_timeout', 'Upload Timeout'), ('high_error_rate', 'High Error Rate'), ('database_connection', 'Database Connection'), ('memory_usage', 'Memory Usage'), ('queue_backup', 'Queue Backup')], db_index=True, help_text='Type of system alert', max_length=50)),
|
||||
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, help_text='Alert severity level', max_length=20)),
|
||||
('message', models.TextField(help_text='Human-readable alert message')),
|
||||
('metadata', models.JSONField(blank=True, help_text='Additional context data for this alert', null=True)),
|
||||
('resolved_at', models.DateTimeField(blank=True, db_index=True, help_text='When this alert was resolved', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('resolved_by', models.ForeignKey(blank=True, help_text='Admin who resolved this alert', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_system_alerts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'System Alert',
|
||||
'verbose_name_plural': 'System Alerts',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['severity', 'created_at'], name='core_system_severit_bd3efd_idx'), models.Index(fields=['alert_type', 'created_at'], name='core_system_alert_t_10942e_idx'), models.Index(fields=['resolved_at', 'created_at'], name='core_system_resolve_9da33f_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-06 17:43
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_add_alert_models'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Incident',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('incident_number', models.CharField(db_index=True, help_text='Auto-generated incident number (INC-YYYYMMDD-XXXX)', max_length=20, unique=True)),
|
||||
('title', models.CharField(help_text='Brief description of the incident', max_length=255)),
|
||||
('description', models.TextField(blank=True, help_text='Detailed description', null=True)),
|
||||
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, help_text='Incident severity level', max_length=20)),
|
||||
('status', models.CharField(choices=[('open', 'Open'), ('investigating', 'Investigating'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', help_text='Current incident status', max_length=20)),
|
||||
('detected_at', models.DateTimeField(auto_now_add=True, help_text='When the incident was detected')),
|
||||
('acknowledged_at', models.DateTimeField(blank=True, help_text='When someone started investigating', null=True)),
|
||||
('resolved_at', models.DateTimeField(blank=True, help_text='When the incident was resolved', null=True)),
|
||||
('resolution_notes', models.TextField(blank=True, help_text='Notes about the resolution', null=True)),
|
||||
('alert_count', models.PositiveIntegerField(default=0, help_text='Number of linked alerts')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('acknowledged_by', models.ForeignKey(blank=True, help_text='User who acknowledged the incident', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acknowledged_incidents', to=settings.AUTH_USER_MODEL)),
|
||||
('resolved_by', models.ForeignKey(blank=True, help_text='User who resolved the incident', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_incidents', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Incident',
|
||||
'verbose_name_plural': 'Incidents',
|
||||
'ordering': ['-detected_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IncidentAlert',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('alert_source', models.CharField(choices=[('system', 'System Alert'), ('rate_limit', 'Rate Limit Alert')], help_text='Source type of the alert', max_length=20)),
|
||||
('alert_id', models.UUIDField(help_text='ID of the linked alert')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('incident', models.ForeignKey(help_text='The incident this alert is linked to', on_delete=django.db.models.deletion.CASCADE, related_name='linked_alerts', to='core.incident')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Incident Alert',
|
||||
'verbose_name_plural': 'Incident Alerts',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incident',
|
||||
index=models.Index(fields=['status', 'detected_at'], name='core_incide_status_c17ea4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incident',
|
||||
index=models.Index(fields=['severity', 'detected_at'], name='core_incide_severit_24b148_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incidentalert',
|
||||
index=models.Index(fields=['alert_source', 'alert_id'], name='core_incide_alert_s_9e655c_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='incidentalert',
|
||||
unique_together={('incident', 'alert_source', 'alert_id')},
|
||||
),
|
||||
]
|
||||
335
backend/apps/core/migrations/0008_add_analytics_models.py
Normal file
335
backend/apps/core/migrations/0008_add_analytics_models.py
Normal file
@@ -0,0 +1,335 @@
|
||||
# Generated by Django 5.1.6 on 2026-01-06 18:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0007_add_incident_and_report_models"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="pageviewevent",
|
||||
name="pgh_obj",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="pageviewevent",
|
||||
name="content_type",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="pageviewevent",
|
||||
name="pgh_context",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ApprovalTransactionMetric",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
(
|
||||
"submission_id",
|
||||
models.CharField(db_index=True, help_text="ID of the content submission", max_length=255),
|
||||
),
|
||||
(
|
||||
"moderator_id",
|
||||
models.CharField(
|
||||
db_index=True, help_text="ID of the moderator who processed the submission", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"submitter_id",
|
||||
models.CharField(
|
||||
db_index=True, help_text="ID of the user who submitted the content", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"request_id",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Correlation request ID", max_length=255, null=True
|
||||
),
|
||||
),
|
||||
("success", models.BooleanField(db_index=True, help_text="Whether the approval was successful")),
|
||||
(
|
||||
"duration_ms",
|
||||
models.PositiveIntegerField(blank=True, help_text="Processing duration in milliseconds", null=True),
|
||||
),
|
||||
("items_count", models.PositiveIntegerField(default=1, help_text="Number of items processed")),
|
||||
(
|
||||
"rollback_triggered",
|
||||
models.BooleanField(default=False, help_text="Whether a rollback was triggered"),
|
||||
),
|
||||
(
|
||||
"error_code",
|
||||
models.CharField(blank=True, help_text="Error code if failed", max_length=50, null=True),
|
||||
),
|
||||
("error_message", models.TextField(blank=True, help_text="Error message if failed", null=True)),
|
||||
("error_details", models.TextField(blank=True, help_text="Detailed error information", null=True)),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When this metric was recorded"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Approval Transaction Metric",
|
||||
"verbose_name_plural": "Approval Transaction Metrics",
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["success", "created_at"], name="core_approv_success_9c326b_idx"),
|
||||
models.Index(fields=["moderator_id", "created_at"], name="core_approv_moderat_ec41ba_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RequestMetadata",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
(
|
||||
"request_id",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
help_text="Unique request identifier for correlation",
|
||||
max_length=255,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"trace_id",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Distributed tracing ID", max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"session_id",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="User session identifier", max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"parent_request_id",
|
||||
models.CharField(
|
||||
blank=True, help_text="Parent request ID for nested requests", max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"action",
|
||||
models.CharField(
|
||||
blank=True, help_text="Action/operation being performed", max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"method",
|
||||
models.CharField(blank=True, help_text="HTTP method (GET, POST, etc.)", max_length=10, null=True),
|
||||
),
|
||||
(
|
||||
"endpoint",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="API endpoint or URL path", max_length=500, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"request_method",
|
||||
models.CharField(blank=True, help_text="HTTP request method", max_length=10, null=True),
|
||||
),
|
||||
("request_path", models.CharField(blank=True, help_text="Request URL path", max_length=500, null=True)),
|
||||
(
|
||||
"affected_route",
|
||||
models.CharField(blank=True, help_text="Frontend route affected", max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"http_status",
|
||||
models.PositiveIntegerField(blank=True, db_index=True, help_text="HTTP status code", null=True),
|
||||
),
|
||||
(
|
||||
"status_code",
|
||||
models.PositiveIntegerField(blank=True, help_text="Status code (alias for http_status)", null=True),
|
||||
),
|
||||
(
|
||||
"response_status",
|
||||
models.PositiveIntegerField(blank=True, help_text="Response status code", null=True),
|
||||
),
|
||||
(
|
||||
"success",
|
||||
models.BooleanField(
|
||||
blank=True, db_index=True, help_text="Whether the request was successful", null=True
|
||||
),
|
||||
),
|
||||
("started_at", models.DateTimeField(auto_now_add=True, help_text="When the request started")),
|
||||
("completed_at", models.DateTimeField(blank=True, help_text="When the request completed", null=True)),
|
||||
(
|
||||
"duration_ms",
|
||||
models.PositiveIntegerField(blank=True, help_text="Request duration in milliseconds", null=True),
|
||||
),
|
||||
(
|
||||
"response_time_ms",
|
||||
models.PositiveIntegerField(blank=True, help_text="Response time in milliseconds", null=True),
|
||||
),
|
||||
(
|
||||
"error_type",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Type/class of error", max_length=100, null=True
|
||||
),
|
||||
),
|
||||
("error_message", models.TextField(blank=True, help_text="Error message", null=True)),
|
||||
("error_stack", models.TextField(blank=True, help_text="Error stack trace", null=True)),
|
||||
(
|
||||
"error_code",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Application error code", max_length=50, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"error_origin",
|
||||
models.CharField(blank=True, help_text="Where the error originated", max_length=100, null=True),
|
||||
),
|
||||
("component_stack", models.TextField(blank=True, help_text="React component stack trace", null=True)),
|
||||
(
|
||||
"severity",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("debug", "Debug"),
|
||||
("info", "Info"),
|
||||
("warning", "Warning"),
|
||||
("error", "Error"),
|
||||
("critical", "Critical"),
|
||||
],
|
||||
db_index=True,
|
||||
default="info",
|
||||
help_text="Error severity level",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_resolved",
|
||||
models.BooleanField(db_index=True, default=False, help_text="Whether this error has been resolved"),
|
||||
),
|
||||
("resolved_at", models.DateTimeField(blank=True, help_text="When the error was resolved", null=True)),
|
||||
("resolution_notes", models.TextField(blank=True, help_text="Notes about resolution", null=True)),
|
||||
("retry_count", models.PositiveIntegerField(default=0, help_text="Number of retry attempts")),
|
||||
(
|
||||
"retry_attempts",
|
||||
models.PositiveIntegerField(blank=True, help_text="Total retry attempts made", null=True),
|
||||
),
|
||||
("user_agent", models.TextField(blank=True, help_text="User agent string", null=True)),
|
||||
(
|
||||
"ip_address_hash",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Hashed IP address", max_length=64, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_version",
|
||||
models.CharField(blank=True, help_text="Client application version", max_length=50, null=True),
|
||||
),
|
||||
("timezone", models.CharField(blank=True, help_text="User timezone", max_length=50, null=True)),
|
||||
("referrer", models.TextField(blank=True, help_text="HTTP referrer", null=True)),
|
||||
(
|
||||
"entity_type",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Type of entity affected", max_length=50, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"entity_id",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="ID of entity affected", max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When this record was created"),
|
||||
),
|
||||
(
|
||||
"resolved_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who resolved this error",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="resolved_request_metadata",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who made the request",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="request_metadata",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Request Metadata",
|
||||
"verbose_name_plural": "Request Metadata",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RequestBreadcrumb",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("timestamp", models.DateTimeField(help_text="When this breadcrumb occurred")),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
help_text="Breadcrumb category (e.g., 'http', 'navigation', 'console')", max_length=100
|
||||
),
|
||||
),
|
||||
("message", models.TextField(help_text="Breadcrumb message")),
|
||||
(
|
||||
"level",
|
||||
models.CharField(
|
||||
blank=True, help_text="Log level (debug, info, warning, error)", max_length=20, null=True
|
||||
),
|
||||
),
|
||||
("sequence_order", models.PositiveIntegerField(default=0, help_text="Order within the request")),
|
||||
(
|
||||
"request_metadata",
|
||||
models.ForeignKey(
|
||||
help_text="Parent request",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="request_breadcrumbs",
|
||||
to="core.requestmetadata",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Request Breadcrumb",
|
||||
"verbose_name_plural": "Request Breadcrumbs",
|
||||
"ordering": ["sequence_order", "timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PageView",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PageViewEvent",
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="requestmetadata",
|
||||
index=models.Index(fields=["error_type", "created_at"], name="core_reques_error_t_d384f1_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="requestmetadata",
|
||||
index=models.Index(fields=["severity", "created_at"], name="core_reques_severit_04b88d_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="requestmetadata",
|
||||
index=models.Index(fields=["is_resolved", "created_at"], name="core_reques_is_reso_614d34_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="requestmetadata",
|
||||
index=models.Index(fields=["user", "created_at"], name="core_reques_user_id_db6ee3_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="requestbreadcrumb",
|
||||
index=models.Index(fields=["request_metadata", "sequence_order"], name="core_reques_request_0e8be4_idx"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-07 01:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('core', '0008_add_analytics_models'),
|
||||
('pghistory', '0006_delete_aggregateevent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PageView',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('user_agent', models.CharField(blank=True, max_length=512)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_views', to='contenttypes.contenttype')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PageViewEvent',
|
||||
fields=[
|
||||
('pgh_id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('pgh_created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('pgh_label', models.TextField(help_text='The event label.')),
|
||||
('id', models.BigIntegerField()),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('user_agent', models.CharField(blank=True, max_length=512)),
|
||||
('content_type', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', related_query_name='+', to='contenttypes.contenttype')),
|
||||
('pgh_context', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='pghistory.context')),
|
||||
('pgh_obj', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='events', to='core.pageview')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pageview',
|
||||
index=models.Index(fields=['timestamp'], name='core_pagevi_timesta_757ebb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pageview',
|
||||
index=models.Index(fields=['content_type', 'object_id'], name='core_pagevi_content_eda7ad_idx'),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name='pageview',
|
||||
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;', hash='1682d124ea3ba215e630c7cfcde929f7444cf247', operation='INSERT', pgid='pgtrigger_insert_insert_ee1e1', table='core_pageview', when='AFTER')),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name='pageview',
|
||||
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "core_pageviewevent" ("content_type_id", "id", "ip_address", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "timestamp", "user_agent") VALUES (NEW."content_type_id", NEW."id", NEW."ip_address", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."timestamp", NEW."user_agent"); RETURN NULL;', hash='4221b2dd6636cae454f8d69c0c1841c40c47e6a6', operation='UPDATE', pgid='pgtrigger_update_update_3c505', table='core_pageview', when='AFTER')),
|
||||
),
|
||||
]
|
||||
@@ -298,3 +298,754 @@ class ApplicationError(models.Model):
|
||||
def short_error_id(self) -> str:
|
||||
"""Return first 8 characters of error_id for display."""
|
||||
return str(self.error_id)[:8]
|
||||
|
||||
|
||||
class SystemAlert(models.Model):
|
||||
"""
|
||||
System-level alerts for monitoring application health.
|
||||
|
||||
Alert types include orphaned images, stale submissions, circular dependencies,
|
||||
validation errors, ban attempts, upload timeouts, and high error rates.
|
||||
"""
|
||||
|
||||
class AlertType(models.TextChoices):
|
||||
ORPHANED_IMAGES = "orphaned_images", "Orphaned Images"
|
||||
STALE_SUBMISSIONS = "stale_submissions", "Stale Submissions"
|
||||
CIRCULAR_DEPENDENCY = "circular_dependency", "Circular Dependency"
|
||||
VALIDATION_ERROR = "validation_error", "Validation Error"
|
||||
BAN_ATTEMPT = "ban_attempt", "Ban Attempt"
|
||||
UPLOAD_TIMEOUT = "upload_timeout", "Upload Timeout"
|
||||
HIGH_ERROR_RATE = "high_error_rate", "High Error Rate"
|
||||
DATABASE_CONNECTION = "database_connection", "Database Connection"
|
||||
MEMORY_USAGE = "memory_usage", "Memory Usage"
|
||||
QUEUE_BACKUP = "queue_backup", "Queue Backup"
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
LOW = "low", "Low"
|
||||
MEDIUM = "medium", "Medium"
|
||||
HIGH = "high", "High"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
alert_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=AlertType.choices,
|
||||
db_index=True,
|
||||
help_text="Type of system alert",
|
||||
)
|
||||
severity = models.CharField(
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
db_index=True,
|
||||
help_text="Alert severity level",
|
||||
)
|
||||
message = models.TextField(help_text="Human-readable alert message")
|
||||
metadata = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Additional context data for this alert",
|
||||
)
|
||||
resolved_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="When this alert was resolved",
|
||||
)
|
||||
resolved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="resolved_system_alerts",
|
||||
help_text="Admin who resolved this alert",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "System Alert"
|
||||
verbose_name_plural = "System Alerts"
|
||||
indexes = [
|
||||
models.Index(fields=["severity", "created_at"]),
|
||||
models.Index(fields=["alert_type", "created_at"]),
|
||||
models.Index(fields=["resolved_at", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.get_severity_display()}] {self.get_alert_type_display()}: {self.message[:50]}"
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return self.resolved_at is not None
|
||||
|
||||
|
||||
class RateLimitAlertConfig(models.Model):
|
||||
"""
|
||||
Configuration for rate limit alert thresholds.
|
||||
|
||||
Defines thresholds that trigger alerts when exceeded.
|
||||
"""
|
||||
|
||||
class MetricType(models.TextChoices):
|
||||
BLOCK_RATE = "block_rate", "Block Rate"
|
||||
TOTAL_REQUESTS = "total_requests", "Total Requests"
|
||||
UNIQUE_IPS = "unique_ips", "Unique IPs"
|
||||
FUNCTION_SPECIFIC = "function_specific", "Function Specific"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
metric_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=MetricType.choices,
|
||||
db_index=True,
|
||||
help_text="Type of metric to monitor",
|
||||
)
|
||||
threshold_value = models.FloatField(help_text="Threshold value that triggers alert")
|
||||
time_window_ms = models.IntegerField(help_text="Time window in milliseconds for measurement")
|
||||
function_name = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Specific function to monitor (for function_specific metric type)",
|
||||
)
|
||||
enabled = models.BooleanField(default=True, db_index=True, help_text="Whether this config is active")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["metric_type", "-created_at"]
|
||||
verbose_name = "Rate Limit Alert Config"
|
||||
verbose_name_plural = "Rate Limit Alert Configs"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.get_metric_type_display()}: threshold={self.threshold_value}"
|
||||
|
||||
|
||||
class RateLimitAlert(models.Model):
|
||||
"""
|
||||
Alerts triggered when rate limit thresholds are exceeded.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
config = models.ForeignKey(
|
||||
RateLimitAlertConfig,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="alerts",
|
||||
help_text="Configuration that triggered this alert",
|
||||
)
|
||||
metric_type = models.CharField(max_length=50, help_text="Type of metric")
|
||||
metric_value = models.FloatField(help_text="Actual value that triggered the alert")
|
||||
threshold_value = models.FloatField(help_text="Threshold that was exceeded")
|
||||
time_window_ms = models.IntegerField(help_text="Time window of measurement")
|
||||
function_name = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Function name if applicable",
|
||||
)
|
||||
alert_message = models.TextField(help_text="Descriptive alert message")
|
||||
resolved_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="When this alert was resolved",
|
||||
)
|
||||
resolved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="resolved_rate_limit_alerts",
|
||||
help_text="Admin who resolved this alert",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Rate Limit Alert"
|
||||
verbose_name_plural = "Rate Limit Alerts"
|
||||
indexes = [
|
||||
models.Index(fields=["metric_type", "created_at"]),
|
||||
models.Index(fields=["resolved_at", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.metric_type}: {self.metric_value} > {self.threshold_value}"
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return self.resolved_at is not None
|
||||
|
||||
|
||||
class Incident(models.Model):
|
||||
"""
|
||||
Groups related alerts for coordinated investigation.
|
||||
|
||||
Incidents provide a higher-level view of system issues,
|
||||
allowing teams to track and resolve related alerts together.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
OPEN = "open", "Open"
|
||||
INVESTIGATING = "investigating", "Investigating"
|
||||
RESOLVED = "resolved", "Resolved"
|
||||
CLOSED = "closed", "Closed"
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
LOW = "low", "Low"
|
||||
MEDIUM = "medium", "Medium"
|
||||
HIGH = "high", "High"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
incident_number = models.CharField(
|
||||
max_length=20,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Auto-generated incident number (INC-YYYYMMDD-XXXX)",
|
||||
)
|
||||
title = models.CharField(max_length=255, help_text="Brief description of the incident")
|
||||
description = models.TextField(null=True, blank=True, help_text="Detailed description")
|
||||
severity = models.CharField(
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
db_index=True,
|
||||
help_text="Incident severity level",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.OPEN,
|
||||
db_index=True,
|
||||
help_text="Current incident status",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
detected_at = models.DateTimeField(auto_now_add=True, help_text="When the incident was detected")
|
||||
acknowledged_at = models.DateTimeField(null=True, blank=True, help_text="When someone started investigating")
|
||||
acknowledged_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="acknowledged_incidents",
|
||||
help_text="User who acknowledged the incident",
|
||||
)
|
||||
resolved_at = models.DateTimeField(null=True, blank=True, help_text="When the incident was resolved")
|
||||
resolved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="resolved_incidents",
|
||||
help_text="User who resolved the incident",
|
||||
)
|
||||
resolution_notes = models.TextField(null=True, blank=True, help_text="Notes about the resolution")
|
||||
|
||||
# Computed field (denormalized for performance)
|
||||
alert_count = models.PositiveIntegerField(default=0, help_text="Number of linked alerts")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-detected_at"]
|
||||
verbose_name = "Incident"
|
||||
verbose_name_plural = "Incidents"
|
||||
indexes = [
|
||||
models.Index(fields=["status", "detected_at"]),
|
||||
models.Index(fields=["severity", "detected_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.incident_number}: {self.title}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.incident_number:
|
||||
# Auto-generate incident number: INC-YYYYMMDD-XXXX
|
||||
from django.utils import timezone
|
||||
|
||||
today = timezone.now().strftime("%Y%m%d")
|
||||
count = Incident.objects.filter(incident_number__startswith=f"INC-{today}").count() + 1
|
||||
self.incident_number = f"INC-{today}-{count:04d}"
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def update_alert_count(self):
|
||||
"""Update the denormalized alert_count field."""
|
||||
self.alert_count = self.linked_alerts.count()
|
||||
self.save(update_fields=["alert_count"])
|
||||
|
||||
|
||||
class IncidentAlert(models.Model):
|
||||
"""
|
||||
Links alerts to incidents (many-to-many through table).
|
||||
|
||||
Supports linking both system alerts and rate limit alerts.
|
||||
"""
|
||||
|
||||
class AlertSource(models.TextChoices):
|
||||
SYSTEM = "system", "System Alert"
|
||||
RATE_LIMIT = "rate_limit", "Rate Limit Alert"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
incident = models.ForeignKey(
|
||||
Incident,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="linked_alerts",
|
||||
help_text="The incident this alert is linked to",
|
||||
)
|
||||
alert_source = models.CharField(
|
||||
max_length=20,
|
||||
choices=AlertSource.choices,
|
||||
help_text="Source type of the alert",
|
||||
)
|
||||
alert_id = models.UUIDField(help_text="ID of the linked alert")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Incident Alert"
|
||||
verbose_name_plural = "Incident Alerts"
|
||||
unique_together = ["incident", "alert_source", "alert_id"]
|
||||
indexes = [
|
||||
models.Index(fields=["alert_source", "alert_id"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.incident.incident_number} <- {self.alert_source}:{self.alert_id}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
# Update the incident's alert count
|
||||
self.incident.update_alert_count()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
incident = self.incident
|
||||
super().delete(*args, **kwargs)
|
||||
# Update the incident's alert count
|
||||
incident.update_alert_count()
|
||||
|
||||
|
||||
class RequestMetadata(models.Model):
|
||||
"""
|
||||
Comprehensive request tracking for monitoring and debugging.
|
||||
|
||||
Stores detailed information about API requests, including timing,
|
||||
errors, user context, and resolution status. Used by the admin
|
||||
dashboard for error monitoring and analytics.
|
||||
"""
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
DEBUG = "debug", "Debug"
|
||||
INFO = "info", "Info"
|
||||
WARNING = "warning", "Warning"
|
||||
ERROR = "error", "Error"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
# Identity & Correlation
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
request_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Unique request identifier for correlation",
|
||||
)
|
||||
trace_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Distributed tracing ID",
|
||||
)
|
||||
session_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="User session identifier",
|
||||
)
|
||||
parent_request_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Parent request ID for nested requests",
|
||||
)
|
||||
|
||||
# Request Information
|
||||
action = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Action/operation being performed",
|
||||
)
|
||||
method = models.CharField(
|
||||
max_length=10,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="HTTP method (GET, POST, etc.)",
|
||||
)
|
||||
endpoint = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="API endpoint or URL path",
|
||||
)
|
||||
request_method = models.CharField(
|
||||
max_length=10,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="HTTP request method",
|
||||
)
|
||||
request_path = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Request URL path",
|
||||
)
|
||||
affected_route = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Frontend route affected",
|
||||
)
|
||||
|
||||
# Response Information
|
||||
http_status = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="HTTP status code",
|
||||
)
|
||||
status_code = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Status code (alias for http_status)",
|
||||
)
|
||||
response_status = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Response status code",
|
||||
)
|
||||
success = models.BooleanField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Whether the request was successful",
|
||||
)
|
||||
|
||||
# Timing
|
||||
started_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="When the request started",
|
||||
)
|
||||
completed_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="When the request completed",
|
||||
)
|
||||
duration_ms = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Request duration in milliseconds",
|
||||
)
|
||||
response_time_ms = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Response time in milliseconds",
|
||||
)
|
||||
|
||||
# Error Information
|
||||
error_type = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Type/class of error",
|
||||
)
|
||||
error_message = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Error message",
|
||||
)
|
||||
error_stack = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Error stack trace",
|
||||
)
|
||||
error_code = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Application error code",
|
||||
)
|
||||
error_origin = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Where the error originated",
|
||||
)
|
||||
component_stack = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="React component stack trace",
|
||||
)
|
||||
severity = models.CharField(
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
default=Severity.INFO,
|
||||
db_index=True,
|
||||
help_text="Error severity level",
|
||||
)
|
||||
|
||||
# Resolution
|
||||
is_resolved = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether this error has been resolved",
|
||||
)
|
||||
resolved_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="When the error was resolved",
|
||||
)
|
||||
resolved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="resolved_request_metadata",
|
||||
help_text="User who resolved this error",
|
||||
)
|
||||
resolution_notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Notes about resolution",
|
||||
)
|
||||
|
||||
# Retry Information
|
||||
retry_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of retry attempts",
|
||||
)
|
||||
retry_attempts = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Total retry attempts made",
|
||||
)
|
||||
|
||||
# User Context
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="request_metadata",
|
||||
help_text="User who made the request",
|
||||
)
|
||||
user_agent = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="User agent string",
|
||||
)
|
||||
ip_address_hash = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Hashed IP address",
|
||||
)
|
||||
client_version = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Client application version",
|
||||
)
|
||||
timezone = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="User timezone",
|
||||
)
|
||||
referrer = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="HTTP referrer",
|
||||
)
|
||||
|
||||
# Entity Context
|
||||
entity_type = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Type of entity affected",
|
||||
)
|
||||
entity_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="ID of entity affected",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When this record was created",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Request Metadata"
|
||||
verbose_name_plural = "Request Metadata"
|
||||
indexes = [
|
||||
models.Index(fields=["error_type", "created_at"]),
|
||||
models.Index(fields=["severity", "created_at"]),
|
||||
models.Index(fields=["is_resolved", "created_at"]),
|
||||
models.Index(fields=["user", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.request_id} - {self.endpoint or 'unknown'}"
|
||||
|
||||
|
||||
class RequestBreadcrumb(models.Model):
|
||||
"""
|
||||
Breadcrumb trail for request tracing.
|
||||
|
||||
Stores individual breadcrumb events that occurred during a request,
|
||||
useful for debugging and understanding request flow.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
request_metadata = models.ForeignKey(
|
||||
RequestMetadata,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="request_breadcrumbs",
|
||||
help_text="Parent request",
|
||||
)
|
||||
timestamp = models.DateTimeField(
|
||||
help_text="When this breadcrumb occurred",
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Breadcrumb category (e.g., 'http', 'navigation', 'console')",
|
||||
)
|
||||
message = models.TextField(
|
||||
help_text="Breadcrumb message",
|
||||
)
|
||||
level = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Log level (debug, info, warning, error)",
|
||||
)
|
||||
sequence_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Order within the request",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sequence_order", "timestamp"]
|
||||
verbose_name = "Request Breadcrumb"
|
||||
verbose_name_plural = "Request Breadcrumbs"
|
||||
indexes = [
|
||||
models.Index(fields=["request_metadata", "sequence_order"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.category}] {self.message[:50]}"
|
||||
|
||||
|
||||
class ApprovalTransactionMetric(models.Model):
|
||||
"""
|
||||
Metrics for content approval transactions.
|
||||
|
||||
Tracks performance and success/failure of moderation approval
|
||||
operations for analytics and debugging.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
# References
|
||||
submission_id = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="ID of the content submission",
|
||||
)
|
||||
moderator_id = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="ID of the moderator who processed the submission",
|
||||
)
|
||||
submitter_id = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="ID of the user who submitted the content",
|
||||
)
|
||||
request_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Correlation request ID",
|
||||
)
|
||||
|
||||
# Metrics
|
||||
success = models.BooleanField(
|
||||
db_index=True,
|
||||
help_text="Whether the approval was successful",
|
||||
)
|
||||
duration_ms = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Processing duration in milliseconds",
|
||||
)
|
||||
items_count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Number of items processed",
|
||||
)
|
||||
rollback_triggered = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether a rollback was triggered",
|
||||
)
|
||||
|
||||
# Error Information
|
||||
error_code = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Error code if failed",
|
||||
)
|
||||
error_message = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Error message if failed",
|
||||
)
|
||||
error_details = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Detailed error information",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When this metric was recorded",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Approval Transaction Metric"
|
||||
verbose_name_plural = "Approval Transaction Metrics"
|
||||
indexes = [
|
||||
models.Index(fields=["success", "created_at"]),
|
||||
models.Index(fields=["moderator_id", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
status = "✓" if self.success else "✗"
|
||||
return f"{status} Submission {self.submission_id[:8]} by {self.moderator_id[:8]}"
|
||||
|
||||
|
||||
@@ -58,8 +58,22 @@ def with_callbacks(
|
||||
source_state = getattr(instance, field_name, None)
|
||||
|
||||
# Get target state from the transition decorator
|
||||
# The @transition decorator sets _django_fsm_target
|
||||
target_state = getattr(func, "_django_fsm", {}).get("target", None)
|
||||
# The @transition decorator sets _django_fsm attribute (may be dict or FSMMeta object)
|
||||
fsm_meta = getattr(func, "_django_fsm", None)
|
||||
target_state = None
|
||||
if fsm_meta is not None:
|
||||
if isinstance(fsm_meta, dict):
|
||||
target_state = fsm_meta.get("target", None)
|
||||
elif hasattr(fsm_meta, "target"):
|
||||
target_state = fsm_meta.target
|
||||
elif hasattr(fsm_meta, "transitions"):
|
||||
# FSMMeta object - try to get target from first transition
|
||||
try:
|
||||
transitions = list(fsm_meta.transitions.values())
|
||||
if transitions:
|
||||
target_state = transitions[0].target if hasattr(transitions[0], 'target') else None
|
||||
except (AttributeError, TypeError, StopIteration):
|
||||
pass
|
||||
|
||||
# If we can't determine the target from decorator metadata,
|
||||
# we'll capture it after the transition
|
||||
@@ -284,7 +298,7 @@ class TransitionMethodFactory:
|
||||
def create_approve_method(
|
||||
source: str,
|
||||
target: str,
|
||||
field_name: str = "status",
|
||||
field=None,
|
||||
permission_guard: Callable | None = None,
|
||||
enable_callbacks: bool = True,
|
||||
emit_signals: bool = True,
|
||||
@@ -295,7 +309,7 @@ class TransitionMethodFactory:
|
||||
Args:
|
||||
source: Source state value(s)
|
||||
target: Target state value
|
||||
field_name: Name of the FSM field
|
||||
field: FSM field object (required for django-fsm 3.x)
|
||||
permission_guard: Optional permission guard
|
||||
enable_callbacks: Whether to wrap with callback execution
|
||||
emit_signals: Whether to emit Django signals
|
||||
@@ -303,13 +317,15 @@ class TransitionMethodFactory:
|
||||
Returns:
|
||||
Approval transition method
|
||||
"""
|
||||
# Get field name for callback wrapper
|
||||
field_name = field.name if hasattr(field, 'name') else 'status'
|
||||
|
||||
@fsm_log_by
|
||||
@transition(
|
||||
field=field_name,
|
||||
field=field,
|
||||
source=source,
|
||||
target=target,
|
||||
conditions=[permission_guard] if permission_guard else [],
|
||||
permission=permission_guard,
|
||||
)
|
||||
def approve(instance, user=None, comment: str = "", **kwargs):
|
||||
"""Approve and transition to approved state."""
|
||||
@@ -335,7 +351,7 @@ class TransitionMethodFactory:
|
||||
def create_reject_method(
|
||||
source: str,
|
||||
target: str,
|
||||
field_name: str = "status",
|
||||
field=None,
|
||||
permission_guard: Callable | None = None,
|
||||
enable_callbacks: bool = True,
|
||||
emit_signals: bool = True,
|
||||
@@ -346,7 +362,7 @@ class TransitionMethodFactory:
|
||||
Args:
|
||||
source: Source state value(s)
|
||||
target: Target state value
|
||||
field_name: Name of the FSM field
|
||||
field: FSM field object (required for django-fsm 3.x)
|
||||
permission_guard: Optional permission guard
|
||||
enable_callbacks: Whether to wrap with callback execution
|
||||
emit_signals: Whether to emit Django signals
|
||||
@@ -354,13 +370,15 @@ class TransitionMethodFactory:
|
||||
Returns:
|
||||
Rejection transition method
|
||||
"""
|
||||
# Get field name for callback wrapper
|
||||
field_name = field.name if hasattr(field, 'name') else 'status'
|
||||
|
||||
@fsm_log_by
|
||||
@transition(
|
||||
field=field_name,
|
||||
field=field,
|
||||
source=source,
|
||||
target=target,
|
||||
conditions=[permission_guard] if permission_guard else [],
|
||||
permission=permission_guard,
|
||||
)
|
||||
def reject(instance, user=None, reason: str = "", **kwargs):
|
||||
"""Reject and transition to rejected state."""
|
||||
@@ -386,7 +404,7 @@ class TransitionMethodFactory:
|
||||
def create_escalate_method(
|
||||
source: str,
|
||||
target: str,
|
||||
field_name: str = "status",
|
||||
field=None,
|
||||
permission_guard: Callable | None = None,
|
||||
enable_callbacks: bool = True,
|
||||
emit_signals: bool = True,
|
||||
@@ -397,7 +415,7 @@ class TransitionMethodFactory:
|
||||
Args:
|
||||
source: Source state value(s)
|
||||
target: Target state value
|
||||
field_name: Name of the FSM field
|
||||
field: FSM field object (required for django-fsm 3.x)
|
||||
permission_guard: Optional permission guard
|
||||
enable_callbacks: Whether to wrap with callback execution
|
||||
emit_signals: Whether to emit Django signals
|
||||
@@ -405,13 +423,15 @@ class TransitionMethodFactory:
|
||||
Returns:
|
||||
Escalation transition method
|
||||
"""
|
||||
# Get field name for callback wrapper
|
||||
field_name = field.name if hasattr(field, 'name') else 'status'
|
||||
|
||||
@fsm_log_by
|
||||
@transition(
|
||||
field=field_name,
|
||||
field=field,
|
||||
source=source,
|
||||
target=target,
|
||||
conditions=[permission_guard] if permission_guard else [],
|
||||
permission=permission_guard,
|
||||
)
|
||||
def escalate(instance, user=None, reason: str = "", **kwargs):
|
||||
"""Escalate to higher authority."""
|
||||
@@ -438,7 +458,7 @@ class TransitionMethodFactory:
|
||||
method_name: str,
|
||||
source: str,
|
||||
target: str,
|
||||
field_name: str = "status",
|
||||
field=None,
|
||||
permission_guard: Callable | None = None,
|
||||
docstring: str | None = None,
|
||||
enable_callbacks: bool = True,
|
||||
@@ -451,7 +471,7 @@ class TransitionMethodFactory:
|
||||
method_name: Name for the method
|
||||
source: Source state value(s)
|
||||
target: Target state value
|
||||
field_name: Name of the FSM field
|
||||
field: FSM field object (required for django-fsm 3.x)
|
||||
permission_guard: Optional permission guard
|
||||
docstring: Optional docstring for the method
|
||||
enable_callbacks: Whether to wrap with callback execution
|
||||
@@ -460,13 +480,15 @@ class TransitionMethodFactory:
|
||||
Returns:
|
||||
Generic transition method
|
||||
"""
|
||||
# Get field name for callback wrapper
|
||||
field_name = field.name if hasattr(field, 'name') else 'status'
|
||||
|
||||
@fsm_log_by
|
||||
@transition(
|
||||
field=field_name,
|
||||
field=field,
|
||||
source=source,
|
||||
target=target,
|
||||
conditions=[permission_guard] if permission_guard else [],
|
||||
permission=permission_guard,
|
||||
)
|
||||
def generic_transition(instance, user=None, **kwargs):
|
||||
"""Execute state transition."""
|
||||
|
||||
@@ -71,69 +71,79 @@ def generate_transition_methods_for_model(
|
||||
choice_group: Choice group name
|
||||
domain: Domain namespace
|
||||
"""
|
||||
# Get the actual field from the model class - django-fsm 3.x requires
|
||||
# the field object, not just the string name, when creating methods dynamically
|
||||
field = model_class._meta.get_field(field_name)
|
||||
|
||||
builder = StateTransitionBuilder(choice_group, domain)
|
||||
transition_graph = builder.build_transition_graph()
|
||||
factory = TransitionMethodFactory()
|
||||
|
||||
# Group transitions by target to avoid overwriting methods
|
||||
# {target: [source1, source2, ...]}
|
||||
target_to_sources: dict[str, list[str]] = {}
|
||||
for source, targets in transition_graph.items():
|
||||
source_metadata = builder.get_choice_metadata(source)
|
||||
|
||||
for target in targets:
|
||||
# Use shared method name determination
|
||||
method_name = determine_method_name_for_transition(source, target)
|
||||
if target not in target_to_sources:
|
||||
target_to_sources[target] = []
|
||||
target_to_sources[target].append(source)
|
||||
|
||||
# Get target metadata for combined guards
|
||||
target_metadata = builder.get_choice_metadata(target)
|
||||
# Create one transition method per target, handling all valid sources
|
||||
for target, sources in target_to_sources.items():
|
||||
# Use shared method name determination (all sources go to same target = same method)
|
||||
method_name = determine_method_name_for_transition(sources[0], target)
|
||||
|
||||
# Get target metadata for guards
|
||||
target_metadata = builder.get_choice_metadata(target)
|
||||
|
||||
# For permission guard, use target metadata only (all sources share the same permission)
|
||||
# Source-specific guards would need to be checked via conditions, but for FSM 3.x
|
||||
# we use permission which gets called with (instance, user)
|
||||
target_guards = extract_guards_from_metadata(target_metadata)
|
||||
|
||||
# Create combined guard if we have multiple guards
|
||||
combined_guard: Callable | None = None
|
||||
if len(target_guards) == 1:
|
||||
combined_guard = target_guards[0]
|
||||
elif len(target_guards) > 1:
|
||||
combined_guard = CompositeGuard(guards=target_guards, operator="AND")
|
||||
|
||||
# Extract guards from both source and target metadata
|
||||
# This ensures metadata flags like requires_assignment, zero_tolerance,
|
||||
# required_permissions, and escalation_level are enforced
|
||||
guards = extract_guards_from_metadata(source_metadata)
|
||||
target_guards = extract_guards_from_metadata(target_metadata)
|
||||
# Use list of sources for transitions with multiple valid source states
|
||||
source_value = sources if len(sources) > 1 else sources[0]
|
||||
|
||||
# Combine all guards
|
||||
all_guards = guards + target_guards
|
||||
# Create appropriate transition method - pass actual field object
|
||||
if "approve" in method_name or "accept" in method_name:
|
||||
method = factory.create_approve_method(
|
||||
source=source_value,
|
||||
target=target,
|
||||
field=field,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
elif "reject" in method_name or "deny" in method_name:
|
||||
method = factory.create_reject_method(
|
||||
source=source_value,
|
||||
target=target,
|
||||
field=field,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
elif "escalate" in method_name:
|
||||
method = factory.create_escalate_method(
|
||||
source=source_value,
|
||||
target=target,
|
||||
field=field,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
else:
|
||||
method = factory.create_generic_transition_method(
|
||||
method_name=method_name,
|
||||
source=source_value,
|
||||
target=target,
|
||||
field=field,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
|
||||
# Create combined guard if we have multiple guards
|
||||
combined_guard: Callable | None = None
|
||||
if len(all_guards) == 1:
|
||||
combined_guard = all_guards[0]
|
||||
elif len(all_guards) > 1:
|
||||
combined_guard = CompositeGuard(guards=all_guards, operator="AND")
|
||||
|
||||
# Create appropriate transition method
|
||||
if "approve" in method_name or "accept" in method_name:
|
||||
method = factory.create_approve_method(
|
||||
source=source,
|
||||
target=target,
|
||||
field_name=field_name,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
elif "reject" in method_name or "deny" in method_name:
|
||||
method = factory.create_reject_method(
|
||||
source=source,
|
||||
target=target,
|
||||
field_name=field_name,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
elif "escalate" in method_name:
|
||||
method = factory.create_escalate_method(
|
||||
source=source,
|
||||
target=target,
|
||||
field_name=field_name,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
else:
|
||||
method = factory.create_generic_transition_method(
|
||||
method_name=method_name,
|
||||
source=source,
|
||||
target=target,
|
||||
field_name=field_name,
|
||||
permission_guard=combined_guard,
|
||||
)
|
||||
|
||||
# Attach method to model class
|
||||
setattr(model_class, method_name, method)
|
||||
# Attach method to model class
|
||||
setattr(model_class, method_name, method)
|
||||
|
||||
|
||||
class StateMachineModelMixin:
|
||||
|
||||
Reference in New Issue
Block a user