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