Add comprehensive API documentation for ThrillWiki integration and features

- Introduced Next.js integration guide for ThrillWiki API, detailing authentication, core domain APIs, data structures, and implementation patterns.
- Documented the migration to Rich Choice Objects, highlighting changes for frontend developers and enhanced metadata availability.
- Fixed the missing `get_by_slug` method in the Ride model, ensuring proper functionality of ride detail endpoints.
- Created a test script to verify manufacturer syncing with ride models, ensuring data integrity across related models.
This commit is contained in:
pacnpal
2025-09-16 11:29:17 -04:00
parent 61d73a2147
commit c2c26cfd1d
98 changed files with 11476 additions and 4803 deletions

View File

@@ -7,7 +7,7 @@ TypeScript interfaces, providing immediate feedback during development.
import json
import logging
from typing import Dict, Any, Optional
from typing import Dict, Any
from django.conf import settings
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin

View File

@@ -0,0 +1,306 @@
"""
Park Rides API views for ThrillWiki API v1.
This module implements endpoints for accessing rides within specific parks:
- GET /parks/{park_slug}/rides/ - List rides at a park with pagination and filtering
- GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context
"""
from typing import Any
from django.db import models
from django.db.models import Q, Count, Avg
from django.db.models.query import QuerySet
from rest_framework import status, permissions
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Import models
try:
from apps.parks.models import Park
from apps.rides.models import Ride
MODELS_AVAILABLE = True
except Exception:
Park = None # type: ignore
Ride = None # type: ignore
MODELS_AVAILABLE = False
# Import serializers
try:
from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
SERIALIZERS_AVAILABLE = True
except Exception:
SERIALIZERS_AVAILABLE = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class ParkRidesListAPIView(APIView):
"""List rides at a specific park with pagination and filtering."""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List rides at a specific park",
description="Get paginated list of rides at a specific park with filtering options",
parameters=[
# Pagination
OpenApiParameter(name="page", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Page number"),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Number of results per page (max 100)"),
# Filtering
OpenApiParameter(name="category", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by ride category"),
OpenApiParameter(name="status", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by operational status"),
OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Search rides by name"),
# Ordering
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Order results by field"),
],
responses={
200: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks", "Rides"],
)
def get(self, request: Request, park_slug: str) -> Response:
"""List rides at a specific park."""
if not MODELS_AVAILABLE:
return Response(
{"detail": "Park and ride models not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Get the park
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
# Get rides for this park
qs = Ride.objects.filter(park=park).select_related(
"manufacturer", "designer", "ride_model", "park_area"
).prefetch_related("photos")
# Apply filtering
qs = self._apply_filters(qs, request.query_params)
# Apply ordering
ordering = request.query_params.get("ordering", "name")
if ordering:
qs = qs.order_by(ordering)
# Paginate results
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
if SERIALIZERS_AVAILABLE:
serializer = RideListOutputSerializer(
page, many=True, context={"request": request, "park": park}
)
return paginator.get_paginated_response(serializer.data)
else:
# Fallback serialization
serializer_data = [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"category": getattr(ride, "category", ""),
"status": getattr(ride, "status", ""),
"manufacturer": {
"name": ride.manufacturer.name if ride.manufacturer else "",
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
},
}
for ride in page
]
return paginator.get_paginated_response(serializer_data)
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply filtering to the rides queryset."""
# Category filter
category = params.get("category")
if category:
qs = qs.filter(category=category)
# Status filter
status_filter = params.get("status")
if status_filter:
qs = qs.filter(status=status_filter)
# Search filter
search = params.get("search")
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(description__icontains=search) |
Q(manufacturer__name__icontains=search)
)
return qs
class ParkRideDetailAPIView(APIView):
"""Get specific ride details within park context."""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Get ride details within park context",
description="Get comprehensive details for a specific ride at a specific park",
responses={
200: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks", "Rides"],
)
def get(self, request: Request, park_slug: str, ride_slug: str) -> Response:
"""Get ride details within park context."""
if not MODELS_AVAILABLE:
return Response(
{"detail": "Park and ride models not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Get the park
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
# Get the ride
try:
ride, is_historical = Ride.get_by_slug(ride_slug, park=park)
except Ride.DoesNotExist:
raise NotFound("Ride not found at this park")
# Ensure ride belongs to this park
if ride.park_id != park.id:
raise NotFound("Ride not found at this park")
if SERIALIZERS_AVAILABLE:
serializer = RideDetailOutputSerializer(
ride, context={"request": request, "park": park}
)
return Response(serializer.data)
else:
# Fallback serialization
return Response({
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"description": getattr(ride, "description", ""),
"category": getattr(ride, "category", ""),
"status": getattr(ride, "status", ""),
"park": {
"id": park.id,
"name": park.name,
"slug": park.slug,
},
"manufacturer": {
"name": ride.manufacturer.name if ride.manufacturer else "",
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
} if ride.manufacturer else None,
})
class ParkComprehensiveDetailAPIView(APIView):
"""Get comprehensive park details including summary of rides."""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Get comprehensive park details with rides summary",
description="Get complete park details including a summary of rides (first 10) and link to full rides list",
responses={
200: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
)
def get(self, request: Request, park_slug: str) -> Response:
"""Get comprehensive park details with rides summary."""
if not MODELS_AVAILABLE:
return Response(
{"detail": "Park and ride models not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Get the park
try:
park, is_historical = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
# Get park with full related data
park = Park.objects.select_related(
"operator", "property_owner", "location"
).prefetch_related(
"areas", "rides", "photos"
).get(pk=park.pk)
# Get a sample of rides (first 10) for preview
rides_sample = Ride.objects.filter(park=park).select_related(
"manufacturer", "designer", "ride_model"
)[:10]
if SERIALIZERS_AVAILABLE:
# Get full park details
park_serializer = ParkDetailOutputSerializer(
park, context={"request": request}
)
park_data = park_serializer.data
# Add rides summary
rides_serializer = RideListOutputSerializer(
rides_sample, many=True, context={"request": request, "park": park}
)
# Enhance response with rides data
park_data["rides_summary"] = {
"total_count": park.ride_count or 0,
"sample": rides_serializer.data,
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
}
return Response(park_data)
else:
# Fallback serialization
return Response({
"id": park.id,
"name": park.name,
"slug": park.slug,
"description": getattr(park, "description", ""),
"location": str(getattr(park, "location", "")),
"operator": getattr(park.operator, "name", "") if hasattr(park, "operator") else "",
"ride_count": getattr(park, "ride_count", 0),
"rides_summary": {
"total_count": getattr(park, "ride_count", 0),
"sample": [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"category": getattr(ride, "category", ""),
}
for ride in rides_sample
],
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
},
})

View File

@@ -216,8 +216,18 @@ class ParkListCreateAPIView(APIView):
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply filtering to the queryset based on actual model fields."""
qs = self._apply_search_filters(qs, params)
qs = self._apply_location_filters(qs, params)
qs = self._apply_park_attribute_filters(qs, params)
qs = self._apply_company_filters(qs, params)
qs = self._apply_rating_filters(qs, params)
qs = self._apply_ride_count_filters(qs, params)
qs = self._apply_opening_year_filters(qs, params)
qs = self._apply_roller_coaster_filters(qs, params)
return qs
# Search filter
def _apply_search_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply search filtering to the queryset."""
search = params.get("search")
if search:
qs = qs.filter(
@@ -227,53 +237,54 @@ class ParkListCreateAPIView(APIView):
Q(location__state__icontains=search) |
Q(location__country__icontains=search)
)
return qs
# Location filters (only available fields)
country = params.get("country")
if country:
qs = qs.filter(location__country__iexact=country)
def _apply_location_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply location-based filtering to the queryset."""
location_filters = {
'country': 'location__country__iexact',
'state': 'location__state__iexact',
'city': 'location__city__iexact',
'continent': 'location__continent__iexact'
}
for param_name, filter_field in location_filters.items():
value = params.get(param_name)
if value:
qs = qs.filter(**{filter_field: value})
return qs
state = params.get("state")
if state:
qs = qs.filter(location__state__iexact=state)
city = params.get("city")
if city:
qs = qs.filter(location__city__iexact=city)
# Continent filter (now available field)
continent = params.get("continent")
if continent:
qs = qs.filter(location__continent__iexact=continent)
# Park type filter (now available field)
def _apply_park_attribute_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply park attribute filtering to the queryset."""
park_type = params.get("park_type")
if park_type:
qs = qs.filter(park_type=park_type)
# Status filter (available field)
status_filter = params.get("status")
if status_filter:
qs = qs.filter(status=status_filter)
return qs
# Company filters (available fields)
operator_id = params.get("operator_id")
if operator_id:
qs = qs.filter(operator_id=operator_id)
def _apply_company_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply company-related filtering to the queryset."""
company_filters = {
'operator_id': 'operator_id',
'operator_slug': 'operator__slug',
'property_owner_id': 'property_owner_id',
'property_owner_slug': 'property_owner__slug'
}
for param_name, filter_field in company_filters.items():
value = params.get(param_name)
if value:
qs = qs.filter(**{filter_field: value})
return qs
operator_slug = params.get("operator_slug")
if operator_slug:
qs = qs.filter(operator__slug=operator_slug)
property_owner_id = params.get("property_owner_id")
if property_owner_id:
qs = qs.filter(property_owner_id=property_owner_id)
property_owner_slug = params.get("property_owner_slug")
if property_owner_slug:
qs = qs.filter(property_owner__slug=property_owner_slug)
# Rating filters (available field)
def _apply_rating_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply rating-based filtering to the queryset."""
min_rating = params.get("min_rating")
if min_rating:
try:
@@ -287,8 +298,11 @@ class ParkListCreateAPIView(APIView):
qs = qs.filter(average_rating__lte=float(max_rating))
except (ValueError, TypeError):
pass
return qs
# Ride count filters (available field)
def _apply_ride_count_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply ride count filtering to the queryset."""
min_ride_count = params.get("min_ride_count")
if min_ride_count:
try:
@@ -302,8 +316,11 @@ class ParkListCreateAPIView(APIView):
qs = qs.filter(ride_count__lte=int(max_ride_count))
except (ValueError, TypeError):
pass
return qs
# Opening year filters (available field)
def _apply_opening_year_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply opening year filtering to the queryset."""
opening_year = params.get("opening_year")
if opening_year:
try:
@@ -324,8 +341,11 @@ class ParkListCreateAPIView(APIView):
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
except (ValueError, TypeError):
pass
return qs
# Roller coaster filters (using coaster_count field)
def _apply_roller_coaster_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply roller coaster filtering to the queryset."""
has_roller_coasters = params.get("has_roller_coasters")
if has_roller_coasters is not None:
if has_roller_coasters.lower() in ['true', '1', 'yes']:
@@ -346,7 +366,7 @@ class ParkListCreateAPIView(APIView):
qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count))
except (ValueError, TypeError):
pass
return qs
@extend_schema(
@@ -575,32 +595,49 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
"""Return comprehensive filter options with all possible park model fields and attributes."""
if not MODELS_AVAILABLE:
# Fallback comprehensive options with all possible fields
return Response({
"park_types": [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
{"value": "FAMILY_ENTERTAINMENT_CENTER",
"label": "Family Entertainment Center"},
{"value": "CARNIVAL", "label": "Carnival"},
{"value": "FAIR", "label": "Fair"},
{"value": "PIER", "label": "Pier"},
{"value": "BOARDWALK", "label": "Boardwalk"},
{"value": "SAFARI_PARK", "label": "Safari Park"},
{"value": "ZOO", "label": "Zoo"},
{"value": "OTHER", "label": "Other"},
],
"statuses": [
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"},
],
"""Return comprehensive filter options with Rich Choice Objects metadata."""
# Import Rich Choice registry
from apps.core.choices.registry import get_choices
# Always get static choice definitions from Rich Choice Objects (primary source)
park_types = get_choices('types', 'parks')
statuses = get_choices('statuses', 'parks')
# Convert Rich Choice Objects to frontend format with metadata
park_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 park_types
]
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
]
# Get dynamic data from database if models are available
if MODELS_AVAILABLE:
# Add any dynamic data queries here
pass
return Response({
"park_types": park_types_data,
"statuses": statuses_data,
"continents": [
"North America",
"South America",
@@ -665,18 +702,37 @@ class FilterOptionsAPIView(APIView):
],
})
# Try to get dynamic options from database
# Try to get dynamic options from database using Rich Choice Objects
try:
# Get all park types from model choices
park_types = [
{"value": choice[0], "label": choice[1]}
for choice in Park.PARK_TYPE_CHOICES
# Get rich choice objects from registry
park_types = get_choices('types', 'parks')
statuses = get_choices('statuses', 'parks')
# Convert Rich Choice Objects to frontend format with metadata
park_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 park_types
]
# Get all statuses from model choices
statuses = [
{"value": choice[0], "label": choice[1]}
for choice in Park.STATUS_CHOICES
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
]
# Get location data from database
@@ -773,8 +829,8 @@ class FilterOptionsAPIView(APIView):
}
return Response({
"park_types": park_types,
"statuses": statuses,
"park_types": park_types_data,
"statuses": statuses_data,
"continents": continents,
"countries": countries,
"states": states,

View File

@@ -17,6 +17,11 @@ from .park_views import (
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
)
from .park_rides_views import (
ParkRidesListAPIView,
ParkRideDetailAPIView,
ParkComprehensiveDetailAPIView,
)
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
# Create router for nested photo endpoints
@@ -48,6 +53,14 @@ urlpatterns = [
),
# Detail and action endpoints - supports both ID and slug
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
# Park rides endpoints
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"),
path("<str:park_slug>/rides/<str:ride_slug>/", ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
# Comprehensive park detail endpoint with rides summary
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"),
# Park image settings endpoint
path(
"<int:pk>/image-settings/",

View File

@@ -483,15 +483,111 @@ class RideModelFilterOptionsAPIView(APIView):
tags=["Ride Models"],
)
def get(self, request: Request) -> Response:
"""Return filter options for ride models."""
"""Return filter options for ride models with Rich Choice Objects metadata."""
# Import Rich Choice registry
from apps.core.choices.registry import get_choices
if not MODELS_AVAILABLE:
return Response(
{
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
}
)
# Use Rich Choice Objects for fallback options
try:
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in target_markets
]
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},
]
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},
]
return Response({
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard", "slug": "bolliger-mabillard"}],
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
{"value": "first_installation_year", "label": "Oldest First"},
{"value": "-first_installation_year", "label": "Newest First"},
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
})
# Get static choice definitions from Rich Choice Objects (primary source)
# Get dynamic data from database queries
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,
"label": choice.label,
"description": choice.description,
"color": choice.metadata.get('color'),
"icon": choice.metadata.get('icon'),
"css_class": choice.metadata.get('css_class'),
"sort_order": choice.metadata.get('sort_order', 0)
}
for choice in target_markets
]
# Get actual data from database
manufacturers = (
@@ -502,48 +598,22 @@ class RideModelFilterOptionsAPIView(APIView):
.values("id", "name", "slug")
)
(
RideModel.objects.exclude(category="")
.values_list("category", flat=True)
.distinct()
)
return Response({
"categories": categories_data,
"target_markets": target_markets_data,
"manufacturers": list(manufacturers),
"ordering_options": [
{"value": "name", "label": "Name A-Z"},
{"value": "-name", "label": "Name Z-A"},
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
{"value": "first_installation_year", "label": "Oldest First"},
{"value": "-first_installation_year", "label": "Newest First"},
{"value": "total_installations", "label": "Fewest Installations"},
{"value": "-total_installations", "label": "Most Installations"},
],
})
(
RideModel.objects.exclude(target_market="")
.values_list("target_market", flat=True)
.distinct()
)
return Response(
{
"categories": [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
"target_markets": [
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
"manufacturers": list(manufacturers),
"ordering_options": [
("name", "Name A-Z"),
("-name", "Name Z-A"),
("manufacturer__name", "Manufacturer A-Z"),
("-manufacturer__name", "Manufacturer Z-A"),
("first_installation_year", "Oldest First"),
("-first_installation_year", "Newest First"),
("total_installations", "Fewest Installations"),
("-total_installations", "Most Installations"),
],
}
)
# === RIDE MODEL STATISTICS ===

View File

@@ -13,7 +13,7 @@ Notes:
are not present, they return a clear 501 response explaining what to wire up.
"""
from typing import Any, Dict
from typing import Any
import logging
from django.db import models
@@ -38,7 +38,6 @@ from apps.api.v1.serializers.rides import (
)
# Import hybrid filtering components
from apps.api.v1.rides.serializers import HybridRideSerializer
from apps.rides.services.hybrid_loader import SmartRideLoader
# Create smart loader instance
@@ -47,12 +46,14 @@ 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
@@ -307,181 +308,233 @@ class RideListCreateAPIView(APIView):
.prefetch_related("coaster_stats")
) # type: ignore
# Text search
search = request.query_params.get("search")
# 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
# Park filters
park_slug = request.query_params.get("park_slug")
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 = request.query_params.get("park_id")
park_id = params.get("park_id")
if park_id:
try:
qs = qs.filter(park_id=int(park_id))
except (ValueError, TypeError):
pass
return qs
# Category filters (multiple values supported)
categories = request.query_params.getlist("category")
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)
# Status filters (multiple values supported)
statuses = request.query_params.getlist("status")
statuses = params.getlist("status")
if statuses:
qs = qs.filter(status__in=statuses)
return qs
# Manufacturer filters
manufacturer_id = request.query_params.get("manufacturer_id")
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 = request.query_params.get("manufacturer_slug")
manufacturer_slug = params.get("manufacturer_slug")
if manufacturer_slug:
qs = qs.filter(manufacturer__slug=manufacturer_slug)
# Designer filters
designer_id = request.query_params.get("designer_id")
designer_id = params.get("designer_id")
if designer_id:
try:
qs = qs.filter(designer_id=int(designer_id))
except (ValueError, TypeError):
pass
designer_slug = request.query_params.get("designer_slug")
designer_slug = params.get("designer_slug")
if designer_slug:
qs = qs.filter(designer__slug=designer_slug)
return qs
# Ride model filters
ride_model_id = request.query_params.get("ride_model_id")
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 = request.query_params.get("ride_model_slug")
manufacturer_slug_for_model = request.query_params.get("manufacturer_slug")
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
# Rating filters
min_rating = request.query_params.get("min_rating")
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 = request.query_params.get("max_rating")
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
# Height requirement filters
min_height_req = request.query_params.get("min_height_requirement")
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 = request.query_params.get("max_height_requirement")
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
# Capacity filters
min_capacity = request.query_params.get("min_capacity")
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 = request.query_params.get("max_capacity")
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
# Opening year filters
opening_year = request.query_params.get("opening_year")
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 = request.query_params.get("min_opening_year")
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 = request.query_params.get("max_opening_year")
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
# Roller coaster specific filters
roller_coaster_type = request.query_params.get("roller_coaster_type")
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 = request.query_params.get("track_material")
track_material = params.get("track_material")
if track_material:
qs = qs.filter(coaster_stats__track_material=track_material)
launch_type = request.query_params.get("launch_type")
launch_type = params.get("launch_type")
if launch_type:
qs = qs.filter(coaster_stats__launch_type=launch_type)
# Roller coaster height filters
min_height_ft = request.query_params.get("min_height_ft")
# 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 = request.query_params.get("max_height_ft")
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
# Roller coaster speed filters
min_speed_mph = request.query_params.get("min_speed_mph")
# 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 = request.query_params.get("max_speed_mph")
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))
@@ -489,29 +542,32 @@ class RideListCreateAPIView(APIView):
pass
# Inversion filters
min_inversions = request.query_params.get("min_inversions")
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 = request.query_params.get("max_inversions")
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 = request.query_params.get("has_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
# Ordering
ordering = request.query_params.get("ordering", "name")
def _apply_ordering(self, qs, params):
"""Apply ordering to the queryset."""
ordering = params.get("ordering", "name")
valid_orderings = [
"name",
"-name",
@@ -538,13 +594,8 @@ class RideListCreateAPIView(APIView):
qs = qs.order_by(ordering_field)
else:
qs = qs.order_by(ordering)
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = RideListOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
return qs
@extend_schema(
summary="Create a new ride",
@@ -698,28 +749,169 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
"""Return comprehensive filter options with all possible ride model fields and attributes."""
"""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:
# Comprehensive fallback options with all possible fields
# 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')
launch_systems = get_choices('launch_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
]
launch_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 launch_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},
]
launch_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": [
{"value": "RC", "label": "Roller Coaster"},
{"value": "DR", "label": "Dark Ride"},
{"value": "FR", "label": "Flat Ride"},
{"value": "WR", "label": "Water Ride"},
{"value": "TR", "label": "Transport"},
{"value": "OT", "label": "Other"},
],
"statuses": [
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "SBNO", "label": "Standing But Not Operating"},
{"value": "CLOSING", "label": "Closing"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"},
],
"categories": categories_data,
"statuses": statuses_data,
"post_closing_statuses": [
{"value": "SBNO", "label": "Standing But Not Operating"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
@@ -818,119 +1010,178 @@ class FilterOptionsAPIView(APIView):
],
})
# Try to get dynamic options from database
try:
# Get all ride categories from model choices
categories = [
{"value": choice[0], "label": choice[1]}
for choice in Ride.CATEGORY_CHOICES if choice[0] # Skip empty choice
]
# 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')
launch_systems = get_choices('launch_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
]
launch_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 launch_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 all ride statuses from model choices
statuses = [
{"value": choice[0], "label": choice[1]}
for choice in Ride.STATUS_CHOICES if choice[0] # Skip empty choice
]
# 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 post-closing statuses from model choices
post_closing_statuses = [
{"value": choice[0], "label": choice[1]}
for choice in Ride.POST_CLOSING_STATUS_CHOICES
]
# 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 roller coaster types from model choices
from apps.rides.models.rides import RollerCoasterStats
roller_coaster_types = [
{"value": choice[0], "label": choice[1]}
for choice in RollerCoasterStats.COASTER_TYPE_CHOICES
]
# Get manufacturers (companies with MANUFACTURER role)
manufacturers = list(Company.objects.filter(
roles__contains=['MANUFACTURER']
).values('id', 'name', 'slug').order_by('name'))
# Get track materials from model choices
track_materials = [
{"value": choice[0], "label": choice[1]}
for choice in RollerCoasterStats.TRACK_MATERIAL_CHOICES
]
# Get designers (companies with DESIGNER role)
designers = list(Company.objects.filter(
roles__contains=['DESIGNER']
).values('id', 'name', 'slug').order_by('name'))
# Get launch types from model choices
launch_types = [
{"value": choice[0], "label": choice[1]}
for choice in RollerCoasterStats.LAUNCH_CHOICES
]
# 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'))
# Get ride model target markets from model choices
ride_model_target_markets = [
{"value": choice[0], "label": choice[1]}
for choice in RideModel._meta.get_field('target_market').choices
]
# 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'),
)
# 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'))
# 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'),
)
# 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 = {
ranges = {
"rating": {
"min": float(ride_stats['min_rating'] or 1),
"max": float(ride_stats['max_rating'] or 10),
@@ -1017,24 +1268,24 @@ class FilterOptionsAPIView(APIView):
},
}
return Response({
"categories": categories,
"statuses": statuses,
"post_closing_statuses": post_closing_statuses,
"roller_coaster_types": roller_coaster_types,
"track_materials": track_materials,
"launch_types": launch_types,
"ride_model_target_markets": ride_model_target_markets,
"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",
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,
"launch_types": launch_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"},
@@ -1072,124 +1323,6 @@ class FilterOptionsAPIView(APIView):
],
})
except Exception:
# Fallback to static options if database query fails
return Response({
"categories": [
{"value": "RC", "label": "Roller Coaster"},
{"value": "DR", "label": "Dark Ride"},
{"value": "FR", "label": "Flat Ride"},
{"value": "WR", "label": "Water Ride"},
{"value": "TR", "label": "Transport"},
{"value": "OT", "label": "Other"},
],
"statuses": [
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "SBNO", "label": "Standing But Not Operating"},
{"value": "CLOSING", "label": "Closing"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"},
],
"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"},
],
"launch_types": [
{"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)"},
],
})
# --- Company search (autocomplete) -----------------------------------------

