feat: Refactor rides app with unique constraints, mixins, and enhanced documentation

- Added migration to convert unique_together constraints to UniqueConstraint for RideModel.
- Introduced RideFormMixin for handling entity suggestions in ride forms.
- Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements.
- Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling.
- Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples.
- Implemented a benchmarking script for query performance analysis and optimization.
- Developed security documentation detailing measures, configurations, and a security checklist.
- Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields.
This commit is contained in:
pacnpal
2025-12-22 11:17:31 -05:00
parent 45d97b6e68
commit 2e35f8c5d9
71 changed files with 8036 additions and 1462 deletions

View File

@@ -5,34 +5,44 @@ This module contains consolidated park photo viewset for the centralized API str
Enhanced from rogue implementation to maintain full feature parity.
"""
from .serializers import (
ParkPhotoOutputSerializer,
ParkPhotoCreateInputSerializer,
ParkPhotoUpdateInputSerializer,
ParkPhotoListOutputSerializer,
ParkPhotoApprovalInputSerializer,
ParkPhotoStatsOutputSerializer,
)
from typing import Any, cast
import logging
from typing import Any, cast
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from apps.parks.models import ParkPhoto, Park
from apps.core.exceptions import (
NotFoundError,
PermissionDeniedError,
ServiceError,
ValidationException,
)
from apps.core.utils.error_handling import ErrorHandler
from apps.parks.models import Park, ParkPhoto
from apps.parks.services import ParkMediaService
from django.contrib.auth import get_user_model
from apps.parks.services.hybrid_loader import smart_park_loader
UserModel = get_user_model()
from .serializers import (
HybridParkSerializer,
ParkPhotoApprovalInputSerializer,
ParkPhotoCreateInputSerializer,
ParkPhotoListOutputSerializer,
ParkPhotoOutputSerializer,
ParkPhotoStatsOutputSerializer,
ParkPhotoUpdateInputSerializer,
)
logger = logging.getLogger(__name__)
UserModel = get_user_model()
@extend_schema_view(
@@ -113,7 +123,7 @@ class ParkPhotoViewSet(ModelViewSet):
def get_permissions(self):
"""Set permissions based on action."""
if self.action in ['list', 'retrieve', 'stats']:
if self.action in ["list", "retrieve", "stats"]:
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
@@ -166,8 +176,11 @@ class ParkPhotoViewSet(ModelViewSet):
# Set the instance for the serializer response
serializer.instance = photo
except Exception as e:
logger.error(f"Error creating park photo: {e}")
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error creating park photo: {e}")
raise ValidationError(str(e))
except ServiceError as e:
logger.error(f"Service error creating park photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
def perform_update(self, serializer):
@@ -190,8 +203,11 @@ class ParkPhotoViewSet(ModelViewSet):
# Remove is_primary from validated_data since service handles it
if "is_primary" in serializer.validated_data:
del serializer.validated_data["is_primary"]
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error setting primary photo: {e}")
raise ValidationError(str(e))
except ServiceError as e:
logger.error(f"Service error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
def perform_destroy(self, instance):
@@ -205,25 +221,30 @@ class ParkPhotoViewSet(ModelViewSet):
"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 park photo from Cloudflare: {instance.image.cloudflare_id}")
except Exception as e:
logger.error(
f"Failed to delete park photo from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
# 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 park photo from Cloudflare: {instance.image.cloudflare_id}"
)
except ImportError:
logger.warning("CloudflareImagesService not available")
except ServiceError as e:
logger.error(f"Service error deleting from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
try:
ParkMediaService().delete_photo(
instance.id, deleted_by=cast(UserModel, self.request.user)
)
except Exception as e:
logger.error(f"Error deleting park photo: {e}")
except ServiceError as e:
logger.error(f"Service error deleting park photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
@extend_schema(
@@ -265,11 +286,18 @@ class ParkPhotoViewSet(ModelViewSet):
status=status.HTTP_200_OK,
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
return Response(
{"error": f"Failed to set primary photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error setting primary photo: {e}")
return ErrorHandler.handle_api_error(
e,
user_message="Failed to set primary photo",
status_code=status.HTTP_400_BAD_REQUEST,
)
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to set primary photo",
status_code=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
@@ -319,11 +347,18 @@ class ParkPhotoViewSet(ModelViewSet):
status=status.HTTP_200_OK,
)
except Exception as e:
logger.error(f"Error in bulk photo approval: {e}")
return Response(
{"error": f"Failed to update photos: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error in bulk photo approval: {e}")
return ErrorHandler.handle_api_error(
e,
user_message="Failed to update photos",
status_code=status.HTTP_400_BAD_REQUEST,
)
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to update photos",
status_code=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
@@ -345,9 +380,10 @@ class ParkPhotoViewSet(ModelViewSet):
try:
park = Park.objects.get(pk=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found."},
status=status.HTTP_404_NOT_FOUND,
return ErrorHandler.handle_api_error(
NotFoundError(f"Park with id {park_pk} not found"),
user_message="Park not found",
status_code=status.HTTP_404_NOT_FOUND,
)
try:
@@ -359,11 +395,11 @@ class ParkPhotoViewSet(ModelViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error getting park photo stats: {e}")
return Response(
{"error": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to get photo statistics",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Legacy compatibility action using the legacy set_primary logic
@@ -394,9 +430,19 @@ class ParkPhotoViewSet(ModelViewSet):
park_id=photo.park_id, photo_id=photo.id
)
return Response({"message": "Photo set as primary successfully."})
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)
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error in set_primary_photo: {str(e)}")
return ErrorHandler.handle_api_error(
e,
user_message="Failed to set primary photo",
status_code=status.HTTP_400_BAD_REQUEST,
)
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to set primary photo",
status_code=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
summary="Save Cloudflare image as park photo",
@@ -442,60 +488,55 @@ class ParkPhotoViewSet(ModelViewSet):
from django.utils import timezone
# Always fetch the latest image data from Cloudflare API
# Get image details from Cloudflare API
service = CloudflareImagesService()
image_data = service.get_image(cloudflare_image_id)
if not image_data:
return ErrorHandler.handle_api_error(
NotFoundError("Image not found in Cloudflare"),
user_message="Image not found in Cloudflare",
status_code=status.HTTP_400_BAD_REQUEST,
)
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
# Get image details from Cloudflare API
service = CloudflareImagesService()
image_data = service.get_image(cloudflare_image_id)
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id
)
if not image_data:
return Response(
{"error": "Image not found in Cloudflare"},
status=status.HTTP_400_BAD_REQUEST,
)
# 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.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.save()
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
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', {})
except CloudflareImage.DoesNotExist:
# Create new CloudflareImage record from API response
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=request.user,
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", {}),
# Extract variants from nested result structure
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.save()
except CloudflareImage.DoesNotExist:
# Create new CloudflareImage record from API response
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=request.user,
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', {}),
# Extract variants from nested result structure
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', ''),
)
except Exception as api_error:
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)}"},
status=status.HTTP_400_BAD_REQUEST,
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", ""),
)
# Create the park photo with the CloudflareImage reference
@@ -516,25 +557,33 @@ class ParkPhotoViewSet(ModelViewSet):
ParkMediaService().set_primary_photo(
park_id=park.id, photo_id=photo.id
)
except Exception as e:
except ServiceError as e:
logger.error(f"Error setting primary photo: {e}")
# Don't fail the entire operation, just log the error
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"Error saving park photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
except ImportError:
logger.error("CloudflareImagesService not available")
return ErrorHandler.handle_api_error(
ServiceError("Cloudflare Images service not available"),
user_message="Image upload service not available",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except (ValidationException, ValidationError) as e:
logger.warning(f"Validation error saving park photo: {e}")
return ErrorHandler.handle_api_error(
e,
user_message="Failed to save photo",
status_code=status.HTTP_400_BAD_REQUEST,
)
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to save photo",
status_code=status.HTTP_400_BAD_REQUEST,
)
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from .serializers import HybridParkSerializer
from apps.parks.services.hybrid_loader import smart_park_loader
@extend_schema_view(
@@ -542,23 +591,79 @@ from apps.parks.services.hybrid_loader import smart_park_loader
summary="Get parks with hybrid filtering",
description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
parameters=[
OpenApiParameter("status", OpenApiTypes.STR, description="Filter by park status (comma-separated for multiple)"),
OpenApiParameter("park_type", OpenApiTypes.STR, description="Filter by park type (comma-separated for multiple)"),
OpenApiParameter("country", OpenApiTypes.STR, description="Filter by country (comma-separated for multiple)"),
OpenApiParameter("state", 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("size_min", OpenApiTypes.NUMBER, description="Minimum park size in acres"),
OpenApiParameter("size_max", 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("coaster_count_min", OpenApiTypes.INT, description="Minimum coaster count"),
OpenApiParameter("coaster_count_max", OpenApiTypes.INT, description="Maximum coaster count"),
OpenApiParameter("operator", OpenApiTypes.STR, description="Filter by operator slug (comma-separated for multiple)"),
OpenApiParameter("search", OpenApiTypes.STR, description="Search query for park names, descriptions, locations, and operators"),
OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"),
OpenApiParameter(
"status",
OpenApiTypes.STR,
description="Filter by park status (comma-separated for multiple)",
),
OpenApiParameter(
"park_type",
OpenApiTypes.STR,
description="Filter by park type (comma-separated for multiple)",
),
OpenApiParameter(
"country",
OpenApiTypes.STR,
description="Filter by country (comma-separated for multiple)",
),
OpenApiParameter(
"state",
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(
"size_min",
OpenApiTypes.NUMBER,
description="Minimum park size in acres",
),
OpenApiParameter(
"size_max",
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(
"coaster_count_min",
OpenApiTypes.INT,
description="Minimum coaster count",
),
OpenApiParameter(
"coaster_count_max",
OpenApiTypes.INT,
description="Maximum coaster count",
),
OpenApiParameter(
"operator",
OpenApiTypes.STR,
description="Filter by operator slug (comma-separated for multiple)",
),
OpenApiParameter(
"search",
OpenApiTypes.STR,
description="Search query for park names, descriptions, locations, and operators",
),
OpenApiParameter(
"offset",
OpenApiTypes.INT,
description="Offset for progressive loading (server-side pagination)",
),
],
responses={
200: {
@@ -570,31 +675,33 @@ from apps.parks.services.hybrid_loader import smart_park_loader
"properties": {
"parks": {
"type": "array",
"items": {"$ref": "#/components/schemas/HybridParkSerializer"}
"items": {
"$ref": "#/components/schemas/HybridParkSerializer"
},
},
"total_count": {"type": "integer"},
"strategy": {
"type": "string",
"enum": ["client_side", "server_side"],
"description": "Filtering strategy used"
"description": "Filtering strategy used",
},
"has_more": {
"type": "boolean",
"description": "Whether more data is available for progressive loading"
"description": "Whether more data is available for progressive loading",
},
"next_offset": {
"type": "integer",
"nullable": True,
"description": "Next offset for progressive loading"
"description": "Next offset for progressive loading",
},
"filter_metadata": {
"type": "object",
"description": "Available filter options and ranges"
}
}
"description": "Available filter options and ranges",
},
},
}
}
}
},
}
},
tags=["Parks"],
@@ -603,77 +710,83 @@ from apps.parks.services.hybrid_loader import smart_park_loader
class HybridParkAPIView(APIView):
"""
Hybrid Park API View with intelligent filtering strategy.
Automatically chooses between client-side and server-side filtering
based on data size and complexity. Provides progressive loading
for large datasets and complete data for smaller sets.
"""
permission_classes = [AllowAny]
def get(self, request):
"""Get parks with hybrid filtering strategy."""
# Extract filters from query parameters
filters = self._extract_filters(request.query_params)
# Check if this is a progressive load request
offset = request.query_params.get("offset")
if offset is not None:
try:
offset = int(offset)
except ValueError:
return ErrorHandler.handle_api_error(
ValidationException("Invalid offset parameter"),
user_message="Invalid offset parameter",
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
# Extract filters from query parameters
filters = self._extract_filters(request.query_params)
# Check if this is a progressive load request
offset = request.query_params.get('offset')
if offset is not None:
try:
offset = int(offset)
# Get progressive load data
data = smart_park_loader.get_progressive_load(offset, filters)
except ValueError:
return Response(
{"error": "Invalid offset parameter"},
status=status.HTTP_400_BAD_REQUEST
)
# Get progressive load data
data = smart_park_loader.get_progressive_load(offset, filters)
else:
# Get initial load data
data = smart_park_loader.get_initial_load(filters)
# Serialize the parks data
serializer = HybridParkSerializer(data['parks'], many=True)
serializer = HybridParkSerializer(data["parks"], many=True)
# Prepare response
response_data = {
'parks': serializer.data,
'total_count': data['total_count'],
'strategy': data.get('strategy', 'server_side'),
'has_more': data.get('has_more', False),
'next_offset': data.get('next_offset'),
"parks": serializer.data,
"total_count": data["total_count"],
"strategy": data.get("strategy", "server_side"),
"has_more": data.get("has_more", False),
"next_offset": data.get("next_offset"),
}
# Include filter metadata for initial loads
if 'filter_metadata' in data:
response_data['filter_metadata'] = data['filter_metadata']
if "filter_metadata" in data:
response_data["filter_metadata"] = data["filter_metadata"]
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in HybridParkAPIView: {e}")
return Response(
{"error": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to load parks",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def _extract_filters(self, query_params):
"""Extract and parse filters from query parameters."""
filters = {}
# Handle comma-separated list parameters
list_params = ['status', 'park_type', 'country', 'state', 'operator']
list_params = ["status", "park_type", "country", "state", "operator"]
for param in list_params:
value = query_params.get(param)
if value:
filters[param] = [v.strip() for v in value.split(',') if v.strip()]
filters[param] = [v.strip() for v in value.split(",") if v.strip()]
# Handle integer parameters
int_params = [
'opening_year_min', 'opening_year_max',
'ride_count_min', 'ride_count_max',
'coaster_count_min', 'coaster_count_max'
"opening_year_min",
"opening_year_max",
"ride_count_min",
"ride_count_max",
"coaster_count_min",
"coaster_count_max",
]
for param in int_params:
value = query_params.get(param)
@@ -682,9 +795,9 @@ class HybridParkAPIView(APIView):
filters[param] = int(value)
except ValueError:
pass # Skip invalid integer values
# Handle float parameters
float_params = ['size_min', 'size_max', 'rating_min', 'rating_max']
float_params = ["size_min", "size_max", "rating_min", "rating_max"]
for param in float_params:
value = query_params.get(param)
if value:
@@ -692,12 +805,12 @@ class HybridParkAPIView(APIView):
filters[param] = float(value)
except ValueError:
pass # Skip invalid float values
# Handle search parameter
search = query_params.get('search')
search = query_params.get("search")
if search:
filters['search'] = search.strip()
filters["search"] = search.strip()
return filters
@@ -706,7 +819,11 @@ class HybridParkAPIView(APIView):
summary="Get park filter metadata",
description="Get available filter options and ranges for parks filtering.",
parameters=[
OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"),
OpenApiParameter(
"scoped",
OpenApiTypes.BOOL,
description="Whether to scope metadata to current filters",
),
],
responses={
200: {
@@ -719,21 +836,33 @@ class HybridParkAPIView(APIView):
"categorical": {
"type": "object",
"properties": {
"countries": {"type": "array", "items": {"type": "string"}},
"states": {"type": "array", "items": {"type": "string"}},
"park_types": {"type": "array", "items": {"type": "string"}},
"statuses": {"type": "array", "items": {"type": "string"}},
"countries": {
"type": "array",
"items": {"type": "string"},
},
"states": {
"type": "array",
"items": {"type": "string"},
},
"park_types": {
"type": "array",
"items": {"type": "string"},
},
"statuses": {
"type": "array",
"items": {"type": "string"},
},
"operators": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"slug": {"type": "string"}
}
}
}
}
"slug": {"type": "string"},
},
},
},
},
},
"ranges": {
"type": "object",
@@ -741,45 +870,75 @@ class HybridParkAPIView(APIView):
"opening_year": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
"min": {
"type": "integer",
"nullable": True,
},
"max": {
"type": "integer",
"nullable": True,
},
},
},
"size_acres": {
"type": "object",
"properties": {
"min": {"type": "number", "nullable": True},
"max": {"type": "number", "nullable": True}
}
"min": {
"type": "number",
"nullable": True,
},
"max": {
"type": "number",
"nullable": True,
},
},
},
"average_rating": {
"type": "object",
"properties": {
"min": {"type": "number", "nullable": True},
"max": {"type": "number", "nullable": True}
}
"min": {
"type": "number",
"nullable": True,
},
"max": {
"type": "number",
"nullable": True,
},
},
},
"ride_count": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
"min": {
"type": "integer",
"nullable": True,
},
"max": {
"type": "integer",
"nullable": True,
},
},
},
"coaster_count": {
"type": "object",
"properties": {
"min": {"type": "integer", "nullable": True},
"max": {"type": "integer", "nullable": True}
}
}
}
"min": {
"type": "integer",
"nullable": True,
},
"max": {
"type": "integer",
"nullable": True,
},
},
},
},
},
"total_count": {"type": "integer"}
}
"total_count": {"type": "integer"},
},
}
}
}
},
}
},
tags=["Parks"],
@@ -788,35 +947,34 @@ class HybridParkAPIView(APIView):
class ParkFilterMetadataAPIView(APIView):
"""
API view for getting park filter metadata.
Provides information about available filter options and ranges
to help build dynamic filter interfaces.
"""
permission_classes = [AllowAny]
def get(self, request):
"""Get park filter metadata."""
# Check if metadata should be scoped to current filters
scoped = request.query_params.get("scoped", "").lower() == "true"
filters = None
if scoped:
filters = self._extract_filters(request.query_params)
try:
# Check if metadata should be scoped to current filters
scoped = request.query_params.get('scoped', '').lower() == 'true'
filters = None
if scoped:
filters = self._extract_filters(request.query_params)
# Get filter metadata
metadata = smart_park_loader.get_filter_metadata(filters)
return Response(metadata, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in ParkFilterMetadataAPIView: {e}")
return Response(
{"error": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
except ServiceError as e:
return ErrorHandler.handle_api_error(
e,
user_message="Failed to get filter metadata",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def _extract_filters(self, query_params):
"""Extract and parse filters from query parameters."""
# Reuse the same filter extraction logic