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:
pacnpal
2026-01-02 15:55:42 -05:00
parent 1adba1b804
commit 95700c7d7b
43 changed files with 2477 additions and 158 deletions

View File

@@ -43,6 +43,7 @@ from apps.api.v1.serializers.accounts import (
UserPreferencesSerializer, UserPreferencesSerializer,
UserStatisticsSerializer, UserStatisticsSerializer,
) )
from apps.core.utils import capture_and_log
from apps.lists.models import UserList from apps.lists.models import UserList
# Set up logging # Set up logging
@@ -198,16 +199,13 @@ def delete_user_preserve_submissions(request, user_id):
) )
except Exception as e: except Exception as e:
# Log the error for debugging # Capture error to dashboard
logger.error( capture_and_log(
f"Error deleting user {user_id} by admin {request.user.username}: {str(e)}", e,
extra={ f'Delete user {user_id} by admin {request.user.username}',
"admin_user": request.user.username, source='api',
"target_user_id": user_id, request=request,
"detail": str(e), severity='high',
"action": "user_deletion_error",
},
exc_info=True,
) )
return Response( return Response(
@@ -333,7 +331,7 @@ def save_avatar_image(request):
) )
except Exception as api_error: 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( return Response(
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, {"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -357,7 +355,7 @@ def save_avatar_image(request):
service.delete_image(old_avatar) service.delete_image(old_avatar)
logger.info(f"Successfully deleted old avatar from Cloudflare: {old_avatar.cloudflare_id}") logger.info(f"Successfully deleted old avatar from Cloudflare: {old_avatar.cloudflare_id}")
except Exception as e: 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 # Continue with database deletion even if Cloudflare deletion fails
old_avatar.delete() old_avatar.delete()
@@ -390,7 +388,7 @@ def save_avatar_image(request):
) )
except Exception as e: 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( return Response(
{"detail": f"Failed to save avatar: {str(e)}"}, {"detail": f"Failed to save avatar: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -441,7 +439,7 @@ def delete_avatar(request):
service.delete_image(avatar_to_delete) service.delete_image(avatar_to_delete)
logger.info(f"Successfully deleted avatar from Cloudflare: {avatar_to_delete.cloudflare_id}") logger.info(f"Successfully deleted avatar from Cloudflare: {avatar_to_delete.cloudflare_id}")
except Exception as e: 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 # Continue with database deletion even if Cloudflare deletion fails
avatar_to_delete.delete() avatar_to_delete.delete()
@@ -550,16 +548,13 @@ def request_account_deletion(request):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
except Exception as e: except Exception as e:
# Log the error for debugging # Capture error to dashboard
logger.error( capture_and_log(
f"Error creating deletion request for user {request.user.username} (ID: {request.user.user_id}): {str(e)}", e,
extra={ f'Create deletion request for user {request.user.username}',
"user": request.user.username, source='api',
"user_id": request.user.user_id, request=request,
"detail": str(e), severity='high',
"action": "self_deletion_error",
},
exc_info=True,
) )
return Response( return Response(
@@ -1547,7 +1542,7 @@ def export_user_data(request):
export_data = UserExportService.export_user_data(request.user) export_data = UserExportService.export_user_data(request.user)
return Response(export_data, status=status.HTTP_200_OK) return Response(export_data, status=status.HTTP_200_OK)
except Exception as e: 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) return Response({"detail": "Failed to generate data export"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -20,6 +20,7 @@ from drf_spectacular.utils import (
from rest_framework import serializers from rest_framework import serializers
from apps.accounts.models import PasswordReset from apps.accounts.models import PasswordReset
from apps.core.utils import capture_and_log
UserModel = get_user_model() UserModel = get_user_model()
@@ -64,6 +65,7 @@ class UserOutputSerializer(serializers.ModelSerializer):
avatar_url = serializers.SerializerMethodField() avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField() display_name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField()
class Meta: class Meta:
model = UserModel model = UserModel
@@ -74,9 +76,12 @@ class UserOutputSerializer(serializers.ModelSerializer):
"display_name", "display_name",
"date_joined", "date_joined",
"is_active", "is_active",
"is_staff",
"is_superuser",
"role",
"avatar_url", "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): def get_display_name(self, obj):
"""Get the user's display name.""" """Get the user's display name."""
@@ -89,6 +94,15 @@ class UserOutputSerializer(serializers.ModelSerializer):
return obj.profile.get_avatar_url() return obj.profile.get_avatar_url()
return None 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): class LoginInputSerializer(serializers.Serializer):
"""Input serializer for user login.""" """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.") logger.info(f"Verification email sent successfully to {user.email}. No email ID in response.")
except Exception as e: except Exception as e:
# Log the error but don't fail registration # Capture error but don't fail registration
logger.error(f"Failed to send verification email to {user.email}: {e}") capture_and_log(e, f'Send verification email to {user.email}', source='api', severity='low')
class SignupOutputSerializer(serializers.Serializer): class SignupOutputSerializer(serializers.Serializer):

View File

@@ -21,6 +21,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.accounts.services.social_provider_service import SocialProviderService 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) # Import directly from the auth serializers.py file (not the serializers package)
from .serializers import ( from .serializers import (
@@ -188,7 +189,7 @@ class LoginAPIView(APIView):
"access": str(access_token), "access": str(access_token),
"refresh": str(refresh), "refresh": str(refresh),
"user": user, "user": user,
"detail": "Login successful", "message": "Login successful",
} }
) )
return Response(response_serializer.data) return Response(response_serializer.data)
@@ -820,10 +821,7 @@ The ThrillWiki Team
return Response({"detail": "Verification email sent successfully", "success": True}) return Response({"detail": "Verification email sent successfully", "success": True})
except Exception as e: except Exception as e:
import logging capture_and_log(e, 'Send verification email', source='api')
logger = logging.getLogger(__name__)
logger.error(f"Failed to send verification email to {user.email}: {e}")
return Response( return Response(
{"detail": "Failed to send verification email"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR {"detail": "Failed to send verification email"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR

View File

@@ -7,6 +7,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.core.utils import capture_and_log
from apps.core.utils.cloudflare import get_direct_upload_url from apps.core.utils.cloudflare import get_direct_upload_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,11 +22,11 @@ class GenerateUploadURLView(APIView):
result = get_direct_upload_url(user_id=str(request.user.id)) result = get_direct_upload_url(user_id=str(request.user.id))
return Response(result, status=status.HTTP_200_OK) return Response(result, status=status.HTTP_200_OK)
except ImproperlyConfigured as e: 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) return Response({"detail": "Server configuration error."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except requests.RequestException as e: 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) return Response({"detail": "Failed to generate upload URL."}, status=status.HTTP_502_BAD_GATEWAY)
except Exception: except Exception as e:
logger.exception("Unexpected error generating upload URL") 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) return Response({"detail": "An unexpected error occurred."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -38,6 +38,7 @@ from ..serializers.maps import (
MapLocationsResponseSerializer, MapLocationsResponseSerializer,
MapSearchResponseSerializer, MapSearchResponseSerializer,
) )
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -332,7 +333,7 @@ class MapLocationsAPIView(APIView):
return Response(result) return Response(result)
except Exception as e: 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( return Response(
{"status": "error", "detail": "Failed to retrieve map locations"}, {"status": "error", "detail": "Failed to retrieve map locations"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -489,7 +490,7 @@ class MapLocationDetailAPIView(APIView):
) )
except Exception as e: 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( return Response(
{"status": "error", "detail": "Failed to retrieve location details"}, {"status": "error", "detail": "Failed to retrieve location details"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -674,7 +675,7 @@ class MapSearchAPIView(APIView):
) )
except Exception as e: 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( return Response(
{"status": "error", "detail": "Search failed due to internal error"}, {"status": "error", "detail": "Search failed due to internal error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -851,7 +852,7 @@ class MapBoundsAPIView(APIView):
) )
except Exception as e: 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( return Response(
{"status": "error", "detail": "Failed to retrieve locations within bounds"}, {"status": "error", "detail": "Failed to retrieve locations within bounds"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -904,7 +905,7 @@ class MapStatsAPIView(APIView):
) )
except Exception as e: 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( return Response(
{"status": "error", "detail": "Failed to retrieve map statistics"}, {"status": "error", "detail": "Failed to retrieve map statistics"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -956,7 +957,7 @@ class MapCacheAPIView(APIView):
) )
except Exception as e: 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( return Response(
{"status": "error", "detail": "Failed to clear map cache"}, {"status": "error", "detail": "Failed to clear map cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -984,7 +985,7 @@ class MapCacheAPIView(APIView):
) )
except Exception as e: 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( return Response(
{"status": "error", "detail": "Failed to invalidate cache"}, {"status": "error", "detail": "Failed to invalidate cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -14,6 +14,8 @@ from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
from rest_framework.response import Response from rest_framework.response import Response
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -261,7 +263,13 @@ class ContractValidationMiddleware(MiddlewareMixin):
} }
if severity == "ERROR": 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: else:
logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)

View File

@@ -30,6 +30,7 @@ from apps.api.v1.rides.serializers import (
RidePhotoStatsOutputSerializer, RidePhotoStatsOutputSerializer,
RidePhotoUpdateInputSerializer, RidePhotoUpdateInputSerializer,
) )
from apps.core.utils import capture_and_log
from apps.parks.models import Park from apps.parks.models import Park
from apps.rides.models import Ride from apps.rides.models import Ride
from apps.rides.models.media import RidePhoto 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}") logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}")
except Exception as e: 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 raise ValidationError(f"Failed to create photo: {str(e)}") from None
def perform_update(self, serializer): def perform_update(self, serializer):
@@ -203,14 +204,14 @@ class RidePhotoViewSet(ModelViewSet):
if "is_primary" in serializer.validated_data: if "is_primary" in serializer.validated_data:
del serializer.validated_data["is_primary"] del serializer.validated_data["is_primary"]
except Exception as e: 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 raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
try: try:
serializer.save() serializer.save()
logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}") logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}")
except Exception as e: 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 raise ValidationError(f"Failed to update photo: {str(e)}") from None
def perform_destroy(self, instance): def perform_destroy(self, instance):
@@ -229,14 +230,14 @@ class RidePhotoViewSet(ModelViewSet):
service.delete_image(instance.image) service.delete_image(instance.image)
logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}") logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
except Exception as e: 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 # Continue with database deletion even if Cloudflare deletion fails
RideMediaService.delete_photo(instance, deleted_by=self.request.user) RideMediaService.delete_photo(instance, deleted_by=self.request.user)
logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}") logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}")
except Exception as e: 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 raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema( @extend_schema(
@@ -281,7 +282,7 @@ class RidePhotoViewSet(ModelViewSet):
) )
except Exception as e: 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( return Response(
{"detail": f"Failed to set primary photo: {str(e)}"}, {"detail": f"Failed to set primary photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -339,7 +340,7 @@ class RidePhotoViewSet(ModelViewSet):
) )
except Exception as e: 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( return Response(
{"detail": f"Failed to update photos: {str(e)}"}, {"detail": f"Failed to update photos: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -387,7 +388,7 @@ class RidePhotoViewSet(ModelViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: 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( return Response(
{"detail": f"Failed to get photo statistics: {str(e)}"}, {"detail": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -491,7 +492,7 @@ class RidePhotoViewSet(ModelViewSet):
) )
except Exception as api_error: 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( return Response(
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, {"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -514,14 +515,14 @@ class RidePhotoViewSet(ModelViewSet):
try: try:
RideMediaService.set_primary_photo(ride=ride, photo=photo) RideMediaService.set_primary_photo(ride=ride, photo=photo)
except Exception as e: 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 # Don't fail the entire operation, just log the error
serializer = RidePhotoOutputSerializer(photo, context={"request": request}) serializer = RidePhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e: 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( return Response(
{"detail": f"Failed to save photo: {str(e)}"}, {"detail": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,

View File

@@ -31,6 +31,7 @@ from apps.api.v1.serializers.ride_reviews import (
RideReviewStatsOutputSerializer, RideReviewStatsOutputSerializer,
RideReviewUpdateInputSerializer, RideReviewUpdateInputSerializer,
) )
from apps.core.utils import capture_and_log
from apps.parks.models import Park from apps.parks.models import Park
from apps.rides.models import Ride from apps.rides.models import Ride
from apps.rides.models.reviews import RideReview 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}") logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}")
except Exception as e: 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 raise ValidationError(f"Failed to create review: {str(e)}") from None
def perform_update(self, serializer): def perform_update(self, serializer):
@@ -196,7 +197,7 @@ class RideReviewViewSet(ModelViewSet):
serializer.save() serializer.save()
logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}") logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}")
except Exception as e: 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 raise ValidationError(f"Failed to update review: {str(e)}") from None
def perform_destroy(self, instance): 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}") logger.info(f"Deleting ride review {instance.id} by user {self.request.user.username}")
instance.delete() instance.delete()
except Exception as e: 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 raise ValidationError(f"Failed to delete review: {str(e)}") from None
@extend_schema( @extend_schema(
@@ -283,7 +284,7 @@ class RideReviewViewSet(ModelViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: 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( return Response(
{"detail": f"Failed to get review statistics: {str(e)}"}, {"detail": f"Failed to get review statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -360,7 +361,7 @@ class RideReviewViewSet(ModelViewSet):
) )
except Exception as e: 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( return Response(
{"detail": f"Failed to moderate reviews: {str(e)}"}, {"detail": f"Failed to moderate reviews: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,

View File

@@ -32,6 +32,7 @@ from apps.core.exceptions import (
ServiceError, ServiceError,
ValidationException, ValidationException,
) )
from apps.core.utils import capture_and_log
from apps.core.utils.error_handling import ErrorHandler from apps.core.utils.error_handling import ErrorHandler
from apps.parks.models import Park, ParkPhoto from apps.parks.models import Park, ParkPhoto
from apps.parks.services import ParkMediaService from apps.parks.services import ParkMediaService
@@ -188,7 +189,7 @@ class ParkPhotoViewSet(ModelViewSet):
logger.warning(f"Validation error creating park photo: {e}") logger.warning(f"Validation error creating park photo: {e}")
raise ValidationError(str(e)) from None raise ValidationError(str(e)) from None
except ServiceError as e: 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 raise ValidationError(f"Failed to create photo: {str(e)}") from None
def perform_update(self, serializer): def perform_update(self, serializer):
@@ -210,7 +211,7 @@ class ParkPhotoViewSet(ModelViewSet):
logger.warning(f"Validation error setting primary photo: {e}") logger.warning(f"Validation error setting primary photo: {e}")
raise ValidationError(str(e)) from None raise ValidationError(str(e)) from None
except ServiceError as e: 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 raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
def perform_destroy(self, instance): def perform_destroy(self, instance):
@@ -232,13 +233,13 @@ class ParkPhotoViewSet(ModelViewSet):
except ImportError: except ImportError:
logger.warning("CloudflareImagesService not available") logger.warning("CloudflareImagesService not available")
except ServiceError as e: 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 # Continue with database deletion even if Cloudflare deletion fails
try: try:
ParkMediaService().delete_photo(instance.id, deleted_by=cast(UserModel, self.request.user)) ParkMediaService().delete_photo(instance.id, deleted_by=cast(UserModel, self.request.user))
except ServiceError as e: 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 raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema( @extend_schema(
@@ -539,14 +540,14 @@ class ParkPhotoViewSet(ModelViewSet):
try: try:
ParkMediaService().set_primary_photo(park_id=park.id, photo_id=photo.id) ParkMediaService().set_primary_photo(park_id=park.id, photo_id=photo.id)
except ServiceError as e: 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 # Don't fail the entire operation, just log the error
serializer = ParkPhotoOutputSerializer(photo, context={"request": request}) serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
except ImportError: except ImportError as e:
logger.error("CloudflareImagesService not available") capture_and_log(e, 'Cloudflare service import', source='api')
return ErrorHandler.handle_api_error( return ErrorHandler.handle_api_error(
ServiceError("Cloudflare Images service not available"), ServiceError("Cloudflare Images service not available"),
user_message="Image upload service not available", user_message="Image upload service not available",

View File

@@ -31,6 +31,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet 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.models import Ride, RidePhoto
from apps.rides.services.media_service import RideMediaService from apps.rides.services.media_service import RideMediaService
@@ -39,6 +40,7 @@ UserModel = get_user_model()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@extend_schema_view( @extend_schema_view(
list=extend_schema( list=extend_schema(
summary="List ride photos", summary="List ride photos",
@@ -166,7 +168,7 @@ class RidePhotoViewSet(ModelViewSet):
serializer.instance = photo serializer.instance = photo
except Exception as e: 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 raise ValidationError(f"Failed to create photo: {str(e)}") from None
def perform_update(self, serializer): def perform_update(self, serializer):
@@ -185,7 +187,7 @@ class RidePhotoViewSet(ModelViewSet):
if "is_primary" in serializer.validated_data: if "is_primary" in serializer.validated_data:
del serializer.validated_data["is_primary"] del serializer.validated_data["is_primary"]
except Exception as e: 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 raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
def perform_destroy(self, instance): def perform_destroy(self, instance):
@@ -204,12 +206,12 @@ class RidePhotoViewSet(ModelViewSet):
service.delete_image(instance.image) service.delete_image(instance.image)
logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}") logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
except Exception as e: 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 # Continue with database deletion even if Cloudflare deletion fails
RideMediaService.delete_photo(instance, deleted_by=self.request.user) # type: ignore RideMediaService.delete_photo(instance, deleted_by=self.request.user) # type: ignore
except Exception as e: 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 raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema( @extend_schema(
@@ -254,7 +256,7 @@ class RidePhotoViewSet(ModelViewSet):
) )
except Exception as e: 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( return Response(
{"detail": f"Failed to set primary photo: {str(e)}"}, {"detail": f"Failed to set primary photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -308,7 +310,7 @@ class RidePhotoViewSet(ModelViewSet):
) )
except Exception as e: 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( return Response(
{"detail": f"Failed to update photos: {str(e)}"}, {"detail": f"Failed to update photos: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -356,7 +358,7 @@ class RidePhotoViewSet(ModelViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: 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( return Response(
{"detail": f"Failed to get photo statistics: {str(e)}"}, {"detail": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -392,7 +394,7 @@ class RidePhotoViewSet(ModelViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
except Exception as e: 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) return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema( @extend_schema(
@@ -486,7 +488,7 @@ class RidePhotoViewSet(ModelViewSet):
) )
except Exception as api_error: 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( return Response(
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, {"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@@ -509,14 +511,14 @@ class RidePhotoViewSet(ModelViewSet):
try: try:
RideMediaService.set_primary_photo(ride=ride, photo=photo) RideMediaService.set_primary_photo(ride=ride, photo=photo)
except Exception as e: 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 # Don't fail the entire operation, just log the error
serializer = RidePhotoOutputSerializer(photo, context={"request": request}) serializer = RidePhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e: except Exception as e:
logger.error(f"Error saving ride photo: {e}") capture_and_log(e, 'Save ride photo', source='api')
return Response( return Response(
{"detail": f"Failed to save photo: {str(e)}"}, {"detail": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,

View File

@@ -46,6 +46,7 @@ from apps.api.v1.serializers.rides import (
RideUpdateInputSerializer, RideUpdateInputSerializer,
) )
from apps.core.decorators.cache_decorators import cache_api_response 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 from apps.rides.services.hybrid_loader import SmartRideLoader
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -2059,7 +2060,7 @@ class HybridRideAPIView(APIView):
return Response(response_data, status=status.HTTP_200_OK) return Response(response_data, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
logger.error(f"Error in HybridRideAPIView: {e}") capture_and_log(e, 'Get hybrid rides', source='api')
return Response( return Response(
{"detail": "Internal server error"}, {"detail": "Internal server error"},
status=status.HTTP_500_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) return Response(metadata, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
logger.error(f"Error in RideFilterMetadataAPIView: {e}") capture_and_log(e, 'Get ride filter metadata', source='api')
return Response( return Response(
{"detail": "Internal server error"}, {"detail": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -10,10 +10,13 @@ from rest_framework.routers import DefaultRouter
# Import other views from the views directory # Import other views from the views directory
from .views import ( from .views import (
CoasterStatisticsAPIView,
DataCompletenessAPIView,
HealthCheckAPIView, HealthCheckAPIView,
NewContentAPIView, NewContentAPIView,
PerformanceMetricsAPIView, PerformanceMetricsAPIView,
SimpleHealthAPIView, SimpleHealthAPIView,
TechnicalSpecificationsAPIView,
# Trending system views # Trending system views
TrendingAPIView, TrendingAPIView,
TriggerTrendingCalculationAPIView, TriggerTrendingCalculationAPIView,
@@ -71,6 +74,23 @@ urlpatterns = [
TriggerRankingCalculationView.as_view(), TriggerRankingCalculationView.as_view(),
name="trigger-ranking-calculation", 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 # Domain-specific API endpoints
path("parks/", include("apps.api.v1.parks.urls")), path("parks/", include("apps.api.v1.parks.urls")),
path("rides/", include("apps.api.v1.rides.urls")), path("rides/", include("apps.api.v1.rides.urls")),
@@ -86,9 +106,11 @@ urlpatterns = [
path("media/", include("apps.media.urls")), path("media/", include("apps.media.urls")),
path("blog/", include("apps.blog.urls")), path("blog/", include("apps.blog.urls")),
path("support/", include("apps.support.urls")), path("support/", include("apps.support.urls")),
path("errors/", include("apps.core.urls.errors")),
path("images/", include("apps.api.v1.images.urls")), path("images/", include("apps.api.v1.images.urls")),
# Cloudflare Images Toolkit API endpoints # Cloudflare Images Toolkit API endpoints
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")), path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
# Include router URLs (for rankings and any other router-registered endpoints) # Include router URLs (for rankings and any other router-registered endpoints)
path("", include(router.urls)), path("", include(router.urls)),
] ]

View File

@@ -5,9 +5,15 @@ This package contains all API view classes organized by functionality:
- auth.py: Authentication and user management views - auth.py: Authentication and user management views
- health.py: Health check and monitoring views - health.py: Health check and monitoring views
- trending.py: Trending and new content discovery 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 # Import all view classes for easy access
from .admin import (
CoasterStatisticsAPIView,
DataCompletenessAPIView,
TechnicalSpecificationsAPIView,
)
from .auth import ( from .auth import (
AuthStatusAPIView, AuthStatusAPIView,
CurrentUserAPIView, CurrentUserAPIView,
@@ -31,6 +37,10 @@ from .trending import (
# Export all views for import convenience # Export all views for import convenience
__all__ = [ __all__ = [
# Admin views
"DataCompletenessAPIView",
"TechnicalSpecificationsAPIView",
"CoasterStatisticsAPIView",
# Authentication views # Authentication views
"LoginAPIView", "LoginAPIView",
"SignupAPIView", "SignupAPIView",
@@ -49,3 +59,4 @@ __all__ = [
"NewContentAPIView", "NewContentAPIView",
"TriggerTrendingCalculationAPIView", "TriggerTrendingCalculationAPIView",
] ]

View 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,
)

View File

@@ -15,6 +15,7 @@ from rest_framework.serializers import Serializer
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.api.v1.serializers.shared import validate_filter_metadata_contract from apps.api.v1.serializers.shared import validate_filter_metadata_contract
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,17 +46,12 @@ class ContractCompliantAPIView(APIView):
return response return response
except Exception as e: except Exception as e:
# Log the error with context # Capture error to dashboard
logger.error( capture_and_log(
f"API error in {self.__class__.__name__}: {str(e)}", e,
extra={ f'API error in {self.__class__.__name__}',
"view_class": self.__class__.__name__, source='api',
"request_path": request.path, severity='high',
"request_method": request.method,
"user": getattr(request, "user", None),
"detail": str(e),
},
exc_info=True,
) )
# Return standardized error response # Return standardized error response
@@ -194,10 +190,10 @@ class FilterMetadataAPIView(ContractCompliantAPIView):
return self.success_response(validated_metadata) return self.success_response(validated_metadata)
except Exception as e: except Exception as e:
logger.error( capture_and_log(
f"Error getting filter metadata in {self.__class__.__name__}: {str(e)}", e,
extra={"view_class": self.__class__.__name__, "detail": str(e)}, f'Get filter metadata in {self.__class__.__name__}',
exc_info=True, source='api',
) )
return self.error_response(message="Failed to retrieve filter metadata", error_code="FILTER_METADATA_ERROR") 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) return self.success_response(hybrid_data)
except Exception as e: except Exception as e:
logger.error( capture_and_log(
f"Error in hybrid filtering for {self.__class__.__name__}: {str(e)}", e,
extra={ f'Hybrid filtering for {self.__class__.__name__}',
"view_class": self.__class__.__name__, source='api',
"filters": getattr(self, "_extracted_filters", {}),
"detail": str(e),
},
exc_info=True,
) )
return self.error_response(message="Failed to retrieve filtered data", error_code="HYBRID_FILTERING_ERROR") 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 return response
except Exception as e: 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 basic error response
return Response( return Response(

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

View 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,
}
)

View 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 ""

View File

@@ -17,6 +17,7 @@ from django.http import HttpRequest, HttpResponse
from django.utils import timezone from django.utils import timezone
from apps.core.analytics import PageView from apps.core.analytics import PageView
from apps.core.utils import capture_and_log
from apps.parks.models import Park from apps.parks.models import Park
from apps.rides.models import Ride from apps.rides.models import Ride
@@ -65,8 +66,8 @@ class ViewTrackingMiddleware:
try: try:
self._track_view_if_applicable(request) self._track_view_if_applicable(request)
except Exception as e: except Exception as e:
# Log error but don't break the request # Capture error but don't break the request
self.logger.error(f"Error tracking view: {e}", exc_info=True) capture_and_log(e, f'Track view for {request.path}', source='middleware', severity='low')
return response return response
@@ -137,7 +138,7 @@ class ViewTrackingMiddleware:
self.logger.debug(f"Recorded view for {content_type} {slug} from {client_ip}") self.logger.debug(f"Recorded view for {content_type} {slug} from {client_ip}")
except Exception as e: 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: def _get_content_object(self, content_type: str, slug: str) -> ContentObject | None:
"""Get the content object by type and slug.""" """Get the content object by type and slug."""
@@ -156,7 +157,7 @@ class ViewTrackingMiddleware:
except Park.DoesNotExist: except Park.DoesNotExist:
return None return None
except Exception as e: 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 return None
def _is_duplicate_view(self, content_obj: ContentObject, client_ip: str) -> bool: 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: 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)} return {"total_views": 0, "unique_views": 0, "hours": hours, "error": str(e)}

View 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"),
],
},
),
]

View File

@@ -1,4 +1,8 @@
import hashlib
import uuid
import pghistory import pghistory
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models 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 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]

View File

@@ -3,6 +3,7 @@ Core services for ThrillWiki unified map functionality.
""" """
from .clustering_service import ClusteringService from .clustering_service import ClusteringService
from .error_service import ErrorService
from .data_structures import ( from .data_structures import (
ClusterData, ClusterData,
GeoBounds, GeoBounds,
@@ -17,6 +18,7 @@ from .map_service import UnifiedMapService
__all__ = [ __all__ = [
"UnifiedMapService", "UnifiedMapService",
"ClusteringService", "ClusteringService",
"ErrorService",
"MapCacheService", "MapCacheService",
"UnifiedLocation", "UnifiedLocation",
"LocationType", "LocationType",

View File

@@ -12,6 +12,8 @@ from typing import Any
from django.core.cache import caches from django.core.cache import caches
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -122,7 +124,7 @@ class EnhancedCacheService:
else: else:
logger.warning(f"Cache backend does not support pattern deletion for pattern '{pattern}'") logger.warning(f"Cache backend does not support pattern deletion for pattern '{pattern}'")
except Exception as e: 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): def invalidate_model_cache(self, model_name: str, instance_id: int | None = None):
"""Invalidate cache keys related to a specific model""" """Invalidate cache keys related to a specific model"""
@@ -144,7 +146,7 @@ class EnhancedCacheService:
self.default_cache.set(cache_key, data, timeout) self.default_cache.set(cache_key, data, timeout)
logger.info(f"Warmed cache for key '{cache_key}'") logger.info(f"Warmed cache for key '{cache_key}'")
except Exception as e: 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: def _generate_api_cache_key(self, view_name: str, params: dict) -> str:
"""Generate consistent cache keys for API responses""" """Generate consistent cache keys for API responses"""
@@ -250,7 +252,7 @@ class CacheWarmer:
try: try:
self.cache_service.warm_cache(**operation) self.cache_service.warm_cache(**operation)
except Exception as e: 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 # Cache statistics and monitoring

View 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", "")

View File

@@ -20,6 +20,7 @@ from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from apps.core.analytics import PageView from apps.core.analytics import PageView
from apps.core.utils import capture_and_log
from apps.parks.models import Park from apps.parks.models import Park
from apps.rides.models import Ride from apps.rides.models import Ride
@@ -105,7 +106,7 @@ class TrendingService:
return formatted_results return formatted_results
except Exception as e: 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 [] return []
def get_new_content( def get_new_content(
@@ -164,7 +165,7 @@ class TrendingService:
return formatted_results return formatted_results
except Exception as e: 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 [] return []
def _calculate_trending_parks(self, limit: int) -> list[dict[str, Any]]: def _calculate_trending_parks(self, limit: int) -> list[dict[str, Any]]:
@@ -311,7 +312,7 @@ class TrendingService:
return final_score return final_score
except Exception as e: 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 return 0.0
def _calculate_view_growth_score(self, content_type: ContentType, object_id: int) -> float: 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}") self.logger.info(f"Cleared trending caches for {content_type}")
except Exception as e: 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 # Singleton service instance

View File

@@ -16,6 +16,7 @@ from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from apps.core.analytics import PageView from apps.core.analytics import PageView
from apps.core.utils import capture_and_log
from apps.parks.models import Park from apps.parks.models import Park
from apps.rides.models import Ride 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: 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 # Retry the task
raise self.retry(exc=e) from None 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: 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 raise self.retry(exc=e) from None
@@ -185,7 +186,7 @@ def warm_trending_cache(self) -> dict[str, Any]:
} }
except Exception as e: 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 { return {
"success": False, "success": False,
"error": str(e), "error": str(e),
@@ -309,7 +310,7 @@ def _calculate_content_score(
return final_score return final_score
except Exception as e: 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 return 0.0

View File

@@ -43,4 +43,6 @@ urlpatterns = [
path("entities/", include(entity_patterns)), path("entities/", include(entity_patterns)),
# FSM transition endpoints # FSM transition endpoints
path("fsm/", include(fsm_patterns)), path("fsm/", include(fsm_patterns)),
# Error monitoring endpoints (API)
path("errors/", include("apps.core.urls.errors", namespace="errors")),
] ]

View 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"),
]

View File

@@ -12,6 +12,11 @@ from .breadcrumbs import (
build_breadcrumb, build_breadcrumb,
get_model_breadcrumb, get_model_breadcrumb,
) )
from .capture_errors import (
capture_and_log,
capture_errors,
error_context,
)
from .messages import ( from .messages import (
confirm_delete, confirm_delete,
error_network, error_network,
@@ -47,6 +52,10 @@ __all__ = [
"breadcrumbs_to_schema", "breadcrumbs_to_schema",
"build_breadcrumb", "build_breadcrumb",
"get_model_breadcrumb", "get_model_breadcrumb",
# Error Capture
"capture_and_log",
"capture_errors",
"error_context",
# Messages # Messages
"confirm_delete", "confirm_delete",
"error_network", "error_network",
@@ -73,3 +82,4 @@ __all__ = [
"get_og_image", "get_og_image",
"get_twitter_card_type", "get_twitter_card_type",
] ]

View 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"

View File

@@ -4,6 +4,8 @@ import requests
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from .capture_errors import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -47,7 +49,9 @@ def get_direct_upload_url(user_id=None):
if not result.get("success"): if not result.get("success"):
error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message") error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message")
logger.error(f"Cloudflare Direct Upload Error: {error_msg}") # Create error for capture
raise requests.RequestException(f"Cloudflare Error: {error_msg}") e = requests.RequestException(f"Cloudflare Error: {error_msg}")
capture_and_log(e, 'Cloudflare direct upload', source='service')
raise e
return result.get("result", {}) return result.get("result", {})

View File

@@ -18,6 +18,7 @@ from django.views.decorators.gzip import gzip_page
from ..services.data_structures import GeoBounds, LocationType, MapFilters from ..services.data_structures import GeoBounds, LocationType, MapFilters
from ..services.map_service import unified_map_service from ..services.map_service import unified_map_service
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -51,10 +52,7 @@ class MapAPIView(View):
return response return response
except Exception as e: except Exception as e:
logger.error( capture_and_log(e, f'Map API dispatch {request.path}', source='api')
f"API error in {request.path}: {str(e)}",
exc_info=True,
)
return self._error_response("An internal server error occurred", status=500) return self._error_response("An internal server error occurred", status=500)
def options(self, request, *args, **kwargs): def options(self, request, *args, **kwargs):
@@ -373,7 +371,7 @@ class MapLocationsView(MapAPIView):
logger.warning(f"Validation error in MapLocationsView: {str(e)}") logger.warning(f"Validation error in MapLocationsView: {str(e)}")
return self._error_response(str(e), 400, error_code="VALIDATION_ERROR") return self._error_response(str(e), 400, error_code="VALIDATION_ERROR")
except Exception as e: 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( return self._error_response(
"Failed to retrieve map locations", "Failed to retrieve map locations",
500, 500,
@@ -433,10 +431,7 @@ class MapLocationDetailView(MapAPIView):
logger.warning(f"Value error in MapLocationDetailView: {str(e)}") logger.warning(f"Value error in MapLocationDetailView: {str(e)}")
return self._error_response(str(e), 400, error_code="INVALID_PARAMETER") return self._error_response(str(e), 400, error_code="INVALID_PARAMETER")
except Exception as e: except Exception as e:
logger.error( capture_and_log(e, 'MapLocationDetailView get', source='api')
f"Error in MapLocationDetailView: {str(e)}",
exc_info=True,
)
return self._error_response( return self._error_response(
"Failed to retrieve location details", "Failed to retrieve location details",
500, 500,
@@ -529,7 +524,7 @@ class MapSearchView(MapAPIView):
logger.warning(f"Value error in MapSearchView: {str(e)}") logger.warning(f"Value error in MapSearchView: {str(e)}")
return self._error_response(str(e), 400, error_code="INVALID_PARAMETER") return self._error_response(str(e), 400, error_code="INVALID_PARAMETER")
except Exception as e: 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( return self._error_response(
"Search failed due to internal error", "Search failed due to internal error",
500, 500,

View File

@@ -11,6 +11,7 @@ from django.core.cache import cache
from django.db import transaction from django.db import transaction
from ..models import ParkLocation from ..models import ParkLocation
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -90,7 +91,7 @@ class ParkLocationService:
return result_data return result_data
except requests.RequestException as e: except requests.RequestException as e:
logger.error(f"Error searching park locations: {str(e)}") capture_and_log(e, 'Search park locations', source='service')
return { return {
"count": 0, "count": 0,
"results": [], "results": [],
@@ -156,7 +157,7 @@ class ParkLocationService:
return result return result
except requests.RequestException as e: 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"} return {"error": "Reverse geocoding service temporarily unavailable"}
@classmethod @classmethod

View File

@@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile
from django.db import transaction from django.db import transaction
from apps.core.services.media_service import MediaService from apps.core.services.media_service import MediaService
from apps.core.utils import capture_and_log
from ..models import Park, ParkPhoto from ..models import Park, ParkPhoto
@@ -164,7 +165,7 @@ class ParkMediaService:
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}") logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
return True return True
except Exception as e: 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 return False
@staticmethod @staticmethod
@@ -191,7 +192,7 @@ class ParkMediaService:
logger.info(f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}") logger.info(f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}")
return True return True
except Exception as e: 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 return False
@staticmethod @staticmethod

View File

@@ -23,6 +23,7 @@ from django.contrib.gis.measure import Distance
from django.core.cache import cache from django.core.cache import cache
from apps.parks.models import Park from apps.parks.models import Park
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -242,7 +243,7 @@ class RoadTripService:
return None return None
except Exception as e: except Exception as e:
logger.error(f"Geocoding failed for '{address}': {e}") capture_and_log(e, f"Geocode address '{address}'", source='service')
return None return None
def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo | 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) return self._calculate_straight_line_route(start_coords, end_coords)
except Exception as e: except Exception as e:
logger.error(f"Route calculation failed: {e}") capture_and_log(e, 'Calculate route', source='service')
# Fallback to straight-line distance # Fallback to straight-line distance
return self._calculate_straight_line_route(start_coords, end_coords) 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 return max(0, detour_distance) # Don't return negative detours
except Exception as e: 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 return None
def create_multi_park_trip(self, park_list: list["Park"]) -> RoadTrip | None: def create_multi_park_trip(self, park_list: list["Park"]) -> RoadTrip | None:

View File

@@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile
from django.db import transaction from django.db import transaction
from apps.core.services.media_service import MediaService from apps.core.services.media_service import MediaService
from apps.core.utils import capture_and_log
from ..models import Ride, RidePhoto from ..models import Ride, RidePhoto
@@ -190,7 +191,7 @@ class RideMediaService:
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}") logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
return True return True
except Exception as e: 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 return False
@staticmethod @staticmethod
@@ -217,7 +218,7 @@ class RideMediaService:
logger.info(f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}") logger.info(f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}")
return True return True
except Exception as e: 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 return False
@staticmethod @staticmethod

View File

@@ -21,6 +21,7 @@ from apps.rides.models import (
RideRanking, RideRanking,
RideReview, RideReview,
) )
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -96,7 +97,7 @@ class RideRankingService:
} }
except Exception as e: 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 raise
def _get_eligible_rides(self, category: str | None = None) -> list[Ride]: def _get_eligible_rides(self, category: str | None = None) -> list[Ride]:

View File

@@ -6,6 +6,7 @@ Following Django styleguide pattern for business logic encapsulation.
from django.contrib.auth.models import AbstractBaseUser from django.contrib.auth.models import AbstractBaseUser
from django.db import transaction from django.db import transaction
from apps.core.utils import capture_and_log
from apps.rides.models import Ride from apps.rides.models import Ride
@@ -191,14 +192,8 @@ class RideStatusService:
ride.apply_post_closing_status() ride.apply_post_closing_status()
transitioned_rides.append(ride) transitioned_rides.append(ride)
except Exception as e: except Exception as e:
# Log error but continue processing other rides # Capture error to dashboard but continue processing other rides
import logging capture_and_log(e, f'Process closing ride {ride.id}', source='service')
logger = logging.getLogger(__name__)
logger.error(
f"Failed to process closing ride {ride.id}: {e}",
exc_info=True,
)
continue continue
return transitioned_rides return transitioned_rides

View File

@@ -4,6 +4,8 @@ from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from apps.core.utils import capture_and_log
from .models import Ride from .models import Ride
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -126,7 +128,13 @@ def handle_ride_transition_to_closing(instance, source, target, user, **kwargs):
return True return True
if not instance.post_closing_status: 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 return False
if not instance.closing_date: if not instance.closing_date:

View File

@@ -12,6 +12,8 @@ from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@@ -59,12 +61,10 @@ def check_overdue_closings() -> dict:
failed += 1 failed += 1
error_msg = f"Ride {ride.id} ({ride.name}): {str(e)}" error_msg = f"Ride {ride.id} ({ride.name}): {str(e)}"
failures.append(error_msg) failures.append(error_msg)
logger.error( capture_and_log(
"Failed to transition ride %s (%s): %s", e,
ride.id, f'Transition closing ride {ride.id} ({ride.name})',
ride.name, source='task',
str(e),
exc_info=True,
) )
result = { result = {

View File

@@ -132,6 +132,7 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers "apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers
"apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting "apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting
"apps.core.middleware.error_capture.ErrorCaptureMiddleware", # Error capture for monitoring
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
"apps.core.middleware.performance_middleware.PerformanceMiddleware", # Performance monitoring "apps.core.middleware.performance_middleware.PerformanceMiddleware", # Performance monitoring
"apps.core.middleware.performance_middleware.QueryCountMiddleware", # Database query monitoring "apps.core.middleware.performance_middleware.QueryCountMiddleware", # Database query monitoring

308
docs/ERROR_HANDLING.md Normal file
View 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

View File

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