mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-01-01 22:07:03 -05:00
feat: Implement initial schema and add various API, service, and management command enhancements across the application.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
167
backend/apps/api/v1/responses.py
Normal file
167
backend/apps/api/v1/responses.py
Normal 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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>/",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -49,5 +49,4 @@ __all__ = (
|
||||
"UserProfileCreateInputSerializer",
|
||||
"UserProfileUpdateInputSerializer",
|
||||
"UserProfileOutputSerializer",
|
||||
|
||||
)
|
||||
|
||||
@@ -90,7 +90,6 @@ _ACCOUNTS_SYMBOLS: list[str] = [
|
||||
"UserProfileOutputSerializer",
|
||||
"UserProfileCreateInputSerializer",
|
||||
"UserProfileUpdateInputSerializer",
|
||||
|
||||
"UserOutputSerializer",
|
||||
"LoginInputSerializer",
|
||||
"LoginOutputSerializer",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
)
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user