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

View File

@@ -20,6 +20,7 @@ from drf_spectacular.utils import (
from rest_framework import serializers
from apps.accounts.models import PasswordReset
from apps.core.utils import capture_and_log
UserModel = get_user_model()
@@ -64,6 +65,7 @@ class UserOutputSerializer(serializers.ModelSerializer):
avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField()
class Meta:
model = UserModel
@@ -74,9 +76,12 @@ class UserOutputSerializer(serializers.ModelSerializer):
"display_name",
"date_joined",
"is_active",
"is_staff",
"is_superuser",
"role",
"avatar_url",
]
read_only_fields = ["id", "date_joined", "is_active"]
read_only_fields = ["id", "date_joined", "is_active", "is_staff", "is_superuser", "role"]
def get_display_name(self, obj):
"""Get the user's display name."""
@@ -89,6 +94,15 @@ class UserOutputSerializer(serializers.ModelSerializer):
return obj.profile.get_avatar_url()
return None
@extend_schema_field(serializers.CharField())
def get_role(self, obj) -> str:
"""Compute effective role based on permissions."""
if obj.is_superuser:
return "SUPERUSER"
if obj.is_staff:
return "ADMIN"
return "USER"
class LoginInputSerializer(serializers.Serializer):
"""Input serializer for user login."""
@@ -235,8 +249,8 @@ The ThrillWiki Team
logger.info(f"Verification email sent successfully to {user.email}. No email ID in response.")
except Exception as e:
# Log the error but don't fail registration
logger.error(f"Failed to send verification email to {user.email}: {e}")
# Capture error but don't fail registration
capture_and_log(e, f'Send verification email to {user.email}', source='api', severity='low')
class SignupOutputSerializer(serializers.Serializer):

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ from ..serializers.maps import (
MapLocationsResponseSerializer,
MapSearchResponseSerializer,
)
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__)
@@ -332,7 +333,7 @@ class MapLocationsAPIView(APIView):
return Response(result)
except Exception as e:
logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True)
capture_and_log(e, 'Get map locations', source='api')
return Response(
{"status": "error", "detail": "Failed to retrieve map locations"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -489,7 +490,7 @@ class MapLocationDetailAPIView(APIView):
)
except Exception as e:
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
capture_and_log(e, 'Get map location detail', source='api')
return Response(
{"status": "error", "detail": "Failed to retrieve location details"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -674,7 +675,7 @@ class MapSearchAPIView(APIView):
)
except Exception as e:
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
capture_and_log(e, 'Map search', source='api')
return Response(
{"status": "error", "detail": "Search failed due to internal error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -851,7 +852,7 @@ class MapBoundsAPIView(APIView):
)
except Exception as e:
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
capture_and_log(e, 'Get map bounds', source='api')
return Response(
{"status": "error", "detail": "Failed to retrieve locations within bounds"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -904,7 +905,7 @@ class MapStatsAPIView(APIView):
)
except Exception as e:
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
capture_and_log(e, 'Get map stats', source='api')
return Response(
{"status": "error", "detail": "Failed to retrieve map statistics"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -956,7 +957,7 @@ class MapCacheAPIView(APIView):
)
except Exception as e:
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
capture_and_log(e, 'Clear map cache', source='api')
return Response(
{"status": "error", "detail": "Failed to clear map cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -984,7 +985,7 @@ class MapCacheAPIView(APIView):
)
except Exception as e:
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
capture_and_log(e, 'Invalidate map cache', source='api')
return Response(
{"status": "error", "detail": "Failed to invalidate cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -14,6 +14,8 @@ from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
from rest_framework.response import Response
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__)
@@ -261,7 +263,13 @@ class ContractValidationMiddleware(MiddlewareMixin):
}
if severity == "ERROR":
logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
# Contract violations are development issues - capture for visibility
capture_and_log(
ValueError(message),
f'Contract violation [{violation_type}] on {path}',
source='middleware',
severity='medium',
)
else:
logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)

View File

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

View File

@@ -31,6 +31,7 @@ from apps.api.v1.serializers.ride_reviews import (
RideReviewStatsOutputSerializer,
RideReviewUpdateInputSerializer,
)
from apps.core.utils import capture_and_log
from apps.parks.models import Park
from apps.rides.models import Ride
from apps.rides.models.reviews import RideReview
@@ -181,7 +182,7 @@ class RideReviewViewSet(ModelViewSet):
logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}")
except Exception as e:
logger.error(f"Error creating ride review: {e}")
capture_and_log(e, 'Create ride review', source='api', request=self.request)
raise ValidationError(f"Failed to create review: {str(e)}") from None
def perform_update(self, serializer):
@@ -196,7 +197,7 @@ class RideReviewViewSet(ModelViewSet):
serializer.save()
logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}")
except Exception as e:
logger.error(f"Error updating ride review: {e}")
capture_and_log(e, 'Update ride review', source='api', request=self.request)
raise ValidationError(f"Failed to update review: {str(e)}") from None
def perform_destroy(self, instance):
@@ -209,7 +210,7 @@ class RideReviewViewSet(ModelViewSet):
logger.info(f"Deleting ride review {instance.id} by user {self.request.user.username}")
instance.delete()
except Exception as e:
logger.error(f"Error deleting ride review: {e}")
capture_and_log(e, 'Delete ride review', source='api', request=self.request)
raise ValidationError(f"Failed to delete review: {str(e)}") from None
@extend_schema(
@@ -283,7 +284,7 @@ class RideReviewViewSet(ModelViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error getting ride review stats: {e}")
capture_and_log(e, 'Get ride review stats', source='api', request=request)
return Response(
{"detail": f"Failed to get review statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -360,7 +361,7 @@ class RideReviewViewSet(ModelViewSet):
)
except Exception as e:
logger.error(f"Error in bulk review moderation: {e}")
capture_and_log(e, 'Bulk review moderation', source='api', request=request)
return Response(
{"detail": f"Failed to moderate reviews: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
@@ -122,3 +126,175 @@ class SluggedModel(TrackedModel):
)
raise cls.DoesNotExist(f"{cls.__name__} with slug '{slug}' does not exist") from None
class ApplicationError(models.Model):
"""
Stores application errors from frontend and backend sources.
Errors are captured automatically via middleware (backend) or
reported via API (frontend) and displayed in the admin dashboard.
"""
class Severity(models.TextChoices):
CRITICAL = "critical", "Critical"
HIGH = "high", "High"
MEDIUM = "medium", "Medium"
LOW = "low", "Low"
class Source(models.TextChoices):
FRONTEND = "frontend", "Frontend"
BACKEND = "backend", "Backend"
API = "api", "API"
# Identity
error_id = models.UUIDField(
unique=True,
default=uuid.uuid4,
db_index=True,
help_text="Unique identifier for this error instance",
)
request_id = models.CharField(
max_length=255,
blank=True,
db_index=True,
help_text="Request correlation ID if available",
)
# Error information
error_type = models.CharField(
max_length=100,
db_index=True,
help_text="Type/class of the error (e.g., 'ValidationError', 'TypeError')",
)
error_message = models.TextField(
help_text="Human-readable error message",
)
error_stack = models.TextField(
blank=True,
help_text="Stack trace if available",
)
error_code = models.CharField(
max_length=50,
blank=True,
db_index=True,
help_text="Application-specific error code",
)
severity = models.CharField(
max_length=20,
choices=Severity.choices,
default=Severity.MEDIUM,
db_index=True,
help_text="Error severity level",
)
source = models.CharField(
max_length=20,
choices=Source.choices,
db_index=True,
help_text="Where the error originated",
)
# Request context
endpoint = models.CharField(
max_length=500,
blank=True,
help_text="URL/endpoint where the error occurred",
)
http_method = models.CharField(
max_length=10,
blank=True,
help_text="HTTP method of the request",
)
http_status = models.PositiveIntegerField(
null=True,
blank=True,
help_text="HTTP status code returned",
)
user_agent = models.TextField(
blank=True,
help_text="User agent string from the client",
)
# User context
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="application_errors",
help_text="User who encountered the error",
)
ip_address_hash = models.CharField(
max_length=64,
blank=True,
db_index=True,
help_text="Hashed IP address for rate limiting (privacy-preserving)",
)
# Extended metadata
metadata = models.JSONField(
default=dict,
blank=True,
help_text="Additional context (action, entity info, etc.)",
)
environment = models.JSONField(
default=dict,
blank=True,
help_text="Client environment info (viewport, browser, etc.)",
)
# Timestamps and resolution
created_at = models.DateTimeField(
auto_now_add=True,
db_index=True,
help_text="When the error was recorded",
)
resolved = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this error has been addressed",
)
resolved_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the error was marked resolved",
)
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="resolved_errors",
help_text="Admin who resolved this error",
)
resolution_notes = models.TextField(
blank=True,
help_text="Notes about how the error was resolved",
)
class Meta:
ordering = ["-created_at"]
verbose_name = "Application Error"
verbose_name_plural = "Application Errors"
indexes = [
models.Index(fields=["severity", "created_at"]),
models.Index(fields=["source", "created_at"]),
models.Index(fields=["error_type", "created_at"]),
models.Index(fields=["resolved", "created_at"]),
]
def __str__(self) -> str:
return f"[{self.severity.upper()}] {self.error_type}: {self.error_message[:50]}"
@staticmethod
def hash_ip(ip_address: str) -> str:
"""Hash an IP address for privacy-preserving storage."""
if not ip_address:
return ""
salt = getattr(settings, "SECRET_KEY", "")[:16]
return hashlib.sha256(f"{salt}{ip_address}".encode()).hexdigest()
@property
def short_error_id(self) -> str:
"""Return first 8 characters of error_id for display."""
return str(self.error_id)[:8]

View File

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

View File

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

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 apps.core.analytics import PageView
from apps.core.utils import capture_and_log
from apps.parks.models import Park
from apps.rides.models import Ride
@@ -105,7 +106,7 @@ class TrendingService:
return formatted_results
except Exception as e:
self.logger.error(f"Error getting trending content: {e}", exc_info=True)
capture_and_log(e, f'Get trending content ({content_type})', source='service')
return []
def get_new_content(
@@ -164,7 +165,7 @@ class TrendingService:
return formatted_results
except Exception as e:
self.logger.error(f"Error getting new content: {e}", exc_info=True)
capture_and_log(e, f'Get new content ({content_type})', source='service')
return []
def _calculate_trending_parks(self, limit: int) -> list[dict[str, Any]]:
@@ -311,7 +312,7 @@ class TrendingService:
return final_score
except Exception as e:
self.logger.error(f"Error calculating score for {content_type} {content_obj.id}: {e}")
capture_and_log(e, f'Calculate content score ({content_type} {content_obj.id})', source='service', severity='low')
return 0.0
def _calculate_view_growth_score(self, content_type: ContentType, object_id: int) -> float:
@@ -653,7 +654,7 @@ class TrendingService:
self.logger.info(f"Cleared trending caches for {content_type}")
except Exception as e:
self.logger.error(f"Error clearing cache: {e}")
capture_and_log(e, f'Clear trending cache ({content_type})', source='service', severity='low')
# Singleton service instance

View File

@@ -16,6 +16,7 @@ from django.db.models import Q
from django.utils import timezone
from apps.core.analytics import PageView
from apps.core.utils import capture_and_log
from apps.parks.models import Park
from apps.rides.models import Ride
@@ -87,7 +88,7 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50)
}
except Exception as e:
logger.error(f"Error calculating trending content: {e}", exc_info=True)
capture_and_log(e, f'Calculate trending content ({content_type})', source='task')
# Retry the task
raise self.retry(exc=e) from None
@@ -141,7 +142,7 @@ def calculate_new_content(self, content_type: str = "all", days_back: int = 30,
}
except Exception as e:
logger.error(f"Error calculating new content: {e}", exc_info=True)
capture_and_log(e, f'Calculate new content ({content_type})', source='task')
raise self.retry(exc=e) from None
@@ -185,7 +186,7 @@ def warm_trending_cache(self) -> dict[str, Any]:
}
except Exception as e:
logger.error(f"Error warming trending cache: {e}", exc_info=True)
capture_and_log(e, 'Warm trending cache', source='task')
return {
"success": False,
"error": str(e),
@@ -309,7 +310,7 @@ def _calculate_content_score(
return final_score
except Exception as e:
logger.error(f"Error calculating score for {content_type} {content_obj.id}: {e}")
capture_and_log(e, f'Calculate content score ({content_type} {content_obj.id})', source='task', severity='low')
return 0.0

View File

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

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,
get_model_breadcrumb,
)
from .capture_errors import (
capture_and_log,
capture_errors,
error_context,
)
from .messages import (
confirm_delete,
error_network,
@@ -47,6 +52,10 @@ __all__ = [
"breadcrumbs_to_schema",
"build_breadcrumb",
"get_model_breadcrumb",
# Error Capture
"capture_and_log",
"capture_errors",
"error_context",
# Messages
"confirm_delete",
"error_network",
@@ -73,3 +82,4 @@ __all__ = [
"get_og_image",
"get_twitter_card_type",
]

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from apps.core.services.media_service import MediaService
from apps.core.utils import capture_and_log
from ..models import Park, ParkPhoto
@@ -164,7 +165,7 @@ class ParkMediaService:
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
return True
except Exception as e:
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
capture_and_log(e, f'Approve park photo {photo.pk}', source='service')
return False
@staticmethod
@@ -191,7 +192,7 @@ class ParkMediaService:
logger.info(f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}")
return True
except Exception as e:
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
capture_and_log(e, f'Delete park photo {photo.pk}', source='service')
return False
@staticmethod

View File

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

View File

@@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from apps.core.services.media_service import MediaService
from apps.core.utils import capture_and_log
from ..models import Ride, RidePhoto
@@ -190,7 +191,7 @@ class RideMediaService:
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
return True
except Exception as e:
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
capture_and_log(e, f'Approve ride photo {photo.pk}', source='service')
return False
@staticmethod
@@ -217,7 +218,7 @@ class RideMediaService:
logger.info(f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}")
return True
except Exception as e:
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
capture_and_log(e, f'Delete ride photo {photo.pk}', source='service')
return False
@staticmethod

View File

@@ -21,6 +21,7 @@ from apps.rides.models import (
RideRanking,
RideReview,
)
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__)
@@ -96,7 +97,7 @@ class RideRankingService:
}
except Exception as e:
self.logger.error(f"Error updating rankings: {e}", exc_info=True)
capture_and_log(e, 'Update ride rankings', source='service')
raise
def _get_eligible_rides(self, category: str | None = None) -> list[Ride]:

View File

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

View File

@@ -4,6 +4,8 @@ from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from apps.core.utils import capture_and_log
from .models import Ride
logger = logging.getLogger(__name__)
@@ -126,7 +128,13 @@ def handle_ride_transition_to_closing(instance, source, target, user, **kwargs):
return True
if not instance.post_closing_status:
logger.error(f"Cannot transition ride {instance.pk} to CLOSING: " "post_closing_status not set")
# Capture to dashboard as a validation error
capture_and_log(
ValueError('post_closing_status not set for CLOSING transition'),
f'Ride transition to CLOSING for ride {instance.pk}',
source='signal',
severity='medium',
)
return False
if not instance.closing_date:

View File

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