View File

@@ -18,6 +18,7 @@ from apps.accounts.models import (
UserNotification,
NotificationPreference,
)
from apps.core.choices.serializers import RichChoiceFieldSerializer
UserModel = get_user_model()
@@ -190,8 +191,10 @@ class CompleteUserSerializer(serializers.ModelSerializer):
class UserPreferencesSerializer(serializers.Serializer):
"""Serializer for user preferences and settings."""
theme_preference = serializers.ChoiceField(
choices=User.ThemePreference.choices, help_text="User's theme preference"
theme_preference = RichChoiceFieldSerializer(
choice_group="theme_preferences",
domain="accounts",
help_text="User's theme preference"
)
email_notifications = serializers.BooleanField(
default=True, help_text="Whether to receive email notifications"
@@ -199,12 +202,9 @@ class UserPreferencesSerializer(serializers.Serializer):
push_notifications = serializers.BooleanField(
default=False, help_text="Whether to receive push notifications"
)
privacy_level = serializers.ChoiceField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
privacy_level = RichChoiceFieldSerializer(
choice_group="privacy_levels",
domain="accounts",
default="public",
help_text="Profile visibility level",
)
@@ -321,12 +321,9 @@ class NotificationSettingsSerializer(serializers.Serializer):
class PrivacySettingsSerializer(serializers.Serializer):
"""Serializer for privacy and visibility settings."""
profile_visibility = serializers.ChoiceField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
profile_visibility = RichChoiceFieldSerializer(
choice_group="privacy_levels",
domain="accounts",
default="public",
help_text="Overall profile visibility",
)
@@ -363,12 +360,9 @@ class PrivacySettingsSerializer(serializers.Serializer):
search_visibility = serializers.BooleanField(
default=True, help_text="Allow profile to appear in search results"
)
activity_visibility = serializers.ChoiceField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
activity_visibility = RichChoiceFieldSerializer(
choice_group="privacy_levels",
domain="accounts",
default="friends",
help_text="Who can see your activity feed",
)

View File

@@ -12,7 +12,8 @@ from drf_spectacular.utils import (
OpenApiExample,
)
from .shared import CATEGORY_CHOICES, ModelChoices
from .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
# === COMPANY SERIALIZERS ===
@@ -111,7 +112,10 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
category = serializers.CharField()
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
# Manufacturer info
manufacturer = serializers.SerializerMethodField()
@@ -136,7 +140,7 @@ class RideModelCreateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(choices=CATEGORY_CHOICES, required=False)
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
@@ -145,5 +149,5 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
category = serializers.ChoiceField(choices=CATEGORY_CHOICES, required=False)
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)

View File

@@ -9,6 +9,8 @@ from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_field,
)
from .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
# === STATISTICS SERIALIZERS ===
@@ -90,7 +92,10 @@ class ParkReviewOutputSerializer(serializers.Serializer):
class HealthCheckOutputSerializer(serializers.Serializer):
"""Output serializer for health check responses."""
status = serializers.ChoiceField(choices=["healthy", "unhealthy"])
status = RichChoiceFieldSerializer(
choice_group="health_statuses",
domain="core"
)
timestamp = serializers.DateTimeField()
version = serializers.CharField()
environment = serializers.CharField()
@@ -111,6 +116,9 @@ class PerformanceMetricsOutputSerializer(serializers.Serializer):
class SimpleHealthOutputSerializer(serializers.Serializer):
"""Output serializer for simple health check."""
status = serializers.ChoiceField(choices=["ok", "error"])
status = RichChoiceFieldSerializer(
choice_group="simple_health_statuses",
domain="core"
)
timestamp = serializers.DateTimeField()
error = serializers.CharField(required=False)

