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

@@ -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/",