feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -23,6 +23,7 @@ class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
if cloudflare_id:
from django_cloudflareimages_toolkit.models import CloudflareImage
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
instance.avatar = image

View File

@@ -76,9 +76,7 @@ urlpatterns = [
name="update_privacy_settings",
),
# Security settings endpoints
path(
"settings/security/", views.get_security_settings, name="get_security_settings"
),
path("settings/security/", views.get_security_settings, name="get_security_settings"),
path(
"settings/security/update/",
views.update_security_settings,
@@ -90,9 +88,7 @@ urlpatterns = [
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
path("top-lists/create/", views.create_top_list, name="create_top_list"),
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
path(
"top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"
),
path("top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"),
# Notification endpoints
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
path(
@@ -114,18 +110,13 @@ urlpatterns = [
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
# Login history endpoint
path("login-history/", views.get_login_history, name="get_login_history"),
# Magic Link (Login by Code) endpoints
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
# Public Profile
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
# ViewSet routes
path("", include(router.urls)),
]

View File

@@ -69,8 +69,7 @@ logger = logging.getLogger(__name__)
200: {
"description": "User successfully deleted with submissions preserved",
"example": {
"success": True,
"message": "User successfully deleted with submissions preserved",
"detail": "User successfully deleted with submissions preserved",
"deleted_user": {
"username": "john_doe",
"user_id": "1234",
@@ -92,17 +91,16 @@ logger = logging.getLogger(__name__)
400: {
"description": "Bad request - user cannot be deleted",
"example": {
"success": False,
"error": "Cannot delete user: Cannot delete superuser accounts",
"detail": "Cannot delete user: Cannot delete superuser accounts",
},
},
404: {
"description": "User not found",
"example": {"success": False, "error": "User not found"},
"example": {"detail": "User not found"},
},
403: {
"description": "Permission denied - admin access required",
"example": {"success": False, "error": "Admin access required"},
"example": {"detail": "Admin access required"},
},
},
tags=["User Management"],
@@ -137,7 +135,7 @@ def delete_user_preserve_submissions(request, user_id):
"is_superuser": user.is_superuser,
"user_role": user.role,
"rejection_reason": reason,
}
},
)
# Determine error code based on reason
@@ -151,8 +149,7 @@ def delete_user_preserve_submissions(request, user_id):
return Response(
{
"success": False,
"error": f"Cannot delete user: {reason}",
"detail": f"Cannot delete user: {reason}",
"error_code": error_code,
"user_info": {
"username": user.username,
@@ -174,7 +171,7 @@ def delete_user_preserve_submissions(request, user_id):
"target_user": user.username,
"target_user_id": user_id,
"action": "user_deletion",
}
},
)
# Perform the deletion
@@ -185,17 +182,16 @@ def delete_user_preserve_submissions(request, user_id):
f"Successfully deleted user {result['deleted_user']['username']} (ID: {user_id}) by admin {request.user.username}",
extra={
"admin_user": request.user.username,
"deleted_user": result['deleted_user']['username'],
"deleted_user": result["deleted_user"]["username"],
"deleted_user_id": user_id,
"preserved_submissions": result['preserved_submissions'],
"preserved_submissions": result["preserved_submissions"],
"action": "user_deletion_completed",
}
},
)
return Response(
{
"success": True,
"message": "User successfully deleted with submissions preserved",
"detail": "User successfully deleted with submissions preserved",
**result,
},
status=status.HTTP_200_OK,
@@ -208,16 +204,15 @@ def delete_user_preserve_submissions(request, user_id):
extra={
"admin_user": request.user.username,
"target_user_id": user_id,
"error": str(e),
"detail": str(e),
"action": "user_deletion_error",
},
exc_info=True
exc_info=True,
)
return Response(
{
"success": False,
"error": f"Error deleting user: {str(e)}",
"detail": f"Error deleting user: {str(e)}",
"error_code": "DELETION_ERROR",
"help_text": "Please try again or contact system administrator if the problem persists.",
},
@@ -259,8 +254,7 @@ def delete_user_preserve_submissions(request, user_id):
},
},
"example": {
"success": True,
"message": "Avatar saved successfully",
"detail": "Avatar saved successfully",
"avatar_url": "https://imagedelivery.net/account-hash/image-id/avatar",
"avatar_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/image-id/thumbnail",
@@ -285,7 +279,7 @@ def save_avatar_image(request):
if not cloudflare_image_id:
return Response(
{"success": False, "error": "cloudflare_image_id is required"},
{"detail": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -299,26 +293,25 @@ def save_avatar_image(request):
if not image_data:
return Response(
{"success": False, "error": "Image not found in Cloudflare"},
{"detail": "Image not found in Cloudflare"},
status=status.HTTP_400_BAD_REQUEST,
)
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id)
cloudflare_image = CloudflareImage.objects.get(cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.status = "uploaded"
cloudflare_image.uploaded_at = timezone.now()
cloudflare_image.metadata = image_data.get('meta', {})
cloudflare_image.metadata = image_data.get("meta", {})
# Extract variants from nested result structure
cloudflare_image.variants = image_data.get('result', {}).get('variants', [])
cloudflare_image.variants = image_data.get("result", {}).get("variants", [])
cloudflare_image.cloudflare_metadata = image_data
cloudflare_image.width = image_data.get('width')
cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '')
cloudflare_image.width = image_data.get("width")
cloudflare_image.height = image_data.get("height")
cloudflare_image.format = image_data.get("format", "")
cloudflare_image.save()
except CloudflareImage.DoesNotExist:
@@ -326,25 +319,23 @@ def save_avatar_image(request):
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=user,
status='uploaded',
upload_url='', # Not needed for uploaded images
status="uploaded",
upload_url="", # Not needed for uploaded images
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
uploaded_at=timezone.now(),
metadata=image_data.get('meta', {}),
metadata=image_data.get("meta", {}),
# Extract variants from nested result structure
variants=image_data.get('result', {}).get('variants', []),
variants=image_data.get("result", {}).get("variants", []),
cloudflare_metadata=image_data,
width=image_data.get('width'),
height=image_data.get('height'),
format=image_data.get('format', ''),
width=image_data.get("width"),
height=image_data.get("height"),
format=image_data.get("format", ""),
)
except Exception as api_error:
logger.error(
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
return Response(
{"success": False,
"error": 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,
)
@@ -391,8 +382,7 @@ def save_avatar_image(request):
return Response(
{
"success": True,
"message": "Avatar saved successfully",
"detail": "Avatar saved successfully",
"avatar_url": avatar_url,
"avatar_variants": avatar_variants,
},
@@ -402,7 +392,7 @@ def save_avatar_image(request):
except Exception as e:
logger.error(f"Error saving avatar image: {str(e)}", exc_info=True)
return Response(
{"success": False, "error": f"Failed to save avatar: {str(e)}"},
{"detail": f"Failed to save avatar: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -420,8 +410,7 @@ def save_avatar_image(request):
"avatar_url": {"type": "string"},
},
"example": {
"success": True,
"message": "Avatar deleted successfully",
"detail": "Avatar deleted successfully",
"avatar_url": "https://ui-avatars.com/api/?name=J&size=200&background=random&color=fff&bold=true",
},
},
@@ -447,6 +436,7 @@ def delete_avatar(request):
# Delete from Cloudflare first, then from database
try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService
service = CloudflareImagesService()
service.delete_image(avatar_to_delete)
logger.info(f"Successfully deleted avatar from Cloudflare: {avatar_to_delete.cloudflare_id}")
@@ -461,8 +451,7 @@ def delete_avatar(request):
return Response(
{
"success": True,
"message": "Avatar deleted successfully",
"detail": "Avatar deleted successfully",
"avatar_url": avatar_url,
},
status=status.HTTP_200_OK,
@@ -471,8 +460,7 @@ def delete_avatar(request):
except UserProfile.DoesNotExist:
return Response(
{
"success": True,
"message": "No avatar to delete",
"detail": "No avatar to delete",
"avatar_url": f"https://ui-avatars.com/api/?name={user.username[0].upper()}&size=200&background=random&color=fff&bold=true",
},
status=status.HTTP_200_OK,
@@ -480,7 +468,7 @@ def delete_avatar(request):
except Exception as e:
return Response(
{"success": False, "error": f"Failed to delete avatar: {str(e)}"},
{"detail": f"Failed to delete avatar: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -506,7 +494,7 @@ def request_account_deletion(request):
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
return Response(
{"success": False, "error": reason},
{"detail": reason},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -515,8 +503,7 @@ def request_account_deletion(request):
return Response(
{
"success": True,
"message": "Verification code sent to your email",
"detail": "Verification code sent to your email",
"expires_at": deletion_request.expires_at,
"email": user.email,
},
@@ -534,7 +521,7 @@ def request_account_deletion(request):
"user_role": request.user.role,
"rejection_reason": str(e),
"action": "self_deletion_rejected",
}
},
)
# Determine error code based on reason
@@ -549,8 +536,7 @@ def request_account_deletion(request):
return Response(
{
"success": False,
"error": error_message,
"detail": error_message,
"error_code": error_code,
"user_info": {
"username": request.user.username,
@@ -570,16 +556,15 @@ def request_account_deletion(request):
extra={
"user": request.user.username,
"user_id": request.user.user_id,
"error": str(e),
"detail": str(e),
"action": "self_deletion_error",
},
exc_info=True
exc_info=True,
)
return Response(
{
"success": False,
"error": f"Error creating deletion request: {str(e)}",
"detail": f"Error creating deletion request: {str(e)}",
"error_code": "DELETION_REQUEST_ERROR",
"help_text": "Please try again or contact support if the problem persists.",
},
@@ -611,8 +596,7 @@ def request_account_deletion(request):
200: {
"description": "Account successfully deleted",
"example": {
"success": True,
"message": "Account successfully deleted with submissions preserved",
"detail": "Account successfully deleted with submissions preserved",
"deleted_user": {
"username": "john_doe",
"user_id": "1234",
@@ -637,7 +621,7 @@ def request_account_deletion(request):
},
400: {
"description": "Invalid or expired verification code",
"example": {"success": False, "error": "Verification code has expired"},
"example": {"detail": "Verification code has expired"},
},
},
tags=["Self-Service Account Management"],
@@ -663,7 +647,7 @@ def verify_account_deletion(request):
if not verification_code:
return Response(
{"success": False, "error": "Verification code is required"},
{"detail": "Verification code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -672,20 +656,17 @@ def verify_account_deletion(request):
return Response(
{
"success": True,
"message": "Account successfully deleted with submissions preserved",
"detail": "Account successfully deleted with submissions preserved",
**result,
},
status=status.HTTP_200_OK,
)
except ValueError as e:
return Response(
{"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
return Response(
{"success": False, "error": f"Error verifying deletion: {str(e)}"},
{"detail": f"Error verifying deletion: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -701,14 +682,13 @@ def verify_account_deletion(request):
200: {
"description": "Deletion request cancelled or no request found",
"example": {
"success": True,
"message": "Deletion request cancelled",
"detail": "Deletion request cancelled",
"had_pending_request": True,
},
},
401: {
"description": "Authentication required",
"example": {"success": False, "error": "Authentication required"},
"example": {"detail": "Authentication required"},
},
},
tags=["Self-Service Account Management"],
@@ -732,12 +712,7 @@ def cancel_account_deletion(request):
return Response(
{
"success": True,
"message": (
"Deletion request cancelled"
if had_request
else "No pending deletion request found"
),
"detail": ("Deletion request cancelled" if had_request else "No pending deletion request found"),
"had_pending_request": had_request,
},
status=status.HTTP_200_OK,
@@ -745,7 +720,7 @@ def cancel_account_deletion(request):
except Exception as e:
return Response(
{"success": False, "error": f"Error cancelling deletion request: {str(e)}"},
{"detail": f"Error cancelling deletion request: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -753,10 +728,7 @@ def cancel_account_deletion(request):
@extend_schema(
operation_id="check_user_deletion_eligibility",
summary="Check if user can be deleted",
description=(
"Check if a user can be safely deleted and get a preview of "
"what submissions would be preserved."
),
description=("Check if a user can be safely deleted and get a preview of " "what submissions would be preserved."),
parameters=[
OpenApiParameter(
name="user_id",
@@ -792,11 +764,11 @@ def cancel_account_deletion(request):
},
404: {
"description": "User not found",
"example": {"success": False, "error": "User not found"},
"example": {"detail": "User not found"},
},
403: {
"description": "Permission denied - admin access required",
"example": {"success": False, "error": "Admin access required"},
"example": {"detail": "Admin access required"},
},
},
tags=["User Management"],
@@ -821,27 +793,13 @@ def check_user_deletion_eligibility(request, user_id):
# Count submissions
submission_counts = {
"park_reviews": getattr(
user, "park_reviews", user.__class__.objects.none()
).count(),
"ride_reviews": getattr(
user, "ride_reviews", user.__class__.objects.none()
).count(),
"uploaded_park_photos": getattr(
user, "uploaded_park_photos", user.__class__.objects.none()
).count(),
"uploaded_ride_photos": getattr(
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "user_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
).count(),
"photo_submissions": getattr(
user, "photo_submissions", user.__class__.objects.none()
).count(),
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
"top_lists": getattr(user, "user_lists", user.__class__.objects.none()).count(),
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
}
total_submissions = sum(submission_counts.values())
@@ -865,7 +823,7 @@ def check_user_deletion_eligibility(request, user_id):
except Exception as e:
return Response(
{"success": False, "error": f"Error checking user: {str(e)}"},
{"detail": f"Error checking user: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -912,9 +870,7 @@ def get_user_profile(request):
@permission_classes([IsAuthenticated])
def update_user_account(request):
"""Update basic account information."""
serializer = AccountUpdateSerializer(
request.user, data=request.data, partial=True, context={"request": request}
)
serializer = AccountUpdateSerializer(request.user, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
serializer.save()
@@ -944,9 +900,7 @@ def update_user_profile(request):
"""Update user profile information."""
profile, created = UserProfile.objects.get_or_create(user=request.user)
serializer = ProfileUpdateSerializer(
profile, data=request.data, partial=True, context={"request": request}
)
serializer = ProfileUpdateSerializer(profile, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
serializer.save()
@@ -1046,9 +1000,7 @@ def update_user_preferences(request):
@permission_classes([IsAuthenticated])
def update_theme_preference(request):
"""Update theme preference."""
serializer = ThemePreferenceSerializer(
request.user, data=request.data, partial=True
)
serializer = ThemePreferenceSerializer(request.user, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -1395,14 +1347,9 @@ def update_top_list(request, list_id):
try:
top_list = UserList.objects.get(id=list_id, user=request.user)
except UserList.DoesNotExist:
return Response(
{"error": "Top list not found"},
status=status.HTTP_404_NOT_FOUND
)
return Response({"detail": "Top list not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = UserListSerializer(
top_list, data=request.data, partial=True, context={"request": request}
)
serializer = UserListSerializer(top_list, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
serializer.save()
@@ -1430,10 +1377,7 @@ def delete_top_list(request, list_id):
top_list.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except UserList.DoesNotExist:
return Response(
{"error": "Top list not found"},
status=status.HTTP_404_NOT_FOUND
)
return Response({"detail": "Top list not found"}, status=status.HTTP_404_NOT_FOUND)
# === NOTIFICATION ENDPOINTS ===
@@ -1453,9 +1397,9 @@ def delete_top_list(request, list_id):
@permission_classes([IsAuthenticated])
def get_user_notifications(request):
"""Get user notifications."""
notifications = UserNotification.objects.filter(
user=request.user
).order_by("-created_at")[:50] # Limit to 50 most recent
notifications = UserNotification.objects.filter(user=request.user).order_by("-created_at")[
:50
] # Limit to 50 most recent
serializer = UserNotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1483,19 +1427,16 @@ def mark_notifications_read(request):
mark_all = serializer.validated_data.get("mark_all", False)
if mark_all:
UserNotification.objects.filter(
user=request.user, is_read=False
).update(is_read=True, read_at=timezone.now())
UserNotification.objects.filter(user=request.user, is_read=False).update(
is_read=True, read_at=timezone.now()
)
count = UserNotification.objects.filter(user=request.user).count()
else:
count = UserNotification.objects.filter(
id__in=notification_ids, user=request.user, is_read=False
).update(is_read=True, read_at=timezone.now())
count = UserNotification.objects.filter(id__in=notification_ids, user=request.user, is_read=False).update(
is_read=True, read_at=timezone.now()
)
return Response(
{"message": f"Marked {count} notifications as read"},
status=status.HTTP_200_OK
)
return Response({"detail": f"Marked {count} notifications as read"}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1544,9 +1485,7 @@ def update_notification_preferences(request):
except NotificationPreference.DoesNotExist:
preferences = NotificationPreference.objects.create(user=request.user)
serializer = NotificationPreferenceSerializer(
preferences, data=request.data, partial=True
)
serializer = NotificationPreferenceSerializer(preferences, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -1578,10 +1517,7 @@ def upload_avatar(request):
if serializer.is_valid():
# Handle avatar upload logic here
# This would typically involve saving the file and updating the user profile
return Response(
{"message": "Avatar uploaded successfully"},
status=status.HTTP_200_OK
)
return Response({"detail": "Avatar uploaded successfully"}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1596,8 +1532,8 @@ def upload_avatar(request):
"example": {
"account": {"username": "user", "email": "user@example.com"},
"profile": {"display_name": "User"},
"content": {"park_reviews": [], "lists": []}
}
"content": {"park_reviews": [], "lists": []},
},
},
401: {"description": "Authentication required"},
},
@@ -1612,10 +1548,7 @@ def export_user_data(request):
return Response(export_data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
return Response(
{"error": "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)
@extend_schema(
@@ -1690,20 +1623,25 @@ def get_login_history(request):
# Serialize
results = []
for entry in entries:
results.append({
"id": entry.id,
"ip_address": entry.ip_address,
"user_agent": entry.user_agent[:100] if entry.user_agent else None, # Truncate long user agents
"login_method": entry.login_method,
"login_method_display": dict(LoginHistory._meta.get_field('login_method').choices).get(entry.login_method, entry.login_method),
"login_timestamp": entry.login_timestamp.isoformat(),
"country": entry.country,
"city": entry.city,
"success": entry.success,
})
return Response({
"results": results,
"count": len(results),
})
results.append(
{
"id": entry.id,
"ip_address": entry.ip_address,
"user_agent": entry.user_agent[:100] if entry.user_agent else None, # Truncate long user agents
"login_method": entry.login_method,
"login_method_display": dict(LoginHistory._meta.get_field("login_method").choices).get(
entry.login_method, entry.login_method
),
"login_timestamp": entry.login_timestamp.isoformat(),
"country": entry.country,
"city": entry.city,
"success": entry.success,
}
)
return Response(
{
"results": results,
"count": len(results),
}
)

View File

@@ -15,22 +15,23 @@ class RideCreditViewSet(viewsets.ModelViewSet):
ViewSet for managing Ride Credits.
Allows users to track rides they have ridden.
"""
serializer_class = RideCreditSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug']
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating', 'display_order']
ordering = ['display_order', '-last_ridden_at']
filterset_fields = ["user__username", "ride__park__slug", "ride__manufacturer__slug"]
ordering_fields = ["first_ridden_at", "last_ridden_at", "created_at", "count", "rating", "display_order"]
ordering = ["display_order", "-last_ridden_at"]
def get_queryset(self):
"""
Return ride credits.
Optionally filter by user via query param ?user=username
"""
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
queryset = RideCredit.objects.all().select_related("ride", "ride__park", "user")
# Filter by user if provided
username = self.request.query_params.get('user')
username = self.request.query_params.get("user")
if username:
queryset = queryset.filter(user__username=username)
@@ -40,64 +41,49 @@ class RideCreditViewSet(viewsets.ModelViewSet):
"""Associate the current user with the ride credit."""
serializer.save(user=self.request.user)
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
@action(detail=False, methods=["post"], permission_classes=[permissions.IsAuthenticated])
@extend_schema(
summary="Reorder ride credits",
description="Bulk update the display order of ride credits. Send a list of {id, order} objects.",
request={
'application/json': {
'type': 'object',
'properties': {
'order': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'id': {'type': 'integer'},
'order': {'type': 'integer'}
},
'required': ['id', 'order']
}
"application/json": {
"type": "object",
"properties": {
"order": {
"type": "array",
"items": {
"type": "object",
"properties": {"id": {"type": "integer"}, "order": {"type": "integer"}},
"required": ["id", "order"],
},
}
}
},
}
}
},
)
def reorder(self, request):
"""
Bulk update display_order for multiple credits.
Expects: {"order": [{"id": 1, "order": 0}, {"id": 2, "order": 1}, ...]}
"""
order_data = request.data.get('order', [])
order_data = request.data.get("order", [])
if not order_data:
return Response(
{'error': 'No order data provided'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": "No order data provided"}, status=status.HTTP_400_BAD_REQUEST)
# Validate that all credits belong to the current user
credit_ids = [item['id'] for item in order_data]
user_credits = RideCredit.objects.filter(
id__in=credit_ids,
user=request.user
).values_list('id', flat=True)
credit_ids = [item["id"] for item in order_data]
user_credits = RideCredit.objects.filter(id__in=credit_ids, user=request.user).values_list("id", flat=True)
if set(credit_ids) != set(user_credits):
return Response(
{'error': 'You can only reorder your own credits'},
status=status.HTTP_403_FORBIDDEN
)
return Response({"detail": "You can only reorder your own credits"}, status=status.HTTP_403_FORBIDDEN)
# Bulk update in a transaction
with transaction.atomic():
for item in order_data:
RideCredit.objects.filter(
id=item['id'],
user=request.user
).update(display_order=item['order'])
RideCredit.objects.filter(id=item["id"], user=request.user).update(display_order=item["order"])
return Response({'status': 'reordered', 'count': len(order_data)})
return Response({"status": "reordered", "count": len(order_data)})
@extend_schema(
summary="List ride credits",
@@ -109,8 +95,7 @@ class RideCreditViewSet(viewsets.ModelViewSet):
type=OpenApiTypes.STR,
description="Filter by username",
),
]
],
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)

View File

@@ -4,6 +4,7 @@ Magic Link (Login by Code) API views.
Provides API endpoints for passwordless login via email code.
Uses django-allauth's built-in login-by-code functionality.
"""
from django.conf import settings
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status
@@ -15,6 +16,7 @@ try:
from allauth.account.internal.flows.login_by_code import perform_login_by_code, request_login_code
from allauth.account.models import EmailAddress
from allauth.account.utils import user_email # noqa: F401 - imported to verify availability
HAS_LOGIN_BY_CODE = True
except ImportError:
HAS_LOGIN_BY_CODE = False
@@ -24,27 +26,19 @@ except ImportError:
summary="Request magic link login code",
description="Send a one-time login code to the user's email address.",
request={
'application/json': {
'type': 'object',
'properties': {
'email': {'type': 'string', 'format': 'email'}
},
'required': ['email']
"application/json": {
"type": "object",
"properties": {"email": {"type": "string", "format": "email"}},
"required": ["email"],
}
},
responses={
200: {'description': 'Login code sent successfully'},
400: {'description': 'Invalid email or feature disabled'},
200: {"description": "Login code sent successfully"},
400: {"description": "Invalid email or feature disabled"},
},
examples=[
OpenApiExample(
'Request login code',
value={'email': 'user@example.com'},
request_only=True
)
]
examples=[OpenApiExample("Request login code", value={"email": "user@example.com"}, request_only=True)],
)
@api_view(['POST'])
@api_view(["POST"])
@permission_classes([AllowAny])
def request_magic_link(request):
"""
@@ -55,25 +49,18 @@ def request_magic_link(request):
2. If the email exists, a code is sent
3. User enters the code to complete login
"""
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
return Response(
{'error': 'Magic link login is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
if not getattr(settings, "ACCOUNT_LOGIN_BY_CODE_ENABLED", False):
return Response({"detail": "Magic link login is not enabled"}, status=status.HTTP_400_BAD_REQUEST)
if not HAS_LOGIN_BY_CODE:
return Response(
{'error': 'Login by code is not available in this version of allauth'},
status=status.HTTP_400_BAD_REQUEST
{"detail": "Login by code is not available in this version of allauth"}, status=status.HTTP_400_BAD_REQUEST
)
email = request.data.get('email', '').lower().strip()
email = request.data.get("email", "").lower().strip()
if not email:
return Response(
{'error': 'Email is required'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": "Email is required"}, status=status.HTTP_400_BAD_REQUEST)
# Check if email exists (don't reveal if it doesn't for security)
try:
@@ -83,40 +70,39 @@ def request_magic_link(request):
# Request the login code
request_login_code(request._request, user)
return Response({
'success': True,
'message': 'If an account exists with this email, a login code has been sent.',
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
})
return Response(
{
"detail": "If an account exists with this email, a login code has been sent.",
"timeout": getattr(settings, "ACCOUNT_LOGIN_BY_CODE_TIMEOUT", 300),
}
)
except EmailAddress.DoesNotExist:
# Don't reveal that the email doesn't exist
return Response({
'success': True,
'message': 'If an account exists with this email, a login code has been sent.',
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
})
return Response(
{
"detail": "If an account exists with this email, a login code has been sent.",
"timeout": getattr(settings, "ACCOUNT_LOGIN_BY_CODE_TIMEOUT", 300),
}
)
@extend_schema(
summary="Verify magic link code",
description="Verify the login code and complete the login process.",
request={
'application/json': {
'type': 'object',
'properties': {
'email': {'type': 'string', 'format': 'email'},
'code': {'type': 'string'}
},
'required': ['email', 'code']
"application/json": {
"type": "object",
"properties": {"email": {"type": "string", "format": "email"}, "code": {"type": "string"}},
"required": ["email", "code"],
}
},
responses={
200: {'description': 'Login successful'},
400: {'description': 'Invalid or expired code'},
}
200: {"description": "Login successful"},
400: {"description": "Invalid or expired code"},
},
)
@api_view(['POST'])
@api_view(["POST"])
@permission_classes([AllowAny])
def verify_magic_link(request):
"""
@@ -124,26 +110,17 @@ def verify_magic_link(request):
This is the second step of the magic link flow.
"""
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
return Response(
{'error': 'Magic link login is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
if not getattr(settings, "ACCOUNT_LOGIN_BY_CODE_ENABLED", False):
return Response({"detail": "Magic link login is not enabled"}, status=status.HTTP_400_BAD_REQUEST)
if not HAS_LOGIN_BY_CODE:
return Response(
{'error': 'Login by code is not available'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": "Login by code is not available"}, status=status.HTTP_400_BAD_REQUEST)
email = request.data.get('email', '').lower().strip()
code = request.data.get('code', '').strip()
email = request.data.get("email", "").lower().strip()
code = request.data.get("code", "").strip()
if not email or not code:
return Response(
{'error': 'Email and code are required'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": "Email and code are required"}, status=status.HTTP_400_BAD_REQUEST)
try:
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
@@ -153,28 +130,20 @@ def verify_magic_link(request):
success = perform_login_by_code(request._request, user, code)
if success:
return Response({
'success': True,
'message': 'Login successful',
'user': {
'id': user.id,
'username': user.username,
'email': user.email
return Response(
{
"detail": "Login successful",
"user": {"id": user.id, "username": user.username, "email": user.email},
}
})
)
else:
return Response(
{'error': 'Invalid or expired code. Please request a new one.'},
status=status.HTTP_400_BAD_REQUEST
{"detail": "Invalid or expired code. Please request a new one."}, status=status.HTTP_400_BAD_REQUEST
)
except EmailAddress.DoesNotExist:
return Response(
{'error': 'Invalid email or code'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
return Response(
{'error': 'Invalid or expired code. Please request a new one.'},
status=status.HTTP_400_BAD_REQUEST
{"detail": "Invalid or expired code. Please request a new one."}, status=status.HTTP_400_BAD_REQUEST
)

View File

@@ -17,6 +17,7 @@ from rest_framework.response import Response
try:
import qrcode
HAS_QRCODE = True
except ImportError:
HAS_QRCODE = False
@@ -59,12 +60,14 @@ def get_mfa_status(request):
except Authenticator.DoesNotExist:
pass
return Response({
"mfa_enabled": totp_enabled,
"totp_enabled": totp_enabled,
"recovery_codes_enabled": recovery_enabled,
"recovery_codes_count": recovery_count,
})
return Response(
{
"mfa_enabled": totp_enabled,
"totp_enabled": totp_enabled,
"recovery_codes_enabled": recovery_enabled,
"recovery_codes_count": recovery_count,
}
)
@extend_schema(
@@ -110,11 +113,13 @@ def setup_totp(request):
# Store secret in session for later verification
request.session["pending_totp_secret"] = secret
return Response({
"secret": secret,
"provisioning_uri": uri,
"qr_code_base64": qr_code_base64,
})
return Response(
{
"secret": secret,
"provisioning_uri": uri,
"qr_code_base64": qr_code_base64,
}
)
@extend_schema(
@@ -138,8 +143,7 @@ def setup_totp(request):
200: {
"description": "TOTP activated successfully",
"example": {
"success": True,
"message": "Two-factor authentication enabled",
"detail": "Two-factor authentication enabled",
"recovery_codes": ["ABCD1234", "EFGH5678"],
},
},
@@ -160,7 +164,7 @@ def activate_totp(request):
if not code:
return Response(
{"success": False, "error": "Verification code is required"},
{"detail": "Verification code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -168,21 +172,21 @@ def activate_totp(request):
secret = request.session.get("pending_totp_secret")
if not secret:
return Response(
{"success": False, "error": "No pending TOTP setup. Please start setup again."},
{"detail": "No pending TOTP setup. Please start setup again."},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify the code
if not totp_auth.validate_totp_code(secret, code):
return Response(
{"success": False, "error": "Invalid verification code"},
{"detail": "Invalid verification code"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if already has TOTP
if Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
return Response(
{"success": False, "error": "TOTP is already enabled"},
{"detail": "TOTP is already enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -204,11 +208,12 @@ def activate_totp(request):
# Clear session
del request.session["pending_totp_secret"]
return Response({
"success": True,
"message": "Two-factor authentication enabled",
"recovery_codes": codes,
})
return Response(
{
"detail": "Two-factor authentication enabled",
"recovery_codes": codes,
}
)
@extend_schema(
@@ -230,7 +235,7 @@ def activate_totp(request):
responses={
200: {
"description": "TOTP disabled",
"example": {"success": True, "message": "Two-factor authentication disabled"},
"example": {"detail": "Two-factor authentication disabled"},
},
400: {"description": "Invalid password or MFA not enabled"},
},
@@ -248,26 +253,26 @@ def deactivate_totp(request):
# Verify password
if not user.check_password(password):
return Response(
{"success": False, "error": "Invalid password"},
{"detail": "Invalid password"},
status=status.HTTP_400_BAD_REQUEST,
)
# Remove TOTP and recovery codes
deleted_count, _ = Authenticator.objects.filter(
user=user,
type__in=[Authenticator.Type.TOTP, Authenticator.Type.RECOVERY_CODES]
user=user, type__in=[Authenticator.Type.TOTP, Authenticator.Type.RECOVERY_CODES]
).delete()
if deleted_count == 0:
return Response(
{"success": False, "error": "Two-factor authentication is not enabled"},
{"detail": "Two-factor authentication is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response({
"success": True,
"message": "Two-factor authentication disabled",
})
return Response(
{
"detail": "Two-factor authentication disabled",
}
)
@extend_schema(
@@ -277,9 +282,7 @@ def deactivate_totp(request):
request={
"application/json": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "6-digit TOTP code"}
},
"properties": {"code": {"type": "string", "description": "6-digit TOTP code"}},
"required": ["code"],
}
},
@@ -301,7 +304,7 @@ def verify_totp(request):
if not code:
return Response(
{"success": False, "error": "Verification code is required"},
{"detail": "Verification code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -313,12 +316,12 @@ def verify_totp(request):
return Response({"success": True})
else:
return Response(
{"success": False, "error": "Invalid verification code"},
{"detail": "Invalid verification code"},
status=status.HTTP_400_BAD_REQUEST,
)
except Authenticator.DoesNotExist:
return Response(
{"success": False, "error": "TOTP is not enabled"},
{"detail": "TOTP is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -330,9 +333,7 @@ def verify_totp(request):
request={
"application/json": {
"type": "object",
"properties": {
"password": {"type": "string", "description": "Current password"}
},
"properties": {"password": {"type": "string", "description": "Current password"}},
"required": ["password"],
}
},
@@ -358,14 +359,14 @@ def regenerate_recovery_codes(request):
# Verify password
if not user.check_password(password):
return Response(
{"success": False, "error": "Invalid password"},
{"detail": "Invalid password"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if TOTP is enabled
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
return Response(
{"success": False, "error": "Two-factor authentication is not enabled"},
{"detail": "Two-factor authentication is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -379,7 +380,9 @@ def regenerate_recovery_codes(request):
defaults={"data": {"codes": codes}},
)
return Response({
"success": True,
"recovery_codes": codes,
})
return Response(
{
"success": True,
"recovery_codes": codes,
}
)

View File

@@ -38,8 +38,6 @@ class ModelChoices:
"""Model choices utility class."""
# === AUTHENTICATION SERIALIZERS ===
@@ -95,12 +93,8 @@ class UserOutputSerializer(serializers.ModelSerializer):
class LoginInputSerializer(serializers.Serializer):
"""Input serializer for user login."""
username = serializers.CharField(
max_length=254, help_text="Username or email address"
)
password = serializers.CharField(
max_length=128, style={"input_type": "password"}, trim_whitespace=False
)
username = serializers.CharField(max_length=254, help_text="Username or email address")
password = serializers.CharField(max_length=128, style={"input_type": "password"}, trim_whitespace=False)
def validate(self, attrs):
username = attrs.get("username")
@@ -129,9 +123,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
validators=[validate_password],
style={"input_type": "password"},
)
password_confirm = serializers.CharField(
write_only=True, style={"input_type": "password"}
)
password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"})
class Meta:
model = UserModel
@@ -158,9 +150,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
def validate_username(self, value):
"""Validate username is unique."""
if UserModel.objects.filter(username=value).exists():
raise serializers.ValidationError(
"A user with this username already exists."
)
raise serializers.ValidationError("A user with this username already exists.")
return value
def validate(self, attrs):
@@ -169,9 +159,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
password_confirm = attrs.get("password_confirm")
if password != password_confirm:
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
return attrs
@@ -204,8 +192,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
# Create or update email verification record
verification, created = EmailVerification.objects.get_or_create(
user=user,
defaults={'token': get_random_string(64)}
user=user, defaults={"token": get_random_string(64)}
)
if not created:
@@ -214,14 +201,12 @@ class SignupInputSerializer(serializers.ModelSerializer):
verification.save()
# Get current site from request context
request = self.context.get('request')
request = self.context.get("request")
if request:
site = get_current_site(request._request)
# Build verification URL
verification_url = request.build_absolute_uri(
f"/api/v1/auth/verify-email/{verification.token}/"
)
verification_url = request.build_absolute_uri(f"/api/v1/auth/verify-email/{verification.token}/")
# Send verification email
try:
@@ -243,13 +228,11 @@ The ThrillWiki Team
)
# Log the ForwardEmail email ID from the response
email_id = response.get('id') if response else None
email_id = response.get("id") if response else None
if email_id:
logger.info(
f"Verification email sent successfully to {user.email}. ForwardEmail ID: {email_id}")
logger.info(f"Verification email sent successfully to {user.email}. ForwardEmail ID: {email_id}")
else:
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:
# Log the error but don't fail registration
@@ -312,17 +295,13 @@ class PasswordResetOutputSerializer(serializers.Serializer):
class PasswordChangeInputSerializer(serializers.Serializer):
"""Input serializer for password change."""
old_password = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
old_password = serializers.CharField(max_length=128, style={"input_type": "password"})
new_password = serializers.CharField(
max_length=128,
validators=[validate_password],
style={"input_type": "password"},
)
new_password_confirm = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
new_password_confirm = serializers.CharField(max_length=128, style={"input_type": "password"})
def validate_old_password(self, value):
"""Validate old password is correct."""
@@ -337,9 +316,7 @@ class PasswordChangeInputSerializer(serializers.Serializer):
new_password_confirm = attrs.get("new_password_confirm")
if new_password != new_password_confirm:
raise serializers.ValidationError(
{"new_password_confirm": "New passwords do not match."}
)
raise serializers.ValidationError({"new_password_confirm": "New passwords do not match."})
return attrs
@@ -471,6 +448,3 @@ class UserProfileUpdateInputSerializer(serializers.Serializer):
dark_ride_credits = serializers.IntegerField(required=False)
flat_ride_credits = serializers.IntegerField(required=False)
water_ride_credits = serializers.IntegerField(required=False)

View File

@@ -19,13 +19,13 @@ from .social import (
__all__ = [
# Social authentication serializers
'ConnectedProviderSerializer',
'AvailableProviderSerializer',
'SocialAuthStatusSerializer',
'ConnectProviderInputSerializer',
'ConnectProviderOutputSerializer',
'DisconnectProviderOutputSerializer',
'SocialProviderListOutputSerializer',
'ConnectedProvidersListOutputSerializer',
'SocialProviderErrorSerializer',
"ConnectedProviderSerializer",
"AvailableProviderSerializer",
"SocialAuthStatusSerializer",
"ConnectProviderInputSerializer",
"ConnectProviderOutputSerializer",
"DisconnectProviderOutputSerializer",
"SocialProviderListOutputSerializer",
"ConnectedProvidersListOutputSerializer",
"SocialProviderErrorSerializer",
]

View File

@@ -14,74 +14,36 @@ User = get_user_model()
class ConnectedProviderSerializer(serializers.Serializer):
"""Serializer for connected social provider information."""
provider = serializers.CharField(
help_text="Provider ID (e.g., 'google', 'discord')"
)
provider_name = serializers.CharField(
help_text="Human-readable provider name"
)
uid = serializers.CharField(
help_text="User ID on the social provider"
)
date_joined = serializers.DateTimeField(
help_text="When this provider was connected"
)
can_disconnect = serializers.BooleanField(
help_text="Whether this provider can be safely disconnected"
)
provider = serializers.CharField(help_text="Provider ID (e.g., 'google', 'discord')")
provider_name = serializers.CharField(help_text="Human-readable provider name")
uid = serializers.CharField(help_text="User ID on the social provider")
date_joined = serializers.DateTimeField(help_text="When this provider was connected")
can_disconnect = serializers.BooleanField(help_text="Whether this provider can be safely disconnected")
disconnect_reason = serializers.CharField(
allow_null=True,
required=False,
help_text="Reason why provider cannot be disconnected (if applicable)"
)
extra_data = serializers.JSONField(
required=False,
help_text="Additional data from the social provider"
allow_null=True, required=False, help_text="Reason why provider cannot be disconnected (if applicable)"
)
extra_data = serializers.JSONField(required=False, help_text="Additional data from the social provider")
class AvailableProviderSerializer(serializers.Serializer):
"""Serializer for available social provider information."""
id = serializers.CharField(
help_text="Provider ID (e.g., 'google', 'discord')"
)
name = serializers.CharField(
help_text="Human-readable provider name"
)
auth_url = serializers.URLField(
help_text="URL to initiate authentication with this provider"
)
connect_url = serializers.URLField(
help_text="API URL to connect this provider"
)
id = serializers.CharField(help_text="Provider ID (e.g., 'google', 'discord')")
name = serializers.CharField(help_text="Human-readable provider name")
auth_url = serializers.URLField(help_text="URL to initiate authentication with this provider")
connect_url = serializers.URLField(help_text="API URL to connect this provider")
class SocialAuthStatusSerializer(serializers.Serializer):
"""Serializer for comprehensive social authentication status."""
user_id = serializers.IntegerField(
help_text="User's ID"
)
username = serializers.CharField(
help_text="User's username"
)
email = serializers.EmailField(
help_text="User's email address"
)
has_password_auth = serializers.BooleanField(
help_text="Whether user has email/password authentication set up"
)
connected_providers = ConnectedProviderSerializer(
many=True,
help_text="List of connected social providers"
)
total_auth_methods = serializers.IntegerField(
help_text="Total number of authentication methods available"
)
can_disconnect_any = serializers.BooleanField(
help_text="Whether user can safely disconnect any provider"
)
user_id = serializers.IntegerField(help_text="User's ID")
username = serializers.CharField(help_text="User's username")
email = serializers.EmailField(help_text="User's email address")
has_password_auth = serializers.BooleanField(help_text="Whether user has email/password authentication set up")
connected_providers = ConnectedProviderSerializer(many=True, help_text="List of connected social providers")
total_auth_methods = serializers.IntegerField(help_text="Total number of authentication methods available")
can_disconnect_any = serializers.BooleanField(help_text="Whether user can safely disconnect any provider")
requires_password_setup = serializers.BooleanField(
help_text="Whether user needs to set up password before disconnecting"
)
@@ -90,9 +52,7 @@ class SocialAuthStatusSerializer(serializers.Serializer):
class ConnectProviderInputSerializer(serializers.Serializer):
"""Serializer for social provider connection requests."""
provider = serializers.CharField(
help_text="Provider ID to connect (e.g., 'google', 'discord')"
)
provider = serializers.CharField(help_text="Provider ID to connect (e.g., 'google', 'discord')")
def validate_provider(self, value):
"""Validate that the provider is supported and configured."""
@@ -108,93 +68,51 @@ class ConnectProviderInputSerializer(serializers.Serializer):
class ConnectProviderOutputSerializer(serializers.Serializer):
"""Serializer for social provider connection responses."""
success = serializers.BooleanField(
help_text="Whether the connection was successful"
)
message = serializers.CharField(
help_text="Success or error message"
)
provider = serializers.CharField(
help_text="Provider that was connected"
)
auth_url = serializers.URLField(
required=False,
help_text="URL to complete the connection process"
)
success = serializers.BooleanField(help_text="Whether the connection was successful")
message = serializers.CharField(help_text="Success or error message")
provider = serializers.CharField(help_text="Provider that was connected")
auth_url = serializers.URLField(required=False, help_text="URL to complete the connection process")
class DisconnectProviderOutputSerializer(serializers.Serializer):
"""Serializer for social provider disconnection responses."""
success = serializers.BooleanField(
help_text="Whether the disconnection was successful"
)
message = serializers.CharField(
help_text="Success or error message"
)
provider = serializers.CharField(
help_text="Provider that was disconnected"
)
success = serializers.BooleanField(help_text="Whether the disconnection was successful")
message = serializers.CharField(help_text="Success or error message")
provider = serializers.CharField(help_text="Provider that was disconnected")
remaining_providers = serializers.ListField(
child=serializers.CharField(),
help_text="List of remaining connected providers"
)
has_password_auth = serializers.BooleanField(
help_text="Whether user still has password authentication"
child=serializers.CharField(), help_text="List of remaining connected providers"
)
has_password_auth = serializers.BooleanField(help_text="Whether user still has password authentication")
suggestions = serializers.ListField(
child=serializers.CharField(),
required=False,
help_text="Suggestions for maintaining account access (if applicable)"
help_text="Suggestions for maintaining account access (if applicable)",
)
class SocialProviderListOutputSerializer(serializers.Serializer):
"""Serializer for listing available social providers."""
available_providers = AvailableProviderSerializer(
many=True,
help_text="List of available social providers"
)
count = serializers.IntegerField(
help_text="Number of available providers"
)
available_providers = AvailableProviderSerializer(many=True, help_text="List of available social providers")
count = serializers.IntegerField(help_text="Number of available providers")
class ConnectedProvidersListOutputSerializer(serializers.Serializer):
"""Serializer for listing connected social providers."""
connected_providers = ConnectedProviderSerializer(
many=True,
help_text="List of connected social providers"
)
count = serializers.IntegerField(
help_text="Number of connected providers"
)
has_password_auth = serializers.BooleanField(
help_text="Whether user has password authentication"
)
can_disconnect_any = serializers.BooleanField(
help_text="Whether user can safely disconnect any provider"
)
connected_providers = ConnectedProviderSerializer(many=True, help_text="List of connected social providers")
count = serializers.IntegerField(help_text="Number of connected providers")
has_password_auth = serializers.BooleanField(help_text="Whether user has password authentication")
can_disconnect_any = serializers.BooleanField(help_text="Whether user can safely disconnect any provider")
class SocialProviderErrorSerializer(serializers.Serializer):
"""Serializer for social provider error responses."""
error = serializers.CharField(
help_text="Error message"
)
code = serializers.CharField(
required=False,
help_text="Error code for programmatic handling"
)
error = serializers.CharField(help_text="Error message")
code = serializers.CharField(required=False, help_text="Error code for programmatic handling")
suggestions = serializers.ListField(
child=serializers.CharField(),
required=False,
help_text="Suggestions for resolving the error"
)
provider = serializers.CharField(
required=False,
help_text="Provider related to the error (if applicable)"
child=serializers.CharField(), required=False, help_text="Suggestions for resolving the error"
)
provider = serializers.CharField(required=False, help_text="Provider related to the error (if applicable)")

View File

@@ -36,13 +36,10 @@ urlpatterns = [
path("signup/", SignupAPIView.as_view(), name="auth-signup"),
path("logout/", LogoutAPIView.as_view(), name="auth-logout"),
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
# JWT token management
path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"),
# Social authentication endpoints (dj-rest-auth)
path("social/", include("dj_rest_auth.registration.urls")),
path(
"password/reset/",
PasswordResetAPIView.as_view(),
@@ -58,7 +55,6 @@ urlpatterns = [
SocialProvidersAPIView.as_view(),
name="auth-social-providers",
),
# Social provider management endpoints
path(
"social/providers/available/",
@@ -85,9 +81,7 @@ urlpatterns = [
SocialAuthStatusAPIView.as_view(),
name="auth-social-status",
),
path("status/", AuthStatusAPIView.as_view(), name="auth-status"),
# Email verification endpoints
path(
"verify-email/<str:token>/",
@@ -99,7 +93,6 @@ urlpatterns = [
ResendVerificationAPIView.as_view(),
name="auth-resend-verification",
),
# MFA (Multi-Factor Authentication) endpoints
path("mfa/status/", mfa_views.get_mfa_status, name="auth-mfa-status"),
path("mfa/totp/setup/", mfa_views.setup_totp, name="auth-mfa-totp-setup"),

View File

@@ -85,9 +85,7 @@ def _get_underlying_request(request: Request) -> HttpRequest:
# Helper: encapsulate user lookup + authenticate to reduce complexity in view
def _authenticate_user_by_lookup(
email_or_username: str, password: str, request: Request
) -> UserModel | None:
def _authenticate_user_by_lookup(email_or_username: str, password: str, request: Request) -> UserModel | None:
"""
Try a single optimized query to find a user by email OR username then authenticate.
Returns authenticated user or None.
@@ -154,7 +152,7 @@ class LoginAPIView(APIView):
# instantiate mixin before calling to avoid type-mismatch in static analysis
TurnstileMixin().validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
# If mixin doesn't do anything, continue
pass
@@ -168,7 +166,7 @@ class LoginAPIView(APIView):
if not email_or_username or not password:
return Response(
{"error": "username and password are required"},
{"detail": "username and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -177,8 +175,7 @@ class LoginAPIView(APIView):
if user:
if getattr(user, "is_active", False):
# pass a real HttpRequest to Django login with backend specified
login(_get_underlying_request(request), user,
backend='django.contrib.auth.backends.ModelBackend')
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
@@ -191,22 +188,22 @@ class LoginAPIView(APIView):
"access": str(access_token),
"refresh": str(refresh),
"user": user,
"message": "Login successful",
"detail": "Login successful",
}
)
return Response(response_serializer.data)
else:
return Response(
{
"error": "Email verification required",
"message": "Please verify your email address before logging in. Check your email for a verification link.",
"email_verification_required": True
"detail": "Please verify your email address before logging in. Check your email for a verification link.",
"code": "EMAIL_VERIFICATION_REQUIRED",
"email_verification_required": True,
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"error": "Invalid credentials"},
{"detail": "Invalid credentials"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -237,7 +234,7 @@ class SignupAPIView(APIView):
# instantiate mixin before calling to avoid type-mismatch in static analysis
TurnstileMixin().validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
# If mixin doesn't do anything, continue
pass
@@ -252,7 +249,7 @@ class SignupAPIView(APIView):
"access": None,
"refresh": None,
"user": user,
"message": "Registration successful. Please check your email to verify your account.",
"detail": "Registration successful. Please check your email to verify your account.",
"email_verification_required": True,
}
)
@@ -282,18 +279,18 @@ class LogoutAPIView(APIView):
try:
# Get refresh token from request data with proper type handling
refresh_token = None
if hasattr(request, 'data') and request.data is not None:
data = getattr(request, 'data', {})
if hasattr(data, 'get'):
if hasattr(request, "data") and request.data is not None:
data = getattr(request, "data", {})
if hasattr(data, "get"):
refresh_token = data.get("refresh")
if refresh_token and isinstance(refresh_token, str):
# Blacklist the refresh token
from rest_framework_simplejwt.tokens import RefreshToken
try:
# Create RefreshToken from string and blacklist it
refresh_token_obj = RefreshToken(
refresh_token) # type: ignore[arg-type]
refresh_token_obj = RefreshToken(refresh_token) # type: ignore[arg-type]
refresh_token_obj.blacklist()
except Exception:
# Token might be invalid or already blacklisted
@@ -306,14 +303,10 @@ class LogoutAPIView(APIView):
# Logout from session using the underlying HttpRequest
logout(_get_underlying_request(request))
response_serializer = LogoutOutputSerializer(
{"message": "Logout successful"}
)
response_serializer = LogoutOutputSerializer({"detail": "Logout successful"})
return Response(response_serializer.data)
except Exception:
return Response(
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"detail": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@extend_schema_view(
@@ -357,15 +350,11 @@ class PasswordResetAPIView(APIView):
serializer_class = PasswordResetInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordResetInputSerializer(
data=request.data, context={"request": request}
)
serializer = PasswordResetInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save()
response_serializer = PasswordResetOutputSerializer(
{"detail": "Password reset email sent"}
)
response_serializer = PasswordResetOutputSerializer({"detail": "Password reset email sent"})
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -391,15 +380,11 @@ class PasswordChangeAPIView(APIView):
serializer_class = PasswordChangeInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordChangeInputSerializer(
data=request.data, context={"request": request}
)
serializer = PasswordChangeInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save()
response_serializer = PasswordChangeOutputSerializer(
{"detail": "Password changed successfully"}
)
response_serializer = PasswordChangeOutputSerializer({"detail": "Password changed successfully"})
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -443,13 +428,9 @@ class SocialProvidersAPIView(APIView):
for social_app in social_apps:
try:
provider_name = (
social_app.name or getattr(social_app, "provider", "").title()
)
provider_name = social_app.name or getattr(social_app, "provider", "").title()
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
auth_url = request.build_absolute_uri(f"/accounts/{social_app.provider}/login/")
providers_list.append(
{
@@ -532,7 +513,7 @@ class AvailableProvidersAPIView(APIView):
"name": "Discord",
"login_url": "/auth/social/discord/",
"connect_url": "/auth/social/connect/discord/",
}
},
]
serializer = AvailableProviderSerializer(providers, many=True)
@@ -585,31 +566,29 @@ class ConnectProviderAPIView(APIView):
def post(self, request: Request, provider: str) -> Response:
# Validate provider
if provider not in ['google', 'discord']:
if provider not in ["google", "discord"]:
return Response(
{
"success": False,
"error": "INVALID_PROVIDER",
"message": f"Provider '{provider}' is not supported",
"suggestions": ["Use 'google' or 'discord'"]
"detail": f"Provider '{provider}' is not supported",
"code": "INVALID_PROVIDER",
"suggestions": ["Use 'google' or 'discord'"],
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ConnectProviderInputSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{
"success": False,
"error": "VALIDATION_ERROR",
"message": "Invalid request data",
"detail": "Invalid request data",
"code": "VALIDATION_ERROR",
"details": serializer.errors,
"suggestions": ["Provide a valid access_token"]
"suggestions": ["Provide a valid access_token"],
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
access_token = serializer.validated_data['access_token']
access_token = serializer.validated_data["access_token"]
try:
service = SocialProviderService()
@@ -622,14 +601,14 @@ class ConnectProviderAPIView(APIView):
return Response(
{
"success": False,
"error": "CONNECTION_FAILED",
"detail": "CONNECTION_FAILED",
"message": str(e),
"suggestions": [
"Verify the access token is valid",
"Ensure the provider account is not already connected to another user"
]
"Ensure the provider account is not already connected to another user",
],
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
@@ -653,35 +632,33 @@ class DisconnectProviderAPIView(APIView):
def post(self, request: Request, provider: str) -> Response:
# Validate provider
if provider not in ['google', 'discord']:
if provider not in ["google", "discord"]:
return Response(
{
"success": False,
"error": "INVALID_PROVIDER",
"message": f"Provider '{provider}' is not supported",
"suggestions": ["Use 'google' or 'discord'"]
"detail": f"Provider '{provider}' is not supported",
"code": "INVALID_PROVIDER",
"suggestions": ["Use 'google' or 'discord'"],
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
try:
service = SocialProviderService()
# Check if disconnection is safe
can_disconnect, reason = service.can_disconnect_provider(
request.user, provider)
can_disconnect, reason = service.can_disconnect_provider(request.user, provider)
if not can_disconnect:
return Response(
{
"success": False,
"error": "UNSAFE_DISCONNECTION",
"detail": "UNSAFE_DISCONNECTION",
"message": reason,
"suggestions": [
"Set up email/password authentication before disconnecting",
"Connect another social provider before disconnecting this one"
]
"Connect another social provider before disconnecting this one",
],
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
# Perform disconnection
@@ -694,14 +671,14 @@ class DisconnectProviderAPIView(APIView):
return Response(
{
"success": False,
"error": "DISCONNECTION_FAILED",
"detail": "DISCONNECTION_FAILED",
"message": str(e),
"suggestions": [
"Verify the provider is currently connected",
"Ensure you have alternative authentication methods"
]
"Ensure you have alternative authentication methods",
],
},
status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST,
)
@@ -755,7 +732,7 @@ class EmailVerificationAPIView(APIView):
from apps.accounts.models import EmailVerification
try:
verification = EmailVerification.objects.select_related('user').get(token=token)
verification = EmailVerification.objects.select_related("user").get(token=token)
user = verification.user
# Activate the user
@@ -765,16 +742,10 @@ class EmailVerificationAPIView(APIView):
# Delete the verification record
verification.delete()
return Response({
"message": "Email verified successfully. You can now log in.",
"success": True
})
return Response({"detail": "Email verified successfully. You can now log in.", "success": True})
except EmailVerification.DoesNotExist:
return Response(
{"error": "Invalid or expired verification token"},
status=status.HTTP_404_NOT_FOUND
)
return Response({"detail": "Invalid or expired verification token"}, status=status.HTTP_404_NOT_FOUND)
@extend_schema_view(
@@ -803,27 +774,20 @@ class ResendVerificationAPIView(APIView):
from apps.accounts.models import EmailVerification
email = request.data.get('email')
email = request.data.get("email")
if not email:
return Response(
{"error": "Email address is required"},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": "Email address is required"}, status=status.HTTP_400_BAD_REQUEST)
try:
user = UserModel.objects.get(email__iexact=email.strip().lower())
# Don't resend if user is already active
if user.is_active:
return Response(
{"error": "Email is already verified"},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"detail": "Email is already verified"}, status=status.HTTP_400_BAD_REQUEST)
# Create or update verification record
verification, created = EmailVerification.objects.get_or_create(
user=user,
defaults={'token': get_random_string(64)}
user=user, defaults={"token": get_random_string(64)}
)
if not created:
@@ -833,9 +797,7 @@ class ResendVerificationAPIView(APIView):
# Send verification email
site = get_current_site(_get_underlying_request(request))
verification_url = request.build_absolute_uri(
f"/api/v1/auth/verify-email/{verification.token}/"
)
verification_url = request.build_absolute_uri(f"/api/v1/auth/verify-email/{verification.token}/")
try:
EmailService.send_email(
@@ -855,27 +817,21 @@ The ThrillWiki Team
site=site,
)
return Response({
"message": "Verification email sent successfully",
"success": True
})
return Response({"detail": "Verification email sent successfully", "success": True})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send verification email to {user.email}: {e}")
return Response(
{"error": "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
)
except UserModel.DoesNotExist:
# Don't reveal whether email exists
return Response({
"message": "If the email exists, a verification email has been sent",
"success": True
})
return Response({"detail": "If the email exists, a verification email has been sent", "success": True})
# Note: User Profile, Top List, and Top List Item ViewSets are now handled

View File

@@ -8,7 +8,6 @@ Caching Strategy:
- EntityNotFoundView: No caching - POST requests with context-specific data
"""
import contextlib
from drf_spectacular.utils import extend_schema
@@ -82,9 +81,7 @@ class EntityFuzzySearchView(APIView):
try:
# Parse request data
query = request.data.get("query", "").strip()
entity_types_raw = request.data.get(
"entity_types", ["park", "ride", "company"]
)
entity_types_raw = request.data.get("entity_types", ["park", "ride", "company"])
include_suggestions = request.data.get("include_suggestions", True)
# Validate query
@@ -92,7 +89,7 @@ class EntityFuzzySearchView(APIView):
return Response(
{
"success": False,
"error": "Query must be at least 2 characters long",
"detail": "Query must be at least 2 characters long",
"code": "INVALID_QUERY",
},
status=status.HTTP_400_BAD_REQUEST,
@@ -120,9 +117,7 @@ class EntityFuzzySearchView(APIView):
"query": query,
"matches": [match.to_dict() for match in matches],
"user_authenticated": (
request.user.is_authenticated
if hasattr(request.user, "is_authenticated")
else False
request.user.is_authenticated if hasattr(request.user, "is_authenticated") else False
),
}
@@ -143,7 +138,7 @@ class EntityFuzzySearchView(APIView):
return Response(
{
"success": False,
"error": f"Internal server error: {str(e)}",
"detail": f"Internal server error: {str(e)}",
"code": "INTERNAL_ERROR",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -192,7 +187,7 @@ class EntityNotFoundView(APIView):
return Response(
{
"success": False,
"error": "original_query is required",
"detail": "original_query is required",
"code": "MISSING_QUERY",
},
status=status.HTTP_400_BAD_REQUEST,
@@ -233,9 +228,7 @@ class EntityNotFoundView(APIView):
"context": context,
"matches": [match.to_dict() for match in matches],
"user_authenticated": (
request.user.is_authenticated
if hasattr(request.user, "is_authenticated")
else False
request.user.is_authenticated if hasattr(request.user, "is_authenticated") else False
),
"has_matches": len(matches) > 0,
}
@@ -257,7 +250,7 @@ class EntityNotFoundView(APIView):
return Response(
{
"success": False,
"error": f"Internal server error: {str(e)}",
"detail": f"Internal server error: {str(e)}",
"code": "INTERNAL_ERROR",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -297,9 +290,7 @@ class QuickEntitySuggestionView(APIView):
limit = min(int(request.GET.get("limit", 5)), 10) # Cap at 10
if not query or len(query) < 2:
return Response(
{"suggestions": [], "query": query}, status=status.HTTP_200_OK
)
return Response({"suggestions": [], "query": query}, status=status.HTTP_200_OK)
# Parse entity types
entity_types = []
@@ -312,9 +303,7 @@ class QuickEntitySuggestionView(APIView):
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
# Get fuzzy matches
matches, _ = entity_fuzzy_matcher.find_entity(
query=query, entity_types=entity_types, user=request.user
)
matches, _ = entity_fuzzy_matcher.find_entity(query=query, entity_types=entity_types, user=request.user)
# Format as simple suggestions
suggestions = []
@@ -337,15 +326,13 @@ class QuickEntitySuggestionView(APIView):
except Exception as e:
return Response(
{"suggestions": [], "query": request.GET.get("q", ""), "error": str(e)},
{"suggestions": [], "query": request.GET.get("q", ""), "detail": str(e)},
status=status.HTTP_200_OK,
) # Return 200 even on errors for autocomplete
# Utility function for other views to use
def get_entity_suggestions(
query: str, entity_types: list[str] | None = None, user=None
):
def get_entity_suggestions(query: str, entity_types: list[str] | None = None, user=None):
"""
Utility function for other Django views to get entity suggestions.
@@ -370,8 +357,6 @@ def get_entity_suggestions(
if not parsed_types:
parsed_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
return entity_fuzzy_matcher.find_entity(
query=query, entity_types=parsed_types, user=user
)
return entity_fuzzy_matcher.find_entity(query=query, entity_types=parsed_types, user=user)
except Exception:
return [], None

View File

@@ -76,7 +76,7 @@ class SendEmailView(APIView):
if not all([to, subject, text]):
return Response(
{
"error": "Missing required fields",
"detail": "Missing required fields",
"required_fields": ["to", "subject", "text"],
},
status=status.HTTP_400_BAD_REQUEST,
@@ -96,11 +96,9 @@ class SendEmailView(APIView):
)
return Response(
{"message": "Email sent successfully", "response": response},
{"detail": "Email sent successfully", "response": response},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -37,21 +37,11 @@ class _FallbackSerializer(drf_serializers.Serializer):
return {}
ParkHistoryEventSerializer = getattr(
history_serializers, "ParkHistoryEventSerializer", _FallbackSerializer
)
RideHistoryEventSerializer = getattr(
history_serializers, "RideHistoryEventSerializer", _FallbackSerializer
)
ParkHistoryOutputSerializer = getattr(
history_serializers, "ParkHistoryOutputSerializer", _FallbackSerializer
)
RideHistoryOutputSerializer = getattr(
history_serializers, "RideHistoryOutputSerializer", _FallbackSerializer
)
UnifiedHistoryTimelineSerializer = getattr(
history_serializers, "UnifiedHistoryTimelineSerializer", _FallbackSerializer
)
ParkHistoryEventSerializer = getattr(history_serializers, "ParkHistoryEventSerializer", _FallbackSerializer)
RideHistoryEventSerializer = getattr(history_serializers, "RideHistoryEventSerializer", _FallbackSerializer)
ParkHistoryOutputSerializer = getattr(history_serializers, "ParkHistoryOutputSerializer", _FallbackSerializer)
RideHistoryOutputSerializer = getattr(history_serializers, "RideHistoryOutputSerializer", _FallbackSerializer)
UnifiedHistoryTimelineSerializer = getattr(history_serializers, "UnifiedHistoryTimelineSerializer", _FallbackSerializer)
# --- Constants for model strings to avoid duplication ---
PARK_MODEL = "parks.park"
@@ -201,18 +191,14 @@ class ParkHistoryViewSet(ReadOnlyModelViewSet):
# Base queryset for park events
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=[PARK_MODEL], pgh_obj_id=getattr(park, "id", None)
)
pghistory.models.Events.objects.filter(pgh_model__in=[PARK_MODEL], pgh_obj_id=getattr(park, "id", None))
.select_related()
.order_by("-pgh_created_at")
)
# Apply list filters via helper to reduce complexity
if self.action == "list":
queryset = _apply_list_filters(
queryset, cast(Request, self.request), default_limit=50, max_limit=500
)
queryset = _apply_list_filters(queryset, cast(Request, self.request), default_limit=50, max_limit=500)
return queryset
@@ -322,18 +308,14 @@ class RideHistoryViewSet(ReadOnlyModelViewSet):
# Base queryset for ride events
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=RIDE_MODELS, pgh_obj_id=getattr(ride, "id", None)
)
pghistory.models.Events.objects.filter(pgh_model__in=RIDE_MODELS, pgh_obj_id=getattr(ride, "id", None))
.select_related()
.order_by("-pgh_created_at")
)
# Apply list filters via helper
if self.action == "list":
queryset = _apply_list_filters(
queryset, cast(Request, self.request), default_limit=50, max_limit=500
)
queryset = _apply_list_filters(queryset, cast(Request, self.request), default_limit=50, max_limit=500)
return queryset
@@ -462,9 +444,7 @@ class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
# Apply shared list filters when serving the list action
if self.action == "list":
queryset = _apply_list_filters(
queryset, cast(Request, self.request), default_limit=100, max_limit=1000
)
queryset = _apply_list_filters(queryset, cast(Request, self.request), default_limit=100, max_limit=1000)
return queryset
@@ -477,9 +457,7 @@ class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
events = list(self.get_queryset()) # evaluate for counts / earliest/latest use
# Summary statistics across all tracked models
total_events = pghistory.models.Events.objects.filter(
pgh_model__in=ALL_TRACKED_MODELS
).count()
total_events = pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS).count()
event_type_counts = (
pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS)
@@ -497,12 +475,8 @@ class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
"summary": {
"total_events": total_events,
"events_returned": len(events),
"event_type_breakdown": {
item["pgh_label"]: item["count"] for item in event_type_counts
},
"model_type_breakdown": {
item["pgh_model"]: item["count"] for item in model_type_counts
},
"event_type_breakdown": {item["pgh_label"]: item["count"] for item in event_type_counts},
"model_type_breakdown": {item["pgh_model"]: item["count"] for item in model_type_counts},
"time_range": {
"earliest": events[-1].pgh_created_at if events else None,
"latest": events[0].pgh_created_at if events else None,

View File

@@ -11,6 +11,7 @@ from apps.core.utils.cloudflare import get_direct_upload_url
logger = logging.getLogger(__name__)
class GenerateUploadURLView(APIView):
permission_classes = [IsAuthenticated]
@@ -21,19 +22,10 @@ class GenerateUploadURLView(APIView):
return Response(result, status=status.HTTP_200_OK)
except ImproperlyConfigured as e:
logger.error(f"Configuration Error: {e}")
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:
logger.error(f"Cloudflare API Error: {e}")
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:
logger.exception("Unexpected error generating upload URL")
return Response(
{"detail": "An unexpected error occurred."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"detail": "An unexpected error occurred."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -162,16 +162,13 @@ class MapLocationsAPIView(APIView):
if not all([north, south, east, west]):
return None
try:
return Polygon.from_bbox(
(float(west), float(south), float(east), float(north))
)
return Polygon.from_bbox((float(west), float(south), float(east), float(north)))
except (ValueError, TypeError):
return None
def _serialize_park_location(self, park) -> dict:
"""Serialize park location data."""
location = park.location if hasattr(
park, "location") and park.location else None
location = park.location if hasattr(park, "location") and park.location else None
return {
"city": location.city if location else "",
"state": location.state if location else "",
@@ -181,8 +178,7 @@ class MapLocationsAPIView(APIView):
def _serialize_park_data(self, park) -> dict:
"""Serialize park data for map response."""
location = park.location if hasattr(
park, "location") and park.location else None
location = park.location if hasattr(park, "location") and park.location else None
return {
"id": park.id,
"type": "park",
@@ -195,9 +191,7 @@ class MapLocationsAPIView(APIView):
"stats": {
"coaster_count": park.coaster_count or 0,
"ride_count": park.ride_count or 0,
"average_rating": (
float(park.average_rating) if park.average_rating else None
),
"average_rating": (float(park.average_rating) if park.average_rating else None),
},
}
@@ -206,14 +200,10 @@ class MapLocationsAPIView(APIView):
if "park" not in params["types"]:
return []
parks_query = Park.objects.select_related(
"location", "operator"
).filter(location__point__isnull=False)
parks_query = Park.objects.select_related("location", "operator").filter(location__point__isnull=False)
# Apply bounds filtering
bounds_polygon = self._create_bounds_polygon(
params["north"], params["south"], params["east"], params["west"]
)
bounds_polygon = self._create_bounds_polygon(params["north"], params["south"], params["east"], params["west"])
if bounds_polygon:
parks_query = parks_query.filter(location__point__within=bounds_polygon)
@@ -229,11 +219,7 @@ class MapLocationsAPIView(APIView):
def _serialize_ride_location(self, ride) -> dict:
"""Serialize ride location data."""
location = (
ride.park.location
if hasattr(ride.park, "location") and ride.park.location
else None
)
location = ride.park.location if hasattr(ride.park, "location") and ride.park.location else None
return {
"city": location.city if location else "",
"state": location.state if location else "",
@@ -243,11 +229,7 @@ class MapLocationsAPIView(APIView):
def _serialize_ride_data(self, ride) -> dict:
"""Serialize ride data for map response."""
location = (
ride.park.location
if hasattr(ride.park, "location") and ride.park.location
else None
)
location = ride.park.location if hasattr(ride.park, "location") and ride.park.location else None
return {
"id": ride.id,
"type": "ride",
@@ -259,9 +241,7 @@ class MapLocationsAPIView(APIView):
"location": self._serialize_ride_location(ride),
"stats": {
"category": ride.get_category_display() if ride.category else None,
"average_rating": (
float(ride.average_rating) if ride.average_rating else None
),
"average_rating": (float(ride.average_rating) if ride.average_rating else None),
"park_name": ride.park.name,
},
}
@@ -271,17 +251,14 @@ class MapLocationsAPIView(APIView):
if "ride" not in params["types"]:
return []
rides_query = Ride.objects.select_related(
"park__location", "manufacturer"
).filter(park__location__point__isnull=False)
rides_query = Ride.objects.select_related("park__location", "manufacturer").filter(
park__location__point__isnull=False
)
# Apply bounds filtering
bounds_polygon = self._create_bounds_polygon(
params["north"], params["south"], params["east"], params["west"]
)
bounds_polygon = self._create_bounds_polygon(params["north"], params["south"], params["east"], params["west"])
if bounds_polygon:
rides_query = rides_query.filter(
park__location__point__within=bounds_polygon)
rides_query = rides_query.filter(park__location__point__within=bounds_polygon)
# Apply text search
if params["query"]:
@@ -335,7 +312,7 @@ class MapLocationsAPIView(APIView):
# Use EnhancedCacheService for improved caching with monitoring
cache_service = EnhancedCacheService()
cached_result = cache_service.get_cached_api_response('map_locations', params)
cached_result = cache_service.get_cached_api_response("map_locations", params)
if cached_result:
logger.debug(f"Cache hit for map_locations with key: {cache_key}")
return Response(cached_result)
@@ -349,7 +326,7 @@ class MapLocationsAPIView(APIView):
result = self._build_response(locations, params)
# Cache result for 5 minutes using EnhancedCacheService
cache_service.cache_api_response('map_locations', params, result, timeout=300)
cache_service.cache_api_response("map_locations", params, result, timeout=300)
logger.debug(f"Cached map_locations result for key: {cache_key}")
return Response(result)
@@ -357,7 +334,7 @@ class MapLocationsAPIView(APIView):
except Exception as e:
logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to retrieve map locations"},
{"status": "error", "detail": "Failed to retrieve map locations"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -401,34 +378,28 @@ class MapLocationDetailAPIView(APIView):
permission_classes = [AllowAny]
@cache_api_response(timeout=1800, key_prefix="map_detail")
def get(
self, request: HttpRequest, location_type: str, location_id: int
) -> Response:
def get(self, request: HttpRequest, location_type: str, location_id: int) -> Response:
"""Get detailed information for a specific location."""
try:
if location_type == "park":
try:
obj = Park.objects.select_related("location", "operator").get(
id=location_id
)
obj = Park.objects.select_related("location", "operator").get(id=location_id)
except Park.DoesNotExist:
return Response(
{"status": "error", "message": "Park not found"},
{"status": "error", "detail": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
elif location_type == "ride":
try:
obj = Ride.objects.select_related(
"park__location", "manufacturer"
).get(id=location_id)
obj = Ride.objects.select_related("park__location", "manufacturer").get(id=location_id)
except Ride.DoesNotExist:
return Response(
{"status": "error", "message": "Ride not found"},
{"status": "error", "detail": "Ride not found"},
status=status.HTTP_404_NOT_FOUND,
)
else:
return Response(
{"status": "error", "message": "Invalid location type"},
{"status": "error", "detail": "Invalid location type"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -440,59 +411,27 @@ class MapLocationDetailAPIView(APIView):
"name": obj.name,
"slug": obj.slug,
"description": obj.description,
"latitude": (
obj.location.latitude
if hasattr(obj, "location") and obj.location
else None
),
"longitude": (
obj.location.longitude
if hasattr(obj, "location") and obj.location
else None
),
"latitude": (obj.location.latitude if hasattr(obj, "location") and obj.location else None),
"longitude": (obj.location.longitude if hasattr(obj, "location") and obj.location else None),
"status": obj.status,
"location": {
"street_address": (
obj.location.street_address
if hasattr(obj, "location") and obj.location
else ""
),
"city": (
obj.location.city
if hasattr(obj, "location") and obj.location
else ""
),
"state": (
obj.location.state
if hasattr(obj, "location") and obj.location
else ""
),
"country": (
obj.location.country
if hasattr(obj, "location") and obj.location
else ""
),
"postal_code": (
obj.location.postal_code
if hasattr(obj, "location") and obj.location
else ""
obj.location.street_address if hasattr(obj, "location") and obj.location else ""
),
"city": (obj.location.city if hasattr(obj, "location") and obj.location else ""),
"state": (obj.location.state if hasattr(obj, "location") and obj.location else ""),
"country": (obj.location.country if hasattr(obj, "location") and obj.location else ""),
"postal_code": (obj.location.postal_code if hasattr(obj, "location") and obj.location else ""),
"formatted_address": (
obj.location.formatted_address
if hasattr(obj, "location") and obj.location
else ""
obj.location.formatted_address if hasattr(obj, "location") and obj.location else ""
),
},
"stats": {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"average_rating": (float(obj.average_rating) if obj.average_rating else None),
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
"opening_date": (obj.opening_date.isoformat() if obj.opening_date else None),
},
"nearby_locations": [], # See FUTURE_WORK.md - THRILLWIKI-107
}
@@ -504,14 +443,10 @@ class MapLocationDetailAPIView(APIView):
"slug": obj.slug,
"description": obj.description,
"latitude": (
obj.park.location.latitude
if hasattr(obj.park, "location") and obj.park.location
else None
obj.park.location.latitude if hasattr(obj.park, "location") and obj.park.location else None
),
"longitude": (
obj.park.location.longitude
if hasattr(obj.park, "location") and obj.park.location
else None
obj.park.location.longitude if hasattr(obj.park, "location") and obj.park.location else None
),
"status": obj.status,
"location": {
@@ -520,25 +455,15 @@ class MapLocationDetailAPIView(APIView):
if hasattr(obj.park, "location") and obj.park.location
else ""
),
"city": (
obj.park.location.city
if hasattr(obj.park, "location") and obj.park.location
else ""
),
"city": (obj.park.location.city if hasattr(obj.park, "location") and obj.park.location else ""),
"state": (
obj.park.location.state
if hasattr(obj.park, "location") and obj.park.location
else ""
obj.park.location.state if hasattr(obj.park, "location") and obj.park.location else ""
),
"country": (
obj.park.location.country
if hasattr(obj.park, "location") and obj.park.location
else ""
obj.park.location.country if hasattr(obj.park, "location") and obj.park.location else ""
),
"postal_code": (
obj.park.location.postal_code
if hasattr(obj.park, "location") and obj.park.location
else ""
obj.park.location.postal_code if hasattr(obj.park, "location") and obj.park.location else ""
),
"formatted_address": (
obj.park.location.formatted_address
@@ -547,19 +472,11 @@ class MapLocationDetailAPIView(APIView):
),
},
"stats": {
"category": (
obj.get_category_display() if obj.category else None
),
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"category": (obj.get_category_display() if obj.category else None),
"average_rating": (float(obj.average_rating) if obj.average_rating else None),
"park_name": obj.park.name,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
"manufacturer": (
obj.manufacturer.name if obj.manufacturer else None
),
"opening_date": (obj.opening_date.isoformat() if obj.opening_date else None),
"manufacturer": (obj.manufacturer.name if obj.manufacturer else None),
},
"nearby_locations": [], # See FUTURE_WORK.md - THRILLWIKI-107
}
@@ -574,7 +491,7 @@ class MapLocationDetailAPIView(APIView):
except Exception as e:
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to retrieve location details"},
{"status": "error", "detail": "Failed to retrieve location details"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -640,7 +557,7 @@ class MapSearchAPIView(APIView):
return Response(
{
"status": "error",
"message": "Search query 'q' parameter is required",
"detail": "Search query 'q' parameter is required",
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -672,30 +589,16 @@ class MapSearchAPIView(APIView):
"name": park.name,
"slug": park.slug,
"latitude": (
park.location.latitude
if hasattr(park, "location") and park.location
else None
park.location.latitude if hasattr(park, "location") and park.location else None
),
"longitude": (
park.location.longitude
if hasattr(park, "location") and park.location
else None
park.location.longitude if hasattr(park, "location") and park.location else None
),
"location": {
"city": (
park.location.city
if hasattr(park, "location") and park.location
else ""
),
"state": (
park.location.state
if hasattr(park, "location") and park.location
else ""
),
"city": (park.location.city if hasattr(park, "location") and park.location else ""),
"state": (park.location.state if hasattr(park, "location") and park.location else ""),
"country": (
park.location.country
if hasattr(park, "location") and park.location
else ""
park.location.country if hasattr(park, "location") and park.location else ""
),
},
"relevance_score": 1.0, # See FUTURE_WORK.md - THRILLWIKI-108
@@ -734,20 +637,17 @@ class MapSearchAPIView(APIView):
"location": {
"city": (
ride.park.location.city
if hasattr(ride.park, "location")
and ride.park.location
if hasattr(ride.park, "location") and ride.park.location
else ""
),
"state": (
ride.park.location.state
if hasattr(ride.park, "location")
and ride.park.location
if hasattr(ride.park, "location") and ride.park.location
else ""
),
"country": (
ride.park.location.country
if hasattr(ride.park, "location")
and ride.park.location
if hasattr(ride.park, "location") and ride.park.location
else ""
),
},
@@ -776,7 +676,7 @@ class MapSearchAPIView(APIView):
except Exception as e:
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Search failed due to internal error"},
{"status": "error", "detail": "Search failed due to internal error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -848,8 +748,7 @@ class MapBoundsAPIView(APIView):
if not all([north_str, south_str, east_str, west_str]):
return Response(
{"status": "error",
"message": "All bounds parameters (north, south, east, west) are required"},
{"status": "error", "detail": "All bounds parameters (north, south, east, west) are required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -860,7 +759,7 @@ class MapBoundsAPIView(APIView):
west = float(west_str) if west_str else 0.0
except (TypeError, ValueError):
return Response(
{"status": "error", "message": "Invalid bounds parameters"},
{"status": "error", "detail": "Invalid bounds parameters"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -869,7 +768,7 @@ class MapBoundsAPIView(APIView):
return Response(
{
"status": "error",
"message": "North bound must be greater than south bound",
"detail": "North bound must be greater than south bound",
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -878,7 +777,7 @@ class MapBoundsAPIView(APIView):
return Response(
{
"status": "error",
"message": "West bound must be less than east bound",
"detail": "West bound must be less than east bound",
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -891,9 +790,7 @@ class MapBoundsAPIView(APIView):
# Get parks within bounds
if "park" in types:
parks_query = Park.objects.select_related("location").filter(
location__point__within=bounds_polygon
)
parks_query = Park.objects.select_related("location").filter(location__point__within=bounds_polygon)
for park in parks_query[:100]: # Limit results
locations.append(
@@ -903,14 +800,10 @@ class MapBoundsAPIView(APIView):
"name": park.name,
"slug": park.slug,
"latitude": (
park.location.latitude
if hasattr(park, "location") and park.location
else None
park.location.latitude if hasattr(park, "location") and park.location else None
),
"longitude": (
park.location.longitude
if hasattr(park, "location") and park.location
else None
park.location.longitude if hasattr(park, "location") and park.location else None
),
"status": park.status,
}
@@ -960,7 +853,7 @@ class MapBoundsAPIView(APIView):
except Exception as e:
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to retrieve locations within bounds"},
{"status": "error", "detail": "Failed to retrieve locations within bounds"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -987,18 +880,15 @@ class MapStatsAPIView(APIView):
"""Get map service statistics and performance metrics."""
try:
# Count locations with coordinates
parks_with_location = Park.objects.filter(
location__point__isnull=False
).count()
rides_with_location = Ride.objects.filter(
park__location__point__isnull=False
).count()
parks_with_location = Park.objects.filter(location__point__isnull=False).count()
rides_with_location = Ride.objects.filter(park__location__point__isnull=False).count()
total_locations = parks_with_location + rides_with_location
# Get cache statistics
from apps.core.services.enhanced_cache_service import CacheMonitor
cache_monitor = CacheMonitor()
cache_stats = cache_monitor.get_cache_statistics('map_locations')
cache_stats = cache_monitor.get_cache_statistics("map_locations")
return Response(
{
@@ -1006,17 +896,17 @@ class MapStatsAPIView(APIView):
"total_locations": total_locations,
"parks_with_location": parks_with_location,
"rides_with_location": rides_with_location,
"cache_hits": cache_stats.get('hits', 0),
"cache_misses": cache_stats.get('misses', 0),
"cache_hit_rate": cache_stats.get('hit_rate', 0.0),
"cache_size": cache_stats.get('size', 0),
"cache_hits": cache_stats.get("hits", 0),
"cache_misses": cache_stats.get("misses", 0),
"cache_hit_rate": cache_stats.get("hit_rate", 0.0),
"cache_size": cache_stats.get("size", 0),
}
)
except Exception as e:
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to retrieve map statistics"},
{"status": "error", "detail": "Failed to retrieve map statistics"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -1060,7 +950,7 @@ class MapCacheAPIView(APIView):
return Response(
{
"status": "success",
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
"detail": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
"cleared_count": cleared_count,
}
)
@@ -1068,7 +958,7 @@ class MapCacheAPIView(APIView):
except Exception as e:
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to clear map cache"},
{"status": "error", "detail": "Failed to clear map cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -1076,7 +966,7 @@ class MapCacheAPIView(APIView):
"""Invalidate specific cache entries."""
try:
# Get cache keys to invalidate from request data
request_data = getattr(request, 'data', {})
request_data = getattr(request, "data", {})
cache_keys = request_data.get("cache_keys", []) if request_data else []
if cache_keys:
@@ -1088,7 +978,7 @@ class MapCacheAPIView(APIView):
return Response(
{
"status": "success",
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
"detail": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
"invalidated_count": invalidated_count,
}
)
@@ -1096,7 +986,7 @@ class MapCacheAPIView(APIView):
except Exception as e:
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to invalidate cache"},
{"status": "error", "detail": "Failed to invalidate cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

View File

@@ -33,7 +33,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
def __init__(self, get_response):
super().__init__(get_response)
self.get_response = get_response
self.enabled = getattr(settings, 'DEBUG', False)
self.enabled = getattr(settings, "DEBUG", False)
if self.enabled:
logger.info("Contract validation middleware enabled (DEBUG mode)")
@@ -45,11 +45,11 @@ class ContractValidationMiddleware(MiddlewareMixin):
return response
# Only validate API endpoints
if not request.path.startswith('/api/'):
if not request.path.startswith("/api/"):
return response
# Only validate JSON responses
if not isinstance(response, (JsonResponse, Response)):
if not isinstance(response, JsonResponse | Response):
return response
# Only validate successful responses (2xx status codes)
@@ -58,7 +58,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
try:
# Get response data
data = response.data if isinstance(response, Response) else json.loads(response.content.decode('utf-8'))
data = response.data if isinstance(response, Response) else json.loads(response.content.decode("utf-8"))
# Validate the response
self._validate_response_contract(request.path, data)
@@ -68,11 +68,11 @@ class ContractValidationMiddleware(MiddlewareMixin):
logger.warning(
f"Contract validation error for {request.path}: {str(e)}",
extra={
'path': request.path,
'method': request.method,
'status_code': response.status_code,
'validation_error': str(e)
}
"path": request.path,
"method": request.method,
"status_code": response.status_code,
"validation_error": str(e),
},
)
return response
@@ -81,15 +81,15 @@ class ContractValidationMiddleware(MiddlewareMixin):
"""Validate response data against expected contracts."""
# Check for filter metadata endpoints
if 'filter-options' in path or 'filter_options' in path:
if "filter-options" in path or "filter_options" in path:
self._validate_filter_metadata(path, data)
# Check for hybrid filtering endpoints
if 'hybrid' in path:
if "hybrid" in path:
self._validate_hybrid_response(path, data)
# Check for pagination responses
if isinstance(data, dict) and 'results' in data:
if isinstance(data, dict) and "results" in data:
self._validate_pagination_response(path, data)
# Check for common contract violations
@@ -100,22 +100,20 @@ class ContractValidationMiddleware(MiddlewareMixin):
if not isinstance(data, dict):
self._log_contract_violation(
path,
"FILTER_METADATA_NOT_DICT",
f"Filter metadata should be a dictionary, got {type(data).__name__}"
path, "FILTER_METADATA_NOT_DICT", f"Filter metadata should be a dictionary, got {type(data).__name__}"
)
return
# Check for categorical filters
if 'categorical' in data:
categorical = data['categorical']
if "categorical" in data:
categorical = data["categorical"]
if isinstance(categorical, dict):
for filter_name, filter_options in categorical.items():
self._validate_categorical_filter(path, filter_name, filter_options)
# Check for ranges
if 'ranges' in data:
ranges = data['ranges']
if "ranges" in data:
ranges = data["ranges"]
if isinstance(ranges, dict):
for range_name, range_data in ranges.items():
self._validate_range_filter(path, range_name, range_data)
@@ -127,7 +125,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
self._log_contract_violation(
path,
"CATEGORICAL_FILTER_NOT_ARRAY",
f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}"
f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}",
)
return
@@ -138,28 +136,28 @@ class ContractValidationMiddleware(MiddlewareMixin):
path,
"CATEGORICAL_OPTION_IS_STRING",
f"Categorical filter '{filter_name}' option {i} is a string '{option}' but should be an object with value/label/count properties",
severity="ERROR"
severity="ERROR",
)
elif isinstance(option, dict):
# Validate object structure
if 'value' not in option:
if "value" not in option:
self._log_contract_violation(
path,
"MISSING_VALUE_PROPERTY",
f"Categorical filter '{filter_name}' option {i} missing 'value' property"
f"Categorical filter '{filter_name}' option {i} missing 'value' property",
)
if 'label' not in option:
if "label" not in option:
self._log_contract_violation(
path,
"MISSING_LABEL_PROPERTY",
f"Categorical filter '{filter_name}' option {i} missing 'label' property"
f"Categorical filter '{filter_name}' option {i} missing 'label' property",
)
# Count is optional but should be number if present
if 'count' in option and option['count'] is not None and not isinstance(option['count'], (int, float)):
if "count" in option and option["count"] is not None and not isinstance(option["count"], int | float):
self._log_contract_violation(
path,
"INVALID_COUNT_TYPE",
f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}"
f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}",
)
def _validate_range_filter(self, path: str, range_name: str, range_data: Any) -> None:
@@ -169,26 +167,24 @@ class ContractValidationMiddleware(MiddlewareMixin):
self._log_contract_violation(
path,
"RANGE_FILTER_NOT_OBJECT",
f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}"
f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}",
)
return
# Check required properties
required_props = ['min', 'max']
required_props = ["min", "max"]
for prop in required_props:
if prop not in range_data:
self._log_contract_violation(
path,
"MISSING_RANGE_PROPERTY",
f"Range filter '{range_name}' missing required property '{prop}'"
path, "MISSING_RANGE_PROPERTY", f"Range filter '{range_name}' missing required property '{prop}'"
)
# Check step property
if 'step' in range_data and not isinstance(range_data['step'], (int, float)):
if "step" in range_data and not isinstance(range_data["step"], int | float):
self._log_contract_violation(
path,
"INVALID_STEP_TYPE",
f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}"
f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}",
)
def _validate_hybrid_response(self, path: str, data: Any) -> None:
@@ -198,38 +194,36 @@ class ContractValidationMiddleware(MiddlewareMixin):
return
# Check for strategy field
if 'strategy' in data:
strategy = data['strategy']
if strategy not in ['client_side', 'server_side']:
if "strategy" in data:
strategy = data["strategy"]
if strategy not in ["client_side", "server_side"]:
self._log_contract_violation(
path,
"INVALID_STRATEGY_VALUE",
f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'"
f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'",
)
# Check filter_metadata structure
if 'filter_metadata' in data:
self._validate_filter_metadata(path, data['filter_metadata'])
if "filter_metadata" in data:
self._validate_filter_metadata(path, data["filter_metadata"])
def _validate_pagination_response(self, path: str, data: dict[str, Any]) -> None:
"""Validate pagination response structure."""
# Check for required pagination fields
required_fields = ['count', 'results']
required_fields = ["count", "results"]
for field in required_fields:
if field not in data:
self._log_contract_violation(
path,
"MISSING_PAGINATION_FIELD",
f"Pagination response missing required field '{field}'"
path, "MISSING_PAGINATION_FIELD", f"Pagination response missing required field '{field}'"
)
# Check results is array
if 'results' in data and not isinstance(data['results'], list):
if "results" in data and not isinstance(data["results"], list):
self._log_contract_violation(
path,
"RESULTS_NOT_ARRAY",
f"Pagination 'results' should be an array, got {type(data['results']).__name__}"
f"Pagination 'results' should be an array, got {type(data['results']).__name__}",
)
def _validate_common_patterns(self, path: str, data: Any) -> None:
@@ -238,38 +232,32 @@ class ContractValidationMiddleware(MiddlewareMixin):
if isinstance(data, dict):
# Check for null vs undefined issues
for key, value in data.items():
if value is None and key.endswith('_id'):
if value is None and key.endswith("_id"):
# ID fields should probably be null, not undefined
continue
# Check for numeric fields that might be strings
if key.endswith('_count') and isinstance(value, str):
if key.endswith("_count") and isinstance(value, str):
try:
int(value)
self._log_contract_violation(
path,
"NUMERIC_FIELD_AS_STRING",
f"Field '{key}' appears to be numeric but is a string: '{value}'"
f"Field '{key}' appears to be numeric but is a string: '{value}'",
)
except ValueError:
pass
def _log_contract_violation(
self,
path: str,
violation_type: str,
message: str,
severity: str = "WARNING"
) -> None:
def _log_contract_violation(self, path: str, violation_type: str, message: str, severity: str = "WARNING") -> None:
"""Log a contract violation with structured data."""
log_data = {
'contract_violation': True,
'violation_type': violation_type,
'api_path': path,
'severity': severity,
'message': message,
'suggestion': self._get_violation_suggestion(violation_type)
"contract_violation": True,
"violation_type": violation_type,
"api_path": path,
"severity": severity,
"message": message,
"suggestion": self._get_violation_suggestion(violation_type),
}
if severity == "ERROR":
@@ -302,9 +290,8 @@ class ContractValidationMiddleware(MiddlewareMixin):
"Check serializer field types and database field types."
),
"RESULTS_NOT_ARRAY": (
"Ensure pagination 'results' field is always an array. "
"Check serializer implementation."
)
"Ensure pagination 'results' field is always an array. " "Check serializer implementation."
),
}
return suggestions.get(violation_type, "Check the API response format against frontend TypeScript interfaces.")
@@ -326,9 +313,9 @@ class ContractValidationSettings:
# Paths to exclude from validation
EXCLUDED_PATHS = [
'/api/docs/',
'/api/schema/',
'/api/v1/auth/', # Auth endpoints might have different structures
"/api/docs/",
"/api/schema/",
"/api/v1/auth/", # Auth endpoints might have different structures
]
@classmethod

View File

@@ -17,6 +17,7 @@ class ParkHistoryViewSet(viewsets.GenericViewSet):
"""
ViewSet for retrieving park history.
"""
permission_classes = [AllowAny]
lookup_field = "slug"
lookup_url_kwarg = "park_slug"
@@ -40,12 +41,7 @@ class ParkHistoryViewSet(viewsets.GenericViewSet):
"last_modified": events.first().pgh_created_at if len(events) else None,
}
data = {
"park": park,
"current_state": park,
"summary": summary,
"events": events
}
data = {"park": park, "current_state": park, "summary": summary, "events": events}
serializer = ParkHistoryOutputSerializer(data)
return Response(serializer.data)
@@ -55,6 +51,7 @@ class RideHistoryViewSet(viewsets.GenericViewSet):
"""
ViewSet for retrieving ride history.
"""
permission_classes = [AllowAny]
lookup_field = "slug"
lookup_url_kwarg = "ride_slug"
@@ -79,12 +76,7 @@ class RideHistoryViewSet(viewsets.GenericViewSet):
"last_modified": events.first().pgh_created_at if len(events) else None,
}
data = {
"ride": ride,
"current_state": ride,
"summary": summary,
"events": events
}
data = {"ride": ride, "current_state": ride, "summary": summary, "events": events}
serializer = RideHistoryOutputSerializer(data)
return Response(serializer.data)

View File

@@ -65,14 +65,12 @@ class ParkReviewViewSet(ModelViewSet):
def get_permissions(self):
"""Set permissions based on action."""
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated]
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Get reviews for the current park."""
queryset = ParkReview.objects.select_related(
"park", "user", "user__profile"
)
queryset = ParkReview.objects.select_related("park", "user", "user__profile")
park_slug = self.kwargs.get("park_slug")
if park_slug:
@@ -82,7 +80,7 @@ class ParkReviewViewSet(ModelViewSet):
except Park.DoesNotExist:
return queryset.none()
if not (hasattr(self.request, 'user') and getattr(self.request.user, 'is_staff', False)):
if not (hasattr(self.request, "user") and getattr(self.request.user, "is_staff", False)):
queryset = queryset.filter(is_published=True)
return queryset.order_by("-created_at")
@@ -102,16 +100,12 @@ class ParkReviewViewSet(ModelViewSet):
try:
park, _ = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
raise NotFound("Park not found") from None
if ParkReview.objects.filter(park=park, user=self.request.user).exists():
raise ValidationError("You have already reviewed this park")
serializer.save(
park=park,
user=self.request.user,
is_published=True
)
serializer.save(park=park, user=self.request.user, is_published=True)
def perform_update(self, serializer):
instance = self.get_object()
@@ -134,17 +128,18 @@ class ParkReviewViewSet(ModelViewSet):
try:
park, _ = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
return Response({"error": "Park not found"}, status=status.HTTP_404_NOT_FOUND)
return Response({"detail": "Park not found"}, status=status.HTTP_404_NOT_FOUND)
reviews = ParkReview.objects.filter(park=park, is_published=True)
total_reviews = reviews.count()
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg']
avg_rating = reviews.aggregate(avg=Avg("rating"))["avg"]
rating_distribution = {}
for i in range(1, 11):
rating_distribution[str(i)] = reviews.filter(rating=i).count()
from datetime import timedelta
recent_reviews = reviews.filter(created_at__gte=timezone.now() - timedelta(days=30)).count()
stats = {

View File

@@ -21,6 +21,7 @@ from rest_framework.views import APIView
try:
from apps.parks.models import Park
from apps.rides.models import Ride
MODELS_AVAILABLE = True
except Exception:
Park = None # type: ignore
@@ -31,6 +32,7 @@ except Exception:
try:
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
from apps.api.v1.serializers.rides import RideDetailOutputSerializer, RideListOutputSerializer
SERIALIZERS_AVAILABLE = True
except Exception:
SERIALIZERS_AVAILABLE = False
@@ -52,22 +54,41 @@ class ParkRidesListAPIView(APIView):
description="Get paginated list of rides at a specific park with filtering options",
parameters=[
# Pagination
OpenApiParameter(name="page", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Page number"),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Number of results per page (max 100)"),
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Page number"
),
OpenApiParameter(
name="page_size",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Number of results per page (max 100)",
),
# Filtering
OpenApiParameter(name="category", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by ride category"),
OpenApiParameter(name="status", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by operational status"),
OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Search rides by name"),
OpenApiParameter(
name="category",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by ride category",
),
OpenApiParameter(
name="status",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by operational status",
),
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Search rides by name",
),
# Ordering
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Order results by field"),
OpenApiParameter(
name="ordering",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Order results by field",
),
],
responses={
200: OpenApiTypes.OBJECT,
@@ -87,12 +108,14 @@ class ParkRidesListAPIView(APIView):
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
raise NotFound("Park not found") from None
# Get rides for this park
qs = Ride.objects.filter(park=park).select_related(
"manufacturer", "designer", "ride_model", "park_area"
).prefetch_related("photos")
qs = (
Ride.objects.filter(park=park)
.select_related("manufacturer", "designer", "ride_model", "park_area")
.prefetch_related("photos")
)
# Apply filtering
qs = self._apply_filters(qs, request.query_params)
@@ -107,9 +130,7 @@ class ParkRidesListAPIView(APIView):
page = paginator.paginate_queryset(qs, request)
if SERIALIZERS_AVAILABLE:
serializer = RideListOutputSerializer(
page, many=True, context={"request": request, "park": park}
)
serializer = RideListOutputSerializer(page, many=True, context={"request": request, "park": park})
return paginator.get_paginated_response(serializer.data)
else:
# Fallback serialization
@@ -145,9 +166,7 @@ class ParkRidesListAPIView(APIView):
search = params.get("search")
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(description__icontains=search) |
Q(manufacturer__name__icontains=search)
Q(name__icontains=search) | Q(description__icontains=search) | Q(manufacturer__name__icontains=search)
)
return qs
@@ -179,42 +198,46 @@ class ParkRideDetailAPIView(APIView):
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
raise NotFound("Park not found") from None
# Get the ride
try:
ride, is_historical = Ride.get_by_slug(ride_slug, park=park)
except Ride.DoesNotExist:
raise NotFound("Ride not found at this park")
raise NotFound("Ride not found at this park") from None
# Ensure ride belongs to this park
if ride.park_id != park.id:
raise NotFound("Ride not found at this park")
if SERIALIZERS_AVAILABLE:
serializer = RideDetailOutputSerializer(
ride, context={"request": request, "park": park}
)
serializer = RideDetailOutputSerializer(ride, context={"request": request, "park": park})
return Response(serializer.data)
else:
# Fallback serialization
return Response({
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"description": getattr(ride, "description", ""),
"category": getattr(ride, "category", ""),
"status": getattr(ride, "status", ""),
"park": {
"id": park.id,
"name": park.name,
"slug": park.slug,
},
"manufacturer": {
"name": ride.manufacturer.name if ride.manufacturer else "",
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
} if ride.manufacturer else None,
})
return Response(
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"description": getattr(ride, "description", ""),
"category": getattr(ride, "category", ""),
"status": getattr(ride, "status", ""),
"park": {
"id": park.id,
"name": park.name,
"slug": park.slug,
},
"manufacturer": (
{
"name": ride.manufacturer.name if ride.manufacturer else "",
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
}
if ride.manufacturer
else None
),
}
)
class ParkComprehensiveDetailAPIView(APIView):
@@ -243,25 +266,21 @@ class ParkComprehensiveDetailAPIView(APIView):
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
raise NotFound("Park not found") from None
# Get park with full related data
park = Park.objects.select_related(
"operator", "property_owner", "location"
).prefetch_related(
"areas", "rides", "photos"
).get(pk=park.pk)
park = (
Park.objects.select_related("operator", "property_owner", "location")
.prefetch_related("areas", "rides", "photos")
.get(pk=park.pk)
)
# Get a sample of rides (first 10) for preview
rides_sample = Ride.objects.filter(park=park).select_related(
"manufacturer", "designer", "ride_model"
)[:10]
rides_sample = Ride.objects.filter(park=park).select_related("manufacturer", "designer", "ride_model")[:10]
if SERIALIZERS_AVAILABLE:
# Get full park details
park_serializer = ParkDetailOutputSerializer(
park, context={"request": request}
)
park_serializer = ParkDetailOutputSerializer(park, context={"request": request})
park_data = park_serializer.data
# Add rides summary
@@ -279,25 +298,27 @@ class ParkComprehensiveDetailAPIView(APIView):
return Response(park_data)
else:
# Fallback serialization
return Response({
"id": park.id,
"name": park.name,
"slug": park.slug,
"description": getattr(park, "description", ""),
"location": str(getattr(park, "location", "")),
"operator": getattr(park.operator, "name", "") if hasattr(park, "operator") else "",
"ride_count": getattr(park, "ride_count", 0),
"rides_summary": {
"total_count": getattr(park, "ride_count", 0),
"sample": [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"category": getattr(ride, "category", ""),
}
for ride in rides_sample
],
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
},
})
return Response(
{
"id": park.id,
"name": park.name,
"slug": park.slug,
"description": getattr(park, "description", ""),
"location": str(getattr(park, "location", "")),
"operator": getattr(park.operator, "name", "") if hasattr(park, "operator") else "",
"ride_count": getattr(park, "ride_count", 0),
"rides_summary": {
"total_count": getattr(park, "ride_count", 0),
"sample": [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"category": getattr(ride, "category", ""),
}
for ride in rides_sample
],
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
},
}
)

File diff suppressed because it is too large Load Diff

View File

@@ -116,14 +116,12 @@ class RidePhotoViewSet(ModelViewSet):
def get_permissions(self):
"""Set permissions based on action."""
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated]
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Get photos for the current ride with optimized queries."""
queryset = RidePhoto.objects.select_related(
"ride", "ride__park", "ride__park__operator", "uploaded_by"
)
queryset = RidePhoto.objects.select_related("ride", "ride__park", "ride__park__operator", "uploaded_by")
# Filter by park and ride from URL kwargs
park_slug = self.kwargs.get("park_slug")
@@ -163,9 +161,9 @@ class RidePhotoViewSet(ModelViewSet):
park, _ = Park.get_by_slug(park_slug)
ride, _ = Ride.get_by_slug(ride_slug, park=park)
except Park.DoesNotExist:
raise NotFound("Park not found")
raise NotFound("Park not found") from None
except Ride.DoesNotExist:
raise NotFound("Ride not found at this park")
raise NotFound("Ride not found at this park") from None
try:
# Use the service to create the photo with proper business logic
@@ -187,17 +185,14 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error creating ride photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
raise ValidationError(f"Failed to create photo: {str(e)}") from None
def perform_update(self, serializer):
"""Update ride photo with permission checking."""
instance = self.get_object()
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or getattr(self.request.user, "is_staff", False)
):
if not (self.request.user == instance.uploaded_by or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only edit your own photos or be an admin.")
# Handle primary photo logic using service
@@ -209,48 +204,40 @@ class RidePhotoViewSet(ModelViewSet):
del serializer.validated_data["is_primary"]
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
try:
serializer.save()
logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}")
except Exception as e:
logger.error(f"Error updating ride photo: {e}")
raise ValidationError(f"Failed to update photo: {str(e)}")
raise ValidationError(f"Failed to update photo: {str(e)}") from None
def perform_destroy(self, instance):
"""Delete ride photo with permission checking."""
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or getattr(self.request.user, "is_staff", False)
):
raise PermissionDenied(
"You can only delete your own photos or be an admin."
)
if not (self.request.user == instance.uploaded_by or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only delete your own photos or be an admin.")
try:
# Delete from Cloudflare first if image exists
if instance.image:
try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService
service = CloudflareImagesService()
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:
logger.error(
f"Failed to delete ride photo from Cloudflare: {str(e)}")
logger.error(f"Failed to delete ride photo from Cloudflare: {str(e)}")
# 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}")
except Exception as e:
logger.error(f"Error deleting ride photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema(
summary="Set photo as primary",
@@ -269,13 +256,8 @@ class RidePhotoViewSet(ModelViewSet):
photo = self.get_object()
# Check permissions - allow owner or staff
if not (
request.user == photo.uploaded_by
or getattr(request.user, "is_staff", False)
):
raise PermissionDenied(
"You can only modify your own photos or be an admin."
)
if not (request.user == photo.uploaded_by or getattr(request.user, "is_staff", False)):
raise PermissionDenied("You can only modify your own photos or be an admin.")
try:
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
@@ -287,21 +269,21 @@ class RidePhotoViewSet(ModelViewSet):
return Response(
{
"message": "Photo set as primary successfully",
"detail": "Photo set as primary successfully",
"photo": serializer.data,
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": "Failed to set primary photo"},
{"detail": "Failed to set primary photo"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
return Response(
{"error": f"Failed to set primary photo: {str(e)}"},
{"detail": f"Failed to set primary photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -334,7 +316,7 @@ class RidePhotoViewSet(ModelViewSet):
if photo_ids is None or approve is None:
return Response(
{"error": "Missing required fields: photo_ids and/or approve."},
{"detail": "Missing required fields: photo_ids and/or approve."},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -350,7 +332,7 @@ class RidePhotoViewSet(ModelViewSet):
return Response(
{
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"detail": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"updated_count": updated_count,
},
status=status.HTTP_200_OK,
@@ -359,7 +341,7 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error in bulk photo approval: {e}")
return Response(
{"error": f"Failed to update photos: {str(e)}"},
{"detail": f"Failed to update photos: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -381,7 +363,7 @@ class RidePhotoViewSet(ModelViewSet):
if not park_slug or not ride_slug:
return Response(
{"error": "Park and ride slugs are required"},
{"detail": "Park and ride slugs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -390,12 +372,12 @@ class RidePhotoViewSet(ModelViewSet):
ride, _ = Ride.get_by_slug(ride_slug, park=park)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
{"detail": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found at this park"},
{"detail": "Ride not found at this park"},
status=status.HTTP_404_NOT_FOUND,
)
@@ -407,7 +389,7 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error getting ride photo stats: {e}")
return Response(
{"error": f"Failed to get photo statistics: {str(e)}"},
{"detail": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -431,7 +413,7 @@ class RidePhotoViewSet(ModelViewSet):
if not park_slug or not ride_slug:
return Response(
{"error": "Park and ride slugs are required"},
{"detail": "Park and ride slugs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -440,19 +422,19 @@ class RidePhotoViewSet(ModelViewSet):
ride, _ = Ride.get_by_slug(ride_slug, park=park)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
{"detail": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found at this park"},
{"detail": "Ride not found at this park"},
status=status.HTTP_404_NOT_FOUND,
)
cloudflare_image_id = request.data.get("cloudflare_image_id")
if not cloudflare_image_id:
return Response(
{"error": "cloudflare_image_id is required"},
{"detail": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -469,27 +451,25 @@ class RidePhotoViewSet(ModelViewSet):
if not image_data:
return Response(
{"error": "Image not found in Cloudflare"},
{"detail": "Image not found in Cloudflare"},
status=status.HTTP_400_BAD_REQUEST,
)
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id)
cloudflare_image = CloudflareImage.objects.get(cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.status = "uploaded"
cloudflare_image.uploaded_at = timezone.now()
cloudflare_image.metadata = image_data.get('meta', {})
cloudflare_image.metadata = image_data.get("meta", {})
# Extract variants from nested result structure
cloudflare_image.variants = image_data.get(
'result', {}).get('variants', [])
cloudflare_image.variants = image_data.get("result", {}).get("variants", [])
cloudflare_image.cloudflare_metadata = image_data
cloudflare_image.width = image_data.get('width')
cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '')
cloudflare_image.width = image_data.get("width")
cloudflare_image.height = image_data.get("height")
cloudflare_image.format = image_data.get("format", "")
cloudflare_image.save()
except CloudflareImage.DoesNotExist:
@@ -497,24 +477,23 @@ class RidePhotoViewSet(ModelViewSet):
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=request.user,
status='uploaded',
upload_url='', # Not needed for uploaded images
status="uploaded",
upload_url="", # Not needed for uploaded images
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
uploaded_at=timezone.now(),
metadata=image_data.get('meta', {}),
metadata=image_data.get("meta", {}),
# Extract variants from nested result structure
variants=image_data.get('result', {}).get('variants', []),
variants=image_data.get("result", {}).get("variants", []),
cloudflare_metadata=image_data,
width=image_data.get('width'),
height=image_data.get('height'),
format=image_data.get('format', ''),
width=image_data.get("width"),
height=image_data.get("height"),
format=image_data.get("format", ""),
)
except Exception as api_error:
logger.error(
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
return Response(
{"error": 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,
)
@@ -544,6 +523,6 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error saving ride photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
{"detail": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -115,14 +115,12 @@ class RideReviewViewSet(ModelViewSet):
def get_permissions(self):
"""Set permissions based on action."""
permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated]
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Get reviews for the current ride with optimized queries."""
queryset = RideReview.objects.select_related(
"ride", "ride__park", "user", "user__profile"
)
queryset = RideReview.objects.select_related("ride", "ride__park", "user", "user__profile")
# Filter by park and ride from URL kwargs
park_slug = self.kwargs.get("park_slug")
@@ -138,8 +136,7 @@ class RideReviewViewSet(ModelViewSet):
return queryset.none()
# Filter published reviews for non-staff users
if not (hasattr(self.request, 'user') and
getattr(self.request.user, 'is_staff', False)):
if not (hasattr(self.request, "user") and getattr(self.request.user, "is_staff", False)):
queryset = queryset.filter(is_published=True)
return queryset.order_by("-created_at")
@@ -167,9 +164,9 @@ class RideReviewViewSet(ModelViewSet):
park, _ = Park.get_by_slug(park_slug)
ride, _ = Ride.get_by_slug(ride_slug, park=park)
except Park.DoesNotExist:
raise NotFound("Park not found")
raise NotFound("Park not found") from None
except Ride.DoesNotExist:
raise NotFound("Ride not found at this park")
raise NotFound("Ride not found at this park") from None
# Check if user already has a review for this ride
if RideReview.objects.filter(ride=ride, user=self.request.user).exists():
@@ -178,26 +175,21 @@ class RideReviewViewSet(ModelViewSet):
try:
# Save the review
review = serializer.save(
ride=ride,
user=self.request.user,
is_published=True # Auto-publish for now, can add moderation later
ride=ride, user=self.request.user, is_published=True # Auto-publish for now, can add moderation later
)
logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}")
except Exception as e:
logger.error(f"Error creating ride review: {e}")
raise ValidationError(f"Failed to create review: {str(e)}")
raise ValidationError(f"Failed to create review: {str(e)}") from None
def perform_update(self, serializer):
"""Update ride review with permission checking."""
instance = self.get_object()
# Check permissions - allow owner or staff
if not (
self.request.user == instance.user
or getattr(self.request.user, "is_staff", False)
):
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only edit your own reviews or be an admin.")
try:
@@ -205,15 +197,12 @@ class RideReviewViewSet(ModelViewSet):
logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}")
except Exception as e:
logger.error(f"Error updating ride review: {e}")
raise ValidationError(f"Failed to update review: {str(e)}")
raise ValidationError(f"Failed to update review: {str(e)}") from None
def perform_destroy(self, instance):
"""Delete ride review with permission checking."""
# Check permissions - allow owner or staff
if not (
self.request.user == instance.user
or getattr(self.request.user, "is_staff", False)
):
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only delete your own reviews or be an admin.")
try:
@@ -221,7 +210,7 @@ class RideReviewViewSet(ModelViewSet):
instance.delete()
except Exception as e:
logger.error(f"Error deleting ride review: {e}")
raise ValidationError(f"Failed to delete review: {str(e)}")
raise ValidationError(f"Failed to delete review: {str(e)}") from None
@extend_schema(
summary="Get ride review statistics",
@@ -241,7 +230,7 @@ class RideReviewViewSet(ModelViewSet):
if not park_slug or not ride_slug:
return Response(
{"error": "Park and ride slugs are required"},
{"detail": "Park and ride slugs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -250,12 +239,12 @@ class RideReviewViewSet(ModelViewSet):
ride, _ = Ride.get_by_slug(ride_slug, park=park)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
{"detail": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found at this park"},
{"detail": "Ride not found at this park"},
status=status.HTTP_404_NOT_FOUND,
)
@@ -268,7 +257,7 @@ class RideReviewViewSet(ModelViewSet):
pending_reviews = RideReview.objects.filter(ride=ride, is_published=False).count()
# Calculate average rating
avg_rating = reviews.aggregate(avg_rating=Avg('rating'))['avg_rating']
avg_rating = reviews.aggregate(avg_rating=Avg("rating"))["avg_rating"]
# Get rating distribution
rating_distribution = {}
@@ -277,6 +266,7 @@ class RideReviewViewSet(ModelViewSet):
# Get recent reviews count (last 30 days)
from datetime import timedelta
thirty_days_ago = timezone.now() - timedelta(days=30)
recent_reviews = reviews.filter(created_at__gte=thirty_days_ago).count()
@@ -295,7 +285,7 @@ class RideReviewViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error getting ride review stats: {e}")
return Response(
{"error": f"Failed to get review statistics: {str(e)}"},
{"detail": f"Failed to get review statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -340,7 +330,7 @@ class RideReviewViewSet(ModelViewSet):
is_published=True,
moderated_by=request.user,
moderated_at=timezone.now(),
moderation_notes=moderation_notes
moderation_notes=moderation_notes,
)
message = f"Successfully published {updated_count} reviews"
elif action_type == "unpublish":
@@ -348,7 +338,7 @@ class RideReviewViewSet(ModelViewSet):
is_published=False,
moderated_by=request.user,
moderated_at=timezone.now(),
moderation_notes=moderation_notes
moderation_notes=moderation_notes,
)
message = f"Successfully unpublished {updated_count} reviews"
elif action_type == "delete":
@@ -357,13 +347,13 @@ class RideReviewViewSet(ModelViewSet):
message = f"Successfully deleted {updated_count} reviews"
else:
return Response(
{"error": "Invalid action type"},
{"detail": "Invalid action type"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{
"message": message,
"detail": message,
"updated_count": updated_count,
},
status=status.HTTP_200_OK,
@@ -372,6 +362,6 @@ class RideReviewViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error in bulk review moderation: {e}")
return Response(
{"error": f"Failed to moderate reviews: {str(e)}"},
{"detail": f"Failed to moderate reviews: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -50,18 +50,14 @@ from apps.parks.models import Park, ParkPhoto
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""Enhanced output serializer for park photos with Cloudflare Images support."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
file_size = serializers.SerializerMethodField()
dimensions = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
image_variants = serializers.SerializerMethodField()
@extend_schema_field(
serializers.IntegerField(allow_null=True, help_text="File size in bytes")
)
@extend_schema_field(serializers.IntegerField(allow_null=True, help_text="File size in bytes"))
def get_file_size(self, obj):
"""Get file size in bytes."""
return obj.file_size
@@ -79,11 +75,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""Get image dimensions as [width, height]."""
return obj.dimensions
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset", allow_null=True
)
)
@extend_schema_field(serializers.URLField(help_text="Full URL to the Cloudflare Images asset", allow_null=True))
def get_image_url(self, obj):
"""Get the full Cloudflare Images URL."""
if obj.image:
@@ -175,9 +167,7 @@ class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
"""Optimized output serializer for park photo lists."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
class Meta:
model = ParkPhoto
@@ -196,12 +186,8 @@ class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for bulk photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True, help_text="Whether to approve (True) or reject (False) the photos"
)
photo_ids = serializers.ListField(child=serializers.IntegerField(), help_text="List of photo IDs to approve")
approve = serializers.BooleanField(default=True, help_text="Whether to approve (True) or reject (False) the photos")
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
@@ -261,7 +247,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
def get_city(self, obj):
"""Get city from related location."""
try:
return obj.location.city if hasattr(obj, 'location') and obj.location else None
return obj.location.city if hasattr(obj, "location") and obj.location else None
except AttributeError:
return None
@@ -269,7 +255,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
def get_state(self, obj):
"""Get state from related location."""
try:
return obj.location.state if hasattr(obj, 'location') and obj.location else None
return obj.location.state if hasattr(obj, "location") and obj.location else None
except AttributeError:
return None
@@ -277,7 +263,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
def get_country(self, obj):
"""Get country from related location."""
try:
return obj.location.country if hasattr(obj, 'location') and obj.location else None
return obj.location.country if hasattr(obj, "location") and obj.location else None
except AttributeError:
return None
@@ -285,7 +271,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
def get_continent(self, obj):
"""Get continent from related location."""
try:
return obj.location.continent if hasattr(obj, 'location') and obj.location else None
return obj.location.continent if hasattr(obj, "location") and obj.location else None
except AttributeError:
return None
@@ -293,7 +279,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
def get_latitude(self, obj):
"""Get latitude from related location."""
try:
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
if hasattr(obj, "location") and obj.location and obj.location.coordinates:
return obj.location.coordinates[1] # PostGIS returns [lon, lat]
return None
except (AttributeError, IndexError, TypeError):
@@ -303,7 +289,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
def get_longitude(self, obj):
"""Get longitude from related location."""
try:
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
if hasattr(obj, "location") and obj.location and obj.location.coordinates:
return obj.location.coordinates[0] # PostGIS returns [lon, lat]
return None
except (AttributeError, IndexError, TypeError):
@@ -333,13 +319,11 @@ class HybridParkSerializer(serializers.ModelSerializer):
"description",
"status",
"park_type",
# Dates and computed fields
"opening_date",
"closing_date",
"opening_year",
"operating_season",
# Location fields
"city",
"state",
@@ -347,28 +331,22 @@ class HybridParkSerializer(serializers.ModelSerializer):
"continent",
"latitude",
"longitude",
# Company relationships
"operator_name",
"property_owner_name",
# Statistics
"size_acres",
"average_rating",
"ride_count",
"coaster_count",
# Images
"banner_image_url",
"card_image_url",
# URLs
"website",
"url",
# Computed fields for filtering
"search_text",
# Metadata
"created_at",
"updated_at",

View File

@@ -46,8 +46,8 @@ ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
ride_reviews_router = DefaultRouter()
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
from .history_views import ParkHistoryViewSet, RideHistoryViewSet
from .park_reviews_views import ParkReviewViewSet
from .history_views import ParkHistoryViewSet, RideHistoryViewSet # noqa: E402
from .park_reviews_views import ParkReviewViewSet # noqa: E402
# Create routers for nested park endpoints
reviews_router = DefaultRouter()
@@ -59,11 +59,9 @@ app_name = "api_v1_parks"
urlpatterns = [
# Core list/create endpoints
path("", ParkListCreateAPIView.as_view(), name="park-list-create"),
# Hybrid filtering endpoints
path("hybrid/", HybridParkAPIView.as_view(), name="park-hybrid-list"),
path("hybrid/filter-metadata/", ParkFilterMetadataAPIView.as_view(), name="park-hybrid-filter-metadata"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="park-filter-options"),
# Autocomplete / suggestion endpoints
@@ -79,14 +77,11 @@ urlpatterns = [
),
# Detail and action endpoints - supports both ID and slug
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
# Park rides endpoints
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"),
path("<str:park_slug>/rides/<str:ride_slug>/", ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
# Comprehensive park detail endpoint with rides summary
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"),
# Park image settings endpoint
path(
"<int:pk>/image-settings/",
@@ -95,33 +90,29 @@ urlpatterns = [
),
# Park photo endpoints - domain-specific photo management
path("<str:park_pk>/photos/", include(router.urls)),
# Nested ride photo endpoints - photos for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
# Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Ride History
path("<str:park_slug>/rides/<str:ride_slug>/history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"),
path(
"<str:park_slug>/rides/<str:ride_slug>/history/",
RideHistoryViewSet.as_view({"get": "list"}),
name="ride-history",
),
# Park Reviews
path("<str:park_slug>/reviews/", include(reviews_router.urls)),
# Park History
path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"),
path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({"get": "list"}), name="park-history"),
# Roadtrip API endpoints
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"),
path("roadtrip/find-along-route/", FindParksAlongRouteView.as_view(), name="roadtrip-find"),
path("roadtrip/geocode/", GeocodeAddressView.as_view(), name="roadtrip-geocode"),
path("roadtrip/distance/", ParkDistanceCalculatorView.as_view(), name="roadtrip-distance"),
# Operator endpoints
path("operators/", OperatorListAPIView.as_view(), name="operator-list"),
# Location search endpoints
path("search/location/", location_search, name="location-search"),
path("search/reverse-geocode/", reverse_geocode, name="reverse-geocode"),

View File

@@ -134,9 +134,7 @@ class ParkPhotoViewSet(ModelViewSet):
def get_queryset(self): # type: ignore[override]
"""Get photos for the current park with optimized queries."""
queryset = ParkPhoto.objects.select_related(
"park", "park__operator", "uploaded_by"
)
queryset = ParkPhoto.objects.select_related("park", "park__operator", "uploaded_by")
# If park_pk is provided in URL kwargs, filter by park
# If park_pk is provided in URL kwargs, filter by park
@@ -172,7 +170,7 @@ class ParkPhotoViewSet(ModelViewSet):
# Use real park ID
park_id = park.id
except Park.DoesNotExist:
raise ValidationError("Park not found")
raise ValidationError("Park not found") from None
try:
# Use the service to create the photo with proper business logic
@@ -188,48 +186,38 @@ class ParkPhotoViewSet(ModelViewSet):
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error creating park photo: {e}")
raise ValidationError(str(e))
raise ValidationError(str(e)) from None
except ServiceError as e:
logger.error(f"Service error creating park photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
raise ValidationError(f"Failed to create photo: {str(e)}") from None
def perform_update(self, serializer):
"""Update park photo with permission checking."""
instance = self.get_object()
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or cast(Any, self.request.user).is_staff
):
if not (self.request.user == instance.uploaded_by or cast(Any, self.request.user).is_staff):
raise PermissionDenied("You can only edit your own photos or be an admin.")
# Handle primary photo logic using service
if serializer.validated_data.get("is_primary", False):
try:
ParkMediaService().set_primary_photo(
park_id=instance.park_id, photo_id=instance.id
)
ParkMediaService().set_primary_photo(park_id=instance.park_id, photo_id=instance.id)
# Remove is_primary from validated_data since service handles it
if "is_primary" in serializer.validated_data:
del serializer.validated_data["is_primary"]
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error setting primary photo: {e}")
raise ValidationError(str(e))
raise ValidationError(str(e)) from None
except ServiceError as e:
logger.error(f"Service error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
def perform_destroy(self, instance):
"""Delete park photo with permission checking."""
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or cast(Any, self.request.user).is_staff
):
raise PermissionDenied(
"You can only delete your own photos or be an admin."
)
if not (self.request.user == instance.uploaded_by or cast(Any, self.request.user).is_staff):
raise PermissionDenied("You can only delete your own photos or be an admin.")
# Delete from Cloudflare first if image exists
if instance.image:
@@ -240,9 +228,7 @@ class ParkPhotoViewSet(ModelViewSet):
service = CloudflareImagesService()
service.delete_image(instance.image)
logger.info(
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}"
)
logger.info(f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}")
except ImportError:
logger.warning("CloudflareImagesService not available")
except ServiceError as e:
@@ -250,12 +236,10 @@ class ParkPhotoViewSet(ModelViewSet):
# Continue with database deletion even if Cloudflare deletion fails
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:
logger.error(f"Service error deleting park photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema(
summary="Set photo as primary",
@@ -275,14 +259,10 @@ class ParkPhotoViewSet(ModelViewSet):
# Check permissions - allow owner or staff
if not (request.user == photo.uploaded_by or cast(Any, request.user).is_staff):
raise PermissionDenied(
"You can only modify your own photos or be an admin."
)
raise PermissionDenied("You can only modify your own photos or be an admin.")
try:
ParkMediaService().set_primary_photo(
park_id=photo.park_id, photo_id=photo.id
)
ParkMediaService().set_primary_photo(park_id=photo.park_id, photo_id=photo.id)
# Refresh the photo instance
photo.refresh_from_db()
@@ -290,7 +270,7 @@ class ParkPhotoViewSet(ModelViewSet):
return Response(
{
"message": "Photo set as primary successfully",
"detail": "Photo set as primary successfully",
"photo": serializer.data,
},
status=status.HTTP_200_OK,
@@ -337,7 +317,7 @@ class ParkPhotoViewSet(ModelViewSet):
if photo_ids is None or approve is None:
return Response(
{"error": "Missing required fields: photo_ids and/or approve."},
{"detail": "Missing required fields: photo_ids and/or approve."},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -354,7 +334,7 @@ class ParkPhotoViewSet(ModelViewSet):
return Response(
{
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"detail": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"updated_count": updated_count,
},
status=status.HTTP_200_OK,
@@ -430,19 +410,14 @@ class ParkPhotoViewSet(ModelViewSet):
def set_primary_legacy(self, request, id=None):
"""Legacy set primary action for backwards compatibility."""
photo = self.get_object()
if not (
request.user == photo.uploaded_by
or request.user.has_perm("parks.change_parkphoto")
):
if not (request.user == photo.uploaded_by or request.user.has_perm("parks.change_parkphoto")):
return Response(
{"error": "You do not have permission to edit photos for this park."},
{"detail": "You do not have permission to edit photos for this park."},
status=status.HTTP_403_FORBIDDEN,
)
try:
ParkMediaService().set_primary_photo(
park_id=photo.park_id, photo_id=photo.id
)
return Response({"message": "Photo set as primary successfully."})
ParkMediaService().set_primary_photo(park_id=photo.park_id, photo_id=photo.id)
return Response({"detail": "Photo set as primary successfully."})
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error in set_primary_photo: {str(e)}")
return ErrorHandler.handle_api_error(
@@ -475,7 +450,7 @@ class ParkPhotoViewSet(ModelViewSet):
park_pk = self.kwargs.get("park_pk")
if not park_pk:
return Response(
{"error": "Park ID is required"},
{"detail": "Park ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -483,14 +458,14 @@ class ParkPhotoViewSet(ModelViewSet):
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
{"detail": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
cloudflare_image_id = request.data.get("cloudflare_image_id")
if not cloudflare_image_id:
return Response(
{"error": "cloudflare_image_id is required"},
{"detail": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -515,18 +490,14 @@ class ParkPhotoViewSet(ModelViewSet):
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id
)
cloudflare_image = CloudflareImage.objects.get(cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = "uploaded"
cloudflare_image.uploaded_at = timezone.now()
cloudflare_image.metadata = image_data.get("meta", {})
# Extract variants from nested result structure
cloudflare_image.variants = image_data.get("result", {}).get(
"variants", []
)
cloudflare_image.variants = image_data.get("result", {}).get("variants", [])
cloudflare_image.cloudflare_metadata = image_data
cloudflare_image.width = image_data.get("width")
cloudflare_image.height = image_data.get("height")
@@ -540,8 +511,7 @@ class ParkPhotoViewSet(ModelViewSet):
user=request.user,
status="uploaded",
upload_url="", # Not needed for uploaded images
expires_at=timezone.now()
+ timezone.timedelta(days=365), # Set far future expiry
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
uploaded_at=timezone.now(),
metadata=image_data.get("meta", {}),
# Extract variants from nested result structure
@@ -567,9 +537,7 @@ class ParkPhotoViewSet(ModelViewSet):
# Handle primary photo logic if requested
if request.data.get("is_primary", False):
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:
logger.error(f"Error setting primary photo: {e}")
# Don't fail the entire operation, just log the error
@@ -624,12 +592,8 @@ class ParkPhotoViewSet(ModelViewSet):
OpenApiTypes.STR,
description="Filter by state (comma-separated for multiple)",
),
OpenApiParameter(
"opening_year_min", OpenApiTypes.INT, description="Minimum opening year"
),
OpenApiParameter(
"opening_year_max", OpenApiTypes.INT, description="Maximum opening year"
),
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
OpenApiParameter(
"size_min",
OpenApiTypes.NUMBER,
@@ -640,18 +604,10 @@ class ParkPhotoViewSet(ModelViewSet):
OpenApiTypes.NUMBER,
description="Maximum park size in acres",
),
OpenApiParameter(
"rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"
),
OpenApiParameter(
"rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"
),
OpenApiParameter(
"ride_count_min", OpenApiTypes.INT, description="Minimum ride count"
),
OpenApiParameter(
"ride_count_max", OpenApiTypes.INT, description="Maximum ride count"
),
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"),
OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"),
OpenApiParameter(
"coaster_count_min",
OpenApiTypes.INT,
@@ -688,9 +644,7 @@ class ParkPhotoViewSet(ModelViewSet):
"properties": {
"parks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HybridParkSerializer"
},
"items": {"$ref": "#/components/schemas/HybridParkSerializer"},
},
"total_count": {"type": "integer"},
"strategy": {
@@ -808,7 +762,7 @@ class HybridParkAPIView(APIView):
for param in int_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = int(value)
except ValueError:
pass # Skip invalid integer values
@@ -818,7 +772,7 @@ class HybridParkAPIView(APIView):
for param in float_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = float(value)
except ValueError:
pass # Skip invalid float values

View File

@@ -0,0 +1,167 @@
"""
Standardized API response helpers for ThrillWiki.
This module provides consistent response formatting across all API endpoints:
Success responses:
- Action completed: {"detail": "Success message"}
- With data: {"detail": "...", "data": {...}}
Error responses:
- Validation: {"field": ["error"]} (DRF default)
- Application: {"detail": "Error message", "code": "ERROR_CODE"}
Usage:
from apps.api.v1.responses import success_response, error_response
# Success
return success_response("Avatar saved successfully")
# Error
return error_response("User not found", code="NOT_FOUND", status_code=404)
"""
from rest_framework import status
from rest_framework.response import Response
# Standard error codes for machine-readable error handling
class ErrorCodes:
"""Standard error codes for API responses."""
# Authentication / Authorization
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
TOKEN_EXPIRED = "TOKEN_EXPIRED"
TOKEN_INVALID = "TOKEN_INVALID"
# Resource errors
NOT_FOUND = "NOT_FOUND"
ALREADY_EXISTS = "ALREADY_EXISTS"
CONFLICT = "CONFLICT"
# Validation errors
VALIDATION_ERROR = "VALIDATION_ERROR"
INVALID_INPUT = "INVALID_INPUT"
MISSING_FIELD = "MISSING_FIELD"
# Operation errors
OPERATION_FAILED = "OPERATION_FAILED"
PERMISSION_DENIED = "PERMISSION_DENIED"
RATE_LIMITED = "RATE_LIMITED"
# User-specific errors
USER_NOT_FOUND = "USER_NOT_FOUND"
USER_INACTIVE = "USER_INACTIVE"
USER_BANNED = "USER_BANNED"
CANNOT_DELETE_SUPERUSER = "CANNOT_DELETE_SUPERUSER"
CANNOT_DELETE_SELF = "CANNOT_DELETE_SELF"
# Verification errors
VERIFICATION_EXPIRED = "VERIFICATION_EXPIRED"
VERIFICATION_INVALID = "VERIFICATION_INVALID"
ALREADY_VERIFIED = "ALREADY_VERIFIED"
# External service errors
EXTERNAL_SERVICE_ERROR = "EXTERNAL_SERVICE_ERROR"
CLOUDFLARE_ERROR = "CLOUDFLARE_ERROR"
def success_response(
detail: str,
data: dict | None = None,
status_code: int = status.HTTP_200_OK,
) -> Response:
"""
Create a standardized success response.
Args:
detail: Human-readable success message
data: Optional additional data to include
status_code: HTTP status code (default 200)
Returns:
DRF Response object
Example:
return success_response("Avatar saved successfully")
return success_response("User created", data={"id": user.id}, status_code=201)
"""
response_data = {"detail": detail}
if data:
response_data.update(data)
return Response(response_data, status=status_code)
def error_response(
detail: str,
code: str | None = None,
status_code: int = status.HTTP_400_BAD_REQUEST,
extra: dict | None = None,
) -> Response:
"""
Create a standardized error response.
Args:
detail: Human-readable error message
code: Machine-readable error code from ErrorCodes
status_code: HTTP status code (default 400)
extra: Optional additional data to include
Returns:
DRF Response object
Example:
return error_response("User not found", code=ErrorCodes.NOT_FOUND, status_code=404)
return error_response("Invalid input", code=ErrorCodes.VALIDATION_ERROR)
"""
response_data = {"detail": detail}
if code:
response_data["code"] = code
if extra:
response_data.update(extra)
return Response(response_data, status=status_code)
def created_response(detail: str, data: dict | None = None) -> Response:
"""Convenience wrapper for 201 Created responses."""
return success_response(detail, data=data, status_code=status.HTTP_201_CREATED)
def not_found_response(detail: str = "Resource not found") -> Response:
"""Convenience wrapper for 404 Not Found responses."""
return error_response(
detail,
code=ErrorCodes.NOT_FOUND,
status_code=status.HTTP_404_NOT_FOUND,
)
def forbidden_response(detail: str = "Permission denied") -> Response:
"""Convenience wrapper for 403 Forbidden responses."""
return error_response(
detail,
code=ErrorCodes.FORBIDDEN,
status_code=status.HTTP_403_FORBIDDEN,
)
def unauthorized_response(detail: str = "Authentication required") -> Response:
"""Convenience wrapper for 401 Unauthorized responses."""
return error_response(
detail,
code=ErrorCodes.UNAUTHORIZED,
status_code=status.HTTP_401_UNAUTHORIZED,
)
__all__ = [
"ErrorCodes",
"success_response",
"error_response",
"created_response",
"not_found_response",
"forbidden_response",
"unauthorized_response",
]

View File

@@ -24,6 +24,7 @@ from apps.api.v1.serializers.companies import (
try:
from apps.rides.models.company import Company
MODELS_AVAILABLE = True
except ImportError:
Company = None
@@ -65,9 +66,7 @@ class CompanyListCreateAPIView(APIView):
# Search filter
search = request.query_params.get("search", "")
if search:
qs = qs.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
qs = qs.filter(Q(name__icontains=search) | Q(description__icontains=search))
# Role filter
role = request.query_params.get("role", "")
@@ -120,7 +119,7 @@ class CompanyDetailAPIView(APIView):
try:
return Company.objects.get(pk=pk)
except Company.DoesNotExist:
raise NotFound("Company not found")
raise NotFound("Company not found") from None
@extend_schema(
summary="Retrieve a company",

View File

@@ -93,18 +93,10 @@ class RideModelListCreateAPIView(APIView):
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
OpenApiParameter(name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter(name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter(
name="target_market",
location=OpenApiParameter.QUERY,
@@ -134,7 +126,7 @@ class RideModelListCreateAPIView(APIView):
try:
manufacturer = Company.objects.get(slug=manufacturer_slug)
except Company.DoesNotExist:
raise NotFound("Manufacturer not found")
raise NotFound("Manufacturer not found") from None
qs = (
RideModel.objects.filter(manufacturer=manufacturer)
@@ -176,13 +168,9 @@ class RideModelListCreateAPIView(APIView):
# Year filters
if filters.get("first_installation_year_min"):
qs = qs.filter(
first_installation_year__gte=filters["first_installation_year_min"]
)
qs = qs.filter(first_installation_year__gte=filters["first_installation_year_min"])
if filters.get("first_installation_year_max"):
qs = qs.filter(
first_installation_year__lte=filters["first_installation_year_max"]
)
qs = qs.filter(first_installation_year__lte=filters["first_installation_year_max"])
# Installation count filter
if filters.get("min_installations"):
@@ -190,23 +178,15 @@ class RideModelListCreateAPIView(APIView):
# Height filters
if filters.get("min_height_ft"):
qs = qs.filter(
typical_height_range_max_ft__gte=filters["min_height_ft"]
)
qs = qs.filter(typical_height_range_max_ft__gte=filters["min_height_ft"])
if filters.get("max_height_ft"):
qs = qs.filter(
typical_height_range_min_ft__lte=filters["max_height_ft"]
)
qs = qs.filter(typical_height_range_min_ft__lte=filters["max_height_ft"])
# Speed filters
if filters.get("min_speed_mph"):
qs = qs.filter(
typical_speed_range_max_mph__gte=filters["min_speed_mph"]
)
qs = qs.filter(typical_speed_range_max_mph__gte=filters["min_speed_mph"])
if filters.get("max_speed_mph"):
qs = qs.filter(
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
)
qs = qs.filter(typical_speed_range_min_mph__lte=filters["max_speed_mph"])
# Ordering
ordering = filters.get("ordering", "manufacturer__name,name")
@@ -216,9 +196,7 @@ class RideModelListCreateAPIView(APIView):
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = RideModelListOutputSerializer(
page, many=True, context={"request": request}
)
serializer = RideModelListOutputSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@extend_schema(
@@ -240,9 +218,7 @@ class RideModelListCreateAPIView(APIView):
"""Create a new ride model for a specific manufacturer."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride model creation is not available because domain models are not imported."
},
{"detail": "Ride model creation is not available because domain models are not imported."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
@@ -250,7 +226,7 @@ class RideModelListCreateAPIView(APIView):
try:
manufacturer = Company.objects.get(slug=manufacturer_slug)
except Company.DoesNotExist:
raise NotFound("Manufacturer not found")
raise NotFound("Manufacturer not found") from None
serializer_in = RideModelCreateInputSerializer(data=request.data)
serializer_in.is_valid(raise_exception=True)
@@ -279,18 +255,14 @@ class RideModelListCreateAPIView(APIView):
target_market=validated.get("target_market", ""),
)
out_serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
out_serializer = RideModelDetailOutputSerializer(ride_model, context={"request": request})
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
class RideModelDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_ride_model_or_404(
self, manufacturer_slug: str, ride_model_slug: str
) -> Any:
def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Ride model models not available")
try:
@@ -300,7 +272,7 @@ class RideModelDetailAPIView(APIView):
.get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
raise NotFound("Ride model not found") from None
@extend_schema(
summary="Retrieve a ride model",
@@ -322,13 +294,9 @@ class RideModelDetailAPIView(APIView):
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def get(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def get(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
serializer = RideModelDetailOutputSerializer(ride_model, context={"request": request})
return Response(serializer.data)
@extend_schema(
@@ -352,9 +320,7 @@ class RideModelDetailAPIView(APIView):
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def patch(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def patch(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
@@ -366,20 +332,16 @@ class RideModelDetailAPIView(APIView):
manufacturer = Company.objects.get(id=value)
ride_model.manufacturer = manufacturer
except Company.DoesNotExist:
raise ValidationError({"manufacturer_id": "Manufacturer not found"})
raise ValidationError({"manufacturer_id": "Manufacturer not found"}) from None
else:
setattr(ride_model, field, value)
ride_model.save()
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
serializer = RideModelDetailOutputSerializer(ride_model, context={"request": request})
return Response(serializer.data)
def put(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
# Full replace - reuse patch behavior for simplicity
return self.patch(request, manufacturer_slug, ride_model_slug)
@@ -403,9 +365,7 @@ class RideModelDetailAPIView(APIView):
responses={204: None},
tags=["Ride Models"],
)
def delete(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
def delete(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
ride_model.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -449,9 +409,7 @@ class RideModelSearchAPIView(APIView):
)
qs = RideModel.objects.filter(
Q(name__icontains=q)
| Q(description__icontains=q)
| Q(manufacturer__name__icontains=q)
Q(name__icontains=q) | Q(description__icontains=q) | Q(manufacturer__name__icontains=q)
).select_related("manufacturer")[:20]
results = [
@@ -491,8 +449,8 @@ class RideModelFilterOptionsAPIView(APIView):
# Use Rich Choice Objects for fallback options
try:
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
categories = get_choices("categories", "rides")
target_markets = get_choices("target_markets", "rides")
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
@@ -500,10 +458,10 @@ class RideModelFilterOptionsAPIView(APIView):
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in categories
]
@@ -513,10 +471,10 @@ class RideModelFilterOptionsAPIView(APIView):
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in target_markets
]
@@ -524,25 +482,173 @@ class RideModelFilterOptionsAPIView(APIView):
except Exception:
# Ultimate fallback with basic structure
categories_data = [
{"value": "RC", "label": "Roller Coaster", "description": "High-speed thrill rides with tracks", "color": "red", "icon": "roller-coaster", "css_class": "bg-red-100 text-red-800", "sort_order": 1},
{"value": "DR", "label": "Dark Ride", "description": "Indoor themed experiences", "color": "purple", "icon": "dark-ride", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2},
{"value": "FR", "label": "Flat Ride", "description": "Spinning and rotating attractions", "color": "blue", "icon": "flat-ride", "css_class": "bg-blue-100 text-blue-800", "sort_order": 3},
{"value": "WR", "label": "Water Ride", "description": "Water-based attractions and slides", "color": "cyan", "icon": "water-ride", "css_class": "bg-cyan-100 text-cyan-800", "sort_order": 4},
{"value": "TR", "label": "Transport", "description": "Transportation systems within parks", "color": "green", "icon": "transport", "css_class": "bg-green-100 text-green-800", "sort_order": 5},
{"value": "OT", "label": "Other", "description": "Miscellaneous attractions", "color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6},
{
"value": "RC",
"label": "Roller Coaster",
"description": "High-speed thrill rides with tracks",
"color": "red",
"icon": "roller-coaster",
"css_class": "bg-red-100 text-red-800",
"sort_order": 1,
},
{
"value": "DR",
"label": "Dark Ride",
"description": "Indoor themed experiences",
"color": "purple",
"icon": "dark-ride",
"css_class": "bg-purple-100 text-purple-800",
"sort_order": 2,
},
{
"value": "FR",
"label": "Flat Ride",
"description": "Spinning and rotating attractions",
"color": "blue",
"icon": "flat-ride",
"css_class": "bg-blue-100 text-blue-800",
"sort_order": 3,
},
{
"value": "WR",
"label": "Water Ride",
"description": "Water-based attractions and slides",
"color": "cyan",
"icon": "water-ride",
"css_class": "bg-cyan-100 text-cyan-800",
"sort_order": 4,
},
{
"value": "TR",
"label": "Transport",
"description": "Transportation systems within parks",
"color": "green",
"icon": "transport",
"css_class": "bg-green-100 text-green-800",
"sort_order": 5,
},
{
"value": "OT",
"label": "Other",
"description": "Miscellaneous attractions",
"color": "gray",
"icon": "other",
"css_class": "bg-gray-100 text-gray-800",
"sort_order": 6,
},
]
target_markets_data = [
{"value": "FAMILY", "label": "Family", "description": "Suitable for all family members", "color": "green", "icon": "family", "css_class": "bg-green-100 text-green-800", "sort_order": 1},
{"value": "THRILL", "label": "Thrill", "description": "High-intensity thrill experience", "color": "orange", "icon": "thrill", "css_class": "bg-orange-100 text-orange-800", "sort_order": 2},
{"value": "EXTREME", "label": "Extreme", "description": "Maximum intensity experience", "color": "red", "icon": "extreme", "css_class": "bg-red-100 text-red-800", "sort_order": 3},
{"value": "KIDDIE", "label": "Kiddie", "description": "Designed for young children", "color": "pink", "icon": "kiddie", "css_class": "bg-pink-100 text-pink-800", "sort_order": 4},
{"value": "ALL_AGES", "label": "All Ages", "description": "Enjoyable for all age groups", "color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5},
{
"value": "FAMILY",
"label": "Family",
"description": "Suitable for all family members",
"color": "green",
"icon": "family",
"css_class": "bg-green-100 text-green-800",
"sort_order": 1,
},
{
"value": "THRILL",
"label": "Thrill",
"description": "High-intensity thrill experience",
"color": "orange",
"icon": "thrill",
"css_class": "bg-orange-100 text-orange-800",
"sort_order": 2,
},
{
"value": "EXTREME",
"label": "Extreme",
"description": "Maximum intensity experience",
"color": "red",
"icon": "extreme",
"css_class": "bg-red-100 text-red-800",
"sort_order": 3,
},
{
"value": "KIDDIE",
"label": "Kiddie",
"description": "Designed for young children",
"color": "pink",
"icon": "kiddie",
"css_class": "bg-pink-100 text-pink-800",
"sort_order": 4,
},
{
"value": "ALL_AGES",
"label": "All Ages",
"description": "Enjoyable for all age groups",
"color": "blue",
"icon": "all-ages",
"css_class": "bg-blue-100 text-blue-800",
"sort_order": 5,
},
]
return Response({
return Response(
{
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard", "slug": "bolliger-mabillard"}],
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
{"value": "first_installation_year", "label": "Oldest First"},
{"value": "-first_installation_year", "label": "Newest First"},
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
}
)
# Get static choice definitions from Rich Choice Objects (primary source)
# Get dynamic data from database queries
# Get rich choice objects from registry
categories = get_choices("categories", "rides")
target_markets = get_choices("target_markets", "rides")
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get("color"),
"icon": choice.metadata.get("icon"),
"css_class": choice.metadata.get("css_class"),
"sort_order": choice.metadata.get("sort_order", 0),
}
for choice in target_markets
]
# Get actual data from database
manufacturers = (
Company.objects.filter(roles__contains=["MANUFACTURER"], ride_models__isnull=False)
.distinct()
.values("id", "name", "slug")
)
return Response(
{
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard", "slug": "bolliger-mabillard"}],
"manufacturers": list(manufacturers),
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
@@ -553,68 +659,9 @@ class RideModelFilterOptionsAPIView(APIView):
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
})
# Get static choice definitions from Rich Choice Objects (primary source)
# Get dynamic data from database queries
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in target_markets
]
# Get actual data from database
manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.values("id", "name", "slug")
)
return Response({
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": list(manufacturers),
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
{"value": "first_installation_year", "label": "Oldest First"},
{"value": "-first_installation_year", "label": "Newest First"},
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
})
# === RIDE MODEL STATISTICS ===
@@ -646,37 +693,23 @@ class RideModelStatsAPIView(APIView):
# Calculate statistics
total_models = RideModel.objects.count()
total_installations = (
RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
)
total_installations = RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
active_manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.count()
Company.objects.filter(roles__contains=["MANUFACTURER"], ride_models__isnull=False).distinct().count()
)
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
# Category breakdown
by_category = {}
category_counts = (
RideModel.objects.exclude(category="")
.values("category")
.annotate(count=Count("id"))
)
category_counts = RideModel.objects.exclude(category="").values("category").annotate(count=Count("id"))
for item in category_counts:
by_category[item["category"]] = item["count"]
# Target market breakdown
by_target_market = {}
market_counts = (
RideModel.objects.exclude(target_market="")
.values("target_market")
.annotate(count=Count("id"))
)
market_counts = RideModel.objects.exclude(target_market="").values("target_market").annotate(count=Count("id"))
for item in market_counts:
by_target_market[item["target_market"]] = item["count"]
@@ -693,9 +726,7 @@ class RideModelStatsAPIView(APIView):
# Recent models (last 30 days)
thirty_days_ago = timezone.now() - timedelta(days=30)
recent_models = RideModel.objects.filter(
created_at__gte=thirty_days_ago
).count()
recent_models = RideModel.objects.filter(created_at__gte=thirty_days_ago).count()
return Response(
{
@@ -730,7 +761,7 @@ class RideModelVariantListCreateAPIView(APIView):
try:
ride_model = RideModel.objects.get(pk=ride_model_pk)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
raise NotFound("Ride model not found") from None
variants = RideModelVariant.objects.filter(ride_model=ride_model)
serializer = RideModelVariantOutputSerializer(variants, many=True)
@@ -753,7 +784,7 @@ class RideModelVariantListCreateAPIView(APIView):
try:
ride_model = RideModel.objects.get(pk=ride_model_pk)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
raise NotFound("Ride model not found") from None
# Override ride_model_id in the data
data = request.data.copy()
@@ -787,7 +818,7 @@ class RideModelVariantDetailAPIView(APIView):
try:
return RideModelVariant.objects.get(ride_model_id=ride_model_pk, pk=pk)
except RideModelVariant.DoesNotExist:
raise NotFound("Variant not found")
raise NotFound("Variant not found") from None
@extend_schema(
summary="Get a ride model variant",
@@ -807,9 +838,7 @@ class RideModelVariantDetailAPIView(APIView):
)
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
variant = self._get_variant_or_404(ride_model_pk, pk)
serializer_in = RideModelVariantUpdateInputSerializer(
data=request.data, partial=True
)
serializer_in = RideModelVariantUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
for field, value in serializer_in.validated_data.items():

View File

@@ -118,9 +118,7 @@ class RidePhotoViewSet(ModelViewSet):
def get_queryset(self): # type: ignore[override]
"""Get photos for the current ride with optimized queries."""
queryset = RidePhoto.objects.select_related(
"ride", "ride__park", "ride__park__operator", "uploaded_by"
)
queryset = RidePhoto.objects.select_related("ride", "ride__park", "ride__park__operator", "uploaded_by")
# If ride_pk is provided in URL kwargs, filter by ride
ride_pk = self.kwargs.get("ride_pk")
@@ -149,7 +147,7 @@ class RidePhotoViewSet(ModelViewSet):
try:
ride = Ride.objects.get(pk=ride_id)
except Ride.DoesNotExist:
raise ValidationError("Ride not found")
raise ValidationError("Ride not found") from None
try:
# Use the service to create the photo with proper business logic
@@ -169,17 +167,14 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error creating ride photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
raise ValidationError(f"Failed to create photo: {str(e)}") from None
def perform_update(self, serializer):
"""Update ride photo with permission checking."""
instance = self.get_object()
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or getattr(self.request.user, "is_staff", False)
):
if not (self.request.user == instance.uploaded_by or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only edit your own photos or be an admin.")
# Handle primary photo logic using service
@@ -191,39 +186,31 @@ class RidePhotoViewSet(ModelViewSet):
del serializer.validated_data["is_primary"]
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
raise ValidationError(f"Failed to set primary photo: {str(e)}") from None
def perform_destroy(self, instance):
"""Delete ride photo with permission checking."""
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or getattr(self.request.user, "is_staff", False)
):
raise PermissionDenied(
"You can only delete your own photos or be an admin."
)
if not (self.request.user == instance.uploaded_by or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only delete your own photos or be an admin.")
try:
# Delete from Cloudflare first if image exists
if instance.image:
try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService
service = CloudflareImagesService()
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:
logger.error(
f"Failed to delete ride photo from Cloudflare: {str(e)}")
logger.error(f"Failed to delete ride photo from Cloudflare: {str(e)}")
# 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:
logger.error(f"Error deleting ride photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
raise ValidationError(f"Failed to delete photo: {str(e)}") from None
@extend_schema(
summary="Set photo as primary",
@@ -242,13 +229,8 @@ class RidePhotoViewSet(ModelViewSet):
photo = self.get_object()
# Check permissions - allow owner or staff
if not (
request.user == photo.uploaded_by
or getattr(request.user, "is_staff", False)
):
raise PermissionDenied(
"You can only modify your own photos or be an admin."
)
if not (request.user == photo.uploaded_by or getattr(request.user, "is_staff", False)):
raise PermissionDenied("You can only modify your own photos or be an admin.")
try:
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
@@ -260,21 +242,21 @@ class RidePhotoViewSet(ModelViewSet):
return Response(
{
"message": "Photo set as primary successfully",
"detail": "Photo set as primary successfully",
"photo": serializer.data,
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": "Failed to set primary photo"},
{"detail": "Failed to set primary photo"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
return Response(
{"error": f"Failed to set primary photo: {str(e)}"},
{"detail": f"Failed to set primary photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -305,7 +287,7 @@ class RidePhotoViewSet(ModelViewSet):
if photo_ids is None or approve is None:
return Response(
{"error": "Missing required fields: photo_ids and/or approve."},
{"detail": "Missing required fields: photo_ids and/or approve."},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -319,7 +301,7 @@ class RidePhotoViewSet(ModelViewSet):
return Response(
{
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"detail": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"updated_count": updated_count,
},
status=status.HTTP_200_OK,
@@ -328,7 +310,7 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error in bulk photo approval: {e}")
return Response(
{"error": f"Failed to update photos: {str(e)}"},
{"detail": f"Failed to update photos: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -352,7 +334,7 @@ class RidePhotoViewSet(ModelViewSet):
ride = Ride.objects.get(pk=ride_pk)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found."},
{"detail": "Ride not found."},
status=status.HTTP_404_NOT_FOUND,
)
@@ -363,16 +345,10 @@ class RidePhotoViewSet(ModelViewSet):
# Global stats across all rides
stats = {
"total_photos": RidePhoto.objects.count(),
"approved_photos": RidePhoto.objects.filter(
is_approved=True
).count(),
"pending_photos": RidePhoto.objects.filter(
is_approved=False
).count(),
"approved_photos": RidePhoto.objects.filter(is_approved=True).count(),
"pending_photos": RidePhoto.objects.filter(is_approved=False).count(),
"has_primary": False, # Not applicable for global stats
"recent_uploads": RidePhoto.objects.order_by("-created_at")[
:5
].count(),
"recent_uploads": RidePhoto.objects.order_by("-created_at")[:5].count(),
"by_type": {},
}
@@ -382,7 +358,7 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error getting ride photo stats: {e}")
return Response(
{"error": f"Failed to get photo statistics: {str(e)}"},
{"detail": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -401,26 +377,23 @@ class RidePhotoViewSet(ModelViewSet):
def set_primary_legacy(self, request, id=None):
"""Legacy set primary action for backwards compatibility."""
photo = self.get_object()
if not (
request.user == photo.uploaded_by
or request.user.has_perm("rides.change_ridephoto")
):
if not (request.user == photo.uploaded_by or request.user.has_perm("rides.change_ridephoto")):
return Response(
{"error": "You do not have permission to edit photos for this ride."},
{"detail": "You do not have permission to edit photos for this ride."},
status=status.HTTP_403_FORBIDDEN,
)
try:
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
if success:
return Response({"message": "Photo set as primary successfully."})
return Response({"detail": "Photo set as primary successfully."})
else:
return Response(
{"error": "Failed to set primary photo"},
{"detail": "Failed to set primary photo"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Save Cloudflare image as ride photo",
@@ -440,7 +413,7 @@ class RidePhotoViewSet(ModelViewSet):
ride_pk = self.kwargs.get("ride_pk")
if not ride_pk:
return Response(
{"error": "Ride ID is required"},
{"detail": "Ride ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -448,14 +421,14 @@ class RidePhotoViewSet(ModelViewSet):
ride = Ride.objects.get(pk=ride_pk)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found"},
{"detail": "Ride not found"},
status=status.HTTP_404_NOT_FOUND,
)
cloudflare_image_id = request.data.get("cloudflare_image_id")
if not cloudflare_image_id:
return Response(
{"error": "cloudflare_image_id is required"},
{"detail": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -473,27 +446,25 @@ class RidePhotoViewSet(ModelViewSet):
if not image_data:
return Response(
{"error": "Image not found in Cloudflare"},
{"detail": "Image not found in Cloudflare"},
status=status.HTTP_400_BAD_REQUEST,
)
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id)
cloudflare_image = CloudflareImage.objects.get(cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.status = "uploaded"
cloudflare_image.uploaded_at = timezone.now()
cloudflare_image.metadata = image_data.get('meta', {})
cloudflare_image.metadata = image_data.get("meta", {})
# Extract variants from nested result structure
cloudflare_image.variants = image_data.get(
'result', {}).get('variants', [])
cloudflare_image.variants = image_data.get("result", {}).get("variants", [])
cloudflare_image.cloudflare_metadata = image_data
cloudflare_image.width = image_data.get('width')
cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '')
cloudflare_image.width = image_data.get("width")
cloudflare_image.height = image_data.get("height")
cloudflare_image.format = image_data.get("format", "")
cloudflare_image.save()
except CloudflareImage.DoesNotExist:
@@ -501,24 +472,23 @@ class RidePhotoViewSet(ModelViewSet):
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=request.user,
status='uploaded',
upload_url='', # Not needed for uploaded images
status="uploaded",
upload_url="", # Not needed for uploaded images
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
uploaded_at=timezone.now(),
metadata=image_data.get('meta', {}),
metadata=image_data.get("meta", {}),
# Extract variants from nested result structure
variants=image_data.get('result', {}).get('variants', []),
variants=image_data.get("result", {}).get("variants", []),
cloudflare_metadata=image_data,
width=image_data.get('width'),
height=image_data.get('height'),
format=image_data.get('format', ''),
width=image_data.get("width"),
height=image_data.get("height"),
format=image_data.get("format", ""),
)
except Exception as api_error:
logger.error(
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
return Response(
{"error": 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,
)
@@ -548,6 +518,6 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error saving ride photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
{"detail": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -52,18 +52,14 @@ from apps.rides.models import Ride, RidePhoto
class RidePhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride photos with Cloudflare Images support."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
file_size = serializers.SerializerMethodField()
dimensions = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
image_variants = serializers.SerializerMethodField()
@extend_schema_field(
serializers.IntegerField(allow_null=True, help_text="File size in bytes")
)
@extend_schema_field(serializers.IntegerField(allow_null=True, help_text="File size in bytes"))
def get_file_size(self, obj):
"""Get file size in bytes."""
return obj.file_size
@@ -81,11 +77,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
"""Get image dimensions as [width, height]."""
return obj.dimensions
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset", allow_null=True
)
)
@extend_schema_field(serializers.URLField(help_text="Full URL to the Cloudflare Images asset", allow_null=True))
def get_image_url(self, obj):
"""Get the full Cloudflare Images URL."""
if obj.image:
@@ -186,9 +178,7 @@ class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for ride photo lists."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
class Meta:
model = RidePhoto
@@ -208,12 +198,8 @@ class RidePhotoListOutputSerializer(serializers.ModelSerializer):
class RidePhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True, help_text="Whether to approve (True) or reject (False) the photos"
)
photo_ids = serializers.ListField(child=serializers.IntegerField(), help_text="List of photo IDs to approve")
approve = serializers.BooleanField(default=True, help_text="Whether to approve (True) or reject (False) the photos")
class RidePhotoStatsOutputSerializer(serializers.Serializer):
@@ -224,9 +210,7 @@ class RidePhotoStatsOutputSerializer(serializers.Serializer):
pending_photos = serializers.IntegerField()
has_primary = serializers.BooleanField()
recent_uploads = serializers.IntegerField()
by_type = serializers.DictField(
child=serializers.IntegerField(), help_text="Photo counts by type"
)
by_type = serializers.DictField(child=serializers.IntegerField(), help_text="Photo counts by type")
class RidePhotoTypeFilterSerializer(serializers.Serializer):
@@ -292,8 +276,12 @@ class HybridRideSerializer(serializers.ModelSerializer):
ride_model_name = serializers.CharField(source="ride_model.name", read_only=True, allow_null=True)
ride_model_slug = serializers.CharField(source="ride_model.slug", read_only=True, allow_null=True)
ride_model_category = serializers.CharField(source="ride_model.category", read_only=True, allow_null=True)
ride_model_manufacturer_name = serializers.CharField(source="ride_model.manufacturer.name", read_only=True, allow_null=True)
ride_model_manufacturer_slug = serializers.CharField(source="ride_model.manufacturer.slug", read_only=True, allow_null=True)
ride_model_manufacturer_name = serializers.CharField(
source="ride_model.manufacturer.name", read_only=True, allow_null=True
)
ride_model_manufacturer_slug = serializers.CharField(
source="ride_model.manufacturer.slug", read_only=True, allow_null=True
)
# Roller coaster stats fields
coaster_height_ft = serializers.SerializerMethodField()
@@ -323,7 +311,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_park_city(self, obj):
"""Get city from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
if obj.park and hasattr(obj.park, "location") and obj.park.location:
return obj.park.location.city
return None
except AttributeError:
@@ -333,7 +321,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_park_state(self, obj):
"""Get state from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
if obj.park and hasattr(obj.park, "location") and obj.park.location:
return obj.park.location.state
return None
except AttributeError:
@@ -343,7 +331,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_park_country(self, obj):
"""Get country from park location."""
try:
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
if obj.park and hasattr(obj.park, "location") and obj.park.location:
return obj.park.location.country
return None
except AttributeError:
@@ -353,7 +341,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_height_ft(self, obj):
"""Get roller coaster height."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.height_ft) if obj.coaster_stats.height_ft else None
return None
except (AttributeError, TypeError):
@@ -363,7 +351,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_length_ft(self, obj):
"""Get roller coaster length."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.length_ft) if obj.coaster_stats.length_ft else None
return None
except (AttributeError, TypeError):
@@ -373,7 +361,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_speed_mph(self, obj):
"""Get roller coaster speed."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.speed_mph) if obj.coaster_stats.speed_mph else None
return None
except (AttributeError, TypeError):
@@ -383,7 +371,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_inversions(self, obj):
"""Get roller coaster inversions."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.inversions
return None
except AttributeError:
@@ -393,7 +381,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_ride_time_seconds(self, obj):
"""Get roller coaster ride time."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.ride_time_seconds
return None
except AttributeError:
@@ -403,7 +391,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_track_type(self, obj):
"""Get roller coaster track type."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.track_type
return None
except AttributeError:
@@ -413,7 +401,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_track_material(self, obj):
"""Get roller coaster track material."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.track_material
return None
except AttributeError:
@@ -423,7 +411,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_roller_coaster_type(self, obj):
"""Get roller coaster type."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.roller_coaster_type
return None
except AttributeError:
@@ -433,7 +421,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_max_drop_height_ft(self, obj):
"""Get roller coaster max drop height."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return float(obj.coaster_stats.max_drop_height_ft) if obj.coaster_stats.max_drop_height_ft else None
return None
except (AttributeError, TypeError):
@@ -443,7 +431,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_propulsion_system(self, obj):
"""Get roller coaster propulsion system."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.propulsion_system
return None
except AttributeError:
@@ -453,7 +441,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_train_style(self, obj):
"""Get roller coaster train style."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.train_style
return None
except AttributeError:
@@ -463,7 +451,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_trains_count(self, obj):
"""Get roller coaster trains count."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.trains_count
return None
except AttributeError:
@@ -473,7 +461,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_cars_per_train(self, obj):
"""Get roller coaster cars per train."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.cars_per_train
return None
except AttributeError:
@@ -483,7 +471,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
def get_coaster_seats_per_car(self, obj):
"""Get roller coaster seats per car."""
try:
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
return obj.coaster_stats.seats_per_car
return None
except AttributeError:
@@ -514,44 +502,37 @@ class HybridRideSerializer(serializers.ModelSerializer):
"category",
"status",
"post_closing_status",
# Dates and computed fields
"opening_date",
"closing_date",
"status_since",
"opening_year",
# Park fields
"park_name",
"park_slug",
"park_city",
"park_state",
"park_country",
# Park area fields
"park_area_name",
"park_area_slug",
# Company fields
"manufacturer_name",
"manufacturer_slug",
"designer_name",
"designer_slug",
# Ride model fields
"ride_model_name",
"ride_model_slug",
"ride_model_category",
"ride_model_manufacturer_name",
"ride_model_manufacturer_slug",
# Ride specifications
"min_height_in",
"max_height_in",
"capacity_per_hour",
"ride_duration_seconds",
"average_rating",
# Roller coaster stats
"coaster_height_ft",
"coaster_length_ft",
@@ -567,18 +548,14 @@ class HybridRideSerializer(serializers.ModelSerializer):
"coaster_trains_count",
"coaster_cars_per_train",
"coaster_seats_per_car",
# Images
"banner_image_url",
"card_image_url",
# URLs
"url",
"park_url",
# Computed fields for filtering
"search_text",
# Metadata
"created_at",
"updated_at",

View File

@@ -35,11 +35,9 @@ app_name = "api_v1_rides"
urlpatterns = [
# Core list/create endpoints
path("", RideListCreateAPIView.as_view(), name="ride-list-create"),
# Hybrid filtering endpoints
path("hybrid/", HybridRideAPIView.as_view(), name="ride-hybrid-filtering"),
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
# Autocomplete / suggestion endpoints
@@ -61,7 +59,6 @@ urlpatterns = [
# Manufacturer and Designer endpoints
path("manufacturers/", ManufacturerListAPIView.as_view(), name="manufacturer-list"),
path("designers/", DesignerListAPIView.as_view(), name="designer-list"),
# Ride model management endpoints - nested under rides/manufacturers
path(
"manufacturers/<slug:manufacturer_slug>/",

View File

@@ -28,6 +28,7 @@ import logging
from typing import Any
from django.db import models
from django.db.models import Count
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import permissions, status
@@ -333,9 +334,7 @@ class RideListCreateAPIView(APIView):
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = RideListOutputSerializer(
page, many=True, context={"request": request}
)
serializer = RideListOutputSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
def _apply_filters(self, qs, params):
@@ -567,9 +566,9 @@ class RideListCreateAPIView(APIView):
if ordering in valid_orderings:
if ordering in ["height_ft", "-height_ft", "speed_mph", "-speed_mph"]:
# For coaster stats ordering, we need to join and order by the stats
ordering_field = ordering.replace(
"height_ft", "coaster_stats__height_ft"
).replace("speed_mph", "coaster_stats__speed_mph")
ordering_field = ordering.replace("height_ft", "coaster_stats__height_ft").replace(
"speed_mph", "coaster_stats__speed_mph"
)
qs = qs.order_by(ordering_field)
else:
qs = qs.order_by(ordering)
@@ -602,7 +601,7 @@ class RideListCreateAPIView(APIView):
try:
park = Park.objects.get(id=validated["park_id"]) # type: ignore
except Park.DoesNotExist: # type: ignore
raise NotFound("Park not found")
raise NotFound("Park not found") from None
ride = Ride.objects.create( # type: ignore
name=validated["name"],
@@ -658,7 +657,7 @@ class RideDetailAPIView(APIView):
try:
return Ride.objects.select_related("park").get(pk=pk) # type: ignore
except Ride.DoesNotExist: # type: ignore
raise NotFound("Ride not found")
raise NotFound("Ride not found") from None
@cache_api_response(timeout=1800, key_prefix="ride_detail")
def get(self, request: Request, pk: int) -> Response:
@@ -672,9 +671,7 @@ class RideDetailAPIView(APIView):
serializer_in.is_valid(raise_exception=True)
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride update is not available because domain models are not imported."
},
{"detail": "Ride update is not available because domain models are not imported."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
@@ -690,7 +687,7 @@ class RideDetailAPIView(APIView):
# Use the move_to_park method for proper handling
park_change_info = ride.move_to_park(new_park)
except Park.DoesNotExist: # type: ignore
raise NotFound("Target park not found")
raise NotFound("Target park not found") from None
# Apply other field updates
for key, value in validated_data.items():
@@ -715,9 +712,7 @@ class RideDetailAPIView(APIView):
def delete(self, request: Request, pk: int) -> Response:
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride delete is not available because domain models are not imported."
},
{"detail": "Ride delete is not available because domain models are not imported."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
ride = self._get_ride_or_404(pk)
@@ -1491,16 +1486,12 @@ class FilterOptionsAPIView(APIView):
# Get manufacturers (companies with MANUFACTURER role)
manufacturers = list(
Company.objects.filter(roles__contains=["MANUFACTURER"])
.values("id", "name", "slug")
.order_by("name")
Company.objects.filter(roles__contains=["MANUFACTURER"]).values("id", "name", "slug").order_by("name")
)
# Get designers (companies with DESIGNER role)
designers = list(
Company.objects.filter(roles__contains=["DESIGNER"])
.values("id", "name", "slug")
.order_by("name")
Company.objects.filter(roles__contains=["DESIGNER"]).values("id", "name", "slug").order_by("name")
)
# Get ride models data from database
@@ -1722,11 +1713,7 @@ class FilterOptionsAPIView(APIView):
# --- Company search (autocomplete) -----------------------------------------
@extend_schema(
summary="Search companies (manufacturers/designers) for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
parameters=[OpenApiParameter(name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR)],
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
@@ -1753,20 +1740,14 @@ class CompanySearchAPIView(APIView):
)
qs = Company.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
]
results = [{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs]
return Response(results)
# --- Ride model search (autocomplete) --------------------------------------
@extend_schema(
summary="Search ride models for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
parameters=[OpenApiParameter(name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR)],
tags=["Rides"],
)
class RideModelSearchAPIView(APIView):
@@ -1795,21 +1776,14 @@ class RideModelSearchAPIView(APIView):
)
qs = RideModel.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": m.id, "name": m.name, "category": getattr(m, "category", "")}
for m in qs
]
results = [{"id": m.id, "name": m.name, "category": getattr(m, "category", "")} for m in qs]
return Response(results)
# --- Search suggestions -----------------------------------------------------
@extend_schema(
summary="Search suggestions for ride search box",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
parameters=[OpenApiParameter(name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR)],
tags=["Rides"],
)
class RideSearchSuggestionsAPIView(APIView):
@@ -1827,9 +1801,7 @@ class RideSearchSuggestionsAPIView(APIView):
# Very small suggestion implementation: look in ride names if available
if MODELS_AVAILABLE and Ride is not None:
qs = Ride.objects.filter(name__icontains=q).values_list("name", flat=True)[
:10
] # type: ignore
qs = Ride.objects.filter(name__icontains=q).values_list("name", flat=True)[:10] # type: ignore
return Response([{"suggestion": name} for name in qs])
# Fallback suggestions
@@ -1862,7 +1834,7 @@ class RideImageSettingsAPIView(APIView):
try:
return Ride.objects.get(pk=pk) # type: ignore
except Ride.DoesNotExist: # type: ignore
raise NotFound("Ride not found")
raise NotFound("Ride not found") from None
def patch(self, request: Request, pk: int) -> Response:
"""Set banner and card images for the ride."""
@@ -1878,9 +1850,7 @@ class RideImageSettingsAPIView(APIView):
ride.save()
# Return updated ride data
output_serializer = RideDetailOutputSerializer(
ride, context={"request": request}
)
output_serializer = RideDetailOutputSerializer(ride, context={"request": request})
return Response(output_serializer.data)
@@ -1902,12 +1872,8 @@ class RideImageSettingsAPIView(APIView):
OpenApiTypes.STR,
description="Filter by ride status (comma-separated for multiple)",
),
OpenApiParameter(
"park_slug", OpenApiTypes.STR, description="Filter by park slug"
),
OpenApiParameter(
"park_id", OpenApiTypes.INT, description="Filter by park ID"
),
OpenApiParameter("park_slug", OpenApiTypes.STR, description="Filter by park slug"),
OpenApiParameter("park_id", OpenApiTypes.INT, description="Filter by park ID"),
OpenApiParameter(
"manufacturer",
OpenApiTypes.STR,
@@ -1923,18 +1889,10 @@ class RideImageSettingsAPIView(APIView):
OpenApiTypes.STR,
description="Filter by ride model slug (comma-separated for multiple)",
),
OpenApiParameter(
"opening_year_min", OpenApiTypes.INT, description="Minimum opening year"
),
OpenApiParameter(
"opening_year_max", OpenApiTypes.INT, description="Maximum opening year"
),
OpenApiParameter(
"rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"
),
OpenApiParameter(
"rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"
),
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
OpenApiParameter(
"height_requirement_min",
OpenApiTypes.INT,
@@ -1945,12 +1903,8 @@ class RideImageSettingsAPIView(APIView):
OpenApiTypes.INT,
description="Maximum height requirement in inches",
),
OpenApiParameter(
"capacity_min", OpenApiTypes.INT, description="Minimum hourly capacity"
),
OpenApiParameter(
"capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity"
),
OpenApiParameter("capacity_min", OpenApiTypes.INT, description="Minimum hourly capacity"),
OpenApiParameter("capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity"),
OpenApiParameter(
"roller_coaster_type",
OpenApiTypes.STR,
@@ -2022,9 +1976,7 @@ class RideImageSettingsAPIView(APIView):
"properties": {
"rides": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HybridRideSerializer"
},
"items": {"$ref": "#/components/schemas/HybridRideSerializer"},
},
"total_count": {"type": "integer"},
"strategy": {
@@ -2084,7 +2036,7 @@ class HybridRideAPIView(APIView):
data = smart_ride_loader.get_progressive_load(offset, filters)
except ValueError:
return Response(
{"error": "Invalid offset parameter"},
{"detail": "Invalid offset parameter"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
@@ -2109,7 +2061,7 @@ class HybridRideAPIView(APIView):
except Exception as e:
logger.error(f"Error in HybridRideAPIView: {e}")
return Response(
{"error": "Internal server error"},
{"detail": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -2158,7 +2110,7 @@ class HybridRideAPIView(APIView):
for param in int_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = int(value)
except ValueError:
pass # Skip invalid integer values
@@ -2175,7 +2127,7 @@ class HybridRideAPIView(APIView):
for param in float_params:
value = query_params.get(param)
if value:
try:
try: # noqa: SIM105
filters[param] = float(value)
except ValueError:
pass # Skip invalid float values
@@ -2408,7 +2360,7 @@ class RideFilterMetadataAPIView(APIView):
except Exception as e:
logger.error(f"Error in RideFilterMetadataAPIView: {e}")
return Response(
{"error": "Internal server error"},
{"detail": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -2417,18 +2369,18 @@ class RideFilterMetadataAPIView(APIView):
# Reuse the same filter extraction logic
view = HybridRideAPIView()
return view._extract_filters(query_params)
# === MANUFACTURER & DESIGNER LISTS ===
class BaseCompanyListAPIView(APIView):
permission_classes = [permissions.AllowAny]
role = None
def get(self, request: Request) -> Response:
if not MODELS_AVAILABLE:
return Response(
{"detail": "Models not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
)
return Response({"detail": "Models not available"}, status=status.HTTP_501_NOT_IMPLEMENTED)
companies = (
Company.objects.filter(roles__contains=[self.role])
@@ -2448,10 +2400,8 @@ class BaseCompanyListAPIView(APIView):
for c in companies
]
return Response({
"results": data,
"count": len(data)
})
return Response({"results": data, "count": len(data)})
@extend_schema(
summary="List manufacturers",
@@ -2462,6 +2412,7 @@ class BaseCompanyListAPIView(APIView):
class ManufacturerListAPIView(BaseCompanyListAPIView):
role = "MANUFACTURER"
@extend_schema(
summary="List designers",
description="List all companies with DESIGNER role.",

View File

@@ -49,5 +49,4 @@ __all__ = (
"UserProfileCreateInputSerializer",
"UserProfileUpdateInputSerializer",
"UserProfileOutputSerializer",
)

View File

@@ -90,7 +90,6 @@ _ACCOUNTS_SYMBOLS: list[str] = [
"UserProfileOutputSerializer",
"UserProfileCreateInputSerializer",
"UserProfileUpdateInputSerializer",
"UserOutputSerializer",
"LoginInputSerializer",
"LoginOutputSerializer",

View File

@@ -187,6 +187,7 @@ class PublicUserSerializer(serializers.ModelSerializer):
Public user serializer for viewing other users' profiles.
Only exposes public information.
"""
profile = UserProfileSerializer(read_only=True)
class Meta:
@@ -228,37 +229,21 @@ class UserPreferencesSerializer(serializers.Serializer):
"""Serializer for user preferences and settings."""
theme_preference = RichChoiceFieldSerializer(
choice_group="theme_preferences",
domain="accounts",
help_text="User's theme preference"
)
email_notifications = serializers.BooleanField(
default=True, help_text="Whether to receive email notifications"
)
push_notifications = serializers.BooleanField(
default=False, help_text="Whether to receive push notifications"
choice_group="theme_preferences", domain="accounts", help_text="User's theme preference"
)
email_notifications = serializers.BooleanField(default=True, help_text="Whether to receive email notifications")
push_notifications = serializers.BooleanField(default=False, help_text="Whether to receive push notifications")
privacy_level = RichChoiceFieldSerializer(
choice_group="privacy_levels",
domain="accounts",
default="public",
help_text="Profile visibility level",
)
show_email = serializers.BooleanField(
default=False, help_text="Whether to show email on profile"
)
show_real_name = serializers.BooleanField(
default=True, help_text="Whether to show real name on profile"
)
show_statistics = serializers.BooleanField(
default=True, help_text="Whether to show ride statistics on profile"
)
allow_friend_requests = serializers.BooleanField(
default=True, help_text="Whether to allow friend requests"
)
allow_messages = serializers.BooleanField(
default=True, help_text="Whether to allow direct messages"
)
show_email = serializers.BooleanField(default=False, help_text="Whether to show email on profile")
show_real_name = serializers.BooleanField(default=True, help_text="Whether to show real name on profile")
show_statistics = serializers.BooleanField(default=True, help_text="Whether to show ride statistics on profile")
allow_friend_requests = serializers.BooleanField(default=True, help_text="Whether to allow friend requests")
allow_messages = serializers.BooleanField(default=True, help_text="Whether to allow direct messages")
# === NOTIFICATION SETTINGS SERIALIZERS ===
@@ -363,39 +348,17 @@ class PrivacySettingsSerializer(serializers.Serializer):
default="public",
help_text="Overall profile visibility",
)
show_email = serializers.BooleanField(
default=False, help_text="Show email address on profile"
)
show_real_name = serializers.BooleanField(
default=True, help_text="Show real name on profile"
)
show_join_date = serializers.BooleanField(
default=True, help_text="Show join date on profile"
)
show_statistics = serializers.BooleanField(
default=True, help_text="Show ride statistics on profile"
)
show_reviews = serializers.BooleanField(
default=True, help_text="Show reviews on profile"
)
show_photos = serializers.BooleanField(
default=True, help_text="Show uploaded photos on profile"
)
show_top_lists = serializers.BooleanField(
default=True, help_text="Show top lists on profile"
)
allow_friend_requests = serializers.BooleanField(
default=True, help_text="Allow others to send friend requests"
)
allow_messages = serializers.BooleanField(
default=True, help_text="Allow others to send direct messages"
)
allow_profile_comments = serializers.BooleanField(
default=False, help_text="Allow others to comment on profile"
)
search_visibility = serializers.BooleanField(
default=True, help_text="Allow profile to appear in search results"
)
show_email = serializers.BooleanField(default=False, help_text="Show email address on profile")
show_real_name = serializers.BooleanField(default=True, help_text="Show real name on profile")
show_join_date = serializers.BooleanField(default=True, help_text="Show join date on profile")
show_statistics = serializers.BooleanField(default=True, help_text="Show ride statistics on profile")
show_reviews = serializers.BooleanField(default=True, help_text="Show reviews on profile")
show_photos = serializers.BooleanField(default=True, help_text="Show uploaded photos on profile")
show_top_lists = serializers.BooleanField(default=True, help_text="Show top lists on profile")
allow_friend_requests = serializers.BooleanField(default=True, help_text="Allow others to send friend requests")
allow_messages = serializers.BooleanField(default=True, help_text="Allow others to send direct messages")
allow_profile_comments = serializers.BooleanField(default=False, help_text="Allow others to comment on profile")
search_visibility = serializers.BooleanField(default=True, help_text="Allow profile to appear in search results")
activity_visibility = RichChoiceFieldSerializer(
choice_group="privacy_levels",
domain="accounts",
@@ -431,21 +394,13 @@ class SecuritySettingsSerializer(serializers.Serializer):
two_factor_enabled = serializers.BooleanField(
default=False, help_text="Whether two-factor authentication is enabled"
)
login_notifications = serializers.BooleanField(
default=True, help_text="Send notifications for new logins"
)
login_notifications = serializers.BooleanField(default=True, help_text="Send notifications for new logins")
session_timeout = serializers.IntegerField(
default=30, min_value=5, max_value=180, help_text="Session timeout in days"
)
require_password_change = serializers.BooleanField(
default=False, help_text="Whether password change is required"
)
last_password_change = serializers.DateTimeField(
read_only=True, help_text="When password was last changed"
)
active_sessions = serializers.IntegerField(
read_only=True, help_text="Number of active sessions"
)
require_password_change = serializers.BooleanField(default=False, help_text="Whether password change is required")
last_password_change = serializers.DateTimeField(read_only=True, help_text="When password was last changed")
active_sessions = serializers.IntegerField(read_only=True, help_text="Number of active sessions")
login_history_retention = serializers.IntegerField(
default=90,
min_value=30,
@@ -699,7 +654,7 @@ class ThemePreferenceSerializer(serializers.ModelSerializer):
"id": 1,
"notification_type": "submission_approved",
"title": "Your submission has been approved!",
"message": "Your photo submission for Cedar Point has been approved and is now live on the site.",
"detail": "Your photo submission for Cedar Point has been approved and is now live on the site.",
"priority": "normal",
"is_read": False,
"read_at": None,
@@ -866,15 +821,11 @@ class MarkNotificationsReadSerializer(serializers.Serializer):
def validate_notification_ids(self, value):
"""Validate that all notification IDs belong to the requesting user."""
user = self.context["request"].user
valid_ids = UserNotification.objects.filter(
id__in=value, user=user
).values_list("id", flat=True)
valid_ids = UserNotification.objects.filter(id__in=value, user=user).values_list("id", flat=True)
invalid_ids = set(value) - set(valid_ids)
if invalid_ids:
raise serializers.ValidationError(
f"Invalid notification IDs: {list(invalid_ids)}"
)
raise serializers.ValidationError(f"Invalid notification IDs: {list(invalid_ids)}")
return value
@@ -901,9 +852,8 @@ class AvatarUploadSerializer(serializers.Serializer):
raise serializers.ValidationError("No file provided")
# Check file size constraints (max 10MB for Cloudflare Images)
if hasattr(value, 'size') and value.size > 10 * 1024 * 1024:
raise serializers.ValidationError(
"Image file too large. Maximum size is 10MB.")
if hasattr(value, "size") and value.size > 10 * 1024 * 1024:
raise serializers.ValidationError("Image file too large. Maximum size is 10MB.")
# Try to validate with PIL
try:
@@ -926,13 +876,13 @@ class AvatarUploadSerializer(serializers.Serializer):
# Check image dimensions (max 12,000x12,000 for Cloudflare Images)
if image.size[0] > 12000 or image.size[1] > 12000:
raise serializers.ValidationError(
"Image dimensions too large. Maximum is 12,000x12,000 pixels.")
raise serializers.ValidationError("Image dimensions too large. Maximum is 12,000x12,000 pixels.")
# Check if it's a supported format
if image.format not in ['JPEG', 'PNG', 'GIF', 'WEBP']:
if image.format not in ["JPEG", "PNG", "GIF", "WEBP"]:
raise serializers.ValidationError(
f"Unsupported image format: {image.format}. Supported formats: JPEG, PNG, GIF, WebP.")
f"Unsupported image format: {image.format}. Supported formats: JPEG, PNG, GIF, WebP."
)
except serializers.ValidationError:
raise # Re-raise validation errors

View File

@@ -97,7 +97,7 @@ class LoginInputSerializer(serializers.Serializer):
password=password,
)
if not user:
if not user: # noqa: SIM102
# Try email-based authentication if username failed
if "@" in username:
try:
@@ -138,7 +138,7 @@ class LoginInputSerializer(serializers.Serializer):
"first_name": "John",
"last_name": "Doe",
},
"message": "Login successful",
"detail": "Login successful",
},
)
]
@@ -213,7 +213,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
try:
validate_password(value)
except DjangoValidationError as e:
raise serializers.ValidationError(list(e.messages))
raise serializers.ValidationError(list(e.messages)) from None
return value
def validate(self, attrs):
@@ -253,7 +253,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
"first_name": "Jane",
"last_name": "Smith",
},
"message": "Registration successful",
"detail": "Registration successful",
},
)
]
@@ -276,7 +276,7 @@ class SignupOutputSerializer(serializers.Serializer):
summary="Example logout response",
description="Successful logout response",
value={
"message": "Logout successful",
"detail": "Logout successful",
},
)
]
@@ -318,9 +318,9 @@ class PasswordResetInputSerializer(serializers.Serializer):
"""Send password reset email."""
email = self.validated_data["email"] # type: ignore[index]
try:
_user = UserModel.objects.get(email=email)
# Check if email exists (but don't reveal the result for security)
UserModel.objects.get(email=email)
# Here you would typically send a password reset email
# For now, we'll just pass
pass
except UserModel.DoesNotExist:
# Don't reveal if email exists for security
@@ -393,7 +393,7 @@ class PasswordChangeInputSerializer(serializers.Serializer):
try:
validate_password(value, user=self.context["request"].user)
except DjangoValidationError as e:
raise serializers.ValidationError(list(e.messages))
raise serializers.ValidationError(list(e.messages)) from None
return value
def validate(self, attrs):
@@ -492,6 +492,4 @@ class AuthStatusOutputSerializer(serializers.Serializer):
"""Output serializer for authentication status."""
authenticated = serializers.BooleanField(help_text="Whether user is authenticated")
user = UserOutputSerializer(
allow_null=True, help_text="User information if authenticated"
)
user = UserOutputSerializer(allow_null=True, help_text="User information if authenticated")

View File

@@ -112,10 +112,7 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
category = RichChoiceFieldSerializer(choice_group="categories", domain="rides")
# Manufacturer info
manufacturer = serializers.SerializerMethodField()

View File

@@ -99,9 +99,7 @@ class ParkHistoryOutputSerializer(serializers.Serializer):
"slug": park.slug,
"status": park.status,
"opening_date": (
park.opening_date.isoformat()
if hasattr(park, "opening_date") and park.opening_date
else None
park.opening_date.isoformat() if hasattr(park, "opening_date") and park.opening_date else None
),
"coaster_count": getattr(park, "coaster_count", 0),
"ride_count": getattr(park, "ride_count", 0),
@@ -143,9 +141,7 @@ class RideHistoryOutputSerializer(serializers.Serializer):
"park_name": ride.park.name if hasattr(ride, "park") else None,
"status": getattr(ride, "status", "UNKNOWN"),
"opening_date": (
ride.opening_date.isoformat()
if hasattr(ride, "opening_date") and ride.opening_date
else None
ride.opening_date.isoformat() if hasattr(ride, "opening_date") and ride.opening_date else None
),
"ride_type": getattr(ride, "ride_type", "Unknown"),
}

View File

@@ -79,16 +79,12 @@ class MapLocationSerializer(serializers.Serializer):
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"average_rating": (float(obj.average_rating) if obj.average_rating else None),
}
elif obj._meta.model_name == "ride":
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"average_rating": (float(obj.average_rating) if obj.average_rating else None),
"park_name": obj.park.name if obj.park else None,
}
return {}
@@ -339,24 +335,16 @@ class MapLocationDetailSerializer(serializers.Serializer):
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"average_rating": (float(obj.average_rating) if obj.average_rating else None),
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
"opening_date": (obj.opening_date.isoformat() if obj.opening_date else None),
}
elif obj._meta.model_name == "ride":
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"average_rating": (float(obj.average_rating) if obj.average_rating else None),
"park_name": obj.park.name if obj.park else None,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
"opening_date": (obj.opening_date.isoformat() if obj.opening_date else None),
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
}
return {}
@@ -382,9 +370,7 @@ class MapBoundsInputSerializer(serializers.Serializer):
def validate(self, attrs):
"""Validate that bounds make geographic sense."""
if attrs["north"] <= attrs["south"]:
raise serializers.ValidationError(
"North bound must be greater than south bound"
)
raise serializers.ValidationError("North bound must be greater than south bound")
# Handle longitude wraparound (e.g., crossing the international date line)
# For now, we'll require west < east for simplicity

View File

@@ -31,9 +31,7 @@ class PhotoUploadInputSerializer(serializers.Serializer):
allow_blank=True,
help_text="Alt text for accessibility",
)
is_primary = serializers.BooleanField(
default=False, help_text="Whether this should be the primary photo"
)
is_primary = serializers.BooleanField(default=False, help_text="Whether this should be the primary photo")
@extend_schema_serializer(
@@ -89,9 +87,7 @@ class PhotoDetailOutputSerializer(serializers.Serializer):
return {
"id": obj.uploaded_by.id,
"username": obj.uploaded_by.username,
"display_name": getattr(
obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username
)(),
"display_name": getattr(obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username)(),
}

View File

@@ -24,12 +24,8 @@ class ParkStatsOutputSerializer(serializers.Serializer):
under_construction = serializers.IntegerField()
# Averages
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_coaster_count = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
average_coaster_count = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
# Top countries
top_countries = serializers.ListField(child=serializers.DictField())
@@ -50,12 +46,8 @@ class RideStatsOutputSerializer(serializers.Serializer):
rides_by_category = serializers.DictField()
# Averages
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_capacity = serializers.DecimalField(
max_digits=8, decimal_places=2, allow_null=True
)
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
average_capacity = serializers.DecimalField(max_digits=8, decimal_places=2, allow_null=True)
# Top manufacturers
top_manufacturers = serializers.ListField(child=serializers.DictField())
@@ -91,10 +83,7 @@ class ParkReviewOutputSerializer(serializers.Serializer):
class HealthCheckOutputSerializer(serializers.Serializer):
"""Output serializer for health check responses."""
status = RichChoiceFieldSerializer(
choice_group="health_statuses",
domain="core"
)
status = RichChoiceFieldSerializer(choice_group="health_statuses", domain="core")
timestamp = serializers.DateTimeField()
version = serializers.CharField()
environment = serializers.CharField()
@@ -115,9 +104,6 @@ class PerformanceMetricsOutputSerializer(serializers.Serializer):
class SimpleHealthOutputSerializer(serializers.Serializer):
"""Output serializer for simple health check."""
status = RichChoiceFieldSerializer(
choice_group="simple_health_statuses",
domain="core"
)
status = RichChoiceFieldSerializer(choice_group="simple_health_statuses", domain="core")
timestamp = serializers.DateTimeField()
error = serializers.CharField(required=False)

View File

@@ -29,14 +29,10 @@ from apps.parks.models.reviews import ParkReview
"user": {
"username": "park_fan",
"display_name": "Park Fan",
"avatar_url": "https://example.com/avatar.jpg"
"avatar_url": "https://example.com/avatar.jpg",
},
"park": {
"id": 101,
"name": "Cedar Point",
"slug": "cedar-point"
}
}
"park": {"id": 101, "name": "Cedar Point", "slug": "cedar-point"},
},
)
]
)
@@ -145,8 +141,7 @@ class ParkReviewStatsOutputSerializer(serializers.Serializer):
pending_reviews = serializers.IntegerField()
average_rating = serializers.FloatField(allow_null=True)
rating_distribution = serializers.DictField(
child=serializers.IntegerField(),
help_text="Count of reviews by rating (1-10)"
child=serializers.IntegerField(), help_text="Count of reviews by rating (1-10)"
)
recent_reviews = serializers.IntegerField()
@@ -154,20 +149,15 @@ class ParkReviewStatsOutputSerializer(serializers.Serializer):
class ParkReviewModerationInputSerializer(serializers.Serializer):
"""Input serializer for review moderation operations."""
review_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="List of review IDs to moderate"
)
review_ids = serializers.ListField(child=serializers.IntegerField(), help_text="List of review IDs to moderate")
action = serializers.ChoiceField(
choices=[
("publish", "Publish"),
("unpublish", "Unpublish"),
("delete", "Delete"),
],
help_text="Moderation action to perform"
help_text="Moderation action to perform",
)
moderation_notes = serializers.CharField(
required=False,
allow_blank=True,
help_text="Optional notes about the moderation action"
required=False, allow_blank=True, help_text="Optional notes about the moderation action"
)

View File

@@ -52,16 +52,11 @@ class ParkListOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="parks"
)
status = RichChoiceFieldSerializer(choice_group="statuses", domain="parks")
description = serializers.CharField()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
@@ -145,25 +140,18 @@ class ParkDetailOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="parks"
)
status = RichChoiceFieldSerializer(choice_group="statuses", domain="parks")
description = serializers.CharField()
# Details
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
operating_season = serializers.CharField()
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, allow_null=True
)
size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, allow_null=True)
website = serializers.URLField()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
coaster_count = serializers.IntegerField(allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
@@ -211,9 +199,7 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"""Get all approved photos for this park."""
from apps.parks.models import ParkPhoto
photos = ParkPhoto.objects.filter(park=obj, is_approved=True).order_by(
"-is_primary", "-created_at"
)[
photos = ParkPhoto.objects.filter(park=obj, is_approved=True).order_by("-is_primary", "-created_at")[
:10
] # Limit to 10 photos
@@ -228,7 +214,9 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
"thumbnail": MediaURLService.generate_park_photo_url(
obj.slug, photo.caption, photo.pk, "thumbnail"
),
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
@@ -246,9 +234,7 @@ class ParkDetailOutputSerializer(serializers.Serializer):
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.filter(
park=obj, is_primary=True, is_approved=True
).first()
photo = ParkPhoto.objects.filter(park=obj, is_primary=True, is_approved=True).first()
if photo and photo.image:
return {
@@ -261,7 +247,9 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
"thumbnail": MediaURLService.generate_park_photo_url(
obj.slug, photo.caption, photo.pk, "thumbnail"
),
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
@@ -289,10 +277,18 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "public"),
"thumbnail": MediaURLService.generate_park_photo_url(
obj.slug, obj.banner_image.caption, obj.banner_image.pk, "thumbnail"
),
"medium": MediaURLService.generate_park_photo_url(
obj.slug, obj.banner_image.caption, obj.banner_image.pk, "medium"
),
"large": MediaURLService.generate_park_photo_url(
obj.slug, obj.banner_image.caption, obj.banner_image.pk, "large"
),
"public": MediaURLService.generate_park_photo_url(
obj.slug, obj.banner_image.caption, obj.banner_image.pk, "public"
),
},
"caption": obj.banner_image.caption,
"alt_text": obj.banner_image.alt_text,
@@ -303,9 +299,7 @@ class ParkDetailOutputSerializer(serializers.Serializer):
try:
latest_photo = (
ParkPhoto.objects.filter(
park=obj, is_approved=True, image__isnull=False
)
ParkPhoto.objects.filter(park=obj, is_approved=True, image__isnull=False)
.order_by("-created_at")
.first()
)
@@ -321,10 +315,18 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
"thumbnail": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"
),
"medium": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "medium"
),
"large": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "large"
),
"public": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "public"
),
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
@@ -350,10 +352,18 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "public"),
"thumbnail": MediaURLService.generate_park_photo_url(
obj.slug, obj.card_image.caption, obj.card_image.pk, "thumbnail"
),
"medium": MediaURLService.generate_park_photo_url(
obj.slug, obj.card_image.caption, obj.card_image.pk, "medium"
),
"large": MediaURLService.generate_park_photo_url(
obj.slug, obj.card_image.caption, obj.card_image.pk, "large"
),
"public": MediaURLService.generate_park_photo_url(
obj.slug, obj.card_image.caption, obj.card_image.pk, "public"
),
},
"caption": obj.card_image.caption,
"alt_text": obj.card_image.alt_text,
@@ -364,9 +374,7 @@ class ParkDetailOutputSerializer(serializers.Serializer):
try:
latest_photo = (
ParkPhoto.objects.filter(
park=obj, is_approved=True, image__isnull=False
)
ParkPhoto.objects.filter(park=obj, is_approved=True, image__isnull=False)
.order_by("-created_at")
.first()
)
@@ -382,10 +390,18 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
},
"friendly_urls": {
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
"thumbnail": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"
),
"medium": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "medium"
),
"large": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "large"
),
"public": MediaURLService.generate_park_photo_url(
obj.slug, latest_photo.caption, latest_photo.pk, "public"
),
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
@@ -417,7 +433,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
# The park will be validated in the view
return value
except ParkPhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
raise serializers.ValidationError("Photo not found") from None
return value
def validate_card_image_id(self, value):
@@ -430,7 +446,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
# The park will be validated in the view
return value
except ParkPhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
raise serializers.ValidationError("Photo not found") from None
return value
@@ -439,19 +455,13 @@ class ParkCreateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
status = serializers.ChoiceField(
choices=ModelChoices.get_park_status_choices(), default="OPERATING"
)
status = serializers.ChoiceField(choices=ModelChoices.get_park_status_choices(), default="OPERATING")
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
operating_season = serializers.CharField(max_length=255, required=False, allow_blank=True)
size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, allow_null=True)
website = serializers.URLField(required=False, allow_blank=True)
# Required operator
@@ -466,9 +476,7 @@ class ParkCreateInputSerializer(serializers.Serializer):
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
raise serializers.ValidationError("Closing date cannot be before opening date")
return attrs
@@ -478,19 +486,13 @@ class ParkUpdateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
status = serializers.ChoiceField(
choices=ModelChoices.get_park_status_choices(), required=False
)
status = serializers.ChoiceField(choices=ModelChoices.get_park_status_choices(), required=False)
# Optional details
opening_date = serializers.DateField(required=False, allow_null=True)
closing_date = serializers.DateField(required=False, allow_null=True)
operating_season = serializers.CharField(
max_length=255, required=False, allow_blank=True
)
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
operating_season = serializers.CharField(max_length=255, required=False, allow_blank=True)
size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, allow_null=True)
website = serializers.URLField(required=False, allow_blank=True)
# Companies
@@ -503,9 +505,7 @@ class ParkUpdateInputSerializer(serializers.Serializer):
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
raise serializers.ValidationError("Closing date cannot be before opening date")
return attrs
@@ -537,12 +537,8 @@ class ParkFilterInputSerializer(serializers.Serializer):
)
# Size filter
min_size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, min_value=0
)
max_size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, min_value=0
)
min_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=0)
max_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=0)
# Company filters
operator_id = serializers.IntegerField(required=False)
@@ -625,9 +621,7 @@ class ParkAreaCreateInputSerializer(serializers.Serializer):
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
raise serializers.ValidationError("Closing date cannot be before opening date")
return attrs
@@ -646,9 +640,7 @@ class ParkAreaUpdateInputSerializer(serializers.Serializer):
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
raise serializers.ValidationError("Closing date cannot be before opening date")
return attrs

View File

@@ -12,9 +12,7 @@ from apps.parks.models import ParkPhoto
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for park photos."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
file_size = serializers.ReadOnlyField()
dimensions = serializers.ReadOnlyField()
park_slug = serializers.CharField(source="park.slug", read_only=True)
@@ -78,9 +76,7 @@ class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for park photo lists."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
class Meta:
model = ParkPhoto
@@ -99,12 +95,8 @@ class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True, help_text="Whether to approve (True) or reject (False) the photos"
)
photo_ids = serializers.ListField(child=serializers.IntegerField(), help_text="List of photo IDs to approve")
approve = serializers.BooleanField(default=True, help_text="Whether to approve (True) or reject (False) the photos")
class ParkPhotoStatsOutputSerializer(serializers.Serializer):

View File

@@ -8,35 +8,33 @@ from apps.rides.models.credits import RideCredit
class RideCreditSerializer(serializers.ModelSerializer):
"""Serializer for user ride credits."""
ride_id = serializers.PrimaryKeyRelatedField(
queryset=Ride.objects.all(), source='ride', write_only=True
)
ride_id = serializers.PrimaryKeyRelatedField(queryset=Ride.objects.all(), source="ride", write_only=True)
ride = RideListOutputSerializer(read_only=True)
class Meta:
model = RideCredit
fields = [
'id',
'ride',
'ride_id',
'count',
'rating',
'first_ridden_at',
'last_ridden_at',
'notes',
'display_order',
'created_at',
'updated_at',
"id",
"ride",
"ride_id",
"count",
"rating",
"first_ridden_at",
"last_ridden_at",
"notes",
"display_order",
"created_at",
"updated_at",
]
read_only_fields = ['id', 'created_at', 'updated_at']
read_only_fields = ["id", "created_at", "updated_at"]
def validate(self, attrs):
"""
Validate data.
"""
# Ensure dates make sense
first = attrs.get('first_ridden_at')
last = attrs.get('last_ridden_at')
first = attrs.get("first_ridden_at")
last = attrs.get("last_ridden_at")
if first and last and last < first:
raise serializers.ValidationError("Last ridden date cannot be before first ridden date.")
@@ -44,6 +42,6 @@ class RideCreditSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Create a new ride credit."""
user = self.context['request'].user
validated_data['user'] = user
user = self.context["request"].user
validated_data["user"] = user
return super().create(validated_data)

View File

@@ -80,18 +80,10 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
min_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
max_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
min_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
max_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
distinguishing_features = serializers.CharField()
@@ -134,20 +126,14 @@ class RideModelListOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
category = RichChoiceFieldSerializer(choice_group="categories", domain="rides")
description = serializers.CharField()
# Manufacturer info
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
# Market info
target_market = RichChoiceFieldSerializer(
choice_group="target_markets",
domain="rides"
)
target_market = RichChoiceFieldSerializer(choice_group="target_markets", domain="rides")
is_discontinued = serializers.BooleanField()
total_installations = serializers.IntegerField()
first_installation_year = serializers.IntegerField(allow_null=True)
@@ -258,18 +244,10 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
# Technical specifications
typical_height_range_min_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
typical_height_range_max_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
typical_speed_range_min_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
typical_height_range_min_ft = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
typical_height_range_max_ft = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
typical_speed_range_min_mph = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
typical_speed_range_max_mph = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
typical_capacity_range_min = serializers.IntegerField(allow_null=True)
typical_capacity_range_max = serializers.IntegerField(allow_null=True)
@@ -343,9 +321,7 @@ class RideModelCreateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(
choices=ModelChoices.get_ride_category_choices(), allow_blank=True, default=""
)
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), allow_blank=True, default="")
# Required manufacturer
manufacturer_id = serializers.IntegerField()
@@ -363,32 +339,18 @@ class RideModelCreateInputSerializer(serializers.Serializer):
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_max = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_min = serializers.IntegerField(required=False, allow_null=True, min_value=1)
typical_capacity_range_max = serializers.IntegerField(required=False, allow_null=True, min_value=1)
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, default="")
support_structure = serializers.CharField(
max_length=100, allow_blank=True, default=""
)
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, default=""
)
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, default=""
)
support_structure = serializers.CharField(max_length=100, allow_blank=True, default="")
train_configuration = serializers.CharField(max_length=200, allow_blank=True, default="")
restraint_system = serializers.CharField(max_length=100, allow_blank=True, default="")
# Market information
first_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
last_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
first_installation_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
last_installation_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
is_discontinued = serializers.BooleanField(default=False)
# Design features
@@ -406,36 +368,28 @@ class RideModelCreateInputSerializer(serializers.Serializer):
max_height = attrs.get("typical_height_range_max_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
raise serializers.ValidationError("Minimum height cannot be greater than maximum height")
# Speed range validation
min_speed = attrs.get("typical_speed_range_min_mph")
max_speed = attrs.get("typical_speed_range_max_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
raise serializers.ValidationError("Minimum speed cannot be greater than maximum speed")
# Capacity range validation
min_capacity = attrs.get("typical_capacity_range_min")
max_capacity = attrs.get("typical_capacity_range_max")
if min_capacity and max_capacity and min_capacity > max_capacity:
raise serializers.ValidationError(
"Minimum capacity cannot be greater than maximum capacity"
)
raise serializers.ValidationError("Minimum capacity cannot be greater than maximum capacity")
# Installation years validation
first_year = attrs.get("first_installation_year")
last_year = attrs.get("last_installation_year")
if first_year and last_year and first_year > last_year:
raise serializers.ValidationError(
"First installation year cannot be after last installation year"
)
raise serializers.ValidationError("First installation year cannot be after last installation year")
return attrs
@@ -467,32 +421,18 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_max = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_min = serializers.IntegerField(required=False, allow_null=True, min_value=1)
typical_capacity_range_max = serializers.IntegerField(required=False, allow_null=True, min_value=1)
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, required=False)
support_structure = serializers.CharField(
max_length=100, allow_blank=True, required=False
)
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, required=False
)
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, required=False
)
support_structure = serializers.CharField(max_length=100, allow_blank=True, required=False)
train_configuration = serializers.CharField(max_length=200, allow_blank=True, required=False)
restraint_system = serializers.CharField(max_length=100, allow_blank=True, required=False)
# Market information
first_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
last_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
first_installation_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
last_installation_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
is_discontinued = serializers.BooleanField(required=False)
# Design features
@@ -510,36 +450,28 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
max_height = attrs.get("typical_height_range_max_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
raise serializers.ValidationError("Minimum height cannot be greater than maximum height")
# Speed range validation
min_speed = attrs.get("typical_speed_range_min_mph")
max_speed = attrs.get("typical_speed_range_max_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
raise serializers.ValidationError("Minimum speed cannot be greater than maximum speed")
# Capacity range validation
min_capacity = attrs.get("typical_capacity_range_min")
max_capacity = attrs.get("typical_capacity_range_max")
if min_capacity and max_capacity and min_capacity > max_capacity:
raise serializers.ValidationError(
"Minimum capacity cannot be greater than maximum capacity"
)
raise serializers.ValidationError("Minimum capacity cannot be greater than maximum capacity")
# Installation years validation
first_year = attrs.get("first_installation_year")
last_year = attrs.get("last_installation_year")
if first_year and last_year and first_year > last_year:
raise serializers.ValidationError(
"First installation year cannot be after last installation year"
)
raise serializers.ValidationError("First installation year cannot be after last installation year")
return attrs
@@ -551,9 +483,7 @@ class RideModelFilterInputSerializer(serializers.Serializer):
search = serializers.CharField(required=False, allow_blank=True)
# Category filter
category = serializers.MultipleChoiceField(
choices=ModelChoices.get_ride_category_choices(), required=False
)
category = serializers.MultipleChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
# Manufacturer filter
manufacturer_id = serializers.IntegerField(required=False)
@@ -576,20 +506,12 @@ class RideModelFilterInputSerializer(serializers.Serializer):
min_installations = serializers.IntegerField(required=False, min_value=0)
# Height filters
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False
)
min_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False)
max_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False)
# Speed filters
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False
)
min_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False)
max_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False)
# Ordering
ordering = serializers.ChoiceField(
@@ -621,18 +543,10 @@ class RideModelVariantCreateInputSerializer(serializers.Serializer):
description = serializers.CharField(allow_blank=True, default="")
# Variant-specific specifications
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
min_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
max_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
min_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False, allow_null=True)
max_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False, allow_null=True)
# Distinguishing features
distinguishing_features = serializers.CharField(allow_blank=True, default="")
@@ -644,18 +558,14 @@ class RideModelVariantCreateInputSerializer(serializers.Serializer):
max_height = attrs.get("max_height_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
raise serializers.ValidationError("Minimum height cannot be greater than maximum height")
# Speed range validation
min_speed = attrs.get("min_speed_mph")
max_speed = attrs.get("max_speed_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
raise serializers.ValidationError("Minimum speed cannot be greater than maximum speed")
return attrs
@@ -667,18 +577,10 @@ class RideModelVariantUpdateInputSerializer(serializers.Serializer):
description = serializers.CharField(allow_blank=True, required=False)
# Variant-specific specifications
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
min_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
max_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
min_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False, allow_null=True)
max_speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False, allow_null=True)
# Distinguishing features
distinguishing_features = serializers.CharField(allow_blank=True, required=False)
@@ -690,18 +592,14 @@ class RideModelVariantUpdateInputSerializer(serializers.Serializer):
max_height = attrs.get("max_height_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
raise serializers.ValidationError("Minimum height cannot be greater than maximum height")
# Speed range validation
min_speed = attrs.get("min_speed_mph")
max_speed = attrs.get("max_speed_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
raise serializers.ValidationError("Minimum speed cannot be greater than maximum speed")
return attrs
@@ -713,9 +611,7 @@ class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride model technical specifications."""
ride_model_id = serializers.IntegerField()
spec_category = serializers.ChoiceField(
choices=ModelChoices.get_technical_spec_category_choices()
)
spec_category = serializers.ChoiceField(choices=ModelChoices.get_technical_spec_category_choices())
spec_name = serializers.CharField(max_length=100)
spec_value = serializers.CharField(max_length=255)
spec_unit = serializers.CharField(max_length=20, allow_blank=True, default="")
@@ -765,13 +661,9 @@ class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
required=False,
)
is_primary = serializers.BooleanField(required=False)
photographer = serializers.CharField(
max_length=255, allow_blank=True, required=False
)
photographer = serializers.CharField(max_length=255, allow_blank=True, required=False)
source = serializers.CharField(max_length=255, allow_blank=True, required=False)
copyright_info = serializers.CharField(
max_length=255, allow_blank=True, required=False
)
copyright_info = serializers.CharField(max_length=255, allow_blank=True, required=False)
# === RIDE MODEL STATS SERIALIZERS ===
@@ -784,15 +676,9 @@ class RideModelStatsOutputSerializer(serializers.Serializer):
total_installations = serializers.IntegerField()
active_manufacturers = serializers.IntegerField()
discontinued_models = serializers.IntegerField()
by_category = serializers.DictField(
child=serializers.IntegerField(), help_text="Model counts by category"
)
by_category = serializers.DictField(child=serializers.IntegerField(), help_text="Model counts by category")
by_target_market = serializers.DictField(
child=serializers.IntegerField(), help_text="Model counts by target market"
)
by_manufacturer = serializers.DictField(
child=serializers.IntegerField(), help_text="Model counts by manufacturer"
)
recent_models = serializers.IntegerField(
help_text="Models created in the last 30 days"
)
by_manufacturer = serializers.DictField(child=serializers.IntegerField(), help_text="Model counts by manufacturer")
recent_models = serializers.IntegerField(help_text="Models created in the last 30 days")

View File

@@ -54,19 +54,11 @@ class ReviewUserSerializer(serializers.ModelSerializer):
"id": 456,
"username": "coaster_fan",
"display_name": "Coaster Fan",
"avatar_url": "https://example.com/avatar.jpg"
"avatar_url": "https://example.com/avatar.jpg",
},
"ride": {
"id": 789,
"name": "Steel Vengeance",
"slug": "steel-vengeance"
},
"park": {
"id": 101,
"name": "Cedar Point",
"slug": "cedar-point"
}
}
"ride": {"id": 789, "name": "Steel Vengeance", "slug": "steel-vengeance"},
"park": {"id": 101, "name": "Cedar Point", "slug": "cedar-point"},
},
)
]
)
@@ -191,8 +183,7 @@ class RideReviewStatsOutputSerializer(serializers.Serializer):
pending_reviews = serializers.IntegerField()
average_rating = serializers.FloatField(allow_null=True)
rating_distribution = serializers.DictField(
child=serializers.IntegerField(),
help_text="Count of reviews by rating (1-10)"
child=serializers.IntegerField(), help_text="Count of reviews by rating (1-10)"
)
recent_reviews = serializers.IntegerField()
@@ -200,20 +191,15 @@ class RideReviewStatsOutputSerializer(serializers.Serializer):
class RideReviewModerationInputSerializer(serializers.Serializer):
"""Input serializer for review moderation operations."""
review_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="List of review IDs to moderate"
)
review_ids = serializers.ListField(child=serializers.IntegerField(), help_text="List of review IDs to moderate")
action = serializers.ChoiceField(
choices=[
("publish", "Publish"),
("unpublish", "Unpublish"),
("delete", "Delete"),
],
help_text="Moderation action to perform"
help_text="Moderation action to perform",
)
moderation_notes = serializers.CharField(
required=False,
allow_blank=True,
help_text="Optional notes about the moderation action"
required=False, allow_blank=True, help_text="Optional notes about the moderation action"
)

View File

@@ -81,23 +81,15 @@ class RideListOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="rides"
)
category = RichChoiceFieldSerializer(choice_group="categories", domain="rides")
status = RichChoiceFieldSerializer(choice_group="statuses", domain="rides")
description = serializers.CharField()
# Park info
park = RideParkOutputSerializer()
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
capacity_per_hour = serializers.IntegerField(allow_null=True)
# Dates
@@ -178,18 +170,10 @@ class RideDetailOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="rides"
)
category = RichChoiceFieldSerializer(choice_group="categories", domain="rides")
status = RichChoiceFieldSerializer(choice_group="statuses", domain="rides")
post_closing_status = RichChoiceFieldSerializer(
choice_group="post_closing_statuses",
domain="rides",
allow_null=True
choice_group="post_closing_statuses", domain="rides", allow_null=True
)
description = serializers.CharField()
@@ -209,9 +193,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
ride_duration_seconds = serializers.IntegerField(allow_null=True)
# Statistics
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True
)
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
# Companies
manufacturer = serializers.SerializerMethodField()
@@ -273,9 +255,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
"""Get all approved photos for this ride."""
from apps.rides.models import RidePhoto
photos = RidePhoto.objects.filter(ride=obj, is_approved=True).order_by(
"-is_primary", "-created_at"
)[
photos = RidePhoto.objects.filter(ride=obj, is_approved=True).order_by("-is_primary", "-created_at")[
:10
] # Limit to 10 photos
@@ -285,9 +265,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
"image_url": photo.image.url if photo.image else None,
"image_variants": (
{
"thumbnail": (
f"{photo.image.url}/thumbnail" if photo.image else None
),
"thumbnail": (f"{photo.image.url}/thumbnail" if photo.image else None),
"medium": f"{photo.image.url}/medium" if photo.image else None,
"large": f"{photo.image.url}/large" if photo.image else None,
"public": f"{photo.image.url}/public" if photo.image else None,
@@ -309,9 +287,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.filter(
ride=obj, is_primary=True, is_approved=True
).first()
photo = RidePhoto.objects.filter(ride=obj, is_primary=True, is_approved=True).first()
if photo and photo.image:
return {
@@ -356,9 +332,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
try:
latest_photo = (
RidePhoto.objects.filter(
ride=obj, is_approved=True, image__isnull=False
)
RidePhoto.objects.filter(ride=obj, is_approved=True, image__isnull=False)
.order_by("-created_at")
.first()
)
@@ -407,9 +381,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
try:
latest_photo = (
RidePhoto.objects.filter(
ride=obj, is_approved=True, image__isnull=False
)
RidePhoto.objects.filter(ride=obj, is_approved=True, image__isnull=False)
.order_by("-created_at")
.first()
)
@@ -451,7 +423,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
# The ride will be validated in the view
return value
except RidePhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
raise serializers.ValidationError("Photo not found") from None
return value
def validate_card_image_id(self, value):
@@ -464,7 +436,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
# The ride will be validated in the view
return value
except RidePhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
raise serializers.ValidationError("Photo not found") from None
return value
@@ -474,9 +446,7 @@ class RideCreateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices())
status = serializers.ChoiceField(
choices=ModelChoices.get_ride_status_choices(), default="OPERATING"
)
status = serializers.ChoiceField(choices=ModelChoices.get_ride_status_choices(), default="OPERATING")
# Required park
park_id = serializers.IntegerField()
@@ -490,18 +460,10 @@ class RideCreateInputSerializer(serializers.Serializer):
status_since = serializers.DateField(required=False, allow_null=True)
# Optional specs
min_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
min_height_in = serializers.IntegerField(required=False, allow_null=True, min_value=30, max_value=90)
max_height_in = serializers.IntegerField(required=False, allow_null=True, min_value=30, max_value=90)
capacity_per_hour = serializers.IntegerField(required=False, allow_null=True, min_value=1)
ride_duration_seconds = serializers.IntegerField(required=False, allow_null=True, min_value=1)
# Optional companies
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
@@ -517,18 +479,14 @@ class RideCreateInputSerializer(serializers.Serializer):
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
raise serializers.ValidationError("Closing date cannot be before opening date")
# Height validation
min_height = attrs.get("min_height_in")
max_height = attrs.get("max_height_in")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
raise serializers.ValidationError("Minimum height cannot be greater than maximum height")
# Park area validation when park changes
park_id = attrs.get("park_id")
@@ -537,6 +495,7 @@ class RideCreateInputSerializer(serializers.Serializer):
if park_id and park_area_id:
try:
from apps.parks.models import ParkArea
park_area = ParkArea.objects.get(id=park_area_id)
if park_area.park_id != park_id:
raise serializers.ValidationError(
@@ -554,12 +513,8 @@ class RideUpdateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
category = serializers.ChoiceField(
choices=ModelChoices.get_ride_category_choices(), required=False
)
status = serializers.ChoiceField(
choices=ModelChoices.get_ride_status_choices(), required=False
)
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
status = serializers.ChoiceField(choices=ModelChoices.get_ride_status_choices(), required=False)
post_closing_status = serializers.ChoiceField(
choices=ModelChoices.get_ride_post_closing_choices(),
required=False,
@@ -576,18 +531,10 @@ class RideUpdateInputSerializer(serializers.Serializer):
status_since = serializers.DateField(required=False, allow_null=True)
# Specs
min_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
max_height_in = serializers.IntegerField(
required=False, allow_null=True, min_value=30, max_value=90
)
capacity_per_hour = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
ride_duration_seconds = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
min_height_in = serializers.IntegerField(required=False, allow_null=True, min_value=30, max_value=90)
max_height_in = serializers.IntegerField(required=False, allow_null=True, min_value=30, max_value=90)
capacity_per_hour = serializers.IntegerField(required=False, allow_null=True, min_value=1)
ride_duration_seconds = serializers.IntegerField(required=False, allow_null=True, min_value=1)
# Companies
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
@@ -603,18 +550,14 @@ class RideUpdateInputSerializer(serializers.Serializer):
closing_date = attrs.get("closing_date")
if opening_date and closing_date and closing_date < opening_date:
raise serializers.ValidationError(
"Closing date cannot be before opening date"
)
raise serializers.ValidationError("Closing date cannot be before opening date")
# Height validation
min_height = attrs.get("min_height_in")
max_height = attrs.get("max_height_in")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
raise serializers.ValidationError("Minimum height cannot be greater than maximum height")
return attrs
@@ -626,9 +569,7 @@ class RideFilterInputSerializer(serializers.Serializer):
search = serializers.CharField(required=False, allow_blank=True)
# Category filter
category = serializers.MultipleChoiceField(
choices=ModelChoices.get_ride_category_choices(), required=False
)
category = serializers.MultipleChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
# Status filter
status = serializers.MultipleChoiceField(
@@ -707,33 +648,16 @@ class RollerCoasterStatsOutputSerializer(serializers.Serializer):
"""Output serializer for roller coaster statistics."""
id = serializers.IntegerField()
height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
length_ft = serializers.DecimalField(
max_digits=7, decimal_places=2, allow_null=True
)
speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
length_ft = serializers.DecimalField(max_digits=7, decimal_places=2, allow_null=True)
speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
inversions = serializers.IntegerField()
ride_time_seconds = serializers.IntegerField(allow_null=True)
track_type = serializers.CharField()
track_material = RichChoiceFieldSerializer(
choice_group="track_materials",
domain="rides"
)
roller_coaster_type = RichChoiceFieldSerializer(
choice_group="coaster_types",
domain="rides"
)
max_drop_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
propulsion_system = RichChoiceFieldSerializer(
choice_group="propulsion_systems",
domain="rides"
)
track_material = RichChoiceFieldSerializer(choice_group="track_materials", domain="rides")
roller_coaster_type = RichChoiceFieldSerializer(choice_group="coaster_types", domain="rides")
max_drop_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True)
propulsion_system = RichChoiceFieldSerializer(choice_group="propulsion_systems", domain="rides")
train_style = serializers.CharField()
trains_count = serializers.IntegerField(allow_null=True)
cars_per_train = serializers.IntegerField(allow_null=True)
@@ -755,30 +679,16 @@ class RollerCoasterStatsCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating roller coaster statistics."""
ride_id = serializers.IntegerField()
height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
length_ft = serializers.DecimalField(
max_digits=7, decimal_places=2, required=False, allow_null=True
)
speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
length_ft = serializers.DecimalField(max_digits=7, decimal_places=2, required=False, allow_null=True)
speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False, allow_null=True)
inversions = serializers.IntegerField(default=0)
ride_time_seconds = serializers.IntegerField(required=False, allow_null=True)
track_type = serializers.CharField(max_length=255, allow_blank=True, default="")
track_material = serializers.ChoiceField(
choices=ModelChoices.get_coaster_track_choices(), default="STEEL"
)
roller_coaster_type = serializers.ChoiceField(
choices=ModelChoices.get_coaster_type_choices(), default="SITDOWN"
)
max_drop_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
propulsion_system = serializers.ChoiceField(
choices=ModelChoices.get_propulsion_system_choices(), default="CHAIN"
)
track_material = serializers.ChoiceField(choices=ModelChoices.get_coaster_track_choices(), default="STEEL")
roller_coaster_type = serializers.ChoiceField(choices=ModelChoices.get_coaster_type_choices(), default="SITDOWN")
max_drop_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
propulsion_system = serializers.ChoiceField(choices=ModelChoices.get_propulsion_system_choices(), default="CHAIN")
train_style = serializers.CharField(max_length=255, allow_blank=True, default="")
trains_count = serializers.IntegerField(required=False, allow_null=True)
cars_per_train = serializers.IntegerField(required=False, allow_null=True)
@@ -788,33 +698,17 @@ class RollerCoasterStatsCreateInputSerializer(serializers.Serializer):
class RollerCoasterStatsUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating roller coaster statistics."""
height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
length_ft = serializers.DecimalField(
max_digits=7, decimal_places=2, required=False, allow_null=True
)
speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
length_ft = serializers.DecimalField(max_digits=7, decimal_places=2, required=False, allow_null=True)
speed_mph = serializers.DecimalField(max_digits=5, decimal_places=2, required=False, allow_null=True)
inversions = serializers.IntegerField(required=False)
ride_time_seconds = serializers.IntegerField(required=False, allow_null=True)
track_type = serializers.CharField(max_length=255, allow_blank=True, required=False)
track_material = serializers.ChoiceField(
choices=ModelChoices.get_coaster_track_choices(), required=False
)
roller_coaster_type = serializers.ChoiceField(
choices=ModelChoices.get_coaster_type_choices(), required=False
)
max_drop_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
propulsion_system = serializers.ChoiceField(
choices=ModelChoices.get_propulsion_system_choices(), required=False
)
train_style = serializers.CharField(
max_length=255, allow_blank=True, required=False
)
track_material = serializers.ChoiceField(choices=ModelChoices.get_coaster_track_choices(), required=False)
roller_coaster_type = serializers.ChoiceField(choices=ModelChoices.get_coaster_type_choices(), required=False)
max_drop_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, required=False, allow_null=True)
propulsion_system = serializers.ChoiceField(choices=ModelChoices.get_propulsion_system_choices(), required=False)
train_style = serializers.CharField(max_length=255, allow_blank=True, required=False)
trains_count = serializers.IntegerField(required=False, allow_null=True)
cars_per_train = serializers.IntegerField(required=False, allow_null=True)
seats_per_car = serializers.IntegerField(required=False, allow_null=True)

View File

@@ -12,9 +12,7 @@ from apps.rides.models import RidePhoto
class RidePhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride photos."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
file_size = serializers.ReadOnlyField()
dimensions = serializers.ReadOnlyField()
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
@@ -87,9 +85,7 @@ class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for ride photo lists."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
uploaded_by_username = serializers.CharField(source="uploaded_by.username", read_only=True)
class Meta:
model = RidePhoto
@@ -109,12 +105,8 @@ class RidePhotoListOutputSerializer(serializers.ModelSerializer):
class RidePhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True, help_text="Whether to approve (True) or reject (False) the photos"
)
photo_ids = serializers.ListField(child=serializers.IntegerField(), help_text="List of photo IDs to approve")
approve = serializers.BooleanField(default=True, help_text="Whether to approve (True) or reject (False) the photos")
class RidePhotoStatsOutputSerializer(serializers.Serializer):
@@ -125,9 +117,7 @@ class RidePhotoStatsOutputSerializer(serializers.Serializer):
pending_photos = serializers.IntegerField()
has_primary = serializers.BooleanField()
recent_uploads = serializers.IntegerField()
by_type = serializers.DictField(
child=serializers.IntegerField(), help_text="Photo counts by type"
)
by_type = serializers.DictField(child=serializers.IntegerField(), help_text="Photo counts by type")
class RidePhotoTypeFilterSerializer(serializers.Serializer):

View File

@@ -19,9 +19,7 @@ class EntitySearchInputSerializer(serializers.Serializer):
query = serializers.CharField(max_length=255, help_text="Search query string")
entity_types = serializers.ListField(
child=serializers.ChoiceField(
choices=ModelChoices.get_entity_type_choices()
),
child=serializers.ChoiceField(choices=ModelChoices.get_entity_type_choices()),
required=False,
help_text="Types of entities to search for",
)
@@ -39,17 +37,12 @@ class EntitySearchResultSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
type = RichChoiceFieldSerializer(
choice_group="entity_types",
domain="core"
)
type = RichChoiceFieldSerializer(choice_group="entity_types", domain="core")
description = serializers.CharField()
relevance_score = serializers.FloatField()
# Context-specific info — renamed to avoid overriding Serializer.context
context_info = serializers.JSONField(
help_text="Additional context based on entity type"
)
context_info = serializers.JSONField(help_text="Additional context based on entity type")
class EntitySearchOutputSerializer(serializers.Serializer):

View File

@@ -39,9 +39,7 @@ class SimpleHealthOutputSerializer(serializers.Serializer):
status = serializers.CharField(help_text="Simple health status")
timestamp = serializers.DateTimeField(help_text="Timestamp of health check")
error = serializers.CharField(
required=False, help_text="Error message if unhealthy"
)
error = serializers.CharField(required=False, help_text="Error message if unhealthy")
# === EMAIL SERVICE SERIALIZERS ===
@@ -151,7 +149,7 @@ class ModerationSubmissionSerializer(serializers.Serializer):
("PHOTO", "Photo Submission"),
("REVIEW", "Review Submission"),
],
help_text="Type of submission"
help_text="Type of submission",
)
content_type = serializers.CharField(help_text="Content type being modified")
object_id = serializers.IntegerField(help_text="ID of object being modified")
@@ -221,9 +219,7 @@ class RoadtripOutputSerializer(serializers.Serializer):
parks = RoadtripParkSerializer(many=True)
total_distance_miles = serializers.FloatField()
estimated_drive_time_hours = serializers.FloatField()
route_coordinates = serializers.ListField(
child=serializers.ListField(child=serializers.FloatField())
)
route_coordinates = serializers.ListField(child=serializers.ListField(child=serializers.FloatField()))
created_at = serializers.DateTimeField()

View File

@@ -25,21 +25,13 @@ class FilterOptionSerializer(serializers.Serializer):
selected?: boolean;
}
"""
value = serializers.CharField(
help_text="The actual value used for filtering"
)
label = serializers.CharField(
help_text="Human-readable display label"
)
value = serializers.CharField(help_text="The actual value used for filtering")
label = serializers.CharField(help_text="Human-readable display label")
count = serializers.IntegerField(
required=False,
allow_null=True,
help_text="Number of items matching this filter option"
)
selected = serializers.BooleanField(
default=False,
help_text="Whether this option is currently selected"
required=False, allow_null=True, help_text="Number of items matching this filter option"
)
selected = serializers.BooleanField(default=False, help_text="Whether this option is currently selected")
class FilterRangeSerializer(serializers.Serializer):
@@ -54,22 +46,12 @@ class FilterRangeSerializer(serializers.Serializer):
unit?: string;
}
"""
min = serializers.FloatField(
allow_null=True,
help_text="Minimum value for the range"
)
max = serializers.FloatField(
allow_null=True,
help_text="Maximum value for the range"
)
step = serializers.FloatField(
default=1.0,
help_text="Step size for range inputs"
)
min = serializers.FloatField(allow_null=True, help_text="Minimum value for the range")
max = serializers.FloatField(allow_null=True, help_text="Maximum value for the range")
step = serializers.FloatField(default=1.0, help_text="Step size for range inputs")
unit = serializers.CharField(
required=False,
allow_null=True,
help_text="Unit of measurement (e.g., 'feet', 'mph', 'stars')"
required=False, allow_null=True, help_text="Unit of measurement (e.g., 'feet', 'mph', 'stars')"
)
@@ -84,15 +66,10 @@ class BooleanFilterSerializer(serializers.Serializer):
description: string;
}
"""
key = serializers.CharField(
help_text="The filter parameter key"
)
label = serializers.CharField(
help_text="Human-readable label for the filter"
)
description = serializers.CharField(
help_text="Description of what this filter does"
)
key = serializers.CharField(help_text="The filter parameter key")
label = serializers.CharField(help_text="Human-readable label for the filter")
description = serializers.CharField(help_text="Description of what this filter does")
class OrderingOptionSerializer(serializers.Serializer):
@@ -105,12 +82,9 @@ class OrderingOptionSerializer(serializers.Serializer):
label: string;
}
"""
value = serializers.CharField(
help_text="The ordering parameter value"
)
label = serializers.CharField(
help_text="Human-readable label for the ordering option"
)
value = serializers.CharField(help_text="The ordering parameter value")
label = serializers.CharField(help_text="Human-readable label for the ordering option")
class StandardizedFilterMetadataSerializer(serializers.Serializer):
@@ -120,27 +94,16 @@ class StandardizedFilterMetadataSerializer(serializers.Serializer):
This serializer ensures all filter metadata responses follow the same structure
that the frontend expects, preventing runtime type errors.
"""
categorical = serializers.DictField(
child=FilterOptionSerializer(many=True),
help_text="Categorical filter options with value/label/count structure"
child=FilterOptionSerializer(many=True), help_text="Categorical filter options with value/label/count structure"
)
ranges = serializers.DictField(
child=FilterRangeSerializer(),
help_text="Range filter metadata with min/max/step/unit"
)
total_count = serializers.IntegerField(
help_text="Total number of items in the filtered dataset"
)
ordering_options = FilterOptionSerializer(
many=True,
required=False,
help_text="Available ordering options"
)
boolean_filters = BooleanFilterSerializer(
many=True,
required=False,
help_text="Available boolean filter options"
child=FilterRangeSerializer(), help_text="Range filter metadata with min/max/step/unit"
)
total_count = serializers.IntegerField(help_text="Total number of items in the filtered dataset")
ordering_options = FilterOptionSerializer(many=True, required=False, help_text="Available ordering options")
boolean_filters = BooleanFilterSerializer(many=True, required=False, help_text="Available boolean filter options")
class PaginationMetadataSerializer(serializers.Serializer):
@@ -157,28 +120,13 @@ class PaginationMetadataSerializer(serializers.Serializer):
total_pages: number;
}
"""
count = serializers.IntegerField(
help_text="Total number of items across all pages"
)
next = serializers.URLField(
allow_null=True,
required=False,
help_text="URL for the next page of results"
)
previous = serializers.URLField(
allow_null=True,
required=False,
help_text="URL for the previous page of results"
)
page_size = serializers.IntegerField(
help_text="Number of items per page"
)
current_page = serializers.IntegerField(
help_text="Current page number (1-indexed)"
)
total_pages = serializers.IntegerField(
help_text="Total number of pages"
)
count = serializers.IntegerField(help_text="Total number of items across all pages")
next = serializers.URLField(allow_null=True, required=False, help_text="URL for the next page of results")
previous = serializers.URLField(allow_null=True, required=False, help_text="URL for the previous page of results")
page_size = serializers.IntegerField(help_text="Number of items per page")
current_page = serializers.IntegerField(help_text="Current page number (1-indexed)")
total_pages = serializers.IntegerField(help_text="Total number of pages")
class ApiResponseSerializer(serializers.Serializer):
@@ -193,22 +141,14 @@ class ApiResponseSerializer(serializers.Serializer):
errors?: ValidationError;
}
"""
success = serializers.BooleanField(
help_text="Whether the request was successful"
)
success = serializers.BooleanField(help_text="Whether the request was successful")
response_data = serializers.JSONField(
required=False,
help_text="Response data (structure varies by endpoint)",
source='data'
)
message = serializers.CharField(
required=False,
help_text="Human-readable message about the operation"
required=False, help_text="Response data (structure varies by endpoint)", source="data"
)
message = serializers.CharField(required=False, help_text="Human-readable message about the operation")
response_errors = serializers.DictField(
required=False,
help_text="Validation errors (field -> error messages)",
source='errors'
required=False, help_text="Validation errors (field -> error messages)", source="errors"
)
@@ -228,18 +168,11 @@ class ErrorResponseSerializer(serializers.Serializer):
data: null;
}
"""
status = serializers.CharField(
default="error",
help_text="Response status indicator"
)
error = serializers.DictField(
help_text="Error details"
)
status = serializers.CharField(default="error", help_text="Response status indicator")
error = serializers.DictField(help_text="Error details")
response_data = serializers.JSONField(
default=None,
allow_null=True,
help_text="Always null for error responses",
source='data'
default=None, allow_null=True, help_text="Always null for error responses", source="data"
)
@@ -257,32 +190,13 @@ class LocationSerializer(serializers.Serializer):
longitude?: number;
}
"""
city = serializers.CharField(
help_text="City name"
)
state = serializers.CharField(
required=False,
allow_null=True,
help_text="State/province name"
)
country = serializers.CharField(
help_text="Country name"
)
address = serializers.CharField(
required=False,
allow_null=True,
help_text="Street address"
)
latitude = serializers.FloatField(
required=False,
allow_null=True,
help_text="Latitude coordinate"
)
longitude = serializers.FloatField(
required=False,
allow_null=True,
help_text="Longitude coordinate"
)
city = serializers.CharField(help_text="City name")
state = serializers.CharField(required=False, allow_null=True, help_text="State/province name")
country = serializers.CharField(help_text="Country name")
address = serializers.CharField(required=False, allow_null=True, help_text="Street address")
latitude = serializers.FloatField(required=False, allow_null=True, help_text="Latitude coordinate")
longitude = serializers.FloatField(required=False, allow_null=True, help_text="Longitude coordinate")
# Alias for backward compatibility
@@ -301,24 +215,15 @@ class CompanyOutputSerializer(serializers.Serializer):
roles?: string[];
}
"""
id = serializers.IntegerField(
help_text="Company ID"
)
name = serializers.CharField(
help_text="Company name"
)
slug = serializers.SlugField(
help_text="URL-friendly identifier"
)
id = serializers.IntegerField(help_text="Company ID")
name = serializers.CharField(help_text="Company name")
slug = serializers.SlugField(help_text="URL-friendly identifier")
roles = serializers.ListField(
child=serializers.CharField(),
required=False,
help_text="Company roles (manufacturer, operator, etc.)"
child=serializers.CharField(), required=False, help_text="Company roles (manufacturer, operator, etc.)"
)
class ModelChoices:
"""
Utility class to provide model choices for serializers using Rich Choice Objects.
@@ -331,6 +236,7 @@ class ModelChoices:
def get_park_status_choices():
"""Get park status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("statuses", "parks")
return [(choice.value, choice.label) for choice in choices]
@@ -338,6 +244,7 @@ class ModelChoices:
def get_ride_status_choices():
"""Get ride status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("statuses", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -345,6 +252,7 @@ class ModelChoices:
def get_company_role_choices():
"""Get company role choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
# Get rides domain company roles (MANUFACTURER, DESIGNER)
rides_choices = get_choices("company_roles", "rides")
# Get parks domain company roles (OPERATOR, PROPERTY_OWNER)
@@ -356,6 +264,7 @@ class ModelChoices:
def get_ride_category_choices():
"""Get ride category choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("categories", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -363,6 +272,7 @@ class ModelChoices:
def get_ride_post_closing_choices():
"""Get ride post-closing status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("post_closing_statuses", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -370,6 +280,7 @@ class ModelChoices:
def get_coaster_track_choices():
"""Get coaster track material choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("track_materials", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -377,6 +288,7 @@ class ModelChoices:
def get_coaster_type_choices():
"""Get coaster type choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("coaster_types", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -384,6 +296,7 @@ class ModelChoices:
def get_launch_choices():
"""Get launch system choices from Rich Choice registry (legacy method)."""
from apps.core.choices.registry import get_choices
choices = get_choices("propulsion_systems", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -391,6 +304,7 @@ class ModelChoices:
def get_propulsion_system_choices():
"""Get propulsion system choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("propulsion_systems", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -398,6 +312,7 @@ class ModelChoices:
def get_photo_type_choices():
"""Get photo type choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("photo_types", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -405,6 +320,7 @@ class ModelChoices:
def get_spec_category_choices():
"""Get technical specification category choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("spec_categories", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -412,6 +328,7 @@ class ModelChoices:
def get_technical_spec_category_choices():
"""Get technical specification category choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("spec_categories", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -419,6 +336,7 @@ class ModelChoices:
def get_target_market_choices():
"""Get target market choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("target_markets", "rides")
return [(choice.value, choice.label) for choice in choices]
@@ -426,6 +344,7 @@ class ModelChoices:
def get_entity_type_choices():
"""Get entity type choices for search functionality."""
from apps.core.choices.registry import get_choices
choices = get_choices("entity_types", "core")
return [(choice.value, choice.label) for choice in choices]
@@ -433,6 +352,7 @@ class ModelChoices:
def get_health_status_choices():
"""Get health check status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("health_statuses", "core")
return [(choice.value, choice.label) for choice in choices]
@@ -440,6 +360,7 @@ class ModelChoices:
def get_simple_health_status_choices():
"""Get simple health check status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("simple_health_statuses", "core")
return [(choice.value, choice.label) for choice in choices]
@@ -455,15 +376,10 @@ class EntityReferenceSerializer(serializers.Serializer):
slug: string;
}
"""
id = serializers.IntegerField(
help_text="Unique identifier"
)
name = serializers.CharField(
help_text="Display name"
)
slug = serializers.SlugField(
help_text="URL-friendly identifier"
)
id = serializers.IntegerField(help_text="Unique identifier")
name = serializers.CharField(help_text="Display name")
slug = serializers.SlugField(help_text="URL-friendly identifier")
class ImageVariantsSerializer(serializers.Serializer):
@@ -478,19 +394,11 @@ class ImageVariantsSerializer(serializers.Serializer):
avatar?: string;
}
"""
thumbnail = serializers.URLField(
help_text="Thumbnail size image URL"
)
medium = serializers.URLField(
help_text="Medium size image URL"
)
large = serializers.URLField(
help_text="Large size image URL"
)
avatar = serializers.URLField(
required=False,
help_text="Avatar size image URL (for user avatars)"
)
thumbnail = serializers.URLField(help_text="Thumbnail size image URL")
medium = serializers.URLField(help_text="Medium size image URL")
large = serializers.URLField(help_text="Large size image URL")
avatar = serializers.URLField(required=False, help_text="Avatar size image URL (for user avatars)")
class PhotoSerializer(serializers.Serializer):
@@ -509,39 +417,15 @@ class PhotoSerializer(serializers.Serializer):
uploaded_at?: string;
}
"""
id = serializers.IntegerField(
help_text="Photo ID"
)
image_variants = ImageVariantsSerializer(
help_text="Available image size variants"
)
alt_text = serializers.CharField(
required=False,
allow_null=True,
help_text="Alternative text for accessibility"
)
image_url = serializers.URLField(
required=False,
help_text="Primary image URL (for compatibility)"
)
caption = serializers.CharField(
required=False,
allow_null=True,
help_text="Photo caption"
)
photo_type = serializers.CharField(
required=False,
allow_null=True,
help_text="Type/category of photo"
)
uploaded_by = EntityReferenceSerializer(
required=False,
help_text="User who uploaded the photo"
)
uploaded_at = serializers.DateTimeField(
required=False,
help_text="When the photo was uploaded"
)
id = serializers.IntegerField(help_text="Photo ID")
image_variants = ImageVariantsSerializer(help_text="Available image size variants")
alt_text = serializers.CharField(required=False, allow_null=True, help_text="Alternative text for accessibility")
image_url = serializers.URLField(required=False, help_text="Primary image URL (for compatibility)")
caption = serializers.CharField(required=False, allow_null=True, help_text="Photo caption")
photo_type = serializers.CharField(required=False, allow_null=True, help_text="Type/category of photo")
uploaded_by = EntityReferenceSerializer(required=False, help_text="User who uploaded the photo")
uploaded_at = serializers.DateTimeField(required=False, help_text="When the photo was uploaded")
class UserInfoSerializer(serializers.Serializer):
@@ -556,20 +440,11 @@ class UserInfoSerializer(serializers.Serializer):
avatar_url?: string;
}
"""
id = serializers.IntegerField(
help_text="User ID"
)
username = serializers.CharField(
help_text="Username"
)
display_name = serializers.CharField(
help_text="Display name"
)
avatar_url = serializers.URLField(
required=False,
allow_null=True,
help_text="User avatar URL"
)
id = serializers.IntegerField(help_text="User ID")
username = serializers.CharField(help_text="Username")
display_name = serializers.CharField(help_text="Display name")
avatar_url = serializers.URLField(required=False, allow_null=True, help_text="User avatar URL")
def validate_filter_metadata_contract(data: dict[str, Any]) -> dict[str, Any]:
@@ -613,27 +488,22 @@ def ensure_filter_option_format(options: list[Any]) -> list[dict[str, Any]]:
if isinstance(option, dict):
# Already in correct format or close to it
standardized_option = {
'value': str(option.get('value', option.get('id', ''))),
'label': option.get('label', option.get('name', str(option.get('value', '')))),
'count': option.get('count'),
'selected': option.get('selected', False)
"value": str(option.get("value", option.get("id", ""))),
"label": option.get("label", option.get("name", str(option.get("value", "")))),
"count": option.get("count"),
"selected": option.get("selected", False),
}
elif hasattr(option, 'value') and hasattr(option, 'label'):
elif hasattr(option, "value") and hasattr(option, "label"):
# RichChoice object format
standardized_option = {
'value': str(option.value),
'label': str(option.label),
'count': None,
'selected': False
"value": str(option.value),
"label": str(option.label),
"count": None,
"selected": False,
}
else:
# Simple value - use as both value and label
standardized_option = {
'value': str(option),
'label': str(option),
'count': None,
'selected': False
}
standardized_option = {"value": str(option), "label": str(option), "count": None, "selected": False}
standardized.append(standardized_option)
@@ -651,8 +521,8 @@ def ensure_range_format(range_data: dict[str, Any]) -> dict[str, Any]:
Range data in standard format
"""
return {
'min': range_data.get('min'),
'max': range_data.get('max'),
'step': range_data.get('step', 1.0),
'unit': range_data.get('unit')
"min": range_data.get("min"),
"max": range_data.get("max"),
"step": range_data.get("step", 1.0),
"unit": range_data.get("unit"),
}

View File

@@ -16,120 +16,56 @@ class StatsSerializer(serializers.Serializer):
"""
# Core entity counts
total_parks = serializers.IntegerField(
help_text="Total number of parks in the database"
)
total_rides = serializers.IntegerField(
help_text="Total number of rides in the database"
)
total_manufacturers = serializers.IntegerField(
help_text="Total number of ride manufacturers"
)
total_operators = serializers.IntegerField(
help_text="Total number of park operators"
)
total_designers = serializers.IntegerField(
help_text="Total number of ride designers"
)
total_property_owners = serializers.IntegerField(
help_text="Total number of property owners"
)
total_roller_coasters = serializers.IntegerField(
help_text="Total number of roller coasters with detailed stats"
)
total_parks = serializers.IntegerField(help_text="Total number of parks in the database")
total_rides = serializers.IntegerField(help_text="Total number of rides in the database")
total_manufacturers = serializers.IntegerField(help_text="Total number of ride manufacturers")
total_operators = serializers.IntegerField(help_text="Total number of park operators")
total_designers = serializers.IntegerField(help_text="Total number of ride designers")
total_property_owners = serializers.IntegerField(help_text="Total number of property owners")
total_roller_coasters = serializers.IntegerField(help_text="Total number of roller coasters with detailed stats")
# Photo counts
total_photos = serializers.IntegerField(
help_text="Total number of photos (parks + rides combined)"
)
total_park_photos = serializers.IntegerField(
help_text="Total number of park photos"
)
total_ride_photos = serializers.IntegerField(
help_text="Total number of ride photos"
)
total_photos = serializers.IntegerField(help_text="Total number of photos (parks + rides combined)")
total_park_photos = serializers.IntegerField(help_text="Total number of park photos")
total_ride_photos = serializers.IntegerField(help_text="Total number of ride photos")
# Review counts
total_reviews = serializers.IntegerField(
help_text="Total number of reviews (parks + rides)"
)
total_park_reviews = serializers.IntegerField(
help_text="Total number of park reviews"
)
total_ride_reviews = serializers.IntegerField(
help_text="Total number of ride reviews"
)
total_reviews = serializers.IntegerField(help_text="Total number of reviews (parks + rides)")
total_park_reviews = serializers.IntegerField(help_text="Total number of park reviews")
total_ride_reviews = serializers.IntegerField(help_text="Total number of ride reviews")
# Ride category counts (optional fields since they depend on data)
roller_coasters = serializers.IntegerField(
required=False, help_text="Number of rides categorized as roller coasters"
)
dark_rides = serializers.IntegerField(
required=False, help_text="Number of rides categorized as dark rides"
)
flat_rides = serializers.IntegerField(
required=False, help_text="Number of rides categorized as flat rides"
)
water_rides = serializers.IntegerField(
required=False, help_text="Number of rides categorized as water rides"
)
dark_rides = serializers.IntegerField(required=False, help_text="Number of rides categorized as dark rides")
flat_rides = serializers.IntegerField(required=False, help_text="Number of rides categorized as flat rides")
water_rides = serializers.IntegerField(required=False, help_text="Number of rides categorized as water rides")
transport_rides = serializers.IntegerField(
required=False, help_text="Number of rides categorized as transport rides"
)
other_rides = serializers.IntegerField(
required=False, help_text="Number of rides categorized as other"
)
other_rides = serializers.IntegerField(required=False, help_text="Number of rides categorized as other")
# Park status counts (optional fields since they depend on data)
operating_parks = serializers.IntegerField(
required=False, help_text="Number of currently operating parks"
)
temporarily_closed_parks = serializers.IntegerField(
required=False, help_text="Number of temporarily closed parks"
)
permanently_closed_parks = serializers.IntegerField(
required=False, help_text="Number of permanently closed parks"
)
under_construction_parks = serializers.IntegerField(
required=False, help_text="Number of parks under construction"
)
demolished_parks = serializers.IntegerField(
required=False, help_text="Number of demolished parks"
)
relocated_parks = serializers.IntegerField(
required=False, help_text="Number of relocated parks"
)
operating_parks = serializers.IntegerField(required=False, help_text="Number of currently operating parks")
temporarily_closed_parks = serializers.IntegerField(required=False, help_text="Number of temporarily closed parks")
permanently_closed_parks = serializers.IntegerField(required=False, help_text="Number of permanently closed parks")
under_construction_parks = serializers.IntegerField(required=False, help_text="Number of parks under construction")
demolished_parks = serializers.IntegerField(required=False, help_text="Number of demolished parks")
relocated_parks = serializers.IntegerField(required=False, help_text="Number of relocated parks")
# Ride status counts (optional fields since they depend on data)
operating_rides = serializers.IntegerField(
required=False, help_text="Number of currently operating rides"
)
temporarily_closed_rides = serializers.IntegerField(
required=False, help_text="Number of temporarily closed rides"
)
sbno_rides = serializers.IntegerField(
required=False, help_text="Number of rides standing but not operating"
)
closing_rides = serializers.IntegerField(
required=False, help_text="Number of rides in the process of closing"
)
permanently_closed_rides = serializers.IntegerField(
required=False, help_text="Number of permanently closed rides"
)
under_construction_rides = serializers.IntegerField(
required=False, help_text="Number of rides under construction"
)
demolished_rides = serializers.IntegerField(
required=False, help_text="Number of demolished rides"
)
relocated_rides = serializers.IntegerField(
required=False, help_text="Number of relocated rides"
)
operating_rides = serializers.IntegerField(required=False, help_text="Number of currently operating rides")
temporarily_closed_rides = serializers.IntegerField(required=False, help_text="Number of temporarily closed rides")
sbno_rides = serializers.IntegerField(required=False, help_text="Number of rides standing but not operating")
closing_rides = serializers.IntegerField(required=False, help_text="Number of rides in the process of closing")
permanently_closed_rides = serializers.IntegerField(required=False, help_text="Number of permanently closed rides")
under_construction_rides = serializers.IntegerField(required=False, help_text="Number of rides under construction")
demolished_rides = serializers.IntegerField(required=False, help_text="Number of demolished rides")
relocated_rides = serializers.IntegerField(required=False, help_text="Number of relocated rides")
# Metadata
last_updated = serializers.CharField(
help_text="ISO timestamp when these statistics were last calculated"
)
last_updated = serializers.CharField(help_text="ISO timestamp when these statistics were last calculated")
relative_last_updated = serializers.CharField(
help_text="Human-readable relative time since last update (e.g., '2 minutes ago')"
)

View File

@@ -87,9 +87,7 @@ class RideRankingSerializer(serializers.ModelSerializer):
"""Calculate rank change from previous snapshot."""
from apps.rides.models import RankingSnapshot
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
"-snapshot_date"
)[:2]
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by("-snapshot_date")[:2]
if len(latest_snapshots) >= 2:
return latest_snapshots[0].rank - latest_snapshots[1].rank
@@ -100,9 +98,7 @@ class RideRankingSerializer(serializers.ModelSerializer):
"""Get previous rank."""
from apps.rides.models import RankingSnapshot
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
"-snapshot_date"
)[:2]
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by("-snapshot_date")[:2]
if len(latest_snapshots) >= 2:
return latest_snapshots[1].rank
@@ -149,28 +145,14 @@ class RideRankingDetailSerializer(serializers.ModelSerializer):
"name": ride.park.name,
"slug": ride.park.slug,
"location": {
"city": (
ride.park.location.city
if hasattr(ride.park, "location")
else None
),
"state": (
ride.park.location.state
if hasattr(ride.park, "location")
else None
),
"country": (
ride.park.location.country
if hasattr(ride.park, "location")
else None
),
"city": (ride.park.location.city if hasattr(ride.park, "location") else None),
"state": (ride.park.location.state if hasattr(ride.park, "location") else None),
"country": (ride.park.location.country if hasattr(ride.park, "location") else None),
},
},
"category": ride.category,
"manufacturer": (
{"id": ride.manufacturer.id, "name": ride.manufacturer.name}
if ride.manufacturer
else None
{"id": ride.manufacturer.id, "name": ride.manufacturer.name} if ride.manufacturer else None
),
"opening_date": ride.opening_date,
"status": ride.status,
@@ -225,9 +207,7 @@ class RideRankingDetailSerializer(serializers.ModelSerializer):
"""Get recent ranking history."""
from apps.rides.models import RankingSnapshot
history = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
"-snapshot_date"
)[:30]
history = RankingSnapshot.objects.filter(ride=obj.ride).order_by("-snapshot_date")[:30]
return [
{

View File

@@ -29,40 +29,43 @@ class FilterMetadataContractTests(TestCase):
metadata = smart_park_loader.get_filter_metadata()
# Should have required top-level keys
self.assertIn('categorical', metadata)
self.assertIn('ranges', metadata)
self.assertIn('total_count', metadata)
self.assertIn("categorical", metadata)
self.assertIn("ranges", metadata)
self.assertIn("total_count", metadata)
# Categorical filters should be objects with value/label/count
categorical = metadata['categorical']
categorical = metadata["categorical"]
self.assertIsInstance(categorical, dict)
for filter_name, filter_options in categorical.items():
with self.subTest(filter_name=filter_name):
self.assertIsInstance(filter_options, list,
f"Filter '{filter_name}' should be a list")
self.assertIsInstance(filter_options, list, f"Filter '{filter_name}' should be a list")
for i, option in enumerate(filter_options):
with self.subTest(filter_name=filter_name, option_index=i):
self.assertIsInstance(option, dict,
f"Filter '{filter_name}' option {i} should be an object, not {type(option).__name__}")
self.assertIsInstance(
option,
dict,
f"Filter '{filter_name}' option {i} should be an object, not {type(option).__name__}",
)
# Check required properties
self.assertIn('value', option,
f"Filter '{filter_name}' option {i} missing 'value' property")
self.assertIn('label', option,
f"Filter '{filter_name}' option {i} missing 'label' property")
self.assertIn("value", option, f"Filter '{filter_name}' option {i} missing 'value' property")
self.assertIn("label", option, f"Filter '{filter_name}' option {i} missing 'label' property")
# Check types
self.assertIsInstance(option['value'], str,
f"Filter '{filter_name}' option {i} 'value' should be string")
self.assertIsInstance(option['label'], str,
f"Filter '{filter_name}' option {i} 'label' should be string")
self.assertIsInstance(
option["value"], str, f"Filter '{filter_name}' option {i} 'value' should be string"
)
self.assertIsInstance(
option["label"], str, f"Filter '{filter_name}' option {i} 'label' should be string"
)
# Count is optional but should be int if present
if 'count' in option and option['count'] is not None:
self.assertIsInstance(option['count'], int,
f"Filter '{filter_name}' option {i} 'count' should be int")
if "count" in option and option["count"] is not None:
self.assertIsInstance(
option["count"], int, f"Filter '{filter_name}' option {i} 'count' should be int"
)
def test_rides_filter_metadata_structure(self):
"""Test that rides filter metadata has correct structure."""
@@ -70,16 +73,16 @@ class FilterMetadataContractTests(TestCase):
metadata = loader.get_filter_metadata()
# Should have required top-level keys
self.assertIn('categorical', metadata)
self.assertIn('ranges', metadata)
self.assertIn('total_count', metadata)
self.assertIn("categorical", metadata)
self.assertIn("ranges", metadata)
self.assertIn("total_count", metadata)
# Categorical filters should be objects with value/label/count
categorical = metadata['categorical']
categorical = metadata["categorical"]
self.assertIsInstance(categorical, dict)
# Test specific categorical filters that were problematic
critical_filters = ['categories', 'statuses', 'roller_coaster_types', 'track_materials']
critical_filters = ["categories", "statuses", "roller_coaster_types", "track_materials"]
for filter_name in critical_filters:
if filter_name in categorical:
@@ -89,40 +92,42 @@ class FilterMetadataContractTests(TestCase):
for i, option in enumerate(filter_options):
with self.subTest(filter_name=filter_name, option_index=i):
self.assertIsInstance(option, dict,
f"CRITICAL: Filter '{filter_name}' option {i} is {type(option).__name__} but should be dict")
self.assertIsInstance(
option,
dict,
f"CRITICAL: Filter '{filter_name}' option {i} is {type(option).__name__} but should be dict",
)
self.assertIn('value', option)
self.assertIn('label', option)
self.assertIn('count', option)
self.assertIn("value", option)
self.assertIn("label", option)
self.assertIn("count", option)
def test_range_metadata_structure(self):
"""Test that range metadata has correct structure."""
# Test parks ranges
parks_metadata = smart_park_loader.get_filter_metadata()
ranges = parks_metadata['ranges']
ranges = parks_metadata["ranges"]
for range_name, range_data in ranges.items():
with self.subTest(range_name=range_name):
self.assertIsInstance(range_data, dict,
f"Range '{range_name}' should be an object")
self.assertIsInstance(range_data, dict, f"Range '{range_name}' should be an object")
# Check required properties
self.assertIn('min', range_data)
self.assertIn('max', range_data)
self.assertIn('step', range_data)
self.assertIn('unit', range_data)
self.assertIn("min", range_data)
self.assertIn("max", range_data)
self.assertIn("step", range_data)
self.assertIn("unit", range_data)
# Check types (min/max can be None)
if range_data['min'] is not None:
self.assertIsInstance(range_data['min'], (int, float))
if range_data['max'] is not None:
self.assertIsInstance(range_data['max'], (int, float))
if range_data["min"] is not None:
self.assertIsInstance(range_data["min"], (int, float))
if range_data["max"] is not None:
self.assertIsInstance(range_data["max"], (int, float))
self.assertIsInstance(range_data['step'], (int, float))
self.assertIsInstance(range_data["step"], (int, float))
# Unit can be None or string
if range_data['unit'] is not None:
self.assertIsInstance(range_data['unit'], str)
if range_data["unit"] is not None:
self.assertIsInstance(range_data["unit"], str)
class ContractValidationUtilityTests(TestCase):
@@ -131,38 +136,29 @@ class ContractValidationUtilityTests(TestCase):
def test_validate_filter_metadata_contract_valid(self):
"""Test validation passes for valid filter metadata."""
valid_metadata = {
'categorical': {
'statuses': [
{'value': 'OPERATING', 'label': 'Operating', 'count': 5},
{'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2}
"categorical": {
"statuses": [
{"value": "OPERATING", "label": "Operating", "count": 5},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed", "count": 2},
]
},
'ranges': {
'rating': {
'min': 1.0,
'max': 10.0,
'step': 0.1,
'unit': 'stars'
}
},
'total_count': 100
"ranges": {"rating": {"min": 1.0, "max": 10.0, "step": 0.1, "unit": "stars"}},
"total_count": 100,
}
# Should not raise an exception
validated = validate_filter_metadata_contract(valid_metadata)
self.assertIsInstance(validated, dict)
self.assertEqual(validated['total_count'], 100)
self.assertEqual(validated["total_count"], 100)
def test_validate_filter_metadata_contract_invalid(self):
"""Test validation fails for invalid filter metadata."""
from rest_framework import serializers
invalid_metadata = {
'categorical': {
'statuses': ['OPERATING', 'CLOSED_TEMP'] # Should be objects, not strings
},
'ranges': {},
'total_count': 100
"categorical": {"statuses": ["OPERATING", "CLOSED_TEMP"]}, # Should be objects, not strings
"ranges": {},
"total_count": 100,
}
# Should raise ValidationError
@@ -171,82 +167,71 @@ class ContractValidationUtilityTests(TestCase):
def test_ensure_filter_option_format_strings(self):
"""Test converting string arrays to proper format."""
string_options = ['OPERATING', 'CLOSED_TEMP', 'UNDER_CONSTRUCTION']
string_options = ["OPERATING", "CLOSED_TEMP", "UNDER_CONSTRUCTION"]
formatted = ensure_filter_option_format(string_options)
self.assertEqual(len(formatted), 3)
for i, option in enumerate(formatted):
self.assertIsInstance(option, dict)
self.assertIn('value', option)
self.assertIn('label', option)
self.assertIn('count', option)
self.assertIn('selected', option)
self.assertIn("value", option)
self.assertIn("label", option)
self.assertIn("count", option)
self.assertIn("selected", option)
self.assertEqual(option['value'], string_options[i])
self.assertEqual(option['label'], string_options[i])
self.assertIsNone(option['count'])
self.assertFalse(option['selected'])
self.assertEqual(option["value"], string_options[i])
self.assertEqual(option["label"], string_options[i])
self.assertIsNone(option["count"])
self.assertFalse(option["selected"])
def test_ensure_filter_option_format_tuples(self):
"""Test converting tuple arrays to proper format."""
tuple_options = [
('OPERATING', 'Operating', 5),
('CLOSED_TEMP', 'Temporarily Closed', 2)
]
tuple_options = [("OPERATING", "Operating", 5), ("CLOSED_TEMP", "Temporarily Closed", 2)]
formatted = ensure_filter_option_format(tuple_options)
self.assertEqual(len(formatted), 2)
self.assertEqual(formatted[0]['value'], 'OPERATING')
self.assertEqual(formatted[0]['label'], 'Operating')
self.assertEqual(formatted[0]['count'], 5)
self.assertEqual(formatted[0]["value"], "OPERATING")
self.assertEqual(formatted[0]["label"], "Operating")
self.assertEqual(formatted[0]["count"], 5)
self.assertEqual(formatted[1]['value'], 'CLOSED_TEMP')
self.assertEqual(formatted[1]['label'], 'Temporarily Closed')
self.assertEqual(formatted[1]['count'], 2)
self.assertEqual(formatted[1]["value"], "CLOSED_TEMP")
self.assertEqual(formatted[1]["label"], "Temporarily Closed")
self.assertEqual(formatted[1]["count"], 2)
def test_ensure_filter_option_format_dicts(self):
"""Test that properly formatted dicts pass through correctly."""
dict_options = [
{'value': 'OPERATING', 'label': 'Operating', 'count': 5},
{'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2}
{"value": "OPERATING", "label": "Operating", "count": 5},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed", "count": 2},
]
formatted = ensure_filter_option_format(dict_options)
self.assertEqual(len(formatted), 2)
self.assertEqual(formatted[0]['value'], 'OPERATING')
self.assertEqual(formatted[0]['label'], 'Operating')
self.assertEqual(formatted[0]['count'], 5)
self.assertEqual(formatted[0]["value"], "OPERATING")
self.assertEqual(formatted[0]["label"], "Operating")
self.assertEqual(formatted[0]["count"], 5)
def test_ensure_range_format(self):
"""Test range format utility."""
range_data = {
'min': 1.0,
'max': 10.0,
'step': 0.5,
'unit': 'stars'
}
range_data = {"min": 1.0, "max": 10.0, "step": 0.5, "unit": "stars"}
formatted = ensure_range_format(range_data)
self.assertEqual(formatted['min'], 1.0)
self.assertEqual(formatted['max'], 10.0)
self.assertEqual(formatted['step'], 0.5)
self.assertEqual(formatted['unit'], 'stars')
self.assertEqual(formatted["min"], 1.0)
self.assertEqual(formatted["max"], 10.0)
self.assertEqual(formatted["step"], 0.5)
self.assertEqual(formatted["unit"], "stars")
def test_ensure_range_format_missing_step(self):
"""Test range format with missing step defaults to 1.0."""
range_data = {
'min': 1,
'max': 10
}
range_data = {"min": 1, "max": 10}
formatted = ensure_range_format(range_data)
self.assertEqual(formatted['step'], 1.0)
self.assertIsNone(formatted['unit'])
self.assertEqual(formatted["step"], 1.0)
self.assertIsNone(formatted["unit"])
class APIEndpointContractTests(APITestCase):
@@ -278,26 +263,21 @@ class TypeScriptInterfaceComplianceTests(TestCase):
# selected?: boolean;
# }
option = {
'value': 'OPERATING',
'label': 'Operating',
'count': 5,
'selected': False
}
option = {"value": "OPERATING", "label": "Operating", "count": 5, "selected": False}
# All required fields present
self.assertIn('value', option)
self.assertIn('label', option)
self.assertIn("value", option)
self.assertIn("label", option)
# Correct types
self.assertIsInstance(option['value'], str)
self.assertIsInstance(option['label'], str)
self.assertIsInstance(option["value"], str)
self.assertIsInstance(option["label"], str)
# Optional fields have correct types if present
if 'count' in option and option['count'] is not None:
self.assertIsInstance(option['count'], int)
if 'selected' in option:
self.assertIsInstance(option['selected'], bool)
if "count" in option and option["count"] is not None:
self.assertIsInstance(option["count"], int)
if "selected" in option:
self.assertIsInstance(option["selected"], bool)
def test_filter_range_interface_compliance(self):
"""Test FilterRange interface compliance."""
@@ -309,29 +289,24 @@ class TypeScriptInterfaceComplianceTests(TestCase):
# unit?: string;
# }
range_data = {
'min': 1.0,
'max': 10.0,
'step': 0.1,
'unit': 'stars'
}
range_data = {"min": 1.0, "max": 10.0, "step": 0.1, "unit": "stars"}
# All required fields present
self.assertIn('min', range_data)
self.assertIn('max', range_data)
self.assertIn('step', range_data)
self.assertIn("min", range_data)
self.assertIn("max", range_data)
self.assertIn("step", range_data)
# Correct types (min/max can be null)
if range_data['min'] is not None:
self.assertIsInstance(range_data['min'], (int, float))
if range_data['max'] is not None:
self.assertIsInstance(range_data['max'], (int, float))
if range_data["min"] is not None:
self.assertIsInstance(range_data["min"], (int, float))
if range_data["max"] is not None:
self.assertIsInstance(range_data["max"], (int, float))
self.assertIsInstance(range_data['step'], (int, float))
self.assertIsInstance(range_data["step"], (int, float))
# Optional unit field
if 'unit' in range_data and range_data['unit'] is not None:
self.assertIsInstance(range_data['unit'], str)
if "unit" in range_data and range_data["unit"] is not None:
self.assertIsInstance(range_data["unit"], str)
class RegressionTests(TestCase):
@@ -345,7 +320,7 @@ class RegressionTests(TestCase):
# Test parks
parks_metadata = smart_park_loader.get_filter_metadata()
categorical = parks_metadata.get('categorical', {})
categorical = parks_metadata.get("categorical", {})
for filter_name, filter_options in categorical.items():
with self.subTest(filter_name=filter_name):
@@ -353,19 +328,25 @@ class RegressionTests(TestCase):
for i, option in enumerate(filter_options):
with self.subTest(filter_name=filter_name, option_index=i):
self.assertIsInstance(option, dict,
self.assertIsInstance(
option,
dict,
f"REGRESSION: Filter '{filter_name}' option {i} is a {type(option).__name__} "
f"but should be a dict. This causes frontend crashes!")
f"but should be a dict. This causes frontend crashes!",
)
# Must not be a string
self.assertNotIsInstance(option, str,
self.assertNotIsInstance(
option,
str,
f"CRITICAL REGRESSION: Filter '{filter_name}' option {i} is a string '{option}' "
f"but frontend expects object with value/label/count properties!")
f"but frontend expects object with value/label/count properties!",
)
# Test rides
rides_loader = SmartRideLoader()
rides_metadata = rides_loader.get_filter_metadata()
categorical = rides_metadata.get('categorical', {})
categorical = rides_metadata.get("categorical", {})
for filter_name, filter_options in categorical.items():
with self.subTest(filter_name=f"rides_{filter_name}"):
@@ -373,9 +354,12 @@ class RegressionTests(TestCase):
for i, option in enumerate(filter_options):
with self.subTest(filter_name=f"rides_{filter_name}", option_index=i):
self.assertIsInstance(option, dict,
self.assertIsInstance(
option,
dict,
f"REGRESSION: Rides filter '{filter_name}' option {i} is a {type(option).__name__} "
f"but should be a dict. This causes frontend crashes!")
f"but should be a dict. This causes frontend crashes!",
)
def test_ranges_have_step_and_unit(self):
"""Regression test: Ensure ranges have step and unit properties."""
@@ -383,18 +367,15 @@ class RegressionTests(TestCase):
# Backend was sometimes missing step and unit
parks_metadata = smart_park_loader.get_filter_metadata()
ranges = parks_metadata.get('ranges', {})
ranges = parks_metadata.get("ranges", {})
for range_name, range_data in ranges.items():
with self.subTest(range_name=range_name):
self.assertIn('step', range_data,
f"Range '{range_name}' missing 'step' property required by frontend")
self.assertIn('unit', range_data,
f"Range '{range_name}' missing 'unit' property required by frontend")
self.assertIn("step", range_data, f"Range '{range_name}' missing 'step' property required by frontend")
self.assertIn("unit", range_data, f"Range '{range_name}' missing 'unit' property required by frontend")
# Step should be a number
self.assertIsInstance(range_data['step'], (int, float),
f"Range '{range_name}' step should be a number")
self.assertIsInstance(range_data["step"], (int, float), f"Range '{range_name}' step should be a number")
def test_no_undefined_values(self):
"""Regression test: Ensure no undefined values (should be null)."""

View File

@@ -54,9 +54,8 @@ except ImportError:
# Type hint for the mixin
if TYPE_CHECKING:
from typing import Union
TurnstileMixinType = Union[type[FallbackTurnstileMixin], Any]
TurnstileMixinType = type[FallbackTurnstileMixin] | Any
else:
TurnstileMixinType = TurnstileMixin
@@ -87,11 +86,9 @@ class LoginAPIView(TurnstileMixin, APIView): # type: ignore[misc]
# Validate Turnstile if configured
self.validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
serializer = LoginInputSerializer(
data=request.data, context={"request": request}
)
serializer = LoginInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
# The serializer handles authentication validation
user = serializer.validated_data["user"] # type: ignore[index]
@@ -106,7 +103,7 @@ class LoginAPIView(TurnstileMixin, APIView): # type: ignore[misc]
{
"token": token.key,
"user": user,
"message": "Login successful",
"detail": "Login successful",
}
)
return Response(response_serializer.data)
@@ -138,7 +135,7 @@ class SignupAPIView(TurnstileMixin, APIView): # type: ignore[misc]
# Validate Turnstile if configured
self.validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
serializer = SignupInputSerializer(data=request.data)
if serializer.is_valid():
@@ -152,7 +149,7 @@ class SignupAPIView(TurnstileMixin, APIView): # type: ignore[misc]
{
"token": token.key,
"user": user,
"message": "Registration successful",
"detail": "Registration successful",
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@@ -186,14 +183,10 @@ class LogoutAPIView(APIView):
# Logout from session
logout(request._request) # type: ignore[attr-defined]
response_serializer = LogoutOutputSerializer(
{"message": "Logout successful"}
)
response_serializer = LogoutOutputSerializer({"detail": "Logout successful"})
return Response(response_serializer.data)
except Exception:
return Response(
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"detail": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@extend_schema_view(
@@ -237,15 +230,11 @@ class PasswordResetAPIView(APIView):
serializer_class = PasswordResetInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordResetInputSerializer(
data=request.data, context={"request": request}
)
serializer = PasswordResetInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save()
response_serializer = PasswordResetOutputSerializer(
{"detail": "Password reset email sent"}
)
response_serializer = PasswordResetOutputSerializer({"detail": "Password reset email sent"})
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -271,15 +260,11 @@ class PasswordChangeAPIView(APIView):
serializer_class = PasswordChangeInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordChangeInputSerializer(
data=request.data, context={"request": request}
)
serializer = PasswordChangeInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save()
response_serializer = PasswordChangeOutputSerializer(
{"detail": "Password changed successfully"}
)
response_serializer = PasswordChangeOutputSerializer({"detail": "Password changed successfully"})
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -338,9 +323,7 @@ class SocialProvidersAPIView(APIView):
provider_name = social_app.name or social_app.provider.title()
# Build auth URL efficiently
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
auth_url = request.build_absolute_uri(f"/accounts/{social_app.provider}/login/")
providers_list.append(
{
@@ -370,13 +353,9 @@ class SocialProvidersAPIView(APIView):
"status": "error",
"error": {
"code": "SOCIAL_PROVIDERS_ERROR",
"message": "Unable to retrieve social providers",
"detail": "Unable to retrieve social providers",
"details": str(e) if str(e) else None,
"request_user": (
str(request.user)
if hasattr(request, "user")
else "AnonymousUser"
),
"request_user": (str(request.user) if hasattr(request, "user") else "AnonymousUser"),
},
"data": None,
},

View File

@@ -39,7 +39,7 @@ class ContractCompliantAPIView(APIView):
response = super().dispatch(request, *args, **kwargs)
# Validate contract in DEBUG mode
if settings.DEBUG and hasattr(response, 'data'):
if settings.DEBUG and hasattr(response, "data"):
self._validate_response_contract(response.data)
return response
@@ -49,19 +49,18 @@ class ContractCompliantAPIView(APIView):
logger.error(
f"API error in {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'request_path': request.path,
'request_method': request.method,
'user': getattr(request, 'user', None),
'error': str(e)
"view_class": self.__class__.__name__,
"request_path": request.path,
"request_method": request.method,
"user": getattr(request, "user", None),
"detail": str(e),
},
exc_info=True
exc_info=True,
)
# Return standardized error response
return self.error_response(
message="An internal error occurred",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
message="An internal error occurred", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def success_response(
@@ -69,7 +68,7 @@ class ContractCompliantAPIView(APIView):
data: Any = None,
message: str = None,
status_code: int = status.HTTP_200_OK,
headers: dict[str, str] = None
headers: dict[str, str] = None,
) -> Response:
"""
Create a standardized success response.
@@ -83,21 +82,15 @@ class ContractCompliantAPIView(APIView):
Returns:
Response with standardized format
"""
response_data = {
'success': True
}
response_data = {"success": True}
if data is not None:
response_data['data'] = data
response_data["data"] = data
if message:
response_data['message'] = message
response_data["message"] = message
return Response(
response_data,
status=status_code,
headers=headers
)
return Response(response_data, status=status_code, headers=headers)
def error_response(
self,
@@ -105,7 +98,7 @@ class ContractCompliantAPIView(APIView):
status_code: int = status.HTTP_400_BAD_REQUEST,
error_code: str = None,
details: Any = None,
headers: dict[str, str] = None
headers: dict[str, str] = None,
) -> Response:
"""
Create a standardized error response.
@@ -120,37 +113,22 @@ class ContractCompliantAPIView(APIView):
Returns:
Response with standardized error format
"""
error_data = {
'code': error_code or 'API_ERROR',
'message': message
}
error_data = {"code": error_code or "API_ERROR", "message": message}
if details:
error_data['details'] = details
error_data["details"] = details
# Add user context if available
if hasattr(self, 'request') and hasattr(self.request, 'user'):
if hasattr(self, "request") and hasattr(self.request, "user"):
user = self.request.user
if user and user.is_authenticated:
error_data['request_user'] = user.username
error_data["request_user"] = user.username
response_data = {
'status': 'error',
'error': error_data,
'data': None
}
response_data = {"status": "error", "error": error_data, "data": None}
return Response(
response_data,
status=status_code,
headers=headers
)
return Response(response_data, status=status_code, headers=headers)
def validation_error_response(
self,
errors: dict[str, Any],
message: str = "Validation failed"
) -> Response:
def validation_error_response(self, errors: dict[str, Any], message: str = "Validation failed") -> Response:
"""
Create a standardized validation error response.
@@ -161,14 +139,7 @@ class ContractCompliantAPIView(APIView):
Returns:
Response with validation errors
"""
return Response(
{
'success': False,
'message': message,
'errors': errors
},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"success": False, "message": message, "errors": errors}, status=status.HTTP_400_BAD_REQUEST)
def _validate_response_contract(self, data: Any) -> None:
"""
@@ -179,7 +150,7 @@ class ContractCompliantAPIView(APIView):
"""
try:
# Check if this looks like filter metadata
if isinstance(data, dict) and 'categorical' in data and 'ranges' in data:
if isinstance(data, dict) and "categorical" in data and "ranges" in data:
validate_filter_metadata_contract(data)
# Add more contract validations as needed
@@ -188,10 +159,10 @@ class ContractCompliantAPIView(APIView):
logger.warning(
f"Contract validation failed in {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'validation_error': str(e),
'response_data_type': type(data).__name__
}
"view_class": self.__class__.__name__,
"validation_error": str(e),
"response_data_type": type(data).__name__,
},
)
@@ -225,17 +196,11 @@ class FilterMetadataAPIView(ContractCompliantAPIView):
except Exception as e:
logger.error(
f"Error getting filter metadata in {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'error': str(e)
},
exc_info=True
extra={"view_class": self.__class__.__name__, "detail": str(e)},
exc_info=True,
)
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")
class HybridFilteringAPIView(ContractCompliantAPIView):
@@ -276,17 +241,14 @@ class HybridFilteringAPIView(ContractCompliantAPIView):
logger.error(
f"Error in hybrid filtering for {self.__class__.__name__}: {str(e)}",
extra={
'view_class': self.__class__.__name__,
'filters': getattr(self, '_extracted_filters', {}),
'error': str(e)
"view_class": self.__class__.__name__,
"filters": getattr(self, "_extracted_filters", {}),
"detail": str(e),
},
exc_info=True
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")
def extract_filters(self, request) -> dict[str, Any]:
"""
@@ -313,19 +275,19 @@ class HybridFilteringAPIView(ContractCompliantAPIView):
def _validate_hybrid_response(self, data: dict[str, Any]) -> None:
"""Validate hybrid response structure."""
required_fields = ['strategy', 'total_count']
required_fields = ["strategy", "total_count"]
for field in required_fields:
if field not in data:
raise ValueError(f"Hybrid response missing required field: {field}")
# Validate strategy value
if data['strategy'] not in ['client_side', 'server_side']:
if data["strategy"] not in ["client_side", "server_side"]:
raise ValueError(f"Invalid strategy value: {data['strategy']}")
# Validate filter metadata if present
if 'filter_metadata' in data:
validate_filter_metadata_contract(data['filter_metadata'])
if "filter_metadata" in data:
validate_filter_metadata_contract(data["filter_metadata"])
class PaginatedAPIView(ContractCompliantAPIView):
@@ -340,11 +302,7 @@ class PaginatedAPIView(ContractCompliantAPIView):
max_page_size = 100
def get_paginated_response(
self,
queryset,
serializer_class: type[Serializer],
request,
page_size: int = None
self, queryset, serializer_class: type[Serializer], request, page_size: int = None
) -> Response:
"""
Create a paginated response.
@@ -362,13 +320,10 @@ class PaginatedAPIView(ContractCompliantAPIView):
# Determine page size
if page_size is None:
page_size = min(
int(request.query_params.get('page_size', self.default_page_size)),
self.max_page_size
)
page_size = min(int(request.query_params.get("page_size", self.default_page_size)), self.max_page_size)
# Get page number
page_number = request.query_params.get('page', 1)
page_number = request.query_params.get("page", 1)
try:
page_number = int(page_number)
@@ -389,28 +344,28 @@ class PaginatedAPIView(ContractCompliantAPIView):
serializer = serializer_class(page.object_list, many=True)
# Build pagination URLs
request_url = request.build_absolute_uri().split('?')[0]
request_url = request.build_absolute_uri().split("?")[0]
query_params = request.query_params.copy()
next_url = None
if page.has_next():
query_params['page'] = page.next_page_number()
query_params["page"] = page.next_page_number()
next_url = f"{request_url}?{query_params.urlencode()}"
previous_url = None
if page.has_previous():
query_params['page'] = page.previous_page_number()
query_params["page"] = page.previous_page_number()
previous_url = f"{request_url}?{query_params.urlencode()}"
# Create response data
response_data = {
'count': paginator.count,
'next': next_url,
'previous': previous_url,
'results': serializer.data,
'page_size': page_size,
'current_page': page.number,
'total_pages': paginator.num_pages
"count": paginator.count,
"next": next_url,
"previous": previous_url,
"results": serializer.data,
"page_size": page_size,
"current_page": page.number,
"total_pages": paginator.num_pages,
}
return self.success_response(response_data)
@@ -430,29 +385,23 @@ def contract_compliant_view(view_class):
response = original_dispatch(self, request, *args, **kwargs)
# Add contract validation in DEBUG mode
if settings.DEBUG and hasattr(response, 'data'):
if settings.DEBUG and hasattr(response, "data"):
# Basic validation - can be extended
pass
return response
except Exception as e:
logger.error(
f"Error in decorated view {view_class.__name__}: {str(e)}",
exc_info=True
)
logger.error(f"Error in decorated view {view_class.__name__}: {str(e)}", exc_info=True)
# Return basic error response
return Response(
{
'status': 'error',
'error': {
'code': 'API_ERROR',
'message': 'An internal error occurred'
},
'data': None
"status": "error",
"error": {"code": "API_ERROR", "detail": "An internal error occurred"},
"data": None,
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
view_class.dispatch = new_dispatch

View File

@@ -1,4 +1,3 @@
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import AllowAny
@@ -13,6 +12,7 @@ class DiscoveryAPIView(APIView):
"""
API endpoint for discovery content (Top Lists, Opening/Closing Soon).
"""
permission_classes = [AllowAny]
@extend_schema(
@@ -68,7 +68,7 @@ class DiscoveryAPIView(APIView):
"recently_closed": {
"parks": self._serialize(recently_closed_parks, "park"),
"rides": self._serialize(recently_closed_rides, "ride"),
}
},
}
return Response(data)
@@ -83,14 +83,13 @@ class DiscoveryAPIView(APIView):
"average_rating": item.average_rating,
}
if type_ == "park":
data.update({
"city": item.location.city if item.location else None,
"state": item.location.state if item.location else None,
})
data.update(
{
"city": item.location.city if item.location else None,
"state": item.location.state if item.location else None,
}
)
elif type_ == "ride":
data.update({
"park_name": item.park.name,
"park_slug": item.park.slug
})
data.update({"park_name": item.park.name, "park_slug": item.park.slug})
results.append(data)
return results

View File

@@ -30,7 +30,7 @@ class FallbackCacheMonitor:
"""Fallback class if CacheMonitor is not available."""
def get_cache_stats(self):
return {"error": "Cache monitoring not available"}
return {"detail": "Cache monitoring not available"}
class FallbackIndexAnalyzer:
@@ -38,7 +38,7 @@ class FallbackIndexAnalyzer:
@staticmethod
def analyze_slow_queries(threshold):
return {"error": "Query analysis not available"}
return {"detail": "Query analysis not available"}
# Try to import the real classes, use fallbacks if not available
@@ -56,9 +56,7 @@ except ImportError:
@extend_schema_view(
get=extend_schema(
summary="Health check",
description=(
"Get comprehensive health check information including system metrics."
),
description=("Get comprehensive health check information including system metrics."),
responses={
200: HealthCheckOutputSerializer,
503: HealthCheckOutputSerializer,
@@ -88,7 +86,7 @@ class HealthCheckAPIView(APIView):
cache_monitor = CacheMonitor()
cache_stats = cache_monitor.get_cache_stats()
except Exception:
cache_stats = {"error": "Cache monitoring unavailable"}
cache_stats = {"detail": "Cache monitoring unavailable"}
# Build comprehensive health data
health_data = {
@@ -120,9 +118,7 @@ class HealthCheckAPIView(APIView):
critical_service = False
response_time = None
plugin_errors = (
errors.get(plugin_class_name, []) if isinstance(errors, dict) else []
)
plugin_errors = errors.get(plugin_class_name, []) if isinstance(errors, dict) else []
health_data["checks"][plugin_name] = {
"status": "healthy" if not plugin_errors else "unhealthy",
@@ -194,9 +190,7 @@ class HealthCheckAPIView(APIView):
"transactions_committed": row[1],
"transactions_rolled_back": row[2],
"cache_hit_ratio": (
round((row[4] / (row[3] + row[4])) * 100, 2)
if (row[3] + row[4]) > 0
else 0
round((row[4] / (row[3] + row[4])) * 100, 2) if (row[3] + row[4]) > 0 else 0
),
}
)
@@ -206,7 +200,7 @@ class HealthCheckAPIView(APIView):
return metrics
except Exception as e:
return {"connection_status": "error", "error": str(e)}
return {"connection_status": "error", "detail": str(e)}
def _get_system_metrics(self) -> dict:
"""Get system performance metrics."""
@@ -270,7 +264,7 @@ class PerformanceMetricsAPIView(APIView):
def get(self, request: Request) -> Response:
"""Return performance metrics and analysis."""
if not settings.DEBUG:
return Response({"error": "Only available in debug mode"}, status=403)
return Response({"detail": "Only available in debug mode"}, status=403)
metrics = {
"timestamp": timezone.now(),
@@ -306,7 +300,7 @@ class PerformanceMetricsAPIView(APIView):
return analysis
except Exception as e:
return {"error": str(e)}
return {"detail": str(e)}
def _get_cache_performance(self):
"""Get cache performance metrics."""
@@ -314,14 +308,14 @@ class PerformanceMetricsAPIView(APIView):
cache_monitor = CacheMonitor()
return cache_monitor.get_cache_stats()
except Exception as e:
return {"error": str(e)}
return {"detail": str(e)}
def _get_slow_queries(self):
"""Get recent slow queries."""
try:
return IndexAnalyzer.analyze_slow_queries(0.1) # 100ms threshold
except Exception as e:
return {"error": str(e)}
return {"detail": str(e)}
@extend_schema_view(
@@ -336,9 +330,7 @@ class PerformanceMetricsAPIView(APIView):
),
options=extend_schema(
summary="CORS preflight for simple health check",
description=(
"Handle CORS preflight requests for the simple health check endpoint."
),
description=("Handle CORS preflight requests for the simple health check endpoint."),
responses={
200: SimpleHealthOutputSerializer,
},
@@ -370,7 +362,7 @@ class SimpleHealthAPIView(APIView):
except Exception as e:
response_data = {
"status": "error",
"error": str(e),
"detail": str(e),
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)

View File

@@ -1,6 +1,7 @@
"""
Leaderboard views for user rankings
"""
from datetime import timedelta
from django.db.models import Count, Sum
@@ -15,7 +16,7 @@ from apps.reviews.models import Review
from apps.rides.models import RideCredit
@api_view(['GET'])
@api_view(["GET"])
@permission_classes([AllowAny])
def leaderboard(request):
"""
@@ -26,25 +27,25 @@ def leaderboard(request):
- period: 'all' | 'monthly' | 'weekly' (default: all)
- limit: int (default: 25, max: 100)
"""
category = request.query_params.get('category', 'credits')
period = request.query_params.get('period', 'all')
limit = min(int(request.query_params.get('limit', 25)), 100)
category = request.query_params.get("category", "credits")
period = request.query_params.get("period", "all")
limit = min(int(request.query_params.get("limit", 25)), 100)
# Calculate date filter based on period
date_filter = None
if period == 'weekly':
if period == "weekly":
date_filter = timezone.now() - timedelta(days=7)
elif period == 'monthly':
elif period == "monthly":
date_filter = timezone.now() - timedelta(days=30)
if category == 'credits':
if category == "credits":
return _get_credits_leaderboard(date_filter, limit)
elif category == 'reviews':
elif category == "reviews":
return _get_reviews_leaderboard(date_filter, limit)
elif category == 'contributions':
elif category == "contributions":
return _get_contributions_leaderboard(date_filter, limit)
else:
return Response({'error': 'Invalid category'}, status=400)
return Response({"detail": "Invalid category"}, status=400)
def _get_credits_leaderboard(date_filter, limit):
@@ -55,26 +56,34 @@ def _get_credits_leaderboard(date_filter, limit):
queryset = queryset.filter(created_at__gte=date_filter)
# Aggregate credits per user
users_data = queryset.values('user_id', 'user__username', 'user__display_name').annotate(
total_credits=Coalesce(Sum('count'), 0),
unique_rides=Count('ride', distinct=True),
).order_by('-total_credits')[:limit]
users_data = (
queryset.values("user_id", "user__username", "user__display_name")
.annotate(
total_credits=Coalesce(Sum("count"), 0),
unique_rides=Count("ride", distinct=True),
)
.order_by("-total_credits")[:limit]
)
results = []
for rank, entry in enumerate(users_data, 1):
results.append({
'rank': rank,
'user_id': entry['user_id'],
'username': entry['user__username'],
'display_name': entry['user__display_name'] or entry['user__username'],
'total_credits': entry['total_credits'],
'unique_rides': entry['unique_rides'],
})
results.append(
{
"rank": rank,
"user_id": entry["user_id"],
"username": entry["user__username"],
"display_name": entry["user__display_name"] or entry["user__username"],
"total_credits": entry["total_credits"],
"unique_rides": entry["unique_rides"],
}
)
return Response({
'category': 'credits',
'results': results,
})
return Response(
{
"category": "credits",
"results": results,
}
)
def _get_reviews_leaderboard(date_filter, limit):
@@ -85,49 +94,65 @@ def _get_reviews_leaderboard(date_filter, limit):
queryset = queryset.filter(created_at__gte=date_filter)
# Count reviews per user
users_data = queryset.values('user_id', 'user__username', 'user__display_name').annotate(
review_count=Count('id'),
).order_by('-review_count')[:limit]
users_data = (
queryset.values("user_id", "user__username", "user__display_name")
.annotate(
review_count=Count("id"),
)
.order_by("-review_count")[:limit]
)
results = []
for rank, entry in enumerate(users_data, 1):
results.append({
'rank': rank,
'user_id': entry['user_id'],
'username': entry['user__username'],
'display_name': entry['user__display_name'] or entry['user__username'],
'review_count': entry['review_count'],
})
results.append(
{
"rank": rank,
"user_id": entry["user_id"],
"username": entry["user__username"],
"display_name": entry["user__display_name"] or entry["user__username"],
"review_count": entry["review_count"],
}
)
return Response({
'category': 'reviews',
'results': results,
})
return Response(
{
"category": "reviews",
"results": results,
}
)
def _get_contributions_leaderboard(date_filter, limit):
"""Top users by approved contributions."""
queryset = EditSubmission.objects.filter(status='approved')
queryset = EditSubmission.objects.filter(status="approved")
if date_filter:
queryset = queryset.filter(created_at__gte=date_filter)
# Count contributions per user
users_data = queryset.values('submitted_by_id', 'submitted_by__username', 'submitted_by__display_name').annotate(
contribution_count=Count('id'),
).order_by('-contribution_count')[:limit]
users_data = (
queryset.values("submitted_by_id", "submitted_by__username", "submitted_by__display_name")
.annotate(
contribution_count=Count("id"),
)
.order_by("-contribution_count")[:limit]
)
results = []
for rank, entry in enumerate(users_data, 1):
results.append({
'rank': rank,
'user_id': entry['submitted_by_id'],
'username': entry['submitted_by__username'],
'display_name': entry['submitted_by__display_name'] or entry['submitted_by__username'],
'contribution_count': entry['contribution_count'],
})
results.append(
{
"rank": rank,
"user_id": entry["submitted_by_id"],
"username": entry["submitted_by__username"],
"display_name": entry["submitted_by__display_name"] or entry["submitted_by__username"],
"contribution_count": entry["contribution_count"],
}
)
return Response({
'category': 'contributions',
'results': results,
})
return Response(
{
"category": "contributions",
"results": results,
}
)

View File

@@ -186,21 +186,13 @@ class StatsAPIView(APIView):
total_rides = Ride.objects.count()
# Company counts by role
total_manufacturers = RideCompany.objects.filter(
roles__contains=["MANUFACTURER"]
).count()
total_manufacturers = RideCompany.objects.filter(roles__contains=["MANUFACTURER"]).count()
total_operators = ParkCompany.objects.filter(
roles__contains=["OPERATOR"]
).count()
total_operators = ParkCompany.objects.filter(roles__contains=["OPERATOR"]).count()
total_designers = RideCompany.objects.filter(
roles__contains=["DESIGNER"]
).count()
total_designers = RideCompany.objects.filter(roles__contains=["DESIGNER"]).count()
total_property_owners = ParkCompany.objects.filter(
roles__contains=["PROPERTY_OWNER"]
).count()
total_property_owners = ParkCompany.objects.filter(roles__contains=["PROPERTY_OWNER"]).count()
# Photo counts (combined)
total_park_photos = ParkPhoto.objects.count()
@@ -211,11 +203,7 @@ class StatsAPIView(APIView):
total_roller_coasters = RollerCoasterStats.objects.count()
# Ride category counts
ride_categories = (
Ride.objects.values("category")
.annotate(count=Count("id"))
.exclude(category="")
)
ride_categories = Ride.objects.values("category").annotate(count=Count("id")).exclude(category="")
category_stats = {}
for category in ride_categories:
@@ -232,9 +220,7 @@ class StatsAPIView(APIView):
"OT": "other_rides",
}
category_name = category_names.get(
category_code, f"category_{category_code.lower()}"
)
category_name = category_names.get(category_code, f"category_{category_code.lower()}")
category_stats[category_name] = category_count
# Park status counts
@@ -281,9 +267,7 @@ class StatsAPIView(APIView):
"RELOCATED": "relocated_rides",
}
status_name = status_names.get(
status_code, f"ride_status_{status_code.lower()}"
)
status_name = status_names.get(status_code, f"ride_status_{status_code.lower()}")
ride_status_stats[status_name] = status_count
# Review counts
@@ -365,7 +349,7 @@ class StatsRecalculateAPIView(APIView):
# Return success response with the fresh stats
return Response(
{
"message": "Platform statistics have been successfully recalculated",
"detail": "Platform statistics have been successfully recalculated",
"stats": fresh_stats,
"recalculated_at": timezone.now().isoformat(),
},

View File

@@ -127,18 +127,14 @@ class TriggerTrendingCalculationAPIView(APIView):
try:
# Run trending calculation command
with redirect_stdout(trending_output), redirect_stderr(trending_output):
call_command(
"calculate_trending", "--content-type=all", "--limit=50"
)
call_command("calculate_trending", "--content-type=all", "--limit=50")
trending_completed = True
except Exception as e:
trending_output.write(f"Error: {str(e)}")
try:
# Run new content calculation command
with redirect_stdout(new_content_output), redirect_stderr(
new_content_output
):
with redirect_stdout(new_content_output), redirect_stderr(new_content_output):
call_command(
"calculate_new_content",
"--content-type=all",
@@ -153,7 +149,7 @@ class TriggerTrendingCalculationAPIView(APIView):
return Response(
{
"message": "Trending content calculation completed",
"detail": "Trending content calculation completed",
"trending_completed": trending_completed,
"new_content_completed": new_content_completed,
"completion_time": completion_time,
@@ -166,7 +162,7 @@ class TriggerTrendingCalculationAPIView(APIView):
except Exception as e:
return Response(
{
"error": "Failed to trigger trending content calculation",
"detail": "Failed to trigger trending content calculation",
"details": str(e),
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -213,9 +209,7 @@ class NewContentAPIView(APIView):
days_back = min(int(request.query_params.get("days", 30)), 365)
# Get new content using direct calculation service
all_new_content = trending_service.get_new_content(
limit=limit * 2, days_back=days_back
)
all_new_content = trending_service.get_new_content(limit=limit * 2, days_back=days_back)
recently_added = []
newly_opened = []

View File

@@ -26,7 +26,7 @@ class FallbackCacheMonitor:
"""Fallback class if CacheMonitor is not available."""
def get_cache_stats(self):
return {"error": "Cache monitoring not available"}
return {"detail": "Cache monitoring not available"}
class FallbackIndexAnalyzer:
@@ -34,7 +34,7 @@ class FallbackIndexAnalyzer:
@staticmethod
def analyze_slow_queries(threshold):
return {"error": "Query analysis not available"}
return {"detail": "Query analysis not available"}
# Try to import the real classes, use fallbacks if not available

View File

@@ -155,11 +155,7 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
from apps.rides.models import RankingSnapshot
ranking = self.get_object()
history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by(
"-snapshot_date"
)[
:90
] # Last 3 months
history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by("-snapshot_date")[:90] # Last 3 months
serializer = self.get_serializer(history, many=True)
return Response(serializer.data)
@@ -180,11 +176,7 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
top_rated = RideRanking.objects.select_related("ride", "ride__park").first()
# Get most compared ride
most_compared = (
RideRanking.objects.select_related("ride", "ride__park")
.order_by("-comparison_count")
.first()
)
most_compared = RideRanking.objects.select_related("ride", "ride__park").order_by("-comparison_count").first()
# Get biggest rank change (last 7 days)
from datetime import timedelta
@@ -197,9 +189,7 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
current_rankings = RideRanking.objects.select_related("ride")
for ranking in current_rankings[:100]: # Check top 100 for performance
old_snapshot = (
RankingSnapshot.objects.filter(
ride=ranking.ride, snapshot_date__lte=week_ago
)
RankingSnapshot.objects.filter(ride=ranking.ride, snapshot_date__lte=week_ago)
.order_by("-snapshot_date")
.first()
)
@@ -232,11 +222,7 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
"park": top_rated.ride.park.name,
"rank": top_rated.rank,
"winning_percentage": float(top_rated.winning_percentage),
"average_rating": (
float(top_rated.average_rating)
if top_rated.average_rating
else None
),
"average_rating": (float(top_rated.average_rating) if top_rated.average_rating else None),
}
if top_rated
else None
@@ -272,9 +258,7 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
ranking = self.get_object()
comparisons = (
RidePairComparison.objects.filter(
Q(ride_a=ranking.ride) | Q(ride_b=ranking.ride)
)
RidePairComparison.objects.filter(Q(ride_a=ranking.ride) | Q(ride_b=ranking.ride))
.select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park")
.order_by("-mutual_riders_count")[:50]
)
@@ -309,16 +293,8 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
"ties": comp.ties,
"result": result,
"mutual_riders": comp.mutual_riders_count,
"ride_a_avg_rating": (
float(comp.ride_a_avg_rating)
if comp.ride_a_avg_rating
else None
),
"ride_b_avg_rating": (
float(comp.ride_b_avg_rating)
if comp.ride_b_avg_rating
else None
),
"ride_a_avg_rating": (float(comp.ride_a_avg_rating) if comp.ride_a_avg_rating else None),
"ride_b_avg_rating": (float(comp.ride_b_avg_rating) if comp.ride_b_avg_rating else None),
}
)
@@ -345,9 +321,7 @@ class TriggerRankingCalculationView(APIView):
def post(self, request):
"""Trigger ranking calculation."""
if not request.user.is_staff:
return Response(
{"error": "Admin access required"}, status=status.HTTP_403_FORBIDDEN
)
return Response({"detail": "Admin access required"}, status=status.HTTP_403_FORBIDDEN)
# Replace direct import with a guarded runtime import to avoid static-analysis/initialization errors
try:
@@ -367,7 +341,7 @@ class TriggerRankingCalculationView(APIView):
if not RideRankingService:
return Response(
{"error": "Ranking service unavailable"},
{"detail": "Ranking service unavailable"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)