""" Full-featured Rides API views for ThrillWiki API v1. This module implements a "full fat" set of endpoints: - List / Create: GET /rides/ POST /rides/ - Retrieve / Update / Delete: GET /rides/{pk}/ PATCH/PUT/DELETE - Filter options: GET /rides/filter-options/ - Company search: GET /rides/search/companies/?q=... - Ride model search: GET /rides/search-ride-models/?q=... - Search suggestions: GET /rides/search-suggestions/?q=... Notes: - These views try to use real Django models if available. If the domain models/services are not present, they return a clear 501 response explaining what to wire up. """ from typing import Any import logging from django.db import models logger = logging.getLogger(__name__) from rest_framework import status, permissions from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response from rest_framework.pagination import PageNumberPagination from rest_framework.exceptions import NotFound from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from drf_spectacular.types import OpenApiTypes # Reuse existing serializers where possible from apps.api.v1.serializers.rides import ( RideListOutputSerializer, RideDetailOutputSerializer, RideCreateInputSerializer, RideUpdateInputSerializer, RideImageSettingsInputSerializer, ) # Import hybrid filtering components from apps.rides.services.hybrid_loader import SmartRideLoader # Create smart loader instance smart_ride_loader = SmartRideLoader() # Attempt to import model-level helpers; fall back gracefully if not present. try: from apps.rides.models import Ride, RideModel from apps.rides.models.rides import RollerCoasterStats from apps.parks.models import Park, Company MODELS_AVAILABLE = True except Exception: Ride = None # type: ignore RideModel = None # type: ignore RollerCoasterStats = None # type: ignore Company = None # type: ignore Park = None # type: ignore MODELS_AVAILABLE = False # Attempt to import ModelChoices to return filter options try: from apps.api.v1.serializers.shared import ModelChoices # type: ignore HAVE_MODELCHOICES = True except Exception: ModelChoices = None # type: ignore HAVE_MODELCHOICES = False class StandardResultsSetPagination(PageNumberPagination): page_size = 20 page_size_query_param = "page_size" max_page_size = 1000 # --- Ride list & create ----------------------------------------------------- class RideListCreateAPIView(APIView): permission_classes = [permissions.AllowAny] @extend_schema( summary="List rides with comprehensive filtering and pagination", description="List rides with comprehensive filtering options including category, status, manufacturer, designer, ride model, and more.", parameters=[ OpenApiParameter( name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Page number for pagination", ), OpenApiParameter( name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Number of results per page (max 1000)", ), OpenApiParameter( name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Search in ride names and descriptions", ), OpenApiParameter( name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter by park slug", ), OpenApiParameter( name="park_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by park ID", ), OpenApiParameter( name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR", ), OpenApiParameter( name="status", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP", ), OpenApiParameter( name="manufacturer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by manufacturer company ID", ), OpenApiParameter( name="manufacturer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter by manufacturer company slug", ), OpenApiParameter( name="designer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by designer company ID", ), OpenApiParameter( name="designer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter by designer company slug", ), OpenApiParameter( name="ride_model_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by specific ride model ID", ), OpenApiParameter( name="ride_model_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter by ride model slug (requires manufacturer_slug)", ), OpenApiParameter( name="roller_coaster_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)", ), OpenApiParameter( name="track_material", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)", ), OpenApiParameter( name="propulsion_system", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Filter roller coasters by propulsion system (CHAIN, LSM, HYDRAULIC, etc.)", ), OpenApiParameter( name="min_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, description="Filter by minimum average rating (1-10)", ), OpenApiParameter( name="max_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, description="Filter by maximum average rating (1-10)", ), OpenApiParameter( name="min_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by minimum height requirement in inches", ), OpenApiParameter( name="max_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by maximum height requirement in inches", ), OpenApiParameter( name="min_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by minimum hourly capacity", ), OpenApiParameter( name="max_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by maximum hourly capacity", ), OpenApiParameter( name="min_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, description="Filter roller coasters by minimum height in feet", ), OpenApiParameter( name="max_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, description="Filter roller coasters by maximum height in feet", ), OpenApiParameter( name="min_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, description="Filter roller coasters by minimum speed in mph", ), OpenApiParameter( name="max_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, description="Filter roller coasters by maximum speed in mph", ), OpenApiParameter( name="min_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter roller coasters by minimum number of inversions", ), OpenApiParameter( name="max_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter roller coasters by maximum number of inversions", ), OpenApiParameter( name="has_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, description="Filter roller coasters that have inversions (true) or don't have inversions (false)", ), OpenApiParameter( name="opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by opening year", ), OpenApiParameter( name="min_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by minimum opening year", ), OpenApiParameter( name="max_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, description="Filter by maximum opening year", ), OpenApiParameter( name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph", ), ], responses={200: RideListOutputSerializer(many=True)}, tags=["Rides"], ) def get(self, request: Request) -> Response: """List rides with comprehensive filtering and pagination.""" if not MODELS_AVAILABLE: return Response( { "detail": "Ride listing is not available because domain models are not imported. " "Implement apps.rides.models.Ride (and related managers) to enable listing." }, status=status.HTTP_501_NOT_IMPLEMENTED, ) # Start with base queryset with optimized joins qs = ( Ride.objects.all() .select_related( "park", "manufacturer", "designer", "ride_model", "ride_model__manufacturer", ) .prefetch_related("coaster_stats") ) # type: ignore # Apply comprehensive filtering qs = self._apply_filters(qs, request.query_params) # Apply ordering qs = self._apply_ordering(qs, request.query_params) paginator = StandardResultsSetPagination() page = paginator.paginate_queryset(qs, request) serializer = RideListOutputSerializer( page, many=True, context={"request": request} ) return paginator.get_paginated_response(serializer.data) def _apply_filters(self, qs, params): """Apply all filtering to the queryset.""" qs = self._apply_search_filters(qs, params) qs = self._apply_park_filters(qs, params) qs = self._apply_category_status_filters(qs, params) qs = self._apply_company_filters(qs, params) qs = self._apply_ride_model_filters(qs, params) qs = self._apply_rating_filters(qs, params) qs = self._apply_height_requirement_filters(qs, params) qs = self._apply_capacity_filters(qs, params) qs = self._apply_opening_year_filters(qs, params) qs = self._apply_roller_coaster_filters(qs, params) return qs def _apply_search_filters(self, qs, params): """Apply text search filtering.""" search = params.get("search") if search: qs = qs.filter( models.Q(name__icontains=search) | models.Q(description__icontains=search) | models.Q(park__name__icontains=search) ) return qs def _apply_park_filters(self, qs, params): """Apply park-related filtering.""" park_slug = params.get("park_slug") if park_slug: qs = qs.filter(park__slug=park_slug) park_id = params.get("park_id") if park_id: try: qs = qs.filter(park_id=int(park_id)) except (ValueError, TypeError): pass return qs def _apply_category_status_filters(self, qs, params): """Apply category and status filtering.""" categories = params.getlist("category") if categories: qs = qs.filter(category__in=categories) statuses = params.getlist("status") if statuses: qs = qs.filter(status__in=statuses) return qs def _apply_company_filters(self, qs, params): """Apply manufacturer and designer filtering.""" manufacturer_id = params.get("manufacturer_id") if manufacturer_id: try: qs = qs.filter(manufacturer_id=int(manufacturer_id)) except (ValueError, TypeError): pass manufacturer_slug = params.get("manufacturer_slug") if manufacturer_slug: qs = qs.filter(manufacturer__slug=manufacturer_slug) designer_id = params.get("designer_id") if designer_id: try: qs = qs.filter(designer_id=int(designer_id)) except (ValueError, TypeError): pass designer_slug = params.get("designer_slug") if designer_slug: qs = qs.filter(designer__slug=designer_slug) return qs def _apply_ride_model_filters(self, qs, params): """Apply ride model filtering.""" ride_model_id = params.get("ride_model_id") if ride_model_id: try: qs = qs.filter(ride_model_id=int(ride_model_id)) except (ValueError, TypeError): pass ride_model_slug = params.get("ride_model_slug") manufacturer_slug_for_model = params.get("manufacturer_slug") if ride_model_slug and manufacturer_slug_for_model: qs = qs.filter( ride_model__slug=ride_model_slug, ride_model__manufacturer__slug=manufacturer_slug_for_model, ) return qs def _apply_rating_filters(self, qs, params): """Apply rating-based filtering.""" min_rating = params.get("min_rating") if min_rating: try: qs = qs.filter(average_rating__gte=float(min_rating)) except (ValueError, TypeError): pass max_rating = params.get("max_rating") if max_rating: try: qs = qs.filter(average_rating__lte=float(max_rating)) except (ValueError, TypeError): pass return qs def _apply_height_requirement_filters(self, qs, params): """Apply height requirement filtering.""" min_height_req = params.get("min_height_requirement") if min_height_req: try: qs = qs.filter(min_height_in__gte=int(min_height_req)) except (ValueError, TypeError): pass max_height_req = params.get("max_height_requirement") if max_height_req: try: qs = qs.filter(max_height_in__lte=int(max_height_req)) except (ValueError, TypeError): pass return qs def _apply_capacity_filters(self, qs, params): """Apply capacity filtering.""" min_capacity = params.get("min_capacity") if min_capacity: try: qs = qs.filter(capacity_per_hour__gte=int(min_capacity)) except (ValueError, TypeError): pass max_capacity = params.get("max_capacity") if max_capacity: try: qs = qs.filter(capacity_per_hour__lte=int(max_capacity)) except (ValueError, TypeError): pass return qs def _apply_opening_year_filters(self, qs, params): """Apply opening year filtering.""" opening_year = params.get("opening_year") if opening_year: try: qs = qs.filter(opening_date__year=int(opening_year)) except (ValueError, TypeError): pass min_opening_year = params.get("min_opening_year") if min_opening_year: try: qs = qs.filter(opening_date__year__gte=int(min_opening_year)) except (ValueError, TypeError): pass max_opening_year = params.get("max_opening_year") if max_opening_year: try: qs = qs.filter(opening_date__year__lte=int(max_opening_year)) except (ValueError, TypeError): pass return qs def _apply_roller_coaster_filters(self, qs, params): """Apply roller coaster specific filtering.""" roller_coaster_type = params.get("roller_coaster_type") if roller_coaster_type: qs = qs.filter(coaster_stats__roller_coaster_type=roller_coaster_type) track_material = params.get("track_material") if track_material: qs = qs.filter(coaster_stats__track_material=track_material) propulsion_system = params.get("propulsion_system") if propulsion_system: qs = qs.filter(coaster_stats__propulsion_system=propulsion_system) # Height filters min_height_ft = params.get("min_height_ft") if min_height_ft: try: qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft)) except (ValueError, TypeError): pass max_height_ft = params.get("max_height_ft") if max_height_ft: try: qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft)) except (ValueError, TypeError): pass # Speed filters min_speed_mph = params.get("min_speed_mph") if min_speed_mph: try: qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph)) except (ValueError, TypeError): pass max_speed_mph = params.get("max_speed_mph") if max_speed_mph: try: qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph)) except (ValueError, TypeError): pass # Inversion filters min_inversions = params.get("min_inversions") if min_inversions: try: qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions)) except (ValueError, TypeError): pass max_inversions = params.get("max_inversions") if max_inversions: try: qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions)) except (ValueError, TypeError): pass has_inversions = params.get("has_inversions") if has_inversions is not None: if has_inversions.lower() in ["true", "1", "yes"]: qs = qs.filter(coaster_stats__inversions__gt=0) elif has_inversions.lower() in ["false", "0", "no"]: qs = qs.filter(coaster_stats__inversions=0) return qs def _apply_ordering(self, qs, params): """Apply ordering to the queryset.""" ordering = params.get("ordering", "name") valid_orderings = [ "name", "-name", "opening_date", "-opening_date", "average_rating", "-average_rating", "capacity_per_hour", "-capacity_per_hour", "created_at", "-created_at", "height_ft", "-height_ft", "speed_mph", "-speed_mph", ] 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") qs = qs.order_by(ordering_field) else: qs = qs.order_by(ordering) return qs @extend_schema( summary="Create a new ride", description="Create a new ride.", responses={201: RideDetailOutputSerializer()}, tags=["Rides"], ) def post(self, request: Request) -> Response: """Create a new ride.""" serializer_in = RideCreateInputSerializer(data=request.data) serializer_in.is_valid(raise_exception=True) if not MODELS_AVAILABLE: return Response( { "detail": "Ride creation is not available because domain models are not imported. " "Implement apps.rides.models.Ride and necessary create logic." }, status=status.HTTP_501_NOT_IMPLEMENTED, ) validated = serializer_in.validated_data # Minimal create logic using model fields if available. try: park = Park.objects.get(id=validated["park_id"]) # type: ignore except Park.DoesNotExist: # type: ignore raise NotFound("Park not found") ride = Ride.objects.create( # type: ignore name=validated["name"], description=validated.get("description", ""), category=validated.get("category"), status=validated.get("status"), park=park, park_area_id=validated.get("park_area_id"), opening_date=validated.get("opening_date"), closing_date=validated.get("closing_date"), status_since=validated.get("status_since"), min_height_in=validated.get("min_height_in"), max_height_in=validated.get("max_height_in"), capacity_per_hour=validated.get("capacity_per_hour"), ride_duration_seconds=validated.get("ride_duration_seconds"), ) # Optional foreign keys if validated.get("manufacturer_id"): try: ride.manufacturer_id = validated["manufacturer_id"] ride.save() except Exception: # ignore if foreign key constraints or models not present pass out_serializer = RideDetailOutputSerializer(ride, context={"request": request}) return Response(out_serializer.data, status=status.HTTP_201_CREATED) # --- Ride retrieve / update / delete --------------------------------------- @extend_schema( summary="Retrieve, update or delete a ride", responses={200: RideDetailOutputSerializer()}, tags=["Rides"], ) class RideDetailAPIView(APIView): permission_classes = [permissions.AllowAny] def _get_ride_or_404(self, pk: int) -> Any: if not MODELS_AVAILABLE: raise NotFound( "Ride detail is not available because domain models are not imported. " "Implement apps.rides.models.Ride to enable detail endpoints." ) try: return Ride.objects.select_related("park").get(pk=pk) # type: ignore except Ride.DoesNotExist: # type: ignore raise NotFound("Ride not found") def get(self, request: Request, pk: int) -> Response: ride = self._get_ride_or_404(pk) serializer = RideDetailOutputSerializer(ride, context={"request": request}) return Response(serializer.data) def patch(self, request: Request, pk: int) -> Response: ride = self._get_ride_or_404(pk) serializer_in = RideUpdateInputSerializer(data=request.data, partial=True) 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." }, status=status.HTTP_501_NOT_IMPLEMENTED, ) validated_data = serializer_in.validated_data park_change_info = None # Handle park change specially if park_id is being updated if 'park_id' in validated_data: new_park_id = validated_data.pop('park_id') try: new_park = Park.objects.get(id=new_park_id) # type: ignore if new_park.id != ride.park_id: # 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") # Apply other field updates for key, value in validated_data.items(): setattr(ride, key, value) ride.save() # Prepare response data serializer = RideDetailOutputSerializer(ride, context={"request": request}) response_data = serializer.data # Add park change information to response if applicable if park_change_info: response_data['park_change_info'] = park_change_info return Response(response_data) def put(self, request: Request, pk: int) -> Response: # Full replace - reuse patch behavior for simplicity return self.patch(request, pk) 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." }, status=status.HTTP_501_NOT_IMPLEMENTED, ) ride = self._get_ride_or_404(pk) ride.delete() return Response(status=status.HTTP_204_NO_CONTENT) # --- Filter options --------------------------------------------------------- @extend_schema( summary="Get comprehensive filter options for rides", description="Returns all available filter options for rides with complete read-only access to all possible ride model fields and attributes, including dynamic data from database.", responses={200: OpenApiTypes.OBJECT}, tags=["Rides"], ) class FilterOptionsAPIView(APIView): permission_classes = [permissions.AllowAny] def get(self, request: Request) -> Response: """Return comprehensive filter options with Rich Choice Objects metadata.""" # Import Rich Choice registry from apps.core.choices.registry import get_choices if not MODELS_AVAILABLE: # Use Rich Choice Objects for fallback options try: # Get rich choice objects from registry categories = get_choices('categories', 'rides') statuses = get_choices('statuses', 'rides') post_closing_statuses = get_choices('post_closing_statuses', 'rides') track_materials = get_choices('track_materials', 'rides') coaster_types = get_choices('coaster_types', 'rides') propulsion_systems = get_choices('propulsion_systems', '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 ] statuses_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 statuses ] post_closing_statuses_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 post_closing_statuses ] track_materials_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 track_materials ] coaster_types_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 coaster_types ] propulsion_systems_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 propulsion_systems ] 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 ] 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}, ] statuses_data = [ {"value": "OPERATING", "label": "Operating", "description": "Ride is currently open and operating", "color": "green", "icon": "check-circle", "css_class": "bg-green-100 text-green-800", "sort_order": 1}, {"value": "CLOSED_TEMP", "label": "Temporarily Closed", "description": "Ride is temporarily closed for maintenance", "color": "yellow", "icon": "pause-circle", "css_class": "bg-yellow-100 text-yellow-800", "sort_order": 2}, {"value": "SBNO", "label": "Standing But Not Operating", "description": "Ride exists but is not operational", "color": "orange", "icon": "stop-circle", "css_class": "bg-orange-100 text-orange-800", "sort_order": 3}, {"value": "CLOSING", "label": "Closing", "description": "Ride is scheduled to close permanently", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 4}, {"value": "CLOSED_PERM", "label": "Permanently Closed", "description": "Ride has been permanently closed", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 5}, {"value": "UNDER_CONSTRUCTION", "label": "Under Construction", "description": "Ride is currently being built", "color": "blue", "icon": "tool", "css_class": "bg-blue-100 text-blue-800", "sort_order": 6}, {"value": "DEMOLISHED", "label": "Demolished", "description": "Ride has been completely removed", "color": "gray", "icon": "trash", "css_class": "bg-gray-100 text-gray-800", "sort_order": 7}, {"value": "RELOCATED", "label": "Relocated", "description": "Ride has been moved to another location", "color": "purple", "icon": "arrow-right", "css_class": "bg-purple-100 text-purple-800", "sort_order": 8}, ] post_closing_statuses_data = [ {"value": "SBNO", "label": "Standing But Not Operating", "description": "Ride exists but is not operational", "color": "orange", "icon": "stop-circle", "css_class": "bg-orange-100 text-orange-800", "sort_order": 1}, {"value": "CLOSED_PERM", "label": "Permanently Closed", "description": "Ride has been permanently closed", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 2}, ] track_materials_data = [ {"value": "STEEL", "label": "Steel", "description": "Modern steel track construction", "color": "gray", "icon": "steel", "css_class": "bg-gray-100 text-gray-800", "sort_order": 1}, {"value": "WOOD", "label": "Wood", "description": "Traditional wooden track construction", "color": "amber", "icon": "wood", "css_class": "bg-amber-100 text-amber-800", "sort_order": 2}, {"value": "HYBRID", "label": "Hybrid", "description": "Steel track on wooden structure", "color": "orange", "icon": "hybrid", "css_class": "bg-orange-100 text-orange-800", "sort_order": 3}, ] coaster_types_data = [ {"value": "SITDOWN", "label": "Sit Down", "description": "Traditional seated roller coaster", "color": "blue", "icon": "sitdown", "css_class": "bg-blue-100 text-blue-800", "sort_order": 1}, {"value": "INVERTED", "label": "Inverted", "description": "Track above riders, feet dangle", "color": "purple", "icon": "inverted", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2}, {"value": "FLYING", "label": "Flying", "description": "Riders positioned face-down", "color": "sky", "icon": "flying", "css_class": "bg-sky-100 text-sky-800", "sort_order": 3}, {"value": "STANDUP", "label": "Stand Up", "description": "Riders stand during the ride", "color": "green", "icon": "standup", "css_class": "bg-green-100 text-green-800", "sort_order": 4}, {"value": "WING", "label": "Wing", "description": "Seats extend beyond track sides", "color": "indigo", "icon": "wing", "css_class": "bg-indigo-100 text-indigo-800", "sort_order": 5}, {"value": "DIVE", "label": "Dive", "description": "Features steep vertical drops", "color": "red", "icon": "dive", "css_class": "bg-red-100 text-red-800", "sort_order": 6}, ] propulsion_systems_data = [ {"value": "CHAIN", "label": "Chain Lift", "description": "Traditional chain lift hill", "color": "gray", "icon": "chain", "css_class": "bg-gray-100 text-gray-800", "sort_order": 1}, {"value": "LSM", "label": "LSM Launch", "description": "Linear synchronous motor launch", "color": "blue", "icon": "lightning", "css_class": "bg-blue-100 text-blue-800", "sort_order": 2}, {"value": "HYDRAULIC", "label": "Hydraulic Launch", "description": "High-pressure hydraulic launch", "color": "red", "icon": "hydraulic", "css_class": "bg-red-100 text-red-800", "sort_order": 3}, {"value": "GRAVITY", "label": "Gravity", "description": "Gravity-powered ride", "color": "green", "icon": "gravity", "css_class": "bg-green-100 text-green-800", "sort_order": 4}, ] 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}, ] # Comprehensive fallback options with Rich Choice Objects metadata return Response({ "categories": categories_data, "statuses": statuses_data, "post_closing_statuses": [ {"value": "SBNO", "label": "Standing But Not Operating"}, {"value": "CLOSED_PERM", "label": "Permanently Closed"}, ], "roller_coaster_types": [ {"value": "SITDOWN", "label": "Sit Down"}, {"value": "INVERTED", "label": "Inverted"}, {"value": "FLYING", "label": "Flying"}, {"value": "STANDUP", "label": "Stand Up"}, {"value": "WING", "label": "Wing"}, {"value": "DIVE", "label": "Dive"}, {"value": "FAMILY", "label": "Family"}, {"value": "WILD_MOUSE", "label": "Wild Mouse"}, {"value": "SPINNING", "label": "Spinning"}, {"value": "FOURTH_DIMENSION", "label": "4th Dimension"}, {"value": "OTHER", "label": "Other"}, ], "track_materials": [ {"value": "STEEL", "label": "Steel"}, {"value": "WOOD", "label": "Wood"}, {"value": "HYBRID", "label": "Hybrid"}, ], "propulsion_systems": [ {"value": "CHAIN", "label": "Chain Lift"}, {"value": "LSM", "label": "LSM Launch"}, {"value": "HYDRAULIC", "label": "Hydraulic Launch"}, {"value": "GRAVITY", "label": "Gravity"}, {"value": "OTHER", "label": "Other"}, ], "ride_model_target_markets": [ {"value": "FAMILY", "label": "Family"}, {"value": "THRILL", "label": "Thrill"}, {"value": "EXTREME", "label": "Extreme"}, {"value": "KIDDIE", "label": "Kiddie"}, {"value": "ALL_AGES", "label": "All Ages"}, ], "parks": [], "park_areas": [], "manufacturers": [], "designers": [], "ride_models": [], "ranges": { "rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"}, "height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"}, "capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"}, "ride_duration": {"min": 0, "max": 600, "step": 10, "unit": "seconds"}, "height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"}, "length_ft": {"min": 0, "max": 10000, "step": 100, "unit": "feet"}, "speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"}, "inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"}, "ride_time": {"min": 0, "max": 600, "step": 10, "unit": "seconds"}, "max_drop_height_ft": {"min": 0, "max": 500, "step": 10, "unit": "feet"}, "trains_count": {"min": 1, "max": 10, "step": 1, "unit": "trains"}, "cars_per_train": {"min": 1, "max": 20, "step": 1, "unit": "cars"}, "seats_per_car": {"min": 1, "max": 8, "step": 1, "unit": "seats"}, "opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"}, }, "boolean_filters": [ {"key": "has_inversions", "label": "Has Inversions", "description": "Filter roller coasters with or without inversions"}, {"key": "has_coordinates", "label": "Has Location Coordinates", "description": "Filter rides with GPS coordinates"}, {"key": "has_ride_model", "label": "Has Ride Model", "description": "Filter rides with specified ride model"}, {"key": "has_manufacturer", "label": "Has Manufacturer", "description": "Filter rides with specified manufacturer"}, {"key": "has_designer", "label": "Has Designer", "description": "Filter rides with specified designer"}, ], "ordering_options": [ {"value": "name", "label": "Name (A-Z)"}, {"value": "-name", "label": "Name (Z-A)"}, {"value": "opening_date", "label": "Opening Date (Oldest First)"}, {"value": "-opening_date", "label": "Opening Date (Newest First)"}, {"value": "average_rating", "label": "Rating (Lowest First)"}, {"value": "-average_rating", "label": "Rating (Highest First)"}, {"value": "capacity_per_hour", "label": "Capacity (Lowest First)"}, {"value": "-capacity_per_hour", "label": "Capacity (Highest First)"}, {"value": "ride_duration_seconds", "label": "Duration (Shortest First)"}, {"value": "-ride_duration_seconds", "label": "Duration (Longest First)"}, {"value": "height_ft", "label": "Height (Shortest First)"}, {"value": "-height_ft", "label": "Height (Tallest First)"}, {"value": "length_ft", "label": "Length (Shortest First)"}, {"value": "-length_ft", "label": "Length (Longest First)"}, {"value": "speed_mph", "label": "Speed (Slowest First)"}, {"value": "-speed_mph", "label": "Speed (Fastest First)"}, {"value": "inversions", "label": "Inversions (Fewest First)"}, {"value": "-inversions", "label": "Inversions (Most First)"}, {"value": "created_at", "label": "Date Added (Oldest First)"}, {"value": "-created_at", "label": "Date Added (Newest First)"}, {"value": "updated_at", "label": "Last Updated (Oldest First)"}, {"value": "-updated_at", "label": "Last Updated (Newest First)"}, ], }) # 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') statuses = get_choices('statuses', 'rides') post_closing_statuses = get_choices('post_closing_statuses', 'rides') track_materials = get_choices('track_materials', 'rides') coaster_types = get_choices('coaster_types', 'rides') propulsion_systems = get_choices('propulsion_systems', '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 ] statuses_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 statuses ] post_closing_statuses_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 post_closing_statuses ] track_materials_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 track_materials ] coaster_types_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 coaster_types ] propulsion_systems_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 propulsion_systems ] 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 parks data from database parks = list(Ride.objects.exclude( park__isnull=True ).select_related('park').values( 'park__id', 'park__name', 'park__slug' ).distinct().order_by('park__name')) # Get park areas data from database park_areas = list(Ride.objects.exclude( park_area__isnull=True ).select_related('park_area').values( 'park_area__id', 'park_area__name', 'park_area__slug' ).distinct().order_by('park_area__name')) # Get manufacturers (companies with MANUFACTURER role) manufacturers = list(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')) # Get ride models data from database ride_models = list(RideModel.objects.select_related( 'manufacturer' ).values( 'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category' ).order_by('manufacturer__name', 'name')) # Calculate ranges from actual data ride_stats = Ride.objects.aggregate( min_rating=models.Min('average_rating'), max_rating=models.Max('average_rating'), min_height_req=models.Min('min_height_in'), max_height_req=models.Max('max_height_in'), min_capacity=models.Min('capacity_per_hour'), max_capacity=models.Max('capacity_per_hour'), min_duration=models.Min('ride_duration_seconds'), max_duration=models.Max('ride_duration_seconds'), min_year=models.Min('opening_date__year'), max_year=models.Max('opening_date__year'), ) # Calculate roller coaster specific ranges coaster_stats = RollerCoasterStats.objects.aggregate( min_height_ft=models.Min('height_ft'), max_height_ft=models.Max('height_ft'), min_length_ft=models.Min('length_ft'), max_length_ft=models.Max('length_ft'), min_speed_mph=models.Min('speed_mph'), max_speed_mph=models.Max('speed_mph'), min_inversions=models.Min('inversions'), max_inversions=models.Max('inversions'), min_ride_time=models.Min('ride_time_seconds'), max_ride_time=models.Max('ride_time_seconds'), min_drop_height=models.Min('max_drop_height_ft'), max_drop_height=models.Max('max_drop_height_ft'), min_trains=models.Min('trains_count'), max_trains=models.Max('trains_count'), min_cars=models.Min('cars_per_train'), max_cars=models.Max('cars_per_train'), min_seats=models.Min('seats_per_car'), max_seats=models.Max('seats_per_car'), ) ranges = { "rating": { "min": float(ride_stats['min_rating'] or 1), "max": float(ride_stats['max_rating'] or 10), "step": 0.1, "unit": "stars" }, "height_requirement": { "min": ride_stats['min_height_req'] or 30, "max": ride_stats['max_height_req'] or 90, "step": 1, "unit": "inches" }, "capacity": { "min": ride_stats['min_capacity'] or 0, "max": ride_stats['max_capacity'] or 5000, "step": 50, "unit": "riders/hour" }, "ride_duration": { "min": ride_stats['min_duration'] or 0, "max": ride_stats['max_duration'] or 600, "step": 10, "unit": "seconds" }, "height_ft": { "min": float(coaster_stats['min_height_ft'] or 0), "max": float(coaster_stats['max_height_ft'] or 500), "step": 5, "unit": "feet" }, "length_ft": { "min": float(coaster_stats['min_length_ft'] or 0), "max": float(coaster_stats['max_length_ft'] or 10000), "step": 100, "unit": "feet" }, "speed_mph": { "min": float(coaster_stats['min_speed_mph'] or 0), "max": float(coaster_stats['max_speed_mph'] or 150), "step": 5, "unit": "mph" }, "inversions": { "min": coaster_stats['min_inversions'] or 0, "max": coaster_stats['max_inversions'] or 20, "step": 1, "unit": "inversions" }, "ride_time": { "min": coaster_stats['min_ride_time'] or 0, "max": coaster_stats['max_ride_time'] or 600, "step": 10, "unit": "seconds" }, "max_drop_height_ft": { "min": float(coaster_stats['min_drop_height'] or 0), "max": float(coaster_stats['max_drop_height'] or 500), "step": 10, "unit": "feet" }, "trains_count": { "min": coaster_stats['min_trains'] or 1, "max": coaster_stats['max_trains'] or 10, "step": 1, "unit": "trains" }, "cars_per_train": { "min": coaster_stats['min_cars'] or 1, "max": coaster_stats['max_cars'] or 20, "step": 1, "unit": "cars" }, "seats_per_car": { "min": coaster_stats['min_seats'] or 1, "max": coaster_stats['max_seats'] or 8, "step": 1, "unit": "seats" }, "opening_year": { "min": ride_stats['min_year'] or 1800, "max": ride_stats['max_year'] or 2030, "step": 1, "unit": "year" }, } return Response({ "categories": categories_data, "statuses": statuses_data, "post_closing_statuses": post_closing_statuses_data, "roller_coaster_types": coaster_types_data, "track_materials": track_materials_data, "propulsion_systems": propulsion_systems_data, "ride_model_target_markets": target_markets_data, "parks": parks, "park_areas": park_areas, "manufacturers": manufacturers, "designers": designers, "ride_models": ride_models, "ranges": ranges, "boolean_filters": [ {"key": "has_inversions", "label": "Has Inversions", "description": "Filter roller coasters with or without inversions"}, {"key": "has_coordinates", "label": "Has Location Coordinates", "description": "Filter rides with GPS coordinates"}, {"key": "has_ride_model", "label": "Has Ride Model", "description": "Filter rides with specified ride model"}, {"key": "has_manufacturer", "label": "Has Manufacturer", "description": "Filter rides with specified manufacturer"}, {"key": "has_designer", "label": "Has Designer", "description": "Filter rides with specified designer"}, ], "ordering_options": [ {"value": "name", "label": "Name (A-Z)"}, {"value": "-name", "label": "Name (Z-A)"}, {"value": "opening_date", "label": "Opening Date (Oldest First)"}, {"value": "-opening_date", "label": "Opening Date (Newest First)"}, {"value": "average_rating", "label": "Rating (Lowest First)"}, {"value": "-average_rating", "label": "Rating (Highest First)"}, {"value": "capacity_per_hour", "label": "Capacity (Lowest First)"}, {"value": "-capacity_per_hour", "label": "Capacity (Highest First)"}, {"value": "ride_duration_seconds", "label": "Duration (Shortest First)"}, {"value": "-ride_duration_seconds", "label": "Duration (Longest First)"}, {"value": "height_ft", "label": "Height (Shortest First)"}, {"value": "-height_ft", "label": "Height (Tallest First)"}, {"value": "length_ft", "label": "Length (Shortest First)"}, {"value": "-length_ft", "label": "Length (Longest First)"}, {"value": "speed_mph", "label": "Speed (Slowest First)"}, {"value": "-speed_mph", "label": "Speed (Fastest First)"}, {"value": "inversions", "label": "Inversions (Fewest First)"}, {"value": "-inversions", "label": "Inversions (Most First)"}, {"value": "created_at", "label": "Date Added (Oldest First)"}, {"value": "-created_at", "label": "Date Added (Newest First)"}, {"value": "updated_at", "label": "Last Updated (Oldest First)"}, {"value": "-updated_at", "label": "Last Updated (Newest First)"}, ], }) # --- Company search (autocomplete) ----------------------------------------- @extend_schema( summary="Search companies (manufacturers/designers) for autocomplete", parameters=[ OpenApiParameter( name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR ) ], responses={200: OpenApiTypes.OBJECT}, tags=["Rides"], ) class CompanySearchAPIView(APIView): permission_classes = [permissions.AllowAny] def get(self, request: Request) -> Response: q = request.query_params.get("q", "") if not q: return Response([], status=status.HTTP_200_OK) if Company is None: # Provide helpful placeholder structure return Response( [ {"id": 1, "name": "Rocky Mountain Construction", "slug": "rmc"}, {"id": 2, "name": "Bolliger & Mabillard", "slug": "b&m"}, ] ) 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 ] 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 ) ], tags=["Rides"], ) class RideModelSearchAPIView(APIView): permission_classes = [permissions.AllowAny] def get(self, request: Request) -> Response: q = request.query_params.get("q", "") if not q: return Response([], status=status.HTTP_200_OK) if RideModel is None: return Response( [ {"id": 1, "name": "I-Box (RMC)", "category": "ROLLER_COASTER"}, { "id": 2, "name": "Hyper Coaster Model X", "category": "ROLLER_COASTER", }, ] ) 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 ] return Response(results) # --- Search suggestions ----------------------------------------------------- @extend_schema( summary="Search suggestions for ride search box", parameters=[ OpenApiParameter( name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR ) ], tags=["Rides"], ) class RideSearchSuggestionsAPIView(APIView): permission_classes = [permissions.AllowAny] def get(self, request: Request) -> Response: q = request.query_params.get("q", "") if not q: return Response([], status=status.HTTP_200_OK) # 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 return Response([{"suggestion": name} for name in qs]) # Fallback suggestions fallback = [ {"suggestion": f"{q} coaster"}, {"suggestion": f"{q} ride"}, {"suggestion": f"{q} park"}, ] return Response(fallback) # --- Ride image settings --------------------------------------------------- @extend_schema( summary="Set ride banner and card images", description="Set banner_image and card_image for a ride from existing ride photos", request=RideImageSettingsInputSerializer, responses={ 200: RideDetailOutputSerializer, 400: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT, }, tags=["Rides"], ) class RideImageSettingsAPIView(APIView): permission_classes = [permissions.AllowAny] def _get_ride_or_404(self, pk: int) -> Any: if not MODELS_AVAILABLE: raise NotFound("Ride models not available") try: return Ride.objects.get(pk=pk) # type: ignore except Ride.DoesNotExist: # type: ignore raise NotFound("Ride not found") def patch(self, request: Request, pk: int) -> Response: """Set banner and card images for the ride.""" ride = self._get_ride_or_404(pk) serializer = RideImageSettingsInputSerializer(data=request.data, partial=True) serializer.is_valid(raise_exception=True) # Update the ride with the validated data for field, value in serializer.validated_data.items(): setattr(ride, field, value) ride.save() # Return updated ride data output_serializer = RideDetailOutputSerializer( ride, context={"request": request} ) return Response(output_serializer.data) # --- Hybrid Filtering API Views -------------------------------------------- @extend_schema_view( get=extend_schema( summary="Get rides with hybrid filtering", description="Retrieve rides with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.", parameters=[ OpenApiParameter("category", OpenApiTypes.STR, description="Filter by ride category (comma-separated for multiple)"), OpenApiParameter("status", 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("manufacturer", OpenApiTypes.STR, description="Filter by manufacturer slug (comma-separated for multiple)"), OpenApiParameter("designer", OpenApiTypes.STR, description="Filter by designer slug (comma-separated for multiple)"), OpenApiParameter("ride_model", 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("height_requirement_min", OpenApiTypes.INT, description="Minimum height requirement in inches"), OpenApiParameter("height_requirement_max", 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("roller_coaster_type", OpenApiTypes.STR, description="Filter by roller coaster type (comma-separated for multiple)"), OpenApiParameter("track_material", OpenApiTypes.STR, description="Filter by track material (comma-separated for multiple)"), OpenApiParameter("propulsion_system", OpenApiTypes.STR, description="Filter by propulsion system (comma-separated for multiple)"), OpenApiParameter("height_ft_min", OpenApiTypes.NUMBER, description="Minimum roller coaster height in feet"), OpenApiParameter("height_ft_max", OpenApiTypes.NUMBER, description="Maximum roller coaster height in feet"), OpenApiParameter("speed_mph_min", OpenApiTypes.NUMBER, description="Minimum roller coaster speed in mph"), OpenApiParameter("speed_mph_max", OpenApiTypes.NUMBER, description="Maximum roller coaster speed in mph"), OpenApiParameter("inversions_min", OpenApiTypes.INT, description="Minimum number of inversions"), OpenApiParameter("inversions_max", OpenApiTypes.INT, description="Maximum number of inversions"), OpenApiParameter("has_inversions", OpenApiTypes.BOOL, description="Filter rides with inversions (true) or without (false)"), OpenApiParameter("search", OpenApiTypes.STR, description="Search query for ride names, descriptions, parks, and related data"), OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"), ], responses={ 200: { "description": "Rides data with hybrid filtering metadata", "content": { "application/json": { "schema": { "type": "object", "properties": { "rides": { "type": "array", "items": {"$ref": "#/components/schemas/HybridRideSerializer"} }, "total_count": {"type": "integer"}, "strategy": { "type": "string", "enum": ["client_side", "server_side"], "description": "Filtering strategy used" }, "has_more": { "type": "boolean", "description": "Whether more data is available for progressive loading" }, "next_offset": { "type": "integer", "nullable": True, "description": "Next offset for progressive loading" }, "filter_metadata": { "type": "object", "description": "Available filter options and ranges" } } } } } } }, tags=["Rides"], ) ) class HybridRideAPIView(APIView): """ Hybrid Ride 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 = [permissions.AllowAny] def get(self, request): """Get rides with hybrid filtering strategy.""" 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_ride_loader.get_progressive_load(offset, filters) except ValueError: return Response( {"error": "Invalid offset parameter"}, status=status.HTTP_400_BAD_REQUEST ) else: # Get initial load data data = smart_ride_loader.get_initial_load(filters) # Prepare response (rides are already serialized by the service) response_data = { 'rides': data['rides'], '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'] return Response(response_data, status=status.HTTP_200_OK) except Exception as e: logger.error(f"Error in HybridRideAPIView: {e}") return Response( {"error": "Internal server error"}, status=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 = ['category', 'status', 'manufacturer', 'designer', 'ride_model', 'roller_coaster_type', 'track_material', 'propulsion_system'] for param in list_params: value = query_params.get(param) if value: filters[param] = [v.strip() for v in value.split(',') if v.strip()] # Handle single value parameters single_params = ['park_slug', 'park_id'] for param in single_params: value = query_params.get(param) if value: if param == 'park_id': try: filters[param] = int(value) except ValueError: pass else: filters[param] = value # Handle integer parameters int_params = [ 'opening_year_min', 'opening_year_max', 'height_requirement_min', 'height_requirement_max', 'capacity_min', 'capacity_max', 'inversions_min', 'inversions_max' ] for param in int_params: value = query_params.get(param) if value: try: filters[param] = int(value) except ValueError: pass # Skip invalid integer values # Handle float parameters float_params = ['rating_min', 'rating_max', 'height_ft_min', 'height_ft_max', 'speed_mph_min', 'speed_mph_max'] for param in float_params: value = query_params.get(param) if value: try: filters[param] = float(value) except ValueError: pass # Skip invalid float values # Handle boolean parameters has_inversions = query_params.get('has_inversions') if has_inversions is not None: if has_inversions.lower() in ['true', '1', 'yes']: filters['has_inversions'] = True elif has_inversions.lower() in ['false', '0', 'no']: filters['has_inversions'] = False # Handle search parameter search = query_params.get('search') if search: filters['search'] = search.strip() return filters @extend_schema_view( get=extend_schema( summary="Get ride filter metadata", description="Get available filter options and ranges for rides filtering.", parameters=[ OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"), ], responses={ 200: { "description": "Filter metadata", "content": { "application/json": { "schema": { "type": "object", "properties": { "categorical": { "type": "object", "properties": { "categories": {"type": "array", "items": {"type": "string"}}, "statuses": {"type": "array", "items": {"type": "string"}}, "roller_coaster_types": {"type": "array", "items": {"type": "string"}}, "track_materials": {"type": "array", "items": {"type": "string"}}, "propulsion_systems": {"type": "array", "items": {"type": "string"}}, "parks": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "slug": {"type": "string"} } } }, "manufacturers": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "slug": {"type": "string"} } } }, "designers": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "slug": {"type": "string"} } } } } }, "ranges": { "type": "object", "properties": { "opening_year": { "type": "object", "properties": { "min": {"type": "integer", "nullable": True}, "max": {"type": "integer", "nullable": True} } }, "rating": { "type": "object", "properties": { "min": {"type": "number", "nullable": True}, "max": {"type": "number", "nullable": True} } }, "height_requirement": { "type": "object", "properties": { "min": {"type": "integer", "nullable": True}, "max": {"type": "integer", "nullable": True} } }, "capacity": { "type": "object", "properties": { "min": {"type": "integer", "nullable": True}, "max": {"type": "integer", "nullable": True} } }, "height_ft": { "type": "object", "properties": { "min": {"type": "number", "nullable": True}, "max": {"type": "number", "nullable": True} } }, "speed_mph": { "type": "object", "properties": { "min": {"type": "number", "nullable": True}, "max": {"type": "number", "nullable": True} } }, "inversions": { "type": "object", "properties": { "min": {"type": "integer", "nullable": True}, "max": {"type": "integer", "nullable": True} } } } }, "total_count": {"type": "integer"} } } } } } }, tags=["Rides"], ) ) class RideFilterMetadataAPIView(APIView): """ API view for getting ride filter metadata. Provides information about available filter options and ranges to help build dynamic filter interfaces. """ permission_classes = [permissions.AllowAny] def get(self, request): """Get ride filter metadata.""" 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_ride_loader.get_filter_metadata(filters) return Response(metadata, status=status.HTTP_200_OK) except Exception as e: logger.error(f"Error in RideFilterMetadataAPIView: {e}") return Response( {"error": "Internal server error"}, status=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 view = HybridRideAPIView() return view._extract_filters(query_params)