View File

@@ -15,6 +15,7 @@ from config.django import base as settings
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
from apps.core.services.media_url_service import MediaURLService
from apps.core.choices.serializers import RichChoiceFieldSerializer
# === PARK SERIALIZERS ===
@@ -51,7 +52,10 @@ class ParkListOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="parks"
)
description = serializers.CharField()
# Statistics
@@ -141,7 +145,10 @@ class ParkDetailOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
status = serializers.CharField()
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="parks"
)
description = serializers.CharField()
# Details

View File

@@ -14,6 +14,7 @@ from drf_spectacular.utils import (
from config.django import base as settings
from .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
# Use dynamic imports to avoid circular import issues
@@ -132,14 +133,20 @@ class RideModelListOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
description = serializers.CharField()
# Manufacturer info
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
# Market info
target_market = serializers.CharField()
target_market = RichChoiceFieldSerializer(
choice_group="target_markets",
domain="rides"
)
is_discontinued = serializers.BooleanField()
total_installations = serializers.IntegerField()
first_installation_year = serializers.IntegerField(allow_null=True)
@@ -386,15 +393,9 @@ class RideModelCreateInputSerializer(serializers.Serializer):
# Design features
notable_features = serializers.CharField(allow_blank=True, default="")
target_market = serializers.ChoiceField(
choices=[
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
choices=ModelChoices.get_target_market_choices(),
required=False,
allow_blank=True,
default="",
)
def validate(self, attrs):
@@ -496,13 +497,7 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
# Design features
notable_features = serializers.CharField(allow_blank=True, required=False)
target_market = serializers.ChoiceField(
choices=[
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
choices=ModelChoices.get_target_market_choices(),
allow_blank=True,
required=False,
)
@@ -565,13 +560,7 @@ class RideModelFilterInputSerializer(serializers.Serializer):
# Market filter
target_market = serializers.MultipleChoiceField(
choices=[
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
choices=ModelChoices.get_target_market_choices(),
required=False,
)
@@ -724,16 +713,7 @@ class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
ride_model_id = serializers.IntegerField()
spec_category = serializers.ChoiceField(
choices=[
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
]
choices=ModelChoices.get_technical_spec_category_choices()
)
spec_name = serializers.CharField(max_length=100)
spec_value = serializers.CharField(max_length=255)
@@ -745,16 +725,7 @@ class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride model technical specifications."""
spec_category = serializers.ChoiceField(
choices=[
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
],
choices=ModelChoices.get_technical_spec_category_choices(),
required=False,
)
spec_name = serializers.CharField(max_length=100, required=False)
@@ -774,13 +745,7 @@ class RideModelPhotoCreateInputSerializer(serializers.Serializer):
caption = serializers.CharField(max_length=500, allow_blank=True, default="")
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
photo_type = serializers.ChoiceField(
choices=[
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
choices=ModelChoices.get_photo_type_choices(),
default="PROMOTIONAL",
)
is_primary = serializers.BooleanField(default=False)
@@ -795,13 +760,7 @@ class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
caption = serializers.CharField(max_length=500, allow_blank=True, required=False)
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
photo_type = serializers.ChoiceField(
choices=[
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
choices=ModelChoices.get_photo_type_choices(),
required=False,
)
is_primary = serializers.BooleanField(required=False)

View File

@@ -13,6 +13,7 @@ from drf_spectacular.utils import (
)
from config.django import base as settings
from .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
# === RIDE SERIALIZERS ===
@@ -24,6 +25,12 @@ class RideParkOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
url = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this park."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
class RideModelOutputSerializer(serializers.Serializer):
@@ -73,8 +80,14 @@ class RideListOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="rides"
)
description = serializers.CharField()
# Park info
@@ -164,9 +177,19 @@ class RideDetailOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
post_closing_status = serializers.CharField(allow_null=True)
category = RichChoiceFieldSerializer(
choice_group="categories",
domain="rides"
)
status = RichChoiceFieldSerializer(
choice_group="statuses",
domain="rides"
)
post_closing_status = RichChoiceFieldSerializer(
choice_group="post_closing_statuses",
domain="rides",
allow_null=True
)
description = serializers.CharField()
# Park info
@@ -449,10 +472,10 @@ class RideCreateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(choices=[]) # Choices set dynamically
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices())
status = serializers.ChoiceField(
choices=[], default="OPERATING"
) # Choices set dynamically
choices=ModelChoices.get_ride_status_choices(), default="OPERATING"
)
# Required park
park_id = serializers.IntegerField()
@@ -531,11 +554,11 @@ class RideUpdateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
category = serializers.ChoiceField(
choices=[], required=False
) # Choices set dynamically
choices=ModelChoices.get_ride_category_choices(), required=False
)
status = serializers.ChoiceField(
choices=[], required=False
) # Choices set dynamically
choices=ModelChoices.get_ride_status_choices(), required=False
)
post_closing_status = serializers.ChoiceField(
choices=ModelChoices.get_ride_post_closing_choices(),
required=False,
@@ -603,13 +626,13 @@ class RideFilterInputSerializer(serializers.Serializer):
# Category filter
category = serializers.MultipleChoiceField(
choices=[], required=False
) # Choices set dynamically
choices=ModelChoices.get_ride_category_choices(), required=False
)
# Status filter
status = serializers.MultipleChoiceField(
choices=[],
required=False, # Choices set dynamically
choices=ModelChoices.get_ride_status_choices(),
required=False,
)
# Park filter
@@ -695,12 +718,21 @@ class RollerCoasterStatsOutputSerializer(serializers.Serializer):
inversions = serializers.IntegerField()
ride_time_seconds = serializers.IntegerField(allow_null=True)
track_type = serializers.CharField()
track_material = serializers.CharField()
roller_coaster_type = serializers.CharField()
track_material = RichChoiceFieldSerializer(
choice_group="track_materials",
domain="rides"
)
roller_coaster_type = RichChoiceFieldSerializer(
choice_group="coaster_types",
domain="rides"
)
max_drop_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
launch_type = serializers.CharField()
launch_type = RichChoiceFieldSerializer(
choice_group="launch_systems",
domain="rides"
)
train_style = serializers.CharField()
trains_count = serializers.IntegerField(allow_null=True)
cars_per_train = serializers.IntegerField(allow_null=True)

View File

@@ -6,6 +6,8 @@ and other search functionality.
"""
from rest_framework import serializers
from ..shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
# === CORE ENTITY SEARCH SERIALIZERS ===
@@ -16,7 +18,9 @@ class EntitySearchInputSerializer(serializers.Serializer):
query = serializers.CharField(max_length=255, help_text="Search query string")
entity_types = serializers.ListField(
child=serializers.ChoiceField(choices=["park", "ride", "company", "user"]),
child=serializers.ChoiceField(
choices=ModelChoices.get_entity_type_choices()
),
required=False,
help_text="Types of entities to search for",
)
@@ -34,7 +38,10 @@ class EntitySearchResultSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
type = serializers.CharField()
type = RichChoiceFieldSerializer(
choice_group="entity_types",
domain="core"
)
description = serializers.CharField()
relevance_score = serializers.FloatField()

View File

@@ -147,7 +147,12 @@ class ModerationSubmissionSerializer(serializers.Serializer):
"""Serializer for moderation submissions."""
submission_type = serializers.ChoiceField(
choices=["EDIT", "PHOTO", "REVIEW"], help_text="Type of submission"
choices=[
("EDIT", "Edit Submission"),
("PHOTO", "Photo Submission"),
("REVIEW", "Review Submission"),
],
help_text="Type of submission"
)
content_type = serializers.CharField(help_text="Content type being modified")
object_id = serializers.IntegerField(help_text="ID of object being modified")

View File

@@ -9,7 +9,7 @@ for common data structures used throughout the API.
"""
from rest_framework import serializers
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List
class FilterOptionSerializer(serializers.Serializer):
@@ -316,107 +316,124 @@ class CompanyOutputSerializer(serializers.Serializer):
)
# Category choices for ride models
CATEGORY_CHOICES = [
('RC', 'Roller Coaster'),
('DR', 'Dark Ride'),
('FR', 'Flat Ride'),
('WR', 'Water Ride'),
('TR', 'Transport Ride'),
]
class ModelChoices:
"""
Utility class to provide model choices for serializers.
This prevents circular imports while providing access to model choices.
Utility class to provide model choices for serializers using Rich Choice Objects.
This prevents circular imports while providing access to model choices from the registry.
NO FALLBACKS - All choices must be properly defined in Rich Choice Objects.
"""
@staticmethod
def get_park_status_choices():
"""Get park status choices."""
return [
('OPERATING', 'Operating'),
('CLOSED_TEMP', 'Temporarily Closed'),
('CLOSED_PERM', 'Permanently Closed'),
('UNDER_CONSTRUCTION', 'Under Construction'),
('PLANNED', 'Planned'),
]
"""Get park status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("statuses", "parks")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_ride_status_choices():
"""Get ride status choices."""
return [
('OPERATING', 'Operating'),
('CLOSED_TEMP', 'Temporarily Closed'),
('CLOSED_PERM', 'Permanently Closed'),
('SBNO', 'Standing But Not Operating'),
('UNDER_CONSTRUCTION', 'Under Construction'),
('RELOCATED', 'Relocated'),
('DEMOLISHED', 'Demolished'),
]
"""Get ride status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("statuses", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_company_role_choices():
"""Get company role choices."""
return [
('MANUFACTURER', 'Manufacturer'),
('OPERATOR', 'Operator'),
('DESIGNER', 'Designer'),
('PROPERTY_OWNER', 'Property Owner'),
]
"""Get company role choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
# Get rides domain company roles (MANUFACTURER, DESIGNER)
rides_choices = get_choices("company_roles", "rides")
# Get parks domain company roles (OPERATOR, PROPERTY_OWNER)
parks_choices = get_choices("company_roles", "parks")
all_choices = list(rides_choices) + list(parks_choices)
return [(choice.value, choice.label) for choice in all_choices]
@staticmethod
def get_ride_category_choices():
"""Get ride category choices."""
return CATEGORY_CHOICES
"""Get ride category choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("categories", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_ride_post_closing_choices():
"""Get ride post-closing status choices."""
return [
('RELOCATED', 'Relocated'),
('DEMOLISHED', 'Demolished'),
('STORED', 'Stored'),
('UNKNOWN', 'Unknown'),
]
"""Get ride post-closing status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("post_closing_statuses", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_coaster_track_choices():
"""Get coaster track type choices."""
return [
('STEEL', 'Steel'),
('WOOD', 'Wood'),
('HYBRID', 'Hybrid'),
]
"""Get coaster track material choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("track_materials", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_coaster_type_choices():
"""Get coaster type choices."""
return [
('SIT_DOWN', 'Sit Down'),
('INVERTED', 'Inverted'),
('FLOORLESS', 'Floorless'),
('FLYING', 'Flying'),
('STAND_UP', 'Stand Up'),
('SPINNING', 'Spinning'),
('WING', 'Wing'),
('DIVE', 'Dive'),
('LAUNCHED', 'Launched'),
]
"""Get coaster type choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("coaster_types", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_launch_choices():
"""Get launch system choices."""
return [
('NONE', 'None'),
('LIM', 'Linear Induction Motor'),
('LSM', 'Linear Synchronous Motor'),
('HYDRAULIC', 'Hydraulic'),
('PNEUMATIC', 'Pneumatic'),
('CABLE', 'Cable'),
('FLYWHEEL', 'Flywheel'),
]
"""Get launch system choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("launch_systems", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_photo_type_choices():
"""Get photo type choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("photo_types", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_spec_category_choices():
"""Get technical specification category choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("spec_categories", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_technical_spec_category_choices():
"""Get technical specification category choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("spec_categories", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_target_market_choices():
"""Get target market choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("target_markets", "rides")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_entity_type_choices():
"""Get entity type choices for search functionality."""
from apps.core.choices.registry import get_choices
choices = get_choices("entity_types", "core")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_health_status_choices():
"""Get health check status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("health_statuses", "core")
return [(choice.value, choice.label) for choice in choices]
@staticmethod
def get_simple_health_status_choices():
"""Get simple health check status choices from Rich Choice registry."""
from apps.core.choices.registry import get_choices
choices = get_choices("simple_health_statuses", "core")
return [(choice.value, choice.label) for choice in choices]
class EntityReferenceSerializer(serializers.Serializer):
@@ -593,12 +610,12 @@ def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]:
'count': option.get('count'),
'selected': option.get('selected', False)
}
elif isinstance(option, (list, tuple)) and len(option) >= 2:
# Tuple format: (value, label) or (value, label, count)
elif hasattr(option, 'value') and hasattr(option, 'label'):
# RichChoice object format
standardized_option = {
'value': str(option[0]),
'label': str(option[1]),
'count': option[2] if len(option) > 2 else None,
'value': str(option.value),
'label': str(option.label),
'count': None,
'selected': False
}
else:

View File

@@ -5,12 +5,8 @@ These tests verify that API responses match frontend TypeScript interfaces exact
preventing runtime errors and ensuring type safety.
"""
import json
from django.test import TestCase, Client
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from typing import Dict, Any, List
from apps.parks.services.hybrid_loader import smart_park_loader
from apps.rides.services.hybrid_loader import SmartRideLoader

View File

@@ -14,9 +14,7 @@ from rest_framework.serializers import Serializer
from django.conf import settings
from apps.api.v1.serializers.shared import (
validate_filter_metadata_contract,
ApiResponseSerializer,
ErrorResponseSerializer
validate_filter_metadata_contract
)
logger = logging.getLogger(__name__)

View File

@@ -250,7 +250,10 @@ class StatsAPIView(APIView):
"RELOCATED": "relocated_parks",
}
status_name = status_names.get(status_code, f"status_{status_code.lower()}")
if status_code in status_names:
status_name = status_names[status_code]
else:
raise ValueError(f"Unknown park status: {status_code}")
park_status_stats[status_name] = status_count
# Ride status counts