Files
thrillwiki_django_no_react/backend/apps/api/v1/rides/views.py
pacnpal 2e35f8c5d9 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.
2025-12-22 11:17:31 -05:00

2401 lines
97 KiB
Python

"""
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.
"""
import logging
from typing import Any
from django.db import models
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.api.v1.serializers.rides import (
RideCreateInputSerializer,
RideDetailOutputSerializer,
RideImageSettingsInputSerializer,
RideListOutputSerializer,
RideUpdateInputSerializer,
)
from apps.rides.services.hybrid_loader import SmartRideLoader
logger = logging.getLogger(__name__)
# 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)