mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-04 22:55:18 -05:00
feat: Implement centralized error capture and handling with new middleware, services, and API endpoints, and add new admin and statistics API views.
This commit is contained in:
@@ -43,6 +43,7 @@ from apps.api.v1.serializers.accounts import (
|
||||
UserPreferencesSerializer,
|
||||
UserStatisticsSerializer,
|
||||
)
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.lists.models import UserList
|
||||
|
||||
# Set up logging
|
||||
@@ -198,16 +199,13 @@ def delete_user_preserve_submissions(request, user_id):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Log the error for debugging
|
||||
logger.error(
|
||||
f"Error deleting user {user_id} by admin {request.user.username}: {str(e)}",
|
||||
extra={
|
||||
"admin_user": request.user.username,
|
||||
"target_user_id": user_id,
|
||||
"detail": str(e),
|
||||
"action": "user_deletion_error",
|
||||
},
|
||||
exc_info=True,
|
||||
# Capture error to dashboard
|
||||
capture_and_log(
|
||||
e,
|
||||
f'Delete user {user_id} by admin {request.user.username}',
|
||||
source='api',
|
||||
request=request,
|
||||
severity='high',
|
||||
)
|
||||
|
||||
return Response(
|
||||
@@ -333,7 +331,7 @@ def save_avatar_image(request):
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
capture_and_log(api_error, 'Fetch image from Cloudflare API', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -357,7 +355,7 @@ def save_avatar_image(request):
|
||||
service.delete_image(old_avatar)
|
||||
logger.info(f"Successfully deleted old avatar from Cloudflare: {old_avatar.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete old avatar from Cloudflare: {str(e)}")
|
||||
capture_and_log(e, 'Delete old avatar from Cloudflare', source='api', request=request, severity='low')
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
old_avatar.delete()
|
||||
@@ -390,7 +388,7 @@ def save_avatar_image(request):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving avatar image: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Save avatar image', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to save avatar: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -441,7 +439,7 @@ def delete_avatar(request):
|
||||
service.delete_image(avatar_to_delete)
|
||||
logger.info(f"Successfully deleted avatar from Cloudflare: {avatar_to_delete.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete avatar from Cloudflare: {str(e)}")
|
||||
capture_and_log(e, 'Delete avatar from Cloudflare', source='api', request=request, severity='low')
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
avatar_to_delete.delete()
|
||||
@@ -550,16 +548,13 @@ def request_account_deletion(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error for debugging
|
||||
logger.error(
|
||||
f"Error creating deletion request for user {request.user.username} (ID: {request.user.user_id}): {str(e)}",
|
||||
extra={
|
||||
"user": request.user.username,
|
||||
"user_id": request.user.user_id,
|
||||
"detail": str(e),
|
||||
"action": "self_deletion_error",
|
||||
},
|
||||
exc_info=True,
|
||||
# Capture error to dashboard
|
||||
capture_and_log(
|
||||
e,
|
||||
f'Create deletion request for user {request.user.username}',
|
||||
source='api',
|
||||
request=request,
|
||||
severity='high',
|
||||
)
|
||||
|
||||
return Response(
|
||||
@@ -1547,7 +1542,7 @@ def export_user_data(request):
|
||||
export_data = UserExportService.export_user_data(request.user)
|
||||
return Response(export_data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
|
||||
capture_and_log(e, 'Export user data', source='api', request=request)
|
||||
return Response({"detail": "Failed to generate data export"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from drf_spectacular.utils import (
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import PasswordReset
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
@@ -64,6 +65,7 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
role = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
@@ -74,9 +76,12 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"display_name",
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"role",
|
||||
"avatar_url",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
read_only_fields = ["id", "date_joined", "is_active", "is_staff", "is_superuser", "role"]
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
@@ -89,6 +94,15 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
return obj.profile.get_avatar_url()
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_role(self, obj) -> str:
|
||||
"""Compute effective role based on permissions."""
|
||||
if obj.is_superuser:
|
||||
return "SUPERUSER"
|
||||
if obj.is_staff:
|
||||
return "ADMIN"
|
||||
return "USER"
|
||||
|
||||
|
||||
class LoginInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for user login."""
|
||||
@@ -235,8 +249,8 @@ The ThrillWiki Team
|
||||
logger.info(f"Verification email sent successfully to {user.email}. No email ID in response.")
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but don't fail registration
|
||||
logger.error(f"Failed to send verification email to {user.email}: {e}")
|
||||
# Capture error but don't fail registration
|
||||
capture_and_log(e, f'Send verification email to {user.email}', source='api', severity='low')
|
||||
|
||||
|
||||
class SignupOutputSerializer(serializers.Serializer):
|
||||
|
||||
@@ -21,6 +21,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.accounts.services.social_provider_service import SocialProviderService
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
# Import directly from the auth serializers.py file (not the serializers package)
|
||||
from .serializers import (
|
||||
@@ -188,7 +189,7 @@ class LoginAPIView(APIView):
|
||||
"access": str(access_token),
|
||||
"refresh": str(refresh),
|
||||
"user": user,
|
||||
"detail": "Login successful",
|
||||
"message": "Login successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
@@ -820,10 +821,7 @@ The ThrillWiki Team
|
||||
return Response({"detail": "Verification email sent successfully", "success": True})
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send verification email to {user.email}: {e}")
|
||||
capture_and_log(e, 'Send verification email', source='api')
|
||||
|
||||
return Response(
|
||||
{"detail": "Failed to send verification email"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
@@ -7,6 +7,7 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.core.utils.cloudflare import get_direct_upload_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,11 +22,11 @@ class GenerateUploadURLView(APIView):
|
||||
result = get_direct_upload_url(user_id=str(request.user.id))
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
except ImproperlyConfigured as e:
|
||||
logger.error(f"Configuration Error: {e}")
|
||||
capture_and_log(e, 'Generate upload URL - configuration error', source='api')
|
||||
return Response({"detail": "Server configuration error."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Cloudflare API Error: {e}")
|
||||
capture_and_log(e, 'Generate upload URL - Cloudflare API error', source='api')
|
||||
return Response({"detail": "Failed to generate upload URL."}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
except Exception:
|
||||
logger.exception("Unexpected error generating upload URL")
|
||||
except Exception as e:
|
||||
capture_and_log(e, 'Generate upload URL - unexpected error', source='api')
|
||||
return Response({"detail": "An unexpected error occurred."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -38,6 +38,7 @@ from ..serializers.maps import (
|
||||
MapLocationsResponseSerializer,
|
||||
MapSearchResponseSerializer,
|
||||
)
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -332,7 +333,7 @@ class MapLocationsAPIView(APIView):
|
||||
return Response(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Get map locations', source='api')
|
||||
return Response(
|
||||
{"status": "error", "detail": "Failed to retrieve map locations"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -489,7 +490,7 @@ class MapLocationDetailAPIView(APIView):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Get map location detail', source='api')
|
||||
return Response(
|
||||
{"status": "error", "detail": "Failed to retrieve location details"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -674,7 +675,7 @@ class MapSearchAPIView(APIView):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Map search', source='api')
|
||||
return Response(
|
||||
{"status": "error", "detail": "Search failed due to internal error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -851,7 +852,7 @@ class MapBoundsAPIView(APIView):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Get map bounds', source='api')
|
||||
return Response(
|
||||
{"status": "error", "detail": "Failed to retrieve locations within bounds"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -904,7 +905,7 @@ class MapStatsAPIView(APIView):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Get map stats', source='api')
|
||||
return Response(
|
||||
{"status": "error", "detail": "Failed to retrieve map statistics"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -956,7 +957,7 @@ class MapCacheAPIView(APIView):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Clear map cache', source='api')
|
||||
return Response(
|
||||
{"status": "error", "detail": "Failed to clear map cache"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -984,7 +985,7 @@ class MapCacheAPIView(APIView):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Invalidate map cache', source='api')
|
||||
return Response(
|
||||
{"status": "error", "detail": "Failed to invalidate cache"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -14,6 +14,8 @@ from django.http import JsonResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -261,7 +263,13 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
}
|
||||
|
||||
if severity == "ERROR":
|
||||
logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
|
||||
# Contract violations are development issues - capture for visibility
|
||||
capture_and_log(
|
||||
ValueError(message),
|
||||
f'Contract violation [{violation_type}] on {path}',
|
||||
source='middleware',
|
||||
severity='medium',
|
||||
)
|
||||
else:
|
||||
logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from apps.api.v1.rides.serializers import (
|
||||
RidePhotoStatsOutputSerializer,
|
||||
RidePhotoUpdateInputSerializer,
|
||||
)
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from apps.rides.models.media import RidePhoto
|
||||
@@ -184,7 +185,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride photo: {e}")
|
||||
capture_and_log(e, 'Create ride photo', source='api', request=self.request)
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}") from None
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -203,14 +204,14 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Set primary photo', source='api', request=self.request)
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
|
||||
|
||||
try:
|
||||
serializer.save()
|
||||
logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride photo: {e}")
|
||||
capture_and_log(e, 'Update ride photo', source='api', request=self.request)
|
||||
raise ValidationError(f"Failed to update photo: {str(e)}") from None
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -229,14 +230,14 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
service.delete_image(instance.image)
|
||||
logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete ride photo from Cloudflare: {str(e)}")
|
||||
capture_and_log(e, 'Delete ride photo from Cloudflare', source='api', request=self.request, severity='low')
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
RideMediaService.delete_photo(instance, deleted_by=self.request.user)
|
||||
|
||||
logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride photo: {e}")
|
||||
capture_and_log(e, 'Delete ride photo', source='api', request=self.request)
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
|
||||
|
||||
@extend_schema(
|
||||
@@ -281,7 +282,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Set primary photo', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -339,7 +340,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
capture_and_log(e, 'Bulk photo approval', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -387,7 +388,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride photo stats: {e}")
|
||||
capture_and_log(e, 'Get ride photo stats', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -491,7 +492,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
capture_and_log(api_error, 'Fetch image from Cloudflare API', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -514,14 +515,14 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=ride, photo=photo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Set primary photo for saved image', source='api', request=request, severity='low')
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving ride photo: {e}")
|
||||
capture_and_log(e, 'Save ride photo', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -31,6 +31,7 @@ from apps.api.v1.serializers.ride_reviews import (
|
||||
RideReviewStatsOutputSerializer,
|
||||
RideReviewUpdateInputSerializer,
|
||||
)
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
from apps.rides.models.reviews import RideReview
|
||||
@@ -181,7 +182,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride review: {e}")
|
||||
capture_and_log(e, 'Create ride review', source='api', request=self.request)
|
||||
raise ValidationError(f"Failed to create review: {str(e)}") from None
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -196,7 +197,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
serializer.save()
|
||||
logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride review: {e}")
|
||||
capture_and_log(e, 'Update ride review', source='api', request=self.request)
|
||||
raise ValidationError(f"Failed to update review: {str(e)}") from None
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -209,7 +210,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
logger.info(f"Deleting ride review {instance.id} by user {self.request.user.username}")
|
||||
instance.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride review: {e}")
|
||||
capture_and_log(e, 'Delete ride review', source='api', request=self.request)
|
||||
raise ValidationError(f"Failed to delete review: {str(e)}") from None
|
||||
|
||||
@extend_schema(
|
||||
@@ -283,7 +284,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride review stats: {e}")
|
||||
capture_and_log(e, 'Get ride review stats', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to get review statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -360,7 +361,7 @@ class RideReviewViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk review moderation: {e}")
|
||||
capture_and_log(e, 'Bulk review moderation', source='api', request=request)
|
||||
return Response(
|
||||
{"detail": f"Failed to moderate reviews: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -32,6 +32,7 @@ from apps.core.exceptions import (
|
||||
ServiceError,
|
||||
ValidationException,
|
||||
)
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.core.utils.error_handling import ErrorHandler
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
from apps.parks.services import ParkMediaService
|
||||
@@ -188,7 +189,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
logger.warning(f"Validation error creating park photo: {e}")
|
||||
raise ValidationError(str(e)) from None
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error creating park photo: {e}")
|
||||
capture_and_log(e, 'Create park photo', source='api')
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}") from None
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -210,7 +211,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
logger.warning(f"Validation error setting primary photo: {e}")
|
||||
raise ValidationError(str(e)) from None
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Set primary park photo', source='api')
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -232,13 +233,13 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
except ImportError:
|
||||
logger.warning("CloudflareImagesService not available")
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error deleting from Cloudflare: {str(e)}")
|
||||
capture_and_log(e, 'Delete park photo from Cloudflare', source='api', severity='low')
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
try:
|
||||
ParkMediaService().delete_photo(instance.id, deleted_by=cast(UserModel, self.request.user))
|
||||
except ServiceError as e:
|
||||
logger.error(f"Service error deleting park photo: {e}")
|
||||
capture_and_log(e, 'Delete park photo', source='api')
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
|
||||
|
||||
@extend_schema(
|
||||
@@ -539,14 +540,14 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
try:
|
||||
ParkMediaService().set_primary_photo(park_id=park.id, photo_id=photo.id)
|
||||
except ServiceError as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Set primary park photo for saved image', source='api', severity='low')
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except ImportError:
|
||||
logger.error("CloudflareImagesService not available")
|
||||
except ImportError as e:
|
||||
capture_and_log(e, 'Cloudflare service import', source='api')
|
||||
return ErrorHandler.handle_api_error(
|
||||
ServiceError("Cloudflare Images service not available"),
|
||||
user_message="Image upload service not available",
|
||||
|
||||
@@ -31,6 +31,7 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.core.utils import capture_and_log, capture_errors
|
||||
from apps.rides.models import Ride, RidePhoto
|
||||
from apps.rides.services.media_service import RideMediaService
|
||||
|
||||
@@ -39,6 +40,7 @@ UserModel = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride photos",
|
||||
@@ -166,7 +168,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
serializer.instance = photo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride photo: {e}")
|
||||
capture_and_log(e, 'Creating ride photo', source='api', severity='high', entity_type='RidePhoto')
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}") from None
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -185,7 +187,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Setting primary photo', source='api', severity='medium', entity_type='RidePhoto')
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -204,12 +206,12 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
service.delete_image(instance.image)
|
||||
logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete ride photo from Cloudflare: {str(e)}")
|
||||
capture_and_log(e, 'Delete ride photo from Cloudflare', source='api', severity='low')
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
RideMediaService.delete_photo(instance, deleted_by=self.request.user) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride photo: {e}")
|
||||
capture_and_log(e, 'Deleting ride photo', source='api', severity='high', entity_type='RidePhoto')
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
|
||||
|
||||
@extend_schema(
|
||||
@@ -254,7 +256,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Set primary photo', source='api', severity='medium', entity_type='RidePhoto')
|
||||
return Response(
|
||||
{"detail": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -308,7 +310,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
capture_and_log(e, 'Bulk photo approval', source='api', severity='medium', entity_type='RidePhoto')
|
||||
return Response(
|
||||
{"detail": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -356,7 +358,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride photo stats: {e}")
|
||||
capture_and_log(e, 'Getting ride photo stats', source='api', severity='low', entity_type='RidePhoto')
|
||||
return Response(
|
||||
{"detail": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -392,7 +394,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'Set primary photo', source='api')
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
@@ -486,7 +488,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
capture_and_log(api_error, 'Fetch image from Cloudflare API', source='api')
|
||||
return Response(
|
||||
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -509,14 +511,14 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=ride, photo=photo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
capture_and_log(e, 'Set primary photo for saved image', source='api', severity='low')
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving ride photo: {e}")
|
||||
capture_and_log(e, 'Save ride photo', source='api')
|
||||
return Response(
|
||||
{"detail": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -46,6 +46,7 @@ from apps.api.v1.serializers.rides import (
|
||||
RideUpdateInputSerializer,
|
||||
)
|
||||
from apps.core.decorators.cache_decorators import cache_api_response
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.rides.services.hybrid_loader import SmartRideLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -2059,7 +2060,7 @@ class HybridRideAPIView(APIView):
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in HybridRideAPIView: {e}")
|
||||
capture_and_log(e, 'Get hybrid rides', source='api')
|
||||
return Response(
|
||||
{"detail": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -2358,7 +2359,7 @@ class RideFilterMetadataAPIView(APIView):
|
||||
return Response(metadata, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in RideFilterMetadataAPIView: {e}")
|
||||
capture_and_log(e, 'Get ride filter metadata', source='api')
|
||||
return Response(
|
||||
{"detail": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -10,10 +10,13 @@ from rest_framework.routers import DefaultRouter
|
||||
|
||||
# Import other views from the views directory
|
||||
from .views import (
|
||||
CoasterStatisticsAPIView,
|
||||
DataCompletenessAPIView,
|
||||
HealthCheckAPIView,
|
||||
NewContentAPIView,
|
||||
PerformanceMetricsAPIView,
|
||||
SimpleHealthAPIView,
|
||||
TechnicalSpecificationsAPIView,
|
||||
# Trending system views
|
||||
TrendingAPIView,
|
||||
TriggerTrendingCalculationAPIView,
|
||||
@@ -71,6 +74,23 @@ urlpatterns = [
|
||||
TriggerRankingCalculationView.as_view(),
|
||||
name="trigger-ranking-calculation",
|
||||
),
|
||||
# Admin endpoints
|
||||
path(
|
||||
"admin/data-completeness/",
|
||||
DataCompletenessAPIView.as_view(),
|
||||
name="data-completeness",
|
||||
),
|
||||
# Ride search advanced endpoints (for useAdvancedRideSearch composable)
|
||||
path(
|
||||
"rides/technical-specifications/",
|
||||
TechnicalSpecificationsAPIView.as_view(),
|
||||
name="technical-specifications",
|
||||
),
|
||||
path(
|
||||
"rides/coaster-statistics/",
|
||||
CoasterStatisticsAPIView.as_view(),
|
||||
name="coaster-statistics",
|
||||
),
|
||||
# Domain-specific API endpoints
|
||||
path("parks/", include("apps.api.v1.parks.urls")),
|
||||
path("rides/", include("apps.api.v1.rides.urls")),
|
||||
@@ -86,9 +106,11 @@ urlpatterns = [
|
||||
path("media/", include("apps.media.urls")),
|
||||
path("blog/", include("apps.blog.urls")),
|
||||
path("support/", include("apps.support.urls")),
|
||||
path("errors/", include("apps.core.urls.errors")),
|
||||
path("images/", include("apps.api.v1.images.urls")),
|
||||
# Cloudflare Images Toolkit API endpoints
|
||||
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
|
||||
# Include router URLs (for rankings and any other router-registered endpoints)
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -5,9 +5,15 @@ This package contains all API view classes organized by functionality:
|
||||
- auth.py: Authentication and user management views
|
||||
- health.py: Health check and monitoring views
|
||||
- trending.py: Trending and new content discovery views
|
||||
- admin.py: Admin-only data completeness and system management views
|
||||
"""
|
||||
|
||||
# Import all view classes for easy access
|
||||
from .admin import (
|
||||
CoasterStatisticsAPIView,
|
||||
DataCompletenessAPIView,
|
||||
TechnicalSpecificationsAPIView,
|
||||
)
|
||||
from .auth import (
|
||||
AuthStatusAPIView,
|
||||
CurrentUserAPIView,
|
||||
@@ -31,6 +37,10 @@ from .trending import (
|
||||
|
||||
# Export all views for import convenience
|
||||
__all__ = [
|
||||
# Admin views
|
||||
"DataCompletenessAPIView",
|
||||
"TechnicalSpecificationsAPIView",
|
||||
"CoasterStatisticsAPIView",
|
||||
# Authentication views
|
||||
"LoginAPIView",
|
||||
"SignupAPIView",
|
||||
@@ -49,3 +59,4 @@ __all__ = [
|
||||
"NewContentAPIView",
|
||||
"TriggerTrendingCalculationAPIView",
|
||||
]
|
||||
|
||||
|
||||
382
backend/apps/api/v1/views/admin.py
Normal file
382
backend/apps/api/v1/views/admin.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Admin API views for data completeness and system management.
|
||||
|
||||
These views provide admin-only endpoints for analyzing data quality,
|
||||
entity completeness, and system health.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.core.decorators.cache_decorators import cache_api_response
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Company, Ride
|
||||
|
||||
# Define field importance categories per entity type
|
||||
PARK_FIELDS = {
|
||||
"critical": ["name", "slug", "status"],
|
||||
"important": ["short_description", "park_type", "opening_date"],
|
||||
"valuable": ["banner_image_url", "card_image_url", "website_url", "phone", "email"],
|
||||
"supplementary": ["closing_date", "size_acres", "operating_season"],
|
||||
}
|
||||
|
||||
RIDE_FIELDS = {
|
||||
"critical": ["name", "slug", "status", "park_id"],
|
||||
"important": ["category", "opening_date", "manufacturer_id"],
|
||||
"valuable": [
|
||||
"max_speed_kmh",
|
||||
"height_meters",
|
||||
"track_length_meters",
|
||||
"inversions_count",
|
||||
"banner_image_url",
|
||||
"card_image_url",
|
||||
],
|
||||
"supplementary": [
|
||||
"min_height_cm",
|
||||
"max_height_cm",
|
||||
"duration_seconds",
|
||||
"capacity_per_hour",
|
||||
"designer_id",
|
||||
],
|
||||
}
|
||||
|
||||
COMPANY_FIELDS = {
|
||||
"critical": ["name", "slug", "company_type"],
|
||||
"important": ["description", "headquarters_location"],
|
||||
"valuable": ["logo_url", "website_url", "founded_year"],
|
||||
"supplementary": ["banner_image_url", "card_image_url"],
|
||||
}
|
||||
|
||||
|
||||
def calculate_completeness_score(obj, fields_config: dict) -> tuple[int, dict]:
|
||||
"""
|
||||
Calculate completeness score for an entity based on field importance.
|
||||
|
||||
Returns:
|
||||
Tuple of (score, missing_fields_dict)
|
||||
"""
|
||||
weights = {"critical": 40, "important": 30, "valuable": 20, "supplementary": 10}
|
||||
max_score = 100
|
||||
score = 0
|
||||
missing_fields = {}
|
||||
|
||||
for category, fields in fields_config.items():
|
||||
category_weight = weights[category]
|
||||
field_weight = category_weight / len(fields) if fields else 0
|
||||
missing_in_category = []
|
||||
|
||||
for field in fields:
|
||||
value = getattr(obj, field, None)
|
||||
if value is not None and value != "" and value != []:
|
||||
score += field_weight
|
||||
else:
|
||||
missing_in_category.append(field)
|
||||
|
||||
if missing_in_category:
|
||||
missing_fields[category] = missing_in_category
|
||||
|
||||
return min(round(score), max_score), missing_fields
|
||||
|
||||
|
||||
class DataCompletenessAPIView(APIView):
|
||||
"""
|
||||
Admin endpoint for analyzing data completeness across all entity types.
|
||||
|
||||
Returns completeness scores and missing field analysis for parks, rides,
|
||||
companies, and ride models.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Admin"],
|
||||
summary="Get data completeness analysis",
|
||||
description="Analyze data completeness across all entity types with missing field breakdown",
|
||||
)
|
||||
@cache_api_response(timeout=300, key_prefix="data_completeness")
|
||||
def get(self, request):
|
||||
"""
|
||||
Get data completeness analysis.
|
||||
|
||||
Query parameters:
|
||||
- entity_type: Filter by entity type (park, ride, company, ride_model)
|
||||
- min_score: Minimum completeness score (0-100)
|
||||
- max_score: Maximum completeness score (0-100)
|
||||
- missing_category: Filter by missing field category (critical, important, valuable, supplementary)
|
||||
- limit: Max results per entity type (default 50)
|
||||
"""
|
||||
try:
|
||||
entity_type = request.GET.get("entity_type")
|
||||
min_score = request.GET.get("min_score")
|
||||
max_score = request.GET.get("max_score")
|
||||
missing_category = request.GET.get("missing_category")
|
||||
limit = min(int(request.GET.get("limit", 50)), 200)
|
||||
|
||||
results = {
|
||||
"summary": {},
|
||||
"parks": [],
|
||||
"rides": [],
|
||||
"companies": [],
|
||||
"ride_models": [],
|
||||
}
|
||||
|
||||
# Process parks
|
||||
if not entity_type or entity_type == "park":
|
||||
parks = Park.objects.all()[:limit]
|
||||
park_results = []
|
||||
total_park_score = 0
|
||||
parks_complete = 0
|
||||
|
||||
for park in parks:
|
||||
score, missing = calculate_completeness_score(park, PARK_FIELDS)
|
||||
|
||||
# Apply filters
|
||||
if min_score and score < int(min_score):
|
||||
continue
|
||||
if max_score and score > int(max_score):
|
||||
continue
|
||||
if missing_category and missing_category not in missing:
|
||||
continue
|
||||
|
||||
total_park_score += score
|
||||
if score == 100:
|
||||
parks_complete += 1
|
||||
|
||||
park_results.append({
|
||||
"id": str(park.id),
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"entity_type": "park",
|
||||
"updated_at": park.updated_at.isoformat() if hasattr(park, "updated_at") else None,
|
||||
"completeness_score": score,
|
||||
"missing_fields": missing,
|
||||
})
|
||||
|
||||
results["parks"] = park_results
|
||||
results["summary"]["total_parks"] = len(park_results)
|
||||
results["summary"]["avg_park_score"] = (
|
||||
round(total_park_score / len(park_results)) if park_results else 0
|
||||
)
|
||||
results["summary"]["parks_complete"] = parks_complete
|
||||
|
||||
# Process rides
|
||||
if not entity_type or entity_type == "ride":
|
||||
rides = Ride.objects.select_related("park").all()[:limit]
|
||||
ride_results = []
|
||||
total_ride_score = 0
|
||||
rides_complete = 0
|
||||
|
||||
for ride in rides:
|
||||
score, missing = calculate_completeness_score(ride, RIDE_FIELDS)
|
||||
|
||||
if min_score and score < int(min_score):
|
||||
continue
|
||||
if max_score and score > int(max_score):
|
||||
continue
|
||||
if missing_category and missing_category not in missing:
|
||||
continue
|
||||
|
||||
total_ride_score += score
|
||||
if score == 100:
|
||||
rides_complete += 1
|
||||
|
||||
ride_results.append({
|
||||
"id": str(ride.id),
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"entity_type": "ride",
|
||||
"updated_at": ride.updated_at.isoformat() if hasattr(ride, "updated_at") else None,
|
||||
"completeness_score": score,
|
||||
"missing_fields": missing,
|
||||
})
|
||||
|
||||
results["rides"] = ride_results
|
||||
results["summary"]["total_rides"] = len(ride_results)
|
||||
results["summary"]["avg_ride_score"] = (
|
||||
round(total_ride_score / len(ride_results)) if ride_results else 0
|
||||
)
|
||||
results["summary"]["rides_complete"] = rides_complete
|
||||
|
||||
# Process companies
|
||||
if not entity_type or entity_type == "company":
|
||||
companies = Company.objects.all()[:limit]
|
||||
company_results = []
|
||||
total_company_score = 0
|
||||
companies_complete = 0
|
||||
|
||||
for company in companies:
|
||||
score, missing = calculate_completeness_score(company, COMPANY_FIELDS)
|
||||
|
||||
if min_score and score < int(min_score):
|
||||
continue
|
||||
if max_score and score > int(max_score):
|
||||
continue
|
||||
if missing_category and missing_category not in missing:
|
||||
continue
|
||||
|
||||
total_company_score += score
|
||||
if score == 100:
|
||||
companies_complete += 1
|
||||
|
||||
company_results.append({
|
||||
"id": str(company.id),
|
||||
"name": company.name,
|
||||
"slug": company.slug,
|
||||
"entity_type": "company",
|
||||
"updated_at": company.updated_at.isoformat() if hasattr(company, "updated_at") else None,
|
||||
"completeness_score": score,
|
||||
"missing_fields": missing,
|
||||
})
|
||||
|
||||
results["companies"] = company_results
|
||||
results["summary"]["total_companies"] = len(company_results)
|
||||
results["summary"]["avg_company_score"] = (
|
||||
round(total_company_score / len(company_results)) if company_results else 0
|
||||
)
|
||||
results["summary"]["companies_complete"] = companies_complete
|
||||
|
||||
# Ride models - placeholder (if model exists)
|
||||
results["summary"]["total_models"] = 0
|
||||
results["summary"]["avg_model_score"] = 0
|
||||
results["summary"]["models_complete"] = 0
|
||||
|
||||
return Response(results, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"detail": f"Error analyzing data completeness: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class TechnicalSpecificationsAPIView(APIView):
|
||||
"""
|
||||
Endpoint for querying ride technical specifications.
|
||||
Used by advanced ride search functionality.
|
||||
"""
|
||||
|
||||
permission_classes = [] # Public endpoint
|
||||
|
||||
@extend_schema(
|
||||
tags=["Rides"],
|
||||
summary="Get ride technical specifications",
|
||||
description="Query technical specifications across rides for advanced filtering",
|
||||
)
|
||||
def get(self, request):
|
||||
"""
|
||||
Get technical specifications for rides.
|
||||
|
||||
Query parameters:
|
||||
- spec_name: Filter by specification name
|
||||
- ride_id: Filter by specific ride
|
||||
"""
|
||||
try:
|
||||
spec_name = request.GET.get("spec_name")
|
||||
ride_id = request.GET.get("ride_id")
|
||||
|
||||
# For now, return technical specs from ride fields
|
||||
# In a full implementation, this would query a separate specs table
|
||||
rides = Ride.objects.all()
|
||||
|
||||
if ride_id:
|
||||
rides = rides.filter(id=ride_id)
|
||||
|
||||
specs = []
|
||||
spec_fields = [
|
||||
("max_speed_kmh", "Max Speed (km/h)"),
|
||||
("height_meters", "Height (m)"),
|
||||
("track_length_meters", "Track Length (m)"),
|
||||
("inversions_count", "Inversions"),
|
||||
("duration_seconds", "Duration (s)"),
|
||||
("g_force", "G-Force"),
|
||||
]
|
||||
|
||||
for ride in rides[:100]: # Limit to prevent huge responses
|
||||
for field, _name in spec_fields:
|
||||
value = getattr(ride, field, None)
|
||||
if value is not None and (not spec_name or spec_name == field):
|
||||
specs.append({
|
||||
"ride_id": str(ride.id),
|
||||
"spec_name": field,
|
||||
"spec_value": str(value),
|
||||
})
|
||||
|
||||
return Response(specs, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"detail": f"Error fetching specifications: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class CoasterStatisticsAPIView(APIView):
|
||||
"""
|
||||
Endpoint for querying coaster statistics for advanced filtering.
|
||||
"""
|
||||
|
||||
permission_classes = [] # Public endpoint
|
||||
|
||||
@extend_schema(
|
||||
tags=["Rides"],
|
||||
summary="Get coaster statistics",
|
||||
description="Query coaster statistics for advanced ride filtering",
|
||||
)
|
||||
def get(self, request):
|
||||
"""
|
||||
Get coaster statistics.
|
||||
|
||||
Query parameters:
|
||||
- stat_name: Filter by statistic name
|
||||
- stat_value__gte: Minimum value
|
||||
- stat_value__lte: Maximum value
|
||||
"""
|
||||
try:
|
||||
stat_name = request.GET.get("stat_name")
|
||||
min_value = request.GET.get("stat_value__gte")
|
||||
max_value = request.GET.get("stat_value__lte")
|
||||
|
||||
# Query rides with coaster category and relevant stats
|
||||
rides = Ride.objects.filter(category="coaster")
|
||||
|
||||
stats = []
|
||||
stat_fields = [
|
||||
"max_speed_kmh",
|
||||
"height_meters",
|
||||
"track_length_meters",
|
||||
"inversions_count",
|
||||
"g_force",
|
||||
"drop_height_meters",
|
||||
]
|
||||
|
||||
for ride in rides[:100]:
|
||||
for field in stat_fields:
|
||||
if stat_name and stat_name != field:
|
||||
continue
|
||||
|
||||
value = getattr(ride, field, None)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
# Apply value filters
|
||||
if min_value and float(value) < float(min_value):
|
||||
continue
|
||||
if max_value and float(value) > float(max_value):
|
||||
continue
|
||||
|
||||
stats.append({
|
||||
"ride_id": str(ride.id),
|
||||
"stat_name": field,
|
||||
"stat_value": float(value),
|
||||
})
|
||||
|
||||
return Response(stats, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"detail": f"Error fetching statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from rest_framework.serializers import Serializer
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.api.v1.serializers.shared import validate_filter_metadata_contract
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,17 +46,12 @@ class ContractCompliantAPIView(APIView):
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Log the error with context
|
||||
logger.error(
|
||||
f"API error in {self.__class__.__name__}: {str(e)}",
|
||||
extra={
|
||||
"view_class": self.__class__.__name__,
|
||||
"request_path": request.path,
|
||||
"request_method": request.method,
|
||||
"user": getattr(request, "user", None),
|
||||
"detail": str(e),
|
||||
},
|
||||
exc_info=True,
|
||||
# Capture error to dashboard
|
||||
capture_and_log(
|
||||
e,
|
||||
f'API error in {self.__class__.__name__}',
|
||||
source='api',
|
||||
severity='high',
|
||||
)
|
||||
|
||||
# Return standardized error response
|
||||
@@ -194,10 +190,10 @@ class FilterMetadataAPIView(ContractCompliantAPIView):
|
||||
return self.success_response(validated_metadata)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting filter metadata in {self.__class__.__name__}: {str(e)}",
|
||||
extra={"view_class": self.__class__.__name__, "detail": str(e)},
|
||||
exc_info=True,
|
||||
capture_and_log(
|
||||
e,
|
||||
f'Get filter metadata in {self.__class__.__name__}',
|
||||
source='api',
|
||||
)
|
||||
|
||||
return self.error_response(message="Failed to retrieve filter metadata", error_code="FILTER_METADATA_ERROR")
|
||||
@@ -238,14 +234,10 @@ class HybridFilteringAPIView(ContractCompliantAPIView):
|
||||
return self.success_response(hybrid_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in hybrid filtering for {self.__class__.__name__}: {str(e)}",
|
||||
extra={
|
||||
"view_class": self.__class__.__name__,
|
||||
"filters": getattr(self, "_extracted_filters", {}),
|
||||
"detail": str(e),
|
||||
},
|
||||
exc_info=True,
|
||||
capture_and_log(
|
||||
e,
|
||||
f'Hybrid filtering for {self.__class__.__name__}',
|
||||
source='api',
|
||||
)
|
||||
|
||||
return self.error_response(message="Failed to retrieve filtered data", error_code="HYBRID_FILTERING_ERROR")
|
||||
@@ -392,7 +384,7 @@ def contract_compliant_view(view_class):
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in decorated view {view_class.__name__}: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, f'Decorated view {view_class.__name__}', source='api')
|
||||
|
||||
# Return basic error response
|
||||
return Response(
|
||||
|
||||
126
backend/apps/core/api/error_serializers.py
Normal file
126
backend/apps/core/api/error_serializers.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Serializers for the error monitoring API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.models import ApplicationError
|
||||
|
||||
|
||||
class ApplicationErrorSerializer(serializers.ModelSerializer):
|
||||
"""Full error details for admin dashboard."""
|
||||
|
||||
user_email = serializers.SerializerMethodField()
|
||||
user_username = serializers.SerializerMethodField()
|
||||
resolved_by_username = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ApplicationError
|
||||
fields = [
|
||||
"id",
|
||||
"error_id",
|
||||
"request_id",
|
||||
"error_type",
|
||||
"error_message",
|
||||
"error_stack",
|
||||
"error_code",
|
||||
"severity",
|
||||
"source",
|
||||
"endpoint",
|
||||
"http_method",
|
||||
"http_status",
|
||||
"user_agent",
|
||||
"user",
|
||||
"user_email",
|
||||
"user_username",
|
||||
"ip_address_hash",
|
||||
"metadata",
|
||||
"environment",
|
||||
"created_at",
|
||||
"resolved",
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"resolved_by_username",
|
||||
"resolution_notes",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_user_email(self, obj: ApplicationError) -> str | None:
|
||||
"""Get the email of the user who encountered the error."""
|
||||
if obj.user:
|
||||
return obj.user.email
|
||||
return None
|
||||
|
||||
def get_user_username(self, obj: ApplicationError) -> str | None:
|
||||
"""Get the username of the user who encountered the error."""
|
||||
if obj.user:
|
||||
return obj.user.username
|
||||
return None
|
||||
|
||||
def get_resolved_by_username(self, obj: ApplicationError) -> str | None:
|
||||
"""Get the username of the admin who resolved the error."""
|
||||
if obj.resolved_by:
|
||||
return obj.resolved_by.username
|
||||
return None
|
||||
|
||||
|
||||
class ApplicationErrorListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for error list views."""
|
||||
|
||||
class Meta:
|
||||
model = ApplicationError
|
||||
fields = [
|
||||
"id",
|
||||
"error_id",
|
||||
"error_type",
|
||||
"error_message",
|
||||
"severity",
|
||||
"source",
|
||||
"endpoint",
|
||||
"created_at",
|
||||
"resolved",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ErrorReportSerializer(serializers.Serializer):
|
||||
"""Frontend error report submission."""
|
||||
|
||||
error_id = serializers.UUIDField(required=False, allow_null=True)
|
||||
error_type = serializers.CharField(max_length=100)
|
||||
error_message = serializers.CharField(max_length=5000)
|
||||
error_stack = serializers.CharField(required=False, allow_blank=True, max_length=10000)
|
||||
error_code = serializers.CharField(required=False, allow_blank=True, max_length=50)
|
||||
severity = serializers.ChoiceField(
|
||||
choices=["critical", "high", "medium", "low"],
|
||||
default="medium",
|
||||
)
|
||||
endpoint = serializers.CharField(required=False, allow_blank=True, max_length=500)
|
||||
metadata = serializers.JSONField(required=False, default=dict)
|
||||
environment = serializers.JSONField(required=False, default=dict)
|
||||
|
||||
|
||||
class ErrorStatisticsSerializer(serializers.Serializer):
|
||||
"""Error statistics response for dashboard."""
|
||||
|
||||
total_errors = serializers.IntegerField()
|
||||
errors_by_severity = serializers.DictField()
|
||||
errors_by_source = serializers.DictField()
|
||||
errors_by_type = serializers.ListField()
|
||||
errors_over_time = serializers.ListField()
|
||||
resolution_rate = serializers.FloatField()
|
||||
critical_count = serializers.IntegerField()
|
||||
unresolved_count = serializers.IntegerField()
|
||||
period_days = serializers.IntegerField()
|
||||
|
||||
|
||||
class ErrorResolveSerializer(serializers.Serializer):
|
||||
"""Request to resolve an error."""
|
||||
|
||||
notes = serializers.CharField(required=False, allow_blank=True, max_length=1000)
|
||||
|
||||
|
||||
class ErrorCleanupSerializer(serializers.Serializer):
|
||||
"""Request to cleanup old errors."""
|
||||
|
||||
days = serializers.IntegerField(min_value=1, max_value=365, default=30)
|
||||
286
backend/apps/core/api/error_views.py
Normal file
286
backend/apps/core/api/error_views.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
API views for error monitoring and reporting.
|
||||
|
||||
Provides endpoints for:
|
||||
- Frontend error reporting (public, rate-limited)
|
||||
- Error listing and filtering (admin only)
|
||||
- Error detail view (admin only)
|
||||
- Error resolution (admin only)
|
||||
- Error statistics (admin only)
|
||||
- Old error cleanup (superuser only)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db.models import Q
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.core.api.error_serializers import (
|
||||
ApplicationErrorListSerializer,
|
||||
ApplicationErrorSerializer,
|
||||
ErrorCleanupSerializer,
|
||||
ErrorReportSerializer,
|
||||
ErrorResolveSerializer,
|
||||
ErrorStatisticsSerializer,
|
||||
)
|
||||
from apps.core.models import ApplicationError
|
||||
from apps.core.services import ErrorService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorReportThrottle(AnonRateThrottle):
|
||||
"""Rate limit for error reporting - 10 requests per minute per IP."""
|
||||
|
||||
rate = "10/min"
|
||||
|
||||
|
||||
class ErrorReportView(APIView):
|
||||
"""
|
||||
POST /api/v1/errors/report/
|
||||
|
||||
Accept error reports from the frontend.
|
||||
Public endpoint, rate-limited to prevent abuse.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ErrorReportThrottle]
|
||||
|
||||
def post(self, request):
|
||||
"""Record an error report from the frontend."""
|
||||
serializer = ErrorReportSerializer(data=request.data)
|
||||
serializer.validate(request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"error": "Invalid error report", "details": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
data = serializer.validated_data
|
||||
|
||||
# Capture the error
|
||||
error = ErrorService.capture_error(
|
||||
error=data["error_message"],
|
||||
source="frontend",
|
||||
request=request,
|
||||
severity=data.get("severity", "medium"),
|
||||
metadata=data.get("metadata", {}),
|
||||
environment=data.get("environment", {}),
|
||||
error_id=data.get("error_id"),
|
||||
)
|
||||
|
||||
# Update error_type from the submitted data
|
||||
error.error_type = data["error_type"]
|
||||
error.error_stack = data.get("error_stack", "")
|
||||
error.error_code = data.get("error_code", "")
|
||||
error.endpoint = data.get("endpoint", "")
|
||||
error.save(update_fields=["error_type", "error_stack", "error_code", "endpoint"])
|
||||
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"error_id": str(error.error_id),
|
||||
"message": "Error report received",
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class ErrorListView(APIView):
|
||||
"""
|
||||
GET /api/v1/errors/
|
||||
|
||||
List errors with filtering. Admin only.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def get(self, request):
|
||||
"""List errors with optional filters."""
|
||||
queryset = ApplicationError.objects.all()
|
||||
|
||||
# Apply filters
|
||||
severity = request.query_params.get("severity")
|
||||
if severity:
|
||||
queryset = queryset.filter(severity=severity)
|
||||
|
||||
source = request.query_params.get("source")
|
||||
if source:
|
||||
queryset = queryset.filter(source=source)
|
||||
|
||||
error_type = request.query_params.get("error_type")
|
||||
if error_type:
|
||||
queryset = queryset.filter(error_type__icontains=error_type)
|
||||
|
||||
resolved = request.query_params.get("resolved")
|
||||
if resolved is not None:
|
||||
resolved_bool = resolved.lower() in ("true", "1", "yes")
|
||||
queryset = queryset.filter(resolved=resolved_bool)
|
||||
|
||||
search = request.query_params.get("search")
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(error_id__icontains=search)
|
||||
| Q(error_message__icontains=search)
|
||||
| Q(endpoint__icontains=search)
|
||||
| Q(error_type__icontains=search)
|
||||
)
|
||||
|
||||
# Date range filter
|
||||
date_range = request.query_params.get("date_range", "24h")
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
range_map = {
|
||||
"1h": timedelta(hours=1),
|
||||
"24h": timedelta(hours=24),
|
||||
"7d": timedelta(days=7),
|
||||
"30d": timedelta(days=30),
|
||||
}
|
||||
if date_range in range_map:
|
||||
cutoff = timezone.now() - range_map[date_range]
|
||||
queryset = queryset.filter(created_at__gte=cutoff)
|
||||
|
||||
# Pagination
|
||||
limit = min(int(request.query_params.get("limit", 50)), 100)
|
||||
offset = int(request.query_params.get("offset", 0))
|
||||
|
||||
total = queryset.count()
|
||||
errors = queryset[offset : offset + limit]
|
||||
|
||||
serializer = ApplicationErrorListSerializer(errors, many=True)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"data": serializer.data,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ErrorDetailView(APIView):
|
||||
"""
|
||||
GET /api/v1/errors/{id}/
|
||||
|
||||
Get full error details. Admin only.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def get(self, request, pk):
|
||||
"""Get detailed error information."""
|
||||
try:
|
||||
error = ApplicationError.objects.get(pk=pk)
|
||||
except ApplicationError.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Error not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = ApplicationErrorSerializer(error)
|
||||
return Response({"status": "success", "data": serializer.data})
|
||||
|
||||
|
||||
class ErrorResolveView(APIView):
|
||||
"""
|
||||
POST /api/v1/errors/{id}/resolve/
|
||||
|
||||
Mark an error as resolved. Admin only.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def post(self, request, pk):
|
||||
"""Mark error as resolved."""
|
||||
serializer = ErrorResolveSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"error": "Invalid request", "details": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
error = ErrorService.resolve_error(
|
||||
error_id=pk,
|
||||
user=request.user,
|
||||
notes=serializer.validated_data.get("notes", ""),
|
||||
)
|
||||
except ApplicationError.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Error not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Error marked as resolved",
|
||||
"error_id": str(error.error_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ErrorStatisticsView(APIView):
|
||||
"""
|
||||
GET /api/v1/errors/statistics/
|
||||
|
||||
Get error statistics for dashboard. Admin only.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def get(self, request):
|
||||
"""Get error statistics."""
|
||||
days = int(request.query_params.get("days", 7))
|
||||
days = min(max(days, 1), 90) # Clamp between 1 and 90
|
||||
|
||||
stats = ErrorService.get_error_statistics(days=days)
|
||||
serializer = ErrorStatisticsSerializer(stats)
|
||||
|
||||
return Response({"status": "success", "data": serializer.data})
|
||||
|
||||
|
||||
class ErrorCleanupView(APIView):
|
||||
"""
|
||||
POST /api/v1/errors/cleanup/
|
||||
|
||||
Clean up old errors. Superuser only.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def post(self, request):
|
||||
"""Clean up old resolved errors."""
|
||||
# Extra check for superuser
|
||||
if not request.user.is_superuser:
|
||||
return Response(
|
||||
{"error": "Superuser access required"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = ErrorCleanupSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"error": "Invalid request", "details": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
days = serializer.validated_data.get("days", 30)
|
||||
deleted_count = ErrorService.cleanup_old_errors(days=days)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"Deleted {deleted_count} errors older than {days} days",
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
)
|
||||
170
backend/apps/core/middleware/error_capture.py
Normal file
170
backend/apps/core/middleware/error_capture.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
ErrorCaptureMiddleware - Automatically capture backend exceptions.
|
||||
|
||||
This middleware intercepts unhandled exceptions in Django and logs them
|
||||
to the ApplicationError model for display in the admin error dashboard.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from apps.core.services import ErrorService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorCaptureMiddleware:
|
||||
"""
|
||||
Middleware that captures unhandled exceptions and stores them.
|
||||
|
||||
This runs after Django's built-in exception handling but before
|
||||
the exception is raised to the user, allowing us to log all errors
|
||||
that occur during request processing.
|
||||
|
||||
Usage:
|
||||
Add to settings.MIDDLEWARE after SecurityMiddleware but before
|
||||
any middleware that might swallow exceptions:
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'apps.core.middleware.error_capture.ErrorCaptureMiddleware',
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
response = self.get_response(request)
|
||||
|
||||
# Capture 5xx errors that made it through (server errors)
|
||||
if response.status_code >= 500:
|
||||
self._capture_response_error(request, response)
|
||||
|
||||
return response
|
||||
|
||||
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
|
||||
"""
|
||||
Capture exception details before Django handles it.
|
||||
|
||||
This method is called when a view raises an exception.
|
||||
It logs the error to the database but returns None to allow
|
||||
Django to continue with its default exception handling.
|
||||
|
||||
Args:
|
||||
request: The HTTP request that caused the exception
|
||||
exception: The exception that was raised
|
||||
|
||||
Returns:
|
||||
None - let Django continue with default handling
|
||||
"""
|
||||
try:
|
||||
self._capture_exception(request, exception)
|
||||
except Exception as e:
|
||||
# Don't let error capture failures break the request
|
||||
logger.error(f"Failed to capture exception: {e}")
|
||||
|
||||
return None # Let Django continue with default handling
|
||||
|
||||
def _capture_exception(
|
||||
self, request: HttpRequest, exception: Exception
|
||||
) -> None:
|
||||
"""Capture an exception to the database."""
|
||||
# Determine severity based on exception type
|
||||
severity = self._classify_severity(exception)
|
||||
|
||||
# Determine source based on request path
|
||||
source = "api" if "/api/" in request.path else "backend"
|
||||
|
||||
ErrorService.capture_error(
|
||||
error=exception,
|
||||
source=source,
|
||||
request=request,
|
||||
severity=severity,
|
||||
metadata={
|
||||
"view": self._get_view_name(request),
|
||||
"query_params": dict(request.GET),
|
||||
},
|
||||
)
|
||||
|
||||
def _capture_response_error(
|
||||
self, request: HttpRequest, response: HttpResponse
|
||||
) -> None:
|
||||
"""Capture a 5xx response error to the database."""
|
||||
# Only capture if we have a reason phrase or content
|
||||
reason = getattr(response, "reason_phrase", "Server Error")
|
||||
content = ""
|
||||
if hasattr(response, "content"):
|
||||
try:
|
||||
content = response.content.decode("utf-8")[:500]
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
error_message = f"HTTP {response.status_code}: {reason}"
|
||||
if content:
|
||||
error_message += f" - {content[:200]}"
|
||||
|
||||
severity = "critical" if response.status_code >= 503 else "high"
|
||||
source = "api" if "/api/" in request.path else "backend"
|
||||
|
||||
ErrorService.capture_error(
|
||||
error=error_message,
|
||||
source=source,
|
||||
request=request,
|
||||
severity=severity,
|
||||
metadata={
|
||||
"status_code": response.status_code,
|
||||
"reason_phrase": reason,
|
||||
"view": self._get_view_name(request),
|
||||
},
|
||||
)
|
||||
|
||||
def _classify_severity(self, exception: Exception) -> str:
|
||||
"""Classify exception severity based on type."""
|
||||
exception_type = type(exception).__name__
|
||||
|
||||
# Critical: Database, system, and security errors
|
||||
critical_types = {
|
||||
"DatabaseError",
|
||||
"OperationalError",
|
||||
"IntegrityError",
|
||||
"PermissionError",
|
||||
"SystemError",
|
||||
"MemoryError",
|
||||
}
|
||||
|
||||
# High: Unexpected runtime errors
|
||||
high_types = {
|
||||
"RuntimeError",
|
||||
"TypeError",
|
||||
"ValueError",
|
||||
"AttributeError",
|
||||
"KeyError",
|
||||
"IndexError",
|
||||
}
|
||||
|
||||
# Medium: Expected application errors
|
||||
medium_types = {
|
||||
"ValidationError",
|
||||
"Http404",
|
||||
"NotFound",
|
||||
"PermissionDenied",
|
||||
"AuthenticationFailed",
|
||||
}
|
||||
|
||||
if exception_type in critical_types:
|
||||
return "critical"
|
||||
elif exception_type in high_types:
|
||||
return "high"
|
||||
elif exception_type in medium_types:
|
||||
return "medium"
|
||||
else:
|
||||
return "high" # Default to high for unknown errors
|
||||
|
||||
def _get_view_name(self, request: HttpRequest) -> str:
|
||||
"""Get the name of the view that handled the request."""
|
||||
if hasattr(request, "resolver_match") and request.resolver_match:
|
||||
return request.resolver_match.view_name or ""
|
||||
return ""
|
||||
@@ -17,6 +17,7 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.analytics import PageView
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
@@ -65,8 +66,8 @@ class ViewTrackingMiddleware:
|
||||
try:
|
||||
self._track_view_if_applicable(request)
|
||||
except Exception as e:
|
||||
# Log error but don't break the request
|
||||
self.logger.error(f"Error tracking view: {e}", exc_info=True)
|
||||
# Capture error but don't break the request
|
||||
capture_and_log(e, f'Track view for {request.path}', source='middleware', severity='low')
|
||||
|
||||
return response
|
||||
|
||||
@@ -137,7 +138,7 @@ class ViewTrackingMiddleware:
|
||||
self.logger.debug(f"Recorded view for {content_type} {slug} from {client_ip}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to record page view for {content_type} {slug}: {e}")
|
||||
capture_and_log(e, f'Record page view for {content_type} {slug}', source='middleware', severity='low')
|
||||
|
||||
def _get_content_object(self, content_type: str, slug: str) -> ContentObject | None:
|
||||
"""Get the content object by type and slug."""
|
||||
@@ -156,7 +157,7 @@ class ViewTrackingMiddleware:
|
||||
except Park.DoesNotExist:
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting {content_type} with slug {slug}: {e}")
|
||||
capture_and_log(e, f'Get {content_type} with slug {slug}', source='middleware', severity='low')
|
||||
return None
|
||||
|
||||
def _is_duplicate_view(self, content_obj: ContentObject, client_ip: str) -> bool:
|
||||
@@ -298,5 +299,5 @@ def get_view_stats_for_content(content_obj: ContentObject, hours: int = 24) -> d
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting view stats: {e}")
|
||||
capture_and_log(e, f'Get view stats for content', source='service', severity='low')
|
||||
return {"total_views": 0, "unique_views": 0, "hours": hours, "error": str(e)}
|
||||
|
||||
152
backend/apps/core/migrations/0005_add_application_error.py
Normal file
152
backend/apps/core/migrations/0005_add_application_error.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-02 16:18
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0004_alter_slughistory_options_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ApplicationError",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"error_id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
help_text="Unique identifier for this error instance",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"request_id",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Request correlation ID if available", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"error_type",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
help_text="Type/class of the error (e.g., 'ValidationError', 'TypeError')",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
("error_message", models.TextField(help_text="Human-readable error message")),
|
||||
("error_stack", models.TextField(blank=True, help_text="Stack trace if available")),
|
||||
(
|
||||
"error_code",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Application-specific error code", max_length=50
|
||||
),
|
||||
),
|
||||
(
|
||||
"severity",
|
||||
models.CharField(
|
||||
choices=[("critical", "Critical"), ("high", "High"), ("medium", "Medium"), ("low", "Low")],
|
||||
db_index=True,
|
||||
default="medium",
|
||||
help_text="Error severity level",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.CharField(
|
||||
choices=[("frontend", "Frontend"), ("backend", "Backend"), ("api", "API")],
|
||||
db_index=True,
|
||||
help_text="Where the error originated",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"endpoint",
|
||||
models.CharField(blank=True, help_text="URL/endpoint where the error occurred", max_length=500),
|
||||
),
|
||||
("http_method", models.CharField(blank=True, help_text="HTTP method of the request", max_length=10)),
|
||||
(
|
||||
"http_status",
|
||||
models.PositiveIntegerField(blank=True, help_text="HTTP status code returned", null=True),
|
||||
),
|
||||
("user_agent", models.TextField(blank=True, help_text="User agent string from the client")),
|
||||
(
|
||||
"ip_address_hash",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Hashed IP address for rate limiting (privacy-preserving)",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata",
|
||||
models.JSONField(
|
||||
blank=True, default=dict, help_text="Additional context (action, entity info, etc.)"
|
||||
),
|
||||
),
|
||||
(
|
||||
"environment",
|
||||
models.JSONField(
|
||||
blank=True, default=dict, help_text="Client environment info (viewport, browser, etc.)"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When the error was recorded"),
|
||||
),
|
||||
(
|
||||
"resolved",
|
||||
models.BooleanField(
|
||||
db_index=True, default=False, help_text="Whether this error has been addressed"
|
||||
),
|
||||
),
|
||||
(
|
||||
"resolved_at",
|
||||
models.DateTimeField(blank=True, help_text="When the error was marked resolved", null=True),
|
||||
),
|
||||
("resolution_notes", models.TextField(blank=True, help_text="Notes about how the error was resolved")),
|
||||
(
|
||||
"resolved_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Admin who resolved this error",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="resolved_errors",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who encountered the error",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="application_errors",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Application Error",
|
||||
"verbose_name_plural": "Application Errors",
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["severity", "created_at"], name="core_applic_severit_6eeb93_idx"),
|
||||
models.Index(fields=["source", "created_at"], name="core_applic_source_31a37f_idx"),
|
||||
models.Index(fields=["error_type", "created_at"], name="core_applic_error_t_e787f5_idx"),
|
||||
models.Index(fields=["resolved", "created_at"], name="core_applic_resolve_2b0297_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,8 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import pghistory
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
@@ -122,3 +126,175 @@ class SluggedModel(TrackedModel):
|
||||
)
|
||||
|
||||
raise cls.DoesNotExist(f"{cls.__name__} with slug '{slug}' does not exist") from None
|
||||
|
||||
|
||||
class ApplicationError(models.Model):
|
||||
"""
|
||||
Stores application errors from frontend and backend sources.
|
||||
|
||||
Errors are captured automatically via middleware (backend) or
|
||||
reported via API (frontend) and displayed in the admin dashboard.
|
||||
"""
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
CRITICAL = "critical", "Critical"
|
||||
HIGH = "high", "High"
|
||||
MEDIUM = "medium", "Medium"
|
||||
LOW = "low", "Low"
|
||||
|
||||
class Source(models.TextChoices):
|
||||
FRONTEND = "frontend", "Frontend"
|
||||
BACKEND = "backend", "Backend"
|
||||
API = "api", "API"
|
||||
|
||||
# Identity
|
||||
error_id = models.UUIDField(
|
||||
unique=True,
|
||||
default=uuid.uuid4,
|
||||
db_index=True,
|
||||
help_text="Unique identifier for this error instance",
|
||||
)
|
||||
request_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Request correlation ID if available",
|
||||
)
|
||||
|
||||
# Error information
|
||||
error_type = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Type/class of the error (e.g., 'ValidationError', 'TypeError')",
|
||||
)
|
||||
error_message = models.TextField(
|
||||
help_text="Human-readable error message",
|
||||
)
|
||||
error_stack = models.TextField(
|
||||
blank=True,
|
||||
help_text="Stack trace if available",
|
||||
)
|
||||
error_code = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Application-specific error code",
|
||||
)
|
||||
severity = models.CharField(
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
default=Severity.MEDIUM,
|
||||
db_index=True,
|
||||
help_text="Error severity level",
|
||||
)
|
||||
source = models.CharField(
|
||||
max_length=20,
|
||||
choices=Source.choices,
|
||||
db_index=True,
|
||||
help_text="Where the error originated",
|
||||
)
|
||||
|
||||
# Request context
|
||||
endpoint = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="URL/endpoint where the error occurred",
|
||||
)
|
||||
http_method = models.CharField(
|
||||
max_length=10,
|
||||
blank=True,
|
||||
help_text="HTTP method of the request",
|
||||
)
|
||||
http_status = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="HTTP status code returned",
|
||||
)
|
||||
user_agent = models.TextField(
|
||||
blank=True,
|
||||
help_text="User agent string from the client",
|
||||
)
|
||||
|
||||
# User context
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="application_errors",
|
||||
help_text="User who encountered the error",
|
||||
)
|
||||
ip_address_hash = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Hashed IP address for rate limiting (privacy-preserving)",
|
||||
)
|
||||
|
||||
# Extended metadata
|
||||
metadata = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Additional context (action, entity info, etc.)",
|
||||
)
|
||||
environment = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Client environment info (viewport, browser, etc.)",
|
||||
)
|
||||
|
||||
# Timestamps and resolution
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When the error was recorded",
|
||||
)
|
||||
resolved = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether this error has been addressed",
|
||||
)
|
||||
resolved_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the error was marked resolved",
|
||||
)
|
||||
resolved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="resolved_errors",
|
||||
help_text="Admin who resolved this error",
|
||||
)
|
||||
resolution_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes about how the error was resolved",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Application Error"
|
||||
verbose_name_plural = "Application Errors"
|
||||
indexes = [
|
||||
models.Index(fields=["severity", "created_at"]),
|
||||
models.Index(fields=["source", "created_at"]),
|
||||
models.Index(fields=["error_type", "created_at"]),
|
||||
models.Index(fields=["resolved", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.severity.upper()}] {self.error_type}: {self.error_message[:50]}"
|
||||
|
||||
@staticmethod
|
||||
def hash_ip(ip_address: str) -> str:
|
||||
"""Hash an IP address for privacy-preserving storage."""
|
||||
if not ip_address:
|
||||
return ""
|
||||
salt = getattr(settings, "SECRET_KEY", "")[:16]
|
||||
return hashlib.sha256(f"{salt}{ip_address}".encode()).hexdigest()
|
||||
|
||||
@property
|
||||
def short_error_id(self) -> str:
|
||||
"""Return first 8 characters of error_id for display."""
|
||||
return str(self.error_id)[:8]
|
||||
|
||||
@@ -3,6 +3,7 @@ Core services for ThrillWiki unified map functionality.
|
||||
"""
|
||||
|
||||
from .clustering_service import ClusteringService
|
||||
from .error_service import ErrorService
|
||||
from .data_structures import (
|
||||
ClusterData,
|
||||
GeoBounds,
|
||||
@@ -17,6 +18,7 @@ from .map_service import UnifiedMapService
|
||||
__all__ = [
|
||||
"UnifiedMapService",
|
||||
"ClusteringService",
|
||||
"ErrorService",
|
||||
"MapCacheService",
|
||||
"UnifiedLocation",
|
||||
"LocationType",
|
||||
|
||||
@@ -12,6 +12,8 @@ from typing import Any
|
||||
|
||||
from django.core.cache import caches
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -122,7 +124,7 @@ class EnhancedCacheService:
|
||||
else:
|
||||
logger.warning(f"Cache backend does not support pattern deletion for pattern '{pattern}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error invalidating cache pattern '{pattern}': {e}")
|
||||
capture_and_log(e, f"Invalidate cache pattern '{pattern}'", source='service', severity='low')
|
||||
|
||||
def invalidate_model_cache(self, model_name: str, instance_id: int | None = None):
|
||||
"""Invalidate cache keys related to a specific model"""
|
||||
@@ -144,7 +146,7 @@ class EnhancedCacheService:
|
||||
self.default_cache.set(cache_key, data, timeout)
|
||||
logger.info(f"Warmed cache for key '{cache_key}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error warming cache for key '{cache_key}': {e}")
|
||||
capture_and_log(e, f"Warm cache for key '{cache_key}'", source='service', severity='low')
|
||||
|
||||
def _generate_api_cache_key(self, view_name: str, params: dict) -> str:
|
||||
"""Generate consistent cache keys for API responses"""
|
||||
@@ -250,7 +252,7 @@ class CacheWarmer:
|
||||
try:
|
||||
self.cache_service.warm_cache(**operation)
|
||||
except Exception as e:
|
||||
logger.error(f"Error warming cache for {operation['cache_key']}: {e}")
|
||||
capture_and_log(e, f"Warm cache for {operation['cache_key']}", source='service', severity='low')
|
||||
|
||||
|
||||
# Cache statistics and monitoring
|
||||
|
||||
319
backend/apps/core/services/error_service.py
Normal file
319
backend/apps/core/services/error_service.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
ErrorService - Centralized error capture and management.
|
||||
|
||||
Provides methods for:
|
||||
- Capturing errors from frontend and backend
|
||||
- Querying errors with filtering
|
||||
- Generating statistics for dashboard
|
||||
- Resolving errors
|
||||
- Cleaning up old errors
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import ApplicationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorService:
|
||||
"""Service for error capture and management."""
|
||||
|
||||
@staticmethod
|
||||
def capture_error(
|
||||
error: Exception | str,
|
||||
source: str,
|
||||
request: HttpRequest | None = None,
|
||||
user: Any | None = None,
|
||||
severity: str = "medium",
|
||||
metadata: dict | None = None,
|
||||
environment: dict | None = None,
|
||||
error_id: UUID | None = None,
|
||||
) -> ApplicationError:
|
||||
"""
|
||||
Capture and store an error.
|
||||
|
||||
Args:
|
||||
error: The exception or error message
|
||||
source: One of 'frontend', 'backend', 'api'
|
||||
request: Optional HTTP request for context
|
||||
user: Optional user who encountered the error
|
||||
severity: Error severity level
|
||||
metadata: Additional context data
|
||||
environment: Client environment info
|
||||
|
||||
Returns:
|
||||
The created ApplicationError instance
|
||||
"""
|
||||
# Extract error details
|
||||
if isinstance(error, Exception):
|
||||
error_type = type(error).__name__
|
||||
error_message = str(error)
|
||||
error_stack = traceback.format_exc()
|
||||
error_code = getattr(error, "error_code", "") or ""
|
||||
else:
|
||||
error_type = "Error"
|
||||
error_message = str(error)
|
||||
error_stack = ""
|
||||
error_code = ""
|
||||
|
||||
# Extract request details
|
||||
endpoint = ""
|
||||
http_method = ""
|
||||
user_agent = ""
|
||||
ip_address_hash = ""
|
||||
http_status = None
|
||||
|
||||
# Build request_context for additional debugging info
|
||||
request_context: dict[str, Any] = {}
|
||||
|
||||
if request:
|
||||
endpoint = request.path
|
||||
http_method = request.method
|
||||
user_agent = request.META.get("HTTP_USER_AGENT", "")
|
||||
|
||||
# Hash IP for privacy
|
||||
ip = ErrorService._get_client_ip(request)
|
||||
ip_address_hash = ApplicationError.hash_ip(ip)
|
||||
|
||||
# Use request user if not provided
|
||||
if user is None and hasattr(request, "user") and request.user.is_authenticated:
|
||||
user = request.user
|
||||
|
||||
# Capture additional request context for debugging
|
||||
request_context = {
|
||||
"content_type": request.content_type,
|
||||
"query_string": request.META.get("QUERY_STRING", "")[:500],
|
||||
"request_id": request.META.get("HTTP_X_REQUEST_ID", ""),
|
||||
"accept_language": request.META.get("HTTP_ACCEPT_LANGUAGE", ""),
|
||||
"referer": request.META.get("HTTP_REFERER", ""),
|
||||
"origin": request.META.get("HTTP_ORIGIN", ""),
|
||||
}
|
||||
|
||||
# Capture request body snippet for POST/PUT/PATCH (sanitized)
|
||||
if http_method in ("POST", "PUT", "PATCH"):
|
||||
try:
|
||||
body_snippet = request.body.decode("utf-8")[:1000] if request.body else ""
|
||||
# Sanitize sensitive fields
|
||||
for field in ("password", "token", "secret", "key", "auth"):
|
||||
if field in body_snippet.lower():
|
||||
body_snippet = "[REDACTED - contains sensitive data]"
|
||||
break
|
||||
request_context["body_snippet"] = body_snippet
|
||||
except Exception:
|
||||
request_context["body_snippet"] = "[Could not decode body]"
|
||||
|
||||
# Extract exception chain for comprehensive debugging
|
||||
if isinstance(error, Exception):
|
||||
cause_chain = []
|
||||
current_cause = error.__cause__
|
||||
depth = 0
|
||||
while current_cause and depth < 5:
|
||||
cause_chain.append({
|
||||
"type": type(current_cause).__name__,
|
||||
"message": str(current_cause)[:500],
|
||||
})
|
||||
current_cause = current_cause.__cause__
|
||||
depth += 1
|
||||
if cause_chain:
|
||||
request_context["exception_chain"] = cause_chain
|
||||
|
||||
# Merge request_context into metadata
|
||||
merged_metadata = {**(metadata or {}), "request_context": request_context}
|
||||
|
||||
# Create and save error
|
||||
app_error = ApplicationError.objects.create(
|
||||
error_id=error_id or None, # Let model generate if not provided
|
||||
error_type=error_type,
|
||||
error_message=error_message[:5000], # Limit message length
|
||||
error_stack=error_stack[:10000], # Limit stack length
|
||||
error_code=error_code,
|
||||
severity=severity,
|
||||
source=source,
|
||||
endpoint=endpoint,
|
||||
http_method=http_method,
|
||||
user_agent=user_agent[:1000],
|
||||
user=user,
|
||||
ip_address_hash=ip_address_hash,
|
||||
metadata=merged_metadata,
|
||||
environment=environment or {},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Captured error {app_error.short_error_id}: {error_type} from {source}"
|
||||
)
|
||||
|
||||
return app_error
|
||||
|
||||
@staticmethod
|
||||
def capture_frontend_error(
|
||||
error_data: dict,
|
||||
request: HttpRequest | None = None,
|
||||
) -> ApplicationError:
|
||||
"""
|
||||
Capture an error reported from the frontend.
|
||||
|
||||
Args:
|
||||
error_data: Dictionary containing error details from frontend
|
||||
request: HTTP request for IP/user context
|
||||
|
||||
Returns:
|
||||
The created ApplicationError instance
|
||||
"""
|
||||
return ErrorService.capture_error(
|
||||
error=error_data.get("error_message", "Unknown error"),
|
||||
source="frontend",
|
||||
request=request,
|
||||
severity=error_data.get("severity", "medium"),
|
||||
metadata=error_data.get("metadata", {}),
|
||||
environment=error_data.get("environment", {}),
|
||||
error_id=error_data.get("error_id"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_error_statistics(days: int = 7) -> dict:
|
||||
"""
|
||||
Get error statistics for the dashboard.
|
||||
|
||||
Args:
|
||||
days: Number of days to include in statistics
|
||||
|
||||
Returns:
|
||||
Dictionary containing error statistics
|
||||
"""
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
base_queryset = ApplicationError.objects.filter(created_at__gte=cutoff)
|
||||
|
||||
# Total errors
|
||||
total_errors = base_queryset.count()
|
||||
|
||||
# Errors by severity
|
||||
severity_counts = dict(
|
||||
base_queryset.values("severity")
|
||||
.annotate(count=Count("id"))
|
||||
.values_list("severity", "count")
|
||||
)
|
||||
|
||||
# Errors by source
|
||||
source_counts = dict(
|
||||
base_queryset.values("source")
|
||||
.annotate(count=Count("id"))
|
||||
.values_list("source", "count")
|
||||
)
|
||||
|
||||
# Top error types
|
||||
error_types = list(
|
||||
base_queryset.values("error_type")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:10]
|
||||
)
|
||||
|
||||
# Errors over time (daily)
|
||||
errors_over_time = list(
|
||||
base_queryset.annotate(date=TruncDate("created_at"))
|
||||
.values("date")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("date")
|
||||
)
|
||||
|
||||
# Convert dates to strings for JSON serialization
|
||||
for item in errors_over_time:
|
||||
item["date"] = item["date"].isoformat() if item["date"] else None
|
||||
|
||||
# Resolution rate
|
||||
resolved_count = base_queryset.filter(resolved=True).count()
|
||||
resolution_rate = (resolved_count / total_errors * 100) if total_errors > 0 else 0
|
||||
|
||||
# Critical/unresolved counts for quick stats
|
||||
critical_count = base_queryset.filter(severity="critical").count()
|
||||
unresolved_count = base_queryset.filter(resolved=False).count()
|
||||
|
||||
return {
|
||||
"total_errors": total_errors,
|
||||
"errors_by_severity": severity_counts,
|
||||
"errors_by_source": source_counts,
|
||||
"errors_by_type": error_types,
|
||||
"errors_over_time": errors_over_time,
|
||||
"resolution_rate": round(resolution_rate, 1),
|
||||
"critical_count": critical_count,
|
||||
"unresolved_count": unresolved_count,
|
||||
"period_days": days,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def resolve_error(
|
||||
error_id: UUID | int,
|
||||
user: Any,
|
||||
notes: str = "",
|
||||
) -> ApplicationError:
|
||||
"""
|
||||
Mark an error as resolved.
|
||||
|
||||
Args:
|
||||
error_id: UUID or database ID of the error
|
||||
user: User marking the error as resolved
|
||||
notes: Optional resolution notes
|
||||
|
||||
Returns:
|
||||
The updated ApplicationError instance
|
||||
|
||||
Raises:
|
||||
ApplicationError.DoesNotExist: If error not found
|
||||
"""
|
||||
if isinstance(error_id, int):
|
||||
error = ApplicationError.objects.get(id=error_id)
|
||||
else:
|
||||
error = ApplicationError.objects.get(error_id=error_id)
|
||||
|
||||
error.resolved = True
|
||||
error.resolved_at = timezone.now()
|
||||
error.resolved_by = user
|
||||
error.resolution_notes = notes
|
||||
error.save(update_fields=["resolved", "resolved_at", "resolved_by", "resolution_notes"])
|
||||
|
||||
logger.info(
|
||||
f"Error {error.short_error_id} resolved by {user}"
|
||||
)
|
||||
|
||||
return error
|
||||
|
||||
@staticmethod
|
||||
def cleanup_old_errors(days: int = 30) -> int:
|
||||
"""
|
||||
Delete errors older than specified days.
|
||||
|
||||
Args:
|
||||
days: Delete errors older than this many days
|
||||
|
||||
Returns:
|
||||
Number of errors deleted
|
||||
"""
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Only delete resolved errors by default, keep unresolved critical
|
||||
deleted_count, _ = ApplicationError.objects.filter(
|
||||
Q(created_at__lt=cutoff) & (Q(resolved=True) | ~Q(severity="critical"))
|
||||
).delete()
|
||||
|
||||
logger.info(
|
||||
f"Cleaned up {deleted_count} errors older than {days} days"
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
@staticmethod
|
||||
def _get_client_ip(request: HttpRequest) -> str:
|
||||
"""Extract client IP from request, handling proxies."""
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(",")[0].strip()
|
||||
return request.META.get("REMOTE_ADDR", "")
|
||||
@@ -20,6 +20,7 @@ from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.analytics import PageView
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
@@ -105,7 +106,7 @@ class TrendingService:
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting trending content: {e}", exc_info=True)
|
||||
capture_and_log(e, f'Get trending content ({content_type})', source='service')
|
||||
return []
|
||||
|
||||
def get_new_content(
|
||||
@@ -164,7 +165,7 @@ class TrendingService:
|
||||
return formatted_results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting new content: {e}", exc_info=True)
|
||||
capture_and_log(e, f'Get new content ({content_type})', source='service')
|
||||
return []
|
||||
|
||||
def _calculate_trending_parks(self, limit: int) -> list[dict[str, Any]]:
|
||||
@@ -311,7 +312,7 @@ class TrendingService:
|
||||
return final_score
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error calculating score for {content_type} {content_obj.id}: {e}")
|
||||
capture_and_log(e, f'Calculate content score ({content_type} {content_obj.id})', source='service', severity='low')
|
||||
return 0.0
|
||||
|
||||
def _calculate_view_growth_score(self, content_type: ContentType, object_id: int) -> float:
|
||||
@@ -653,7 +654,7 @@ class TrendingService:
|
||||
self.logger.info(f"Cleared trending caches for {content_type}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error clearing cache: {e}")
|
||||
capture_and_log(e, f'Clear trending cache ({content_type})', source='service', severity='low')
|
||||
|
||||
|
||||
# Singleton service instance
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.analytics import PageView
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
@@ -87,7 +88,7 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating trending content: {e}", exc_info=True)
|
||||
capture_and_log(e, f'Calculate trending content ({content_type})', source='task')
|
||||
# Retry the task
|
||||
raise self.retry(exc=e) from None
|
||||
|
||||
@@ -141,7 +142,7 @@ def calculate_new_content(self, content_type: str = "all", days_back: int = 30,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating new content: {e}", exc_info=True)
|
||||
capture_and_log(e, f'Calculate new content ({content_type})', source='task')
|
||||
raise self.retry(exc=e) from None
|
||||
|
||||
|
||||
@@ -185,7 +186,7 @@ def warm_trending_cache(self) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error warming trending cache: {e}", exc_info=True)
|
||||
capture_and_log(e, 'Warm trending cache', source='task')
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
@@ -309,7 +310,7 @@ def _calculate_content_score(
|
||||
return final_score
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating score for {content_type} {content_obj.id}: {e}")
|
||||
capture_and_log(e, f'Calculate content score ({content_type} {content_obj.id})', source='task', severity='low')
|
||||
return 0.0
|
||||
|
||||
|
||||
|
||||
@@ -43,4 +43,6 @@ urlpatterns = [
|
||||
path("entities/", include(entity_patterns)),
|
||||
# FSM transition endpoints
|
||||
path("fsm/", include(fsm_patterns)),
|
||||
# Error monitoring endpoints (API)
|
||||
path("errors/", include("apps.core.urls.errors", namespace="errors")),
|
||||
]
|
||||
|
||||
27
backend/apps/core/urls/errors.py
Normal file
27
backend/apps/core/urls/errors.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
URL configuration for error monitoring API.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from apps.core.api.error_views import (
|
||||
ErrorCleanupView,
|
||||
ErrorDetailView,
|
||||
ErrorListView,
|
||||
ErrorReportView,
|
||||
ErrorResolveView,
|
||||
ErrorStatisticsView,
|
||||
)
|
||||
|
||||
app_name = "errors"
|
||||
|
||||
urlpatterns = [
|
||||
# Public endpoint (rate-limited)
|
||||
path("report/", ErrorReportView.as_view(), name="report"),
|
||||
# Admin endpoints
|
||||
path("", ErrorListView.as_view(), name="list"),
|
||||
path("statistics/", ErrorStatisticsView.as_view(), name="statistics"),
|
||||
path("cleanup/", ErrorCleanupView.as_view(), name="cleanup"),
|
||||
path("<int:pk>/", ErrorDetailView.as_view(), name="detail"),
|
||||
path("<int:pk>/resolve/", ErrorResolveView.as_view(), name="resolve"),
|
||||
]
|
||||
@@ -12,6 +12,11 @@ from .breadcrumbs import (
|
||||
build_breadcrumb,
|
||||
get_model_breadcrumb,
|
||||
)
|
||||
from .capture_errors import (
|
||||
capture_and_log,
|
||||
capture_errors,
|
||||
error_context,
|
||||
)
|
||||
from .messages import (
|
||||
confirm_delete,
|
||||
error_network,
|
||||
@@ -47,6 +52,10 @@ __all__ = [
|
||||
"breadcrumbs_to_schema",
|
||||
"build_breadcrumb",
|
||||
"get_model_breadcrumb",
|
||||
# Error Capture
|
||||
"capture_and_log",
|
||||
"capture_errors",
|
||||
"error_context",
|
||||
# Messages
|
||||
"confirm_delete",
|
||||
"error_network",
|
||||
@@ -73,3 +82,4 @@ __all__ = [
|
||||
"get_og_image",
|
||||
"get_twitter_card_type",
|
||||
]
|
||||
|
||||
|
||||
219
backend/apps/core/utils/capture_errors.py
Normal file
219
backend/apps/core/utils/capture_errors.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Error capture utilities: decorator and context manager.
|
||||
|
||||
Provides ergonomic wrappers around ErrorService for easy error capture.
|
||||
|
||||
Example usage:
|
||||
# Decorator for functions/views
|
||||
@capture_errors(source='api', severity='high')
|
||||
def risky_view(request):
|
||||
...
|
||||
|
||||
# Context manager for code blocks
|
||||
with error_context('Processing payment', severity='critical'):
|
||||
process_payment()
|
||||
|
||||
# Context manager with request for full context
|
||||
with error_context('Creating ride', request=request, entity_type='Ride'):
|
||||
create_ride(data)
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from apps.core.services import ErrorService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
F = TypeVar('F', bound=Callable[..., Any])
|
||||
|
||||
|
||||
def capture_errors(
|
||||
source: str = 'backend',
|
||||
severity: str = 'high',
|
||||
reraise: bool = True,
|
||||
log_errors: bool = True,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Decorator that automatically captures exceptions to the error dashboard.
|
||||
|
||||
Use this on views, service methods, or any function where you want
|
||||
automatic error tracking.
|
||||
|
||||
Args:
|
||||
source: Error source - 'frontend', 'backend', or 'api'
|
||||
severity: Default severity - 'critical', 'high', 'medium', 'low'
|
||||
reraise: Whether to re-raise the exception after capturing
|
||||
log_errors: Whether to also log to Python logger
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Example:
|
||||
@capture_errors(source='api', severity='high')
|
||||
def create_park(request, data):
|
||||
# If this raises, error is automatically captured
|
||||
return ParkService.create(data)
|
||||
|
||||
@capture_errors(severity='critical', reraise=False)
|
||||
def optional_cleanup():
|
||||
# Errors captured but swallowed
|
||||
cleanup_temp_files()
|
||||
"""
|
||||
def decorator(func: F) -> F:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
# Try to extract request from args (common in Django views)
|
||||
request = None
|
||||
for arg in args:
|
||||
if isinstance(arg, HttpRequest):
|
||||
request = arg
|
||||
break
|
||||
if not request:
|
||||
request = kwargs.get('request')
|
||||
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if log_errors:
|
||||
logger.exception(f"Error in {func.__name__}: {e}")
|
||||
|
||||
# Capture to error dashboard
|
||||
try:
|
||||
ErrorService.capture_error(
|
||||
error=e,
|
||||
source=source,
|
||||
request=request,
|
||||
severity=severity,
|
||||
metadata={
|
||||
'function_name': func.__name__,
|
||||
'module': func.__module__,
|
||||
},
|
||||
)
|
||||
except Exception as capture_error:
|
||||
logger.error(f"Failed to capture error: {capture_error}")
|
||||
|
||||
if reraise:
|
||||
raise
|
||||
|
||||
return wrapper # type: ignore
|
||||
return decorator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def error_context(
|
||||
action: str,
|
||||
source: str = 'backend',
|
||||
severity: str = 'high',
|
||||
request: HttpRequest | None = None,
|
||||
entity_type: str | None = None,
|
||||
entity_id: int | str | None = None,
|
||||
reraise: bool = True,
|
||||
metadata: dict | None = None,
|
||||
):
|
||||
"""
|
||||
Context manager for capturing errors in code blocks.
|
||||
|
||||
Use this when you want to capture errors from a specific block of code
|
||||
with rich context about what was happening.
|
||||
|
||||
Args:
|
||||
action: Description of what the code block is doing
|
||||
source: Error source - 'frontend', 'backend', or 'api'
|
||||
severity: Error severity level
|
||||
request: Optional HTTP request for context
|
||||
entity_type: Optional entity type being operated on (e.g., 'Ride', 'Park')
|
||||
entity_id: Optional entity ID being operated on
|
||||
reraise: Whether to re-raise the exception after capturing
|
||||
metadata: Additional metadata to include
|
||||
|
||||
Yields:
|
||||
None
|
||||
|
||||
Example:
|
||||
with error_context('Creating ride submission', request=request, entity_type='Ride'):
|
||||
submission = SubmissionService.create(data)
|
||||
|
||||
with error_context('Bulk import', severity='critical', reraise=False):
|
||||
for item in items:
|
||||
process_item(item) # Errors logged but processing continues
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during '{action}': {e}")
|
||||
|
||||
# Build metadata
|
||||
error_metadata = {
|
||||
'action': action,
|
||||
**(metadata or {}),
|
||||
}
|
||||
if entity_type:
|
||||
error_metadata['entity_type'] = entity_type
|
||||
if entity_id:
|
||||
error_metadata['entity_id'] = entity_id
|
||||
|
||||
# Capture to error dashboard
|
||||
try:
|
||||
ErrorService.capture_error(
|
||||
error=e,
|
||||
source=source,
|
||||
request=request,
|
||||
severity=severity,
|
||||
metadata=error_metadata,
|
||||
)
|
||||
except Exception as capture_error:
|
||||
logger.error(f"Failed to capture error: {capture_error}")
|
||||
|
||||
if reraise:
|
||||
raise
|
||||
|
||||
|
||||
def capture_and_log(
|
||||
error: Exception,
|
||||
action: str,
|
||||
source: str = 'backend',
|
||||
severity: str = 'medium',
|
||||
request: HttpRequest | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""
|
||||
One-liner function to capture an error and return its ID.
|
||||
|
||||
Use this when you've already caught an exception and want to
|
||||
report it without the decorator/context manager.
|
||||
|
||||
Args:
|
||||
error: The exception to capture
|
||||
action: Description of what was happening
|
||||
source: Error source
|
||||
severity: Error severity level
|
||||
request: Optional HTTP request for context
|
||||
**kwargs: Additional metadata fields
|
||||
|
||||
Returns:
|
||||
The error_id (short UUID) for reference
|
||||
|
||||
Example:
|
||||
try:
|
||||
result = risky_operation()
|
||||
except Exception as e:
|
||||
error_id = capture_and_log(e, 'Risky operation failed', severity='high')
|
||||
return Response({'error': f'Failed (ref: {error_id})'}, status=500)
|
||||
"""
|
||||
try:
|
||||
app_error = ErrorService.capture_error(
|
||||
error=error,
|
||||
source=source,
|
||||
request=request,
|
||||
severity=severity,
|
||||
metadata={'action': action, **kwargs},
|
||||
)
|
||||
return app_error.short_error_id
|
||||
except Exception as capture_error:
|
||||
logger.error(f"Failed to capture error: {capture_error}")
|
||||
return "unknown"
|
||||
@@ -4,6 +4,8 @@ import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from .capture_errors import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -47,7 +49,9 @@ def get_direct_upload_url(user_id=None):
|
||||
|
||||
if not result.get("success"):
|
||||
error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message")
|
||||
logger.error(f"Cloudflare Direct Upload Error: {error_msg}")
|
||||
raise requests.RequestException(f"Cloudflare Error: {error_msg}")
|
||||
# Create error for capture
|
||||
e = requests.RequestException(f"Cloudflare Error: {error_msg}")
|
||||
capture_and_log(e, 'Cloudflare direct upload', source='service')
|
||||
raise e
|
||||
|
||||
return result.get("result", {})
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.views.decorators.gzip import gzip_page
|
||||
|
||||
from ..services.data_structures import GeoBounds, LocationType, MapFilters
|
||||
from ..services.map_service import unified_map_service
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,10 +52,7 @@ class MapAPIView(View):
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"API error in {request.path}: {str(e)}",
|
||||
exc_info=True,
|
||||
)
|
||||
capture_and_log(e, f'Map API dispatch {request.path}', source='api')
|
||||
return self._error_response("An internal server error occurred", status=500)
|
||||
|
||||
def options(self, request, *args, **kwargs):
|
||||
@@ -373,7 +371,7 @@ class MapLocationsView(MapAPIView):
|
||||
logger.warning(f"Validation error in MapLocationsView: {str(e)}")
|
||||
return self._error_response(str(e), 400, error_code="VALIDATION_ERROR")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapLocationsView: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'MapLocationsView get', source='api')
|
||||
return self._error_response(
|
||||
"Failed to retrieve map locations",
|
||||
500,
|
||||
@@ -433,10 +431,7 @@ class MapLocationDetailView(MapAPIView):
|
||||
logger.warning(f"Value error in MapLocationDetailView: {str(e)}")
|
||||
return self._error_response(str(e), 400, error_code="INVALID_PARAMETER")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in MapLocationDetailView: {str(e)}",
|
||||
exc_info=True,
|
||||
)
|
||||
capture_and_log(e, 'MapLocationDetailView get', source='api')
|
||||
return self._error_response(
|
||||
"Failed to retrieve location details",
|
||||
500,
|
||||
@@ -529,7 +524,7 @@ class MapSearchView(MapAPIView):
|
||||
logger.warning(f"Value error in MapSearchView: {str(e)}")
|
||||
return self._error_response(str(e), 400, error_code="INVALID_PARAMETER")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapSearchView: {str(e)}", exc_info=True)
|
||||
capture_and_log(e, 'MapSearchView get', source='api')
|
||||
return self._error_response(
|
||||
"Search failed due to internal error",
|
||||
500,
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
|
||||
from ..models import ParkLocation
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -90,7 +91,7 @@ class ParkLocationService:
|
||||
return result_data
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error searching park locations: {str(e)}")
|
||||
capture_and_log(e, 'Search park locations', source='service')
|
||||
return {
|
||||
"count": 0,
|
||||
"results": [],
|
||||
@@ -156,7 +157,7 @@ class ParkLocationService:
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error reverse geocoding park location: {str(e)}")
|
||||
capture_and_log(e, 'Reverse geocode park location', source='service')
|
||||
return {"error": "Reverse geocoding service temporarily unavailable"}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
|
||||
from apps.core.services.media_service import MediaService
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
from ..models import Park, ParkPhoto
|
||||
|
||||
@@ -164,7 +165,7 @@ class ParkMediaService:
|
||||
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
|
||||
capture_and_log(e, f'Approve park photo {photo.pk}', source='service')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@@ -191,7 +192,7 @@ class ParkMediaService:
|
||||
logger.info(f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
|
||||
capture_and_log(e, f'Delete park photo {photo.pk}', source='service')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -23,6 +23,7 @@ from django.contrib.gis.measure import Distance
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.parks.models import Park
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -242,7 +243,7 @@ class RoadTripService:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Geocoding failed for '{address}': {e}")
|
||||
capture_and_log(e, f"Geocode address '{address}'", source='service')
|
||||
return None
|
||||
|
||||
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo | None:
|
||||
@@ -319,7 +320,7 @@ class RoadTripService:
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Route calculation failed: {e}")
|
||||
capture_and_log(e, 'Calculate route', source='service')
|
||||
# Fallback to straight-line distance
|
||||
return self._calculate_straight_line_route(start_coords, end_coords)
|
||||
|
||||
@@ -445,7 +446,7 @@ class RoadTripService:
|
||||
return max(0, detour_distance) # Don't return negative detours
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate detour distance: {e}")
|
||||
capture_and_log(e, 'Calculate detour distance', source='service', severity='low')
|
||||
return None
|
||||
|
||||
def create_multi_park_trip(self, park_list: list["Park"]) -> RoadTrip | None:
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
|
||||
from apps.core.services.media_service import MediaService
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
from ..models import Ride, RidePhoto
|
||||
|
||||
@@ -190,7 +191,7 @@ class RideMediaService:
|
||||
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
|
||||
capture_and_log(e, f'Approve ride photo {photo.pk}', source='service')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@@ -217,7 +218,7 @@ class RideMediaService:
|
||||
logger.info(f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
|
||||
capture_and_log(e, f'Delete ride photo {photo.pk}', source='service')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -21,6 +21,7 @@ from apps.rides.models import (
|
||||
RideRanking,
|
||||
RideReview,
|
||||
)
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,7 +97,7 @@ class RideRankingService:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating rankings: {e}", exc_info=True)
|
||||
capture_and_log(e, 'Update ride rankings', source='service')
|
||||
raise
|
||||
|
||||
def _get_eligible_rides(self, category: str | None = None) -> list[Ride]:
|
||||
|
||||
@@ -6,6 +6,7 @@ Following Django styleguide pattern for business logic encapsulation.
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
from django.db import transaction
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.rides.models import Ride
|
||||
|
||||
|
||||
@@ -191,14 +192,8 @@ class RideStatusService:
|
||||
ride.apply_post_closing_status()
|
||||
transitioned_rides.append(ride)
|
||||
except Exception as e:
|
||||
# Log error but continue processing other rides
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"Failed to process closing ride {ride.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Capture error to dashboard but continue processing other rides
|
||||
capture_and_log(e, f'Process closing ride {ride.id}', source='service')
|
||||
continue
|
||||
|
||||
return transitioned_rides
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
from .models import Ride
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -126,7 +128,13 @@ def handle_ride_transition_to_closing(instance, source, target, user, **kwargs):
|
||||
return True
|
||||
|
||||
if not instance.post_closing_status:
|
||||
logger.error(f"Cannot transition ride {instance.pk} to CLOSING: " "post_closing_status not set")
|
||||
# Capture to dashboard as a validation error
|
||||
capture_and_log(
|
||||
ValueError('post_closing_status not set for CLOSING transition'),
|
||||
f'Ride transition to CLOSING for ride {instance.pk}',
|
||||
source='signal',
|
||||
severity='medium',
|
||||
)
|
||||
return False
|
||||
|
||||
if not instance.closing_date:
|
||||
|
||||
@@ -12,6 +12,8 @@ from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
@@ -59,12 +61,10 @@ def check_overdue_closings() -> dict:
|
||||
failed += 1
|
||||
error_msg = f"Ride {ride.id} ({ride.name}): {str(e)}"
|
||||
failures.append(error_msg)
|
||||
logger.error(
|
||||
"Failed to transition ride %s (%s): %s",
|
||||
ride.id,
|
||||
ride.name,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
capture_and_log(
|
||||
e,
|
||||
f'Transition closing ride {ride.id} ({ride.name})',
|
||||
source='task',
|
||||
)
|
||||
|
||||
result = {
|
||||
|
||||
@@ -132,6 +132,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers
|
||||
"apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting
|
||||
"apps.core.middleware.error_capture.ErrorCaptureMiddleware", # Error capture for monitoring
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"apps.core.middleware.performance_middleware.PerformanceMiddleware", # Performance monitoring
|
||||
"apps.core.middleware.performance_middleware.QueryCountMiddleware", # Database query monitoring
|
||||
|
||||
308
docs/ERROR_HANDLING.md
Normal file
308
docs/ERROR_HANDLING.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Error Handling Guide
|
||||
|
||||
This guide covers ThrillWiki's comprehensive error handling system for both the Django backend and Nuxt frontend.
|
||||
|
||||
## Overview
|
||||
|
||||
The error system captures errors from all sources (frontend, backend, API) and displays them in the admin dashboard at `/admin/errors`.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
```python
|
||||
from apps.core.utils import capture_errors, error_context, capture_and_log
|
||||
|
||||
# Option 1: Decorator on functions/views
|
||||
@capture_errors(severity='high')
|
||||
def create_park(request, data):
|
||||
return ParkService.create(data) # Errors auto-captured
|
||||
|
||||
# Option 2: Context manager for code blocks
|
||||
with error_context('Processing payment', severity='critical'):
|
||||
process_payment()
|
||||
|
||||
# Option 3: Manual capture
|
||||
try:
|
||||
risky_operation()
|
||||
except Exception as e:
|
||||
error_id = capture_and_log(e, 'Risky operation failed')
|
||||
return Response({'error': f'Failed (ref: {error_id})'})
|
||||
```
|
||||
|
||||
### Frontend (TypeScript)
|
||||
|
||||
```typescript
|
||||
// Option 1: useErrorBoundary composable
|
||||
const { wrap, error, isError, retry } = useErrorBoundary({
|
||||
componentName: 'RideDetail'
|
||||
})
|
||||
const ride = await wrap(() => api.get('/rides/1'), 'Loading ride')
|
||||
|
||||
// Option 2: tryCatch utility (Go-style)
|
||||
const [data, err] = await tryCatch(fetchRide(id), 'Fetching ride')
|
||||
if (err) return // Error already reported
|
||||
|
||||
// Option 3: useReportError (manual)
|
||||
const { reportError } = useReportError()
|
||||
try {
|
||||
await riskyOperation()
|
||||
} catch (e) {
|
||||
reportError(e, { action: 'Risky operation' })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Utilities
|
||||
|
||||
### `@capture_errors` Decorator
|
||||
|
||||
Automatically captures exceptions from decorated functions.
|
||||
|
||||
```python
|
||||
from apps.core.utils import capture_errors
|
||||
|
||||
@capture_errors(
|
||||
source='api', # 'frontend', 'backend', or 'api'
|
||||
severity='high', # 'critical', 'high', 'medium', 'low'
|
||||
reraise=True, # Re-raise after capturing (default True)
|
||||
log_errors=True # Also log to Python logger (default True)
|
||||
)
|
||||
def my_view(request):
|
||||
# If this raises, error is automatically captured
|
||||
return do_something()
|
||||
```
|
||||
|
||||
**Use for**: API views, service methods, any function where you want automatic tracking.
|
||||
|
||||
### `error_context` Context Manager
|
||||
|
||||
Captures errors from a code block with rich context.
|
||||
|
||||
```python
|
||||
from apps.core.utils import error_context
|
||||
|
||||
with error_context(
|
||||
'Creating ride submission',
|
||||
source='backend',
|
||||
severity='high',
|
||||
request=request, # Optional: for user/IP context
|
||||
entity_type='Ride', # Optional: what entity
|
||||
entity_id=123, # Optional: which entity
|
||||
reraise=True # Default True
|
||||
):
|
||||
submission = SubmissionService.create(data)
|
||||
```
|
||||
|
||||
**Use for**: Specific operations where you want detailed context about what was happening.
|
||||
|
||||
### `capture_and_log` Function
|
||||
|
||||
One-liner for manual error capture that returns the error ID.
|
||||
|
||||
```python
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
try:
|
||||
result = risky_operation()
|
||||
except Exception as e:
|
||||
error_id = capture_and_log(
|
||||
e,
|
||||
'Risky operation',
|
||||
severity='high',
|
||||
request=request,
|
||||
entity_type='Park',
|
||||
entity_id=42
|
||||
)
|
||||
return Response({'error': f'Failed (ref: {error_id})'}, status=500)
|
||||
```
|
||||
|
||||
**Use for**: When you need the error ID to show to users for support reference.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Utilities
|
||||
|
||||
### `useErrorBoundary` Composable
|
||||
|
||||
Component-level error boundary with retry support.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
error, // Ref<Error | null> - current error
|
||||
isError, // ComputedRef<boolean> - whether error exists
|
||||
lastErrorId, // Ref<string | null> - for support reference
|
||||
clear, // () => void - clear error state
|
||||
retry, // () => Promise<void> - retry last operation
|
||||
wrap, // Wrap async function
|
||||
wrapSync // Wrap sync function
|
||||
} = useErrorBoundary({
|
||||
componentName: 'MyComponent',
|
||||
entityType: 'Ride',
|
||||
defaultSeverity: 'medium',
|
||||
showToast: true,
|
||||
onError: (err, id) => console.log('Error:', err)
|
||||
})
|
||||
|
||||
// In template
|
||||
// <div v-if="isError" class="error">
|
||||
// {{ error?.message }}
|
||||
// <button @click="retry">Try Again</button>
|
||||
// </div>
|
||||
```
|
||||
|
||||
### `tryCatch` Utility
|
||||
|
||||
Go-style error handling for one-liners.
|
||||
|
||||
```typescript
|
||||
import { tryCatch, tryCatchSync } from '~/utils/tryCatch'
|
||||
|
||||
// Async
|
||||
const [ride, error] = await tryCatch(api.get('/rides/1'), 'Loading ride')
|
||||
if (error) {
|
||||
console.log('Failed:', error.message)
|
||||
return
|
||||
}
|
||||
console.log('Got ride:', ride)
|
||||
|
||||
// Sync
|
||||
const [data, err] = tryCatchSync(() => JSON.parse(input), 'Parsing JSON')
|
||||
|
||||
// With options
|
||||
const [data, err] = await tryCatch(fetchData(), 'Fetching', {
|
||||
severity: 'critical',
|
||||
silent: true, // No toast notification
|
||||
entityType: 'Ride',
|
||||
entityId: 123
|
||||
})
|
||||
```
|
||||
|
||||
### `useReportError` Composable
|
||||
|
||||
Full-featured error reporting with maximum context.
|
||||
|
||||
```typescript
|
||||
const { reportError, reportErrorSilent, withErrorReporting } = useReportError()
|
||||
|
||||
// Report with toast
|
||||
await reportError(error, {
|
||||
action: 'Saving ride',
|
||||
componentName: 'EditRideModal',
|
||||
entityType: 'Ride',
|
||||
entityId: 42,
|
||||
severity: 'high',
|
||||
metadata: { custom: 'data' }
|
||||
})
|
||||
|
||||
// Report without toast
|
||||
await reportErrorSilent(error, { action: 'Background task' })
|
||||
|
||||
// Wrap a function
|
||||
const safeFetch = withErrorReporting(fetchRide, {
|
||||
componentName: 'RideDetail',
|
||||
entityType: 'Ride'
|
||||
})
|
||||
```
|
||||
|
||||
### Global Error Plugin
|
||||
|
||||
The `errorPlugin.client.ts` automatically catches:
|
||||
- Vue component errors
|
||||
- Unhandled promise rejections
|
||||
- Global JavaScript errors
|
||||
|
||||
These are reported silently to the dashboard without user intervention.
|
||||
|
||||
---
|
||||
|
||||
## Viewing Errors
|
||||
|
||||
1. Navigate to `/admin/errors` (requires admin role)
|
||||
2. Filter by severity, source, date range, or resolution status
|
||||
3. Click on an error to see full details including:
|
||||
- Stack trace
|
||||
- Browser environment
|
||||
- User context
|
||||
- Request details
|
||||
4. Mark errors as resolved with notes
|
||||
5. Export errors to CSV
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use decorators for views**: All API viewsets should have `@capture_errors`
|
||||
|
||||
2. **Use context managers for critical operations**: Payments, data migrations, bulk operations
|
||||
|
||||
3. **Use `tryCatch` for async code**: Clean, Go-style error handling
|
||||
|
||||
4. **Set appropriate severity**:
|
||||
- `critical`: Database errors, payment failures, data loss
|
||||
- `high`: Unexpected runtime errors, 5xx responses
|
||||
- `medium`: Validation errors, user input issues
|
||||
- `low`: Warnings, graceful degradation
|
||||
|
||||
5. **Include entity context**: Always provide `entity_type` and `entity_id` when operating on specific records
|
||||
|
||||
6. **Don't swallow errors**: Use `reraise=True` (default) unless you have a recovery strategy
|
||||
|
||||
---
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### API ViewSet with Decorator
|
||||
|
||||
```python
|
||||
from rest_framework import viewsets
|
||||
from apps.core.utils import capture_errors
|
||||
|
||||
class RideViewSet(viewsets.ModelViewSet):
|
||||
@capture_errors(source='api')
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
```
|
||||
|
||||
### Vue Component with Error Boundary
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { wrap, isError, error, retry } = useErrorBoundary({
|
||||
componentName: 'RideCard'
|
||||
})
|
||||
|
||||
const ride = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
ride.value = await wrap(() => api.get(`/rides/${props.id}`), 'Loading ride')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isError" class="error-state">
|
||||
<p>{{ error?.message }}</p>
|
||||
<UButton @click="retry">Retry</UButton>
|
||||
</div>
|
||||
<div v-else-if="ride">
|
||||
<!-- Normal content -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Errors not appearing in dashboard?**
|
||||
- Check the backend server logs for capture failures
|
||||
- Verify `MIDDLEWARE` includes `ErrorCaptureMiddleware`
|
||||
- Ensure the `ApplicationError` model is migrated
|
||||
|
||||
**Frontend errors not reporting?**
|
||||
- Check browser console for API call failures
|
||||
- Verify `.env` has correct `NUXT_PUBLIC_API_BASE` (port 8000)
|
||||
- Check network tab for `/api/v1/errors/report/` requests
|
||||
@@ -94,6 +94,85 @@ For every versioned entity, verify:
|
||||
|
||||
---
|
||||
|
||||
## 2. Error Handling MUST Use Capture Utilities
|
||||
|
||||
> [!CAUTION]
|
||||
> **NON-NEGOTIABLE REQUIREMENT**
|
||||
|
||||
All error-prone code on both backend AND frontend MUST use the error capture utilities. Errors should flow to the admin dashboard (`/admin/errors`) for monitoring.
|
||||
|
||||
### Backend Requirements
|
||||
|
||||
Use the utilities from `apps.core.utils`:
|
||||
|
||||
```python
|
||||
from apps.core.utils import capture_errors, error_context, capture_and_log
|
||||
|
||||
# ✅ REQUIRED: Decorator on views and critical functions
|
||||
@capture_errors(source='api', severity='high')
|
||||
def create_park(request, data):
|
||||
return ParkService.create(data)
|
||||
|
||||
# ✅ REQUIRED: Context manager for critical operations
|
||||
with error_context('Processing payment', severity='critical'):
|
||||
process_payment()
|
||||
|
||||
# ✅ ACCEPTABLE: Manual capture when you need the error ID
|
||||
except Exception as e:
|
||||
error_id = capture_and_log(e, 'Operation failed', severity='high')
|
||||
```
|
||||
|
||||
### Frontend Requirements
|
||||
|
||||
Use the composables and utilities:
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Component-level error boundary
|
||||
const { wrap, error, retry } = useErrorBoundary({ componentName: 'RideCard' })
|
||||
const ride = await wrap(() => api.get('/rides/1'), 'Loading ride')
|
||||
|
||||
// ✅ REQUIRED: tryCatch for async operations
|
||||
const [data, error] = await tryCatch(fetchData(), 'Fetching data')
|
||||
|
||||
// ✅ REQUIRED: Report caught errors
|
||||
const { reportError } = useReportError()
|
||||
try { ... } catch (e) { reportError(e, { action: 'Operation' }) }
|
||||
```
|
||||
|
||||
### What Is FORBIDDEN
|
||||
|
||||
```python
|
||||
# ❌ FORBIDDEN: Silent exception swallowing
|
||||
except Exception:
|
||||
pass # VIOLATION! Error lost forever
|
||||
|
||||
# ❌ FORBIDDEN: Logging without dashboard capture
|
||||
except Exception as e:
|
||||
logger.error(e) # VIOLATION! Not visible in dashboard
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ FORBIDDEN: Silent catch
|
||||
catch (e) { console.error(e) } // VIOLATION! Not in dashboard
|
||||
|
||||
// ❌ FORBIDDEN: Uncaptured async errors
|
||||
await riskyOperation() // VIOLATION! No error handling
|
||||
```
|
||||
|
||||
### Compliance Checklist
|
||||
|
||||
- [ ] All API views decorated with `@capture_errors`
|
||||
- [ ] All critical service methods use `error_context`
|
||||
- [ ] All frontend async operations use `tryCatch` or `useErrorBoundary`
|
||||
- [ ] No silent exception swallowing (`except: pass`)
|
||||
- [ ] All caught exceptions reported via utilities
|
||||
|
||||
### Documentation
|
||||
|
||||
Full usage guide: [docs/ERROR_HANDLING.md](file:///Volumes/macminissd/Projects/thrillwiki_django_no_react/docs/ERROR_HANDLING.md)
|
||||
|
||||
---
|
||||
|
||||
## Document Authority
|
||||
|
||||
This document has the same authority as all other `source_docs/` files. Per the `/comply` workflow, these specifications are **immutable law** and must be enforced immediately upon detection of any violation.
|
||||
|
||||
Reference in New Issue
Block a user