mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 12:55:17 -05:00
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
2475 lines
101 KiB
Python
2475 lines
101 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.
|
|
|
|
Caching Strategy:
|
|
- RideListCreateAPIView.get: 10 minutes (600s) - ride lists are frequently queried
|
|
- RideDetailAPIView.get: 30 minutes (1800s) - detail views are stable
|
|
- FilterOptionsAPIView.get: 30 minutes (1800s) - filter options change rarely
|
|
- HybridRideAPIView.get: 10 minutes (600s) - ride lists with filters
|
|
- RideFilterMetadataAPIView.get: 30 minutes (1800s) - metadata is stable
|
|
- CompanySearchAPIView.get: 10 minutes (600s) - company data is stable
|
|
- RideModelSearchAPIView.get: 10 minutes (600s) - ride model data is stable
|
|
- RideSearchSuggestionsAPIView.get: 5 minutes (300s) - suggestions should be fresh
|
|
"""
|
|
|
|
import contextlib
|
|
import logging
|
|
from typing import Any
|
|
|
|
from django.db import models
|
|
from django.db.models import Count
|
|
from drf_spectacular.types import OpenApiTypes
|
|
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
|
|
from rest_framework import permissions, status
|
|
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.core.decorators.cache_decorators import cache_api_response
|
|
from apps.core.utils import capture_and_log
|
|
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.parks.models import Company, Park
|
|
from apps.rides.models import Ride, RideModel
|
|
from apps.rides.models.rides import RollerCoasterStats
|
|
|
|
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):
|
|
"""
|
|
API View for listing and creating rides.
|
|
|
|
Caching: GET requests are cached for 10 minutes (600s).
|
|
POST requests bypass cache and invalidate related cache entries.
|
|
"""
|
|
|
|
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"],
|
|
)
|
|
@cache_api_response(timeout=600, key_prefix="ride_list")
|
|
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:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(park_id=int(park_id))
|
|
|
|
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:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(manufacturer_id=int(manufacturer_id))
|
|
|
|
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:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(designer_id=int(designer_id))
|
|
|
|
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:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(ride_model_id=int(ride_model_id))
|
|
|
|
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:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(average_rating__gte=float(min_rating))
|
|
|
|
max_rating = params.get("max_rating")
|
|
if max_rating:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(average_rating__lte=float(max_rating))
|
|
|
|
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:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(min_height_in__gte=int(min_height_req))
|
|
|
|
max_height_req = params.get("max_height_requirement")
|
|
if max_height_req:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(max_height_in__lte=int(max_height_req))
|
|
|
|
return qs
|
|
|
|
def _apply_capacity_filters(self, qs, params):
|
|
"""Apply capacity filtering."""
|
|
min_capacity = params.get("min_capacity")
|
|
if min_capacity:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(capacity_per_hour__gte=int(min_capacity))
|
|
|
|
max_capacity = params.get("max_capacity")
|
|
if max_capacity:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(capacity_per_hour__lte=int(max_capacity))
|
|
|
|
return qs
|
|
|
|
def _apply_opening_year_filters(self, qs, params):
|
|
"""Apply opening year filtering."""
|
|
opening_year = params.get("opening_year")
|
|
if opening_year:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(opening_date__year=int(opening_year))
|
|
|
|
min_opening_year = params.get("min_opening_year")
|
|
if min_opening_year:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
|
|
|
|
max_opening_year = params.get("max_opening_year")
|
|
if max_opening_year:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
|
|
|
|
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:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft))
|
|
|
|
max_height_ft = params.get("max_height_ft")
|
|
if max_height_ft:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft))
|
|
|
|
# Speed filters
|
|
min_speed_mph = params.get("min_speed_mph")
|
|
if min_speed_mph:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph))
|
|
|
|
max_speed_mph = params.get("max_speed_mph")
|
|
if max_speed_mph:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph))
|
|
|
|
# Inversion filters
|
|
min_inversions = params.get("min_inversions")
|
|
if min_inversions:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions))
|
|
|
|
max_inversions = params.get("max_inversions")
|
|
if max_inversions:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions))
|
|
|
|
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") from None
|
|
|
|
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):
|
|
"""
|
|
API View for retrieving, updating, or deleting a single ride.
|
|
|
|
Caching: GET requests are cached for 30 minutes (1800s).
|
|
PATCH/PUT/DELETE requests bypass cache and should trigger cache invalidation.
|
|
"""
|
|
|
|
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") from None
|
|
|
|
@cache_api_response(timeout=1800, key_prefix="ride_detail")
|
|
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") from None
|
|
|
|
# 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):
|
|
"""
|
|
API View for ride filter options.
|
|
|
|
Caching: 30-minute timeout (1800s) - filter options change rarely
|
|
and are expensive to compute.
|
|
"""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
@cache_api_response(timeout=1800, key_prefix="ride_filter_options")
|
|
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):
|
|
"""
|
|
Caching: 10-minute timeout (600s) - company data is stable.
|
|
"""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
@cache_api_response(timeout=600, key_prefix="company_search")
|
|
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):
|
|
"""
|
|
Caching: 10-minute timeout (600s) - ride model data is stable.
|
|
"""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
@cache_api_response(timeout=600, key_prefix="ride_model_search")
|
|
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):
|
|
"""
|
|
Caching: 5-minute timeout (300s) - suggestions should be relatively fresh.
|
|
"""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
@cache_api_response(timeout=300, key_prefix="ride_suggestions")
|
|
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") from None
|
|
|
|
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.
|
|
|
|
Caching: 10-minute timeout (600s) - ride lists are frequently queried
|
|
but need to reflect new additions within reasonable time.
|
|
"""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
@cache_api_response(timeout=600, key_prefix="hybrid_rides")
|
|
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(
|
|
{"detail": "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:
|
|
capture_and_log(e, 'Get hybrid rides', source='api')
|
|
return Response(
|
|
{"detail": "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":
|
|
with contextlib.suppress(ValueError):
|
|
filters[param] = int(value)
|
|
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: # noqa: SIM105
|
|
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: # noqa: SIM105
|
|
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.
|
|
|
|
Caching: 30-minute timeout (1800s) - filter metadata is stable
|
|
and only changes when new entities are added.
|
|
"""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
@cache_api_response(timeout=1800, key_prefix="ride_filter_metadata")
|
|
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:
|
|
capture_and_log(e, 'Get ride filter metadata', source='api')
|
|
return Response(
|
|
{"detail": "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)
|
|
|
|
|
|
# === MANUFACTURER & DESIGNER LISTS ===
|
|
|
|
|
|
class BaseCompanyListAPIView(APIView):
|
|
permission_classes = [permissions.AllowAny]
|
|
role = None
|
|
|
|
def get(self, request: Request) -> Response:
|
|
if not MODELS_AVAILABLE:
|
|
return Response({"detail": "Models not available"}, status=status.HTTP_501_NOT_IMPLEMENTED)
|
|
|
|
companies = (
|
|
Company.objects.filter(roles__contains=[self.role])
|
|
.annotate(ride_count=Count("manufactured_rides" if self.role == "MANUFACTURER" else "designed_rides"))
|
|
.only("id", "name", "slug", "roles", "description")
|
|
.order_by("name")
|
|
)
|
|
|
|
data = [
|
|
{
|
|
"id": c.id,
|
|
"name": c.name,
|
|
"slug": c.slug,
|
|
"description": c.description,
|
|
"ride_count": c.ride_count,
|
|
}
|
|
for c in companies
|
|
]
|
|
|
|
return Response({"results": data, "count": len(data)})
|
|
|
|
|
|
@extend_schema(
|
|
summary="List manufacturers",
|
|
description="List all companies with MANUFACTURER role.",
|
|
responses={200: OpenApiTypes.OBJECT},
|
|
tags=["Rides"],
|
|
)
|
|
class ManufacturerListAPIView(BaseCompanyListAPIView):
|
|
role = "MANUFACTURER"
|
|
|
|
|
|
@extend_schema(
|
|
summary="List designers",
|
|
description="List all companies with DESIGNER role.",
|
|
responses={200: OpenApiTypes.OBJECT},
|
|
tags=["Rides"],
|
|
)
|
|
class DesignerListAPIView(BaseCompanyListAPIView):
|
|
role = "DESIGNER"
|
|
|
|
|
|
# === RIDE SUB-TYPES ===
|
|
|
|
|
|
@extend_schema(
|
|
summary="List ride sub-types",
|
|
description="List ride sub-types, optionally filtered by category. Used for autocomplete dropdowns.",
|
|
parameters=[
|
|
OpenApiParameter(
|
|
"category",
|
|
OpenApiTypes.STR,
|
|
description="Filter by ride category (e.g., 'RC' for roller coaster)",
|
|
),
|
|
],
|
|
responses={200: OpenApiTypes.OBJECT},
|
|
tags=["Rides"],
|
|
)
|
|
class RideSubTypeListAPIView(APIView):
|
|
"""
|
|
API View for listing ride sub-types.
|
|
|
|
Used by the frontend's useRideSubTypes hook to populate
|
|
sub-type dropdown menus filtered by ride category.
|
|
|
|
Caching: 30-minute timeout (1800s) - sub-types are stable lookup data.
|
|
"""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
@cache_api_response(timeout=1800, key_prefix="ride_sub_types")
|
|
def get(self, request: Request) -> Response:
|
|
from apps.rides.models import RideSubType
|
|
from apps.api.v1.rides.serializers import RideSubTypeSerializer
|
|
|
|
# Start with all sub-types
|
|
queryset = RideSubType.objects.all().order_by("name")
|
|
|
|
# Apply category filter if provided
|
|
category = request.query_params.get("category")
|
|
if category:
|
|
queryset = queryset.filter(category=category)
|
|
|
|
# Serialize and return
|
|
serializer = RideSubTypeSerializer(queryset, many=True)
|
|
return Response({
|
|
"results": serializer.data,
|
|
"count": queryset.count(),
|
|
})
|
|
|