mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:51:09 -05:00
feat: Enhance Park Detail Endpoint with Media URL Service Integration
- Updated ParkDetailOutputSerializer to utilize MediaURLService for generating Cloudflare URLs and friendly URLs for park photos. - Added support for multiple lookup methods (ID and slug) in the park detail endpoint. - Improved documentation for the park detail endpoint, including request properties and response structure. - Created MediaURLService for generating SEO-friendly URLs and handling Cloudflare image URLs. - Comprehensive updates to frontend documentation to reflect new endpoint capabilities and usage examples. - Added detailed park detail endpoint documentation, including request and response structures, field descriptions, and usage examples.
This commit is contained in:
@@ -12,6 +12,7 @@ Supports all 24 filtering parameters from frontend API documentation.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
@@ -401,20 +402,22 @@ class ParkListCreateAPIView(APIView):
|
||||
|
||||
# --- Park retrieve / update / delete ---------------------------------------
|
||||
@extend_schema(
|
||||
summary="Retrieve, update or delete a park",
|
||||
summary="Retrieve, update or delete a park by ID or slug",
|
||||
description="Retrieve full park details including location, photos, areas, rides, and company information. Supports both ID and slug-based lookup with historical slug support.",
|
||||
responses={
|
||||
200: (
|
||||
"ParkDetailOutputSerializer()"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
)
|
||||
),
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
class ParkDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def _get_park_or_404(self, pk: int) -> Any:
|
||||
def _get_park_or_404(self, identifier: str) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound(
|
||||
(
|
||||
@@ -423,13 +426,77 @@ class ParkDetailAPIView(APIView):
|
||||
"to enable detail endpoints."
|
||||
)
|
||||
)
|
||||
|
||||
# Try to parse as integer ID first
|
||||
try:
|
||||
# type: ignore
|
||||
return Park.objects.select_related("operator", "property_owner").get(pk=pk)
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
pk = int(identifier)
|
||||
try:
|
||||
return Park.objects.select_related(
|
||||
"operator", "property_owner", "location"
|
||||
).prefetch_related(
|
||||
"areas", "rides", "photos"
|
||||
).get(pk=pk)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
except ValueError:
|
||||
# Not an integer, try slug lookup
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(identifier)
|
||||
# Ensure we have the full related data
|
||||
return Park.objects.select_related(
|
||||
"operator", "property_owner", "location"
|
||||
).prefetch_related(
|
||||
"areas", "rides", "photos"
|
||||
).get(pk=park.pk)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
def get(self, request: Request, pk: int) -> Response:
|
||||
@extend_schema(
|
||||
summary="Get park full details",
|
||||
description="""
|
||||
Retrieve comprehensive park details including:
|
||||
|
||||
**Core Information:**
|
||||
- Basic park details (name, slug, description, status)
|
||||
- Opening/closing dates and operating season
|
||||
- Size in acres and website URL
|
||||
- Statistics (average rating, ride count, coaster count)
|
||||
|
||||
**Location Data:**
|
||||
- Full address with coordinates
|
||||
- City, state, country information
|
||||
- Formatted address string
|
||||
|
||||
**Company Information:**
|
||||
- Operating company details
|
||||
- Property owner information (if different)
|
||||
|
||||
**Media:**
|
||||
- All approved photos with Cloudflare variants
|
||||
- Primary photo designation
|
||||
- Banner and card image settings
|
||||
|
||||
**Related Content:**
|
||||
- Park areas/themed sections
|
||||
- Associated rides (summary)
|
||||
|
||||
**Lookup Methods:**
|
||||
- By ID: `/api/v1/parks/123/`
|
||||
- By current slug: `/api/v1/parks/cedar-point/`
|
||||
- By historical slug: `/api/v1/parks/old-cedar-point-name/`
|
||||
|
||||
**No Query Parameters Required** - This endpoint returns full details by default.
|
||||
""",
|
||||
responses={
|
||||
200: (
|
||||
"ParkDetailOutputSerializer()"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
),
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
)
|
||||
def get(self, request: Request, pk: str) -> Response:
|
||||
park = self._get_park_or_404(pk)
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
serializer = ParkDetailOutputSerializer(park, context={"request": request})
|
||||
@@ -451,7 +518,7 @@ class ParkDetailAPIView(APIView):
|
||||
}
|
||||
)
|
||||
|
||||
def patch(self, request: Request, pk: int) -> Response:
|
||||
def patch(self, request: Request, pk: str) -> Response:
|
||||
park = self._get_park_or_404(pk)
|
||||
if not SERIALIZERS_AVAILABLE:
|
||||
return Response(
|
||||
@@ -478,11 +545,11 @@ class ParkDetailAPIView(APIView):
|
||||
serializer = ParkDetailOutputSerializer(park, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
def put(self, request: Request, pk: int) -> Response:
|
||||
def put(self, request: Request, pk: str) -> Response:
|
||||
# Full replace - reuse patch behavior for simplicity
|
||||
return self.patch(request, pk)
|
||||
|
||||
def delete(self, request: Request, pk: int) -> Response:
|
||||
def delete(self, request: Request, pk: str) -> Response:
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
@@ -508,9 +575,9 @@ class FilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return comprehensive filter options matching frontend API documentation."""
|
||||
"""Return comprehensive filter options with all possible park model fields and attributes."""
|
||||
if not MODELS_AVAILABLE:
|
||||
# Fallback comprehensive options
|
||||
# Fallback comprehensive options with all possible fields
|
||||
return Response({
|
||||
"park_types": [
|
||||
{"value": "THEME_PARK", "label": "Theme Park"},
|
||||
@@ -518,6 +585,21 @@ class FilterOptionsAPIView(APIView):
|
||||
{"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"},
|
||||
],
|
||||
"continents": [
|
||||
"North America",
|
||||
@@ -546,6 +628,21 @@ class FilterOptionsAPIView(APIView):
|
||||
"Texas",
|
||||
"New York"
|
||||
],
|
||||
"cities": [
|
||||
"Orlando",
|
||||
"Los Angeles",
|
||||
"Cedar Point",
|
||||
"Sandusky"
|
||||
],
|
||||
"operators": [],
|
||||
"property_owners": [],
|
||||
"ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"},
|
||||
"ride_count": {"min": 0, "max": 100, "step": 1, "unit": "rides"},
|
||||
"coaster_count": {"min": 0, "max": 50, "step": 1, "unit": "coasters"},
|
||||
"size_acres": {"min": 0, "max": 10000, "step": 1, "unit": "acres"},
|
||||
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
|
||||
},
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
@@ -553,18 +650,36 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
|
||||
{"value": "ride_count", "label": "Ride Count (Low to High)"},
|
||||
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
|
||||
{"value": "coaster_count", "label": "Coaster Count (Low to High)"},
|
||||
{"value": "-coaster_count", "label": "Coaster Count (High to Low)"},
|
||||
{"value": "average_rating", "label": "Rating (Low to High)"},
|
||||
{"value": "-average_rating", "label": "Rating (High to Low)"},
|
||||
{"value": "roller_coaster_count",
|
||||
"label": "Coaster Count (Low to High)"},
|
||||
{"value": "-roller_coaster_count",
|
||||
"label": "Coaster Count (High to Low)"},
|
||||
{"value": "size_acres", "label": "Size (Small to Large)"},
|
||||
{"value": "-size_acres", "label": "Size (Large to Small)"},
|
||||
{"value": "created_at",
|
||||
"label": "Added to Database (Oldest First)"},
|
||||
{"value": "-created_at",
|
||||
"label": "Added to Database (Newest First)"},
|
||||
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
|
||||
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
|
||||
],
|
||||
})
|
||||
|
||||
# Try to get dynamic options from database
|
||||
try:
|
||||
# Get continents from database (now available field)
|
||||
# Get all park types from model choices
|
||||
park_types = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Park.PARK_TYPE_CHOICES
|
||||
]
|
||||
|
||||
# Get all statuses from model choices
|
||||
statuses = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Park.STATUS_CHOICES
|
||||
]
|
||||
|
||||
# Get location data from database
|
||||
continents = list(Park.objects.exclude(
|
||||
location__continent__isnull=True
|
||||
).exclude(
|
||||
@@ -595,17 +710,78 @@ class FilterOptionsAPIView(APIView):
|
||||
location__state__exact=''
|
||||
).values_list('location__state', flat=True).distinct().order_by('location__state'))
|
||||
|
||||
# Get park types from model choices (now available field)
|
||||
park_types = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Park.PARK_TYPE_CHOICES
|
||||
]
|
||||
cities = list(Park.objects.exclude(
|
||||
location__city__isnull=True
|
||||
).exclude(
|
||||
location__city__exact=''
|
||||
).values_list('location__city', flat=True).distinct().order_by('location__city'))
|
||||
|
||||
# Get operators and property owners
|
||||
operators = list(Company.objects.filter(
|
||||
roles__contains=['OPERATOR']
|
||||
).values('id', 'name', 'slug').order_by('name'))
|
||||
|
||||
property_owners = list(Company.objects.filter(
|
||||
roles__contains=['PROPERTY_OWNER']
|
||||
).values('id', 'name', 'slug').order_by('name'))
|
||||
|
||||
# Calculate ranges from actual data
|
||||
park_stats = Park.objects.aggregate(
|
||||
min_rating=models.Min('average_rating'),
|
||||
max_rating=models.Max('average_rating'),
|
||||
min_ride_count=models.Min('ride_count'),
|
||||
max_ride_count=models.Max('ride_count'),
|
||||
min_coaster_count=models.Min('coaster_count'),
|
||||
max_coaster_count=models.Max('coaster_count'),
|
||||
min_size=models.Min('size_acres'),
|
||||
max_size=models.Max('size_acres'),
|
||||
min_year=models.Min('opening_date__year'),
|
||||
max_year=models.Max('opening_date__year'),
|
||||
)
|
||||
|
||||
ranges = {
|
||||
"rating": {
|
||||
"min": float(park_stats['min_rating'] or 1),
|
||||
"max": float(park_stats['max_rating'] or 10),
|
||||
"step": 0.1,
|
||||
"unit": "stars"
|
||||
},
|
||||
"ride_count": {
|
||||
"min": park_stats['min_ride_count'] or 0,
|
||||
"max": park_stats['max_ride_count'] or 100,
|
||||
"step": 1,
|
||||
"unit": "rides"
|
||||
},
|
||||
"coaster_count": {
|
||||
"min": park_stats['min_coaster_count'] or 0,
|
||||
"max": park_stats['max_coaster_count'] or 50,
|
||||
"step": 1,
|
||||
"unit": "coasters"
|
||||
},
|
||||
"size_acres": {
|
||||
"min": float(park_stats['min_size'] or 0),
|
||||
"max": float(park_stats['max_size'] or 10000),
|
||||
"step": 1,
|
||||
"unit": "acres"
|
||||
},
|
||||
"opening_year": {
|
||||
"min": park_stats['min_year'] or 1800,
|
||||
"max": park_stats['max_year'] or 2030,
|
||||
"step": 1,
|
||||
"unit": "year"
|
||||
},
|
||||
}
|
||||
|
||||
return Response({
|
||||
"park_types": park_types,
|
||||
"statuses": statuses,
|
||||
"continents": continents,
|
||||
"countries": countries,
|
||||
"states": states,
|
||||
"cities": cities,
|
||||
"operators": operators,
|
||||
"property_owners": property_owners,
|
||||
"ranges": ranges,
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
@@ -613,12 +789,18 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
|
||||
{"value": "ride_count", "label": "Ride Count (Low to High)"},
|
||||
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
|
||||
{"value": "coaster_count", "label": "Coaster Count (Low to High)"},
|
||||
{"value": "-coaster_count", "label": "Coaster Count (High to Low)"},
|
||||
{"value": "average_rating", "label": "Rating (Low to High)"},
|
||||
{"value": "-average_rating", "label": "Rating (High to Low)"},
|
||||
{"value": "roller_coaster_count",
|
||||
"label": "Coaster Count (Low to High)"},
|
||||
{"value": "-roller_coaster_count",
|
||||
"label": "Coaster Count (High to Low)"},
|
||||
{"value": "size_acres", "label": "Size (Small to Large)"},
|
||||
{"value": "-size_acres", "label": "Size (Large to Small)"},
|
||||
{"value": "created_at",
|
||||
"label": "Added to Database (Oldest First)"},
|
||||
{"value": "-created_at",
|
||||
"label": "Added to Database (Newest First)"},
|
||||
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
|
||||
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -629,6 +811,23 @@ class FilterOptionsAPIView(APIView):
|
||||
{"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"},
|
||||
],
|
||||
"continents": [
|
||||
"North America",
|
||||
@@ -652,6 +851,20 @@ class FilterOptionsAPIView(APIView):
|
||||
"Ohio",
|
||||
"Pennsylvania"
|
||||
],
|
||||
"cities": [
|
||||
"Orlando",
|
||||
"Los Angeles",
|
||||
"Cedar Point"
|
||||
],
|
||||
"operators": [],
|
||||
"property_owners": [],
|
||||
"ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"},
|
||||
"ride_count": {"min": 0, "max": 100, "step": 1, "unit": "rides"},
|
||||
"coaster_count": {"min": 0, "max": 50, "step": 1, "unit": "coasters"},
|
||||
"size_acres": {"min": 0, "max": 10000, "step": 1, "unit": "acres"},
|
||||
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
|
||||
},
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
@@ -659,6 +872,10 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
|
||||
{"value": "ride_count", "label": "Ride Count (Low to High)"},
|
||||
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
|
||||
{"value": "coaster_count", "label": "Coaster Count (Low to High)"},
|
||||
{"value": "-coaster_count", "label": "Coaster Count (High to Low)"},
|
||||
{"value": "average_rating", "label": "Rating (Low to High)"},
|
||||
{"value": "-average_rating", "label": "Rating (High to Low)"},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ urlpatterns = [
|
||||
ParkSearchSuggestionsAPIView.as_view(),
|
||||
name="park-search-suggestions",
|
||||
),
|
||||
# Detail and action endpoints
|
||||
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Detail and action endpoints - supports both ID and slug
|
||||
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Park image settings endpoint
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
|
||||
@@ -680,7 +680,7 @@ class RideDetailAPIView(APIView):
|
||||
# --- Filter options ---------------------------------------------------------
|
||||
@extend_schema(
|
||||
summary="Get comprehensive filter options for rides",
|
||||
description="Returns all available filter options for rides including categories, statuses, roller coaster types, track materials, launch types, and ordering options.",
|
||||
description="Returns all available filter options for rides with complete read-only access to all possible ride model fields and attributes, including dynamic data from database.",
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Rides"],
|
||||
)
|
||||
@@ -688,132 +688,96 @@ class FilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return comprehensive filter options used by the frontend."""
|
||||
# Try to use ModelChoices if available
|
||||
if HAVE_MODELCHOICES and ModelChoices is not None:
|
||||
try:
|
||||
data = {
|
||||
"categories": ModelChoices.get_ride_category_choices(),
|
||||
"statuses": ModelChoices.get_ride_status_choices(),
|
||||
"post_closing_statuses": ModelChoices.get_ride_post_closing_choices(),
|
||||
"roller_coaster_types": ModelChoices.get_coaster_type_choices(),
|
||||
"track_materials": ModelChoices.get_coaster_track_choices(),
|
||||
"launch_types": ModelChoices.get_launch_choices(),
|
||||
"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": "height_ft", "label": "Height (Shortest First)"},
|
||||
{"value": "-height_ft", "label": "Height (Tallest First)"},
|
||||
{"value": "speed_mph", "label": "Speed (Slowest First)"},
|
||||
{"value": "-speed_mph", "label": "Speed (Fastest First)"},
|
||||
{"value": "created_at", "label": "Date Added (Oldest First)"},
|
||||
{"value": "-created_at", "label": "Date Added (Newest First)"},
|
||||
],
|
||||
"filter_ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1},
|
||||
"height_requirement": {
|
||||
"min": 30,
|
||||
"max": 90,
|
||||
"step": 1,
|
||||
"unit": "inches",
|
||||
},
|
||||
"capacity": {
|
||||
"min": 0,
|
||||
"max": 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour",
|
||||
},
|
||||
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
|
||||
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
|
||||
"inversions": {
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"unit": "inversions",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
],
|
||||
}
|
||||
return Response(data)
|
||||
except Exception:
|
||||
# fallthrough to fallback
|
||||
pass
|
||||
|
||||
# Comprehensive fallback options
|
||||
return Response(
|
||||
{
|
||||
"""Return comprehensive filter options with all possible ride model fields and attributes."""
|
||||
if not MODELS_AVAILABLE:
|
||||
# Comprehensive fallback options with all possible fields
|
||||
return Response({
|
||||
"categories": [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
{"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": [
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
{"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": [
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
{"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": [
|
||||
("STEEL", "Steel"),
|
||||
("WOOD", "Wood"),
|
||||
("HYBRID", "Hybrid"),
|
||||
{"value": "STEEL", "label": "Steel"},
|
||||
{"value": "WOOD", "label": "Wood"},
|
||||
{"value": "HYBRID", "label": "Hybrid"},
|
||||
],
|
||||
"launch_types": [
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
{"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)"},
|
||||
@@ -823,55 +787,399 @@ class FilterOptionsAPIView(APIView):
|
||||
{"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": "-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)"},
|
||||
],
|
||||
"filter_ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1},
|
||||
"height_requirement": {
|
||||
"min": 30,
|
||||
"max": 90,
|
||||
"step": 1,
|
||||
"unit": "inches",
|
||||
},
|
||||
"capacity": {
|
||||
"min": 0,
|
||||
"max": 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour",
|
||||
},
|
||||
})
|
||||
|
||||
# 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 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 post-closing statuses from model choices
|
||||
post_closing_statuses = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Ride.POST_CLOSING_STATUS_CHOICES
|
||||
]
|
||||
|
||||
# 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 track materials from model choices
|
||||
track_materials = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in RollerCoasterStats.TRACK_MATERIAL_CHOICES
|
||||
]
|
||||
|
||||
# Get launch types from model choices
|
||||
launch_types = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in RollerCoasterStats.LAUNCH_CHOICES
|
||||
]
|
||||
|
||||
# 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
|
||||
]
|
||||
|
||||
# Get parks data from database
|
||||
parks = list(Ride.objects.exclude(
|
||||
park__isnull=True
|
||||
).select_related('park').values(
|
||||
'park__id', 'park__name', 'park__slug'
|
||||
).distinct().order_by('park__name'))
|
||||
|
||||
# Get park areas data from database
|
||||
park_areas = list(Ride.objects.exclude(
|
||||
park_area__isnull=True
|
||||
).select_related('park_area').values(
|
||||
'park_area__id', 'park_area__name', 'park_area__slug'
|
||||
).distinct().order_by('park_area__name'))
|
||||
|
||||
# Get manufacturers (companies with MANUFACTURER role)
|
||||
manufacturers = list(Company.objects.filter(
|
||||
roles__contains=['MANUFACTURER']
|
||||
).values('id', 'name', 'slug').order_by('name'))
|
||||
|
||||
# Get designers (companies with DESIGNER role)
|
||||
designers = list(Company.objects.filter(
|
||||
roles__contains=['DESIGNER']
|
||||
).values('id', 'name', 'slug').order_by('name'))
|
||||
|
||||
# Get ride models data from database
|
||||
ride_models = list(RideModel.objects.select_related(
|
||||
'manufacturer'
|
||||
).values(
|
||||
'id', 'name', 'slug', 'manufacturer__name', 'manufacturer__slug', 'category'
|
||||
).order_by('manufacturer__name', 'name'))
|
||||
|
||||
# Calculate ranges from actual data
|
||||
ride_stats = Ride.objects.aggregate(
|
||||
min_rating=models.Min('average_rating'),
|
||||
max_rating=models.Max('average_rating'),
|
||||
min_height_req=models.Min('min_height_in'),
|
||||
max_height_req=models.Max('max_height_in'),
|
||||
min_capacity=models.Min('capacity_per_hour'),
|
||||
max_capacity=models.Max('capacity_per_hour'),
|
||||
min_duration=models.Min('ride_duration_seconds'),
|
||||
max_duration=models.Max('ride_duration_seconds'),
|
||||
min_year=models.Min('opening_date__year'),
|
||||
max_year=models.Max('opening_date__year'),
|
||||
)
|
||||
|
||||
# Calculate roller coaster specific ranges
|
||||
coaster_stats = RollerCoasterStats.objects.aggregate(
|
||||
min_height_ft=models.Min('height_ft'),
|
||||
max_height_ft=models.Max('height_ft'),
|
||||
min_length_ft=models.Min('length_ft'),
|
||||
max_length_ft=models.Max('length_ft'),
|
||||
min_speed_mph=models.Min('speed_mph'),
|
||||
max_speed_mph=models.Max('speed_mph'),
|
||||
min_inversions=models.Min('inversions'),
|
||||
max_inversions=models.Max('inversions'),
|
||||
min_ride_time=models.Min('ride_time_seconds'),
|
||||
max_ride_time=models.Max('ride_time_seconds'),
|
||||
min_drop_height=models.Min('max_drop_height_ft'),
|
||||
max_drop_height=models.Max('max_drop_height_ft'),
|
||||
min_trains=models.Min('trains_count'),
|
||||
max_trains=models.Max('trains_count'),
|
||||
min_cars=models.Min('cars_per_train'),
|
||||
max_cars=models.Max('cars_per_train'),
|
||||
min_seats=models.Min('seats_per_car'),
|
||||
max_seats=models.Max('seats_per_car'),
|
||||
)
|
||||
|
||||
ranges = {
|
||||
"rating": {
|
||||
"min": float(ride_stats['min_rating'] or 1),
|
||||
"max": float(ride_stats['max_rating'] or 10),
|
||||
"step": 0.1,
|
||||
"unit": "stars"
|
||||
},
|
||||
"height_requirement": {
|
||||
"min": ride_stats['min_height_req'] or 30,
|
||||
"max": ride_stats['max_height_req'] or 90,
|
||||
"step": 1,
|
||||
"unit": "inches"
|
||||
},
|
||||
"capacity": {
|
||||
"min": ride_stats['min_capacity'] or 0,
|
||||
"max": ride_stats['max_capacity'] or 5000,
|
||||
"step": 50,
|
||||
"unit": "riders/hour"
|
||||
},
|
||||
"ride_duration": {
|
||||
"min": ride_stats['min_duration'] or 0,
|
||||
"max": ride_stats['max_duration'] or 600,
|
||||
"step": 10,
|
||||
"unit": "seconds"
|
||||
},
|
||||
"height_ft": {
|
||||
"min": float(coaster_stats['min_height_ft'] or 0),
|
||||
"max": float(coaster_stats['max_height_ft'] or 500),
|
||||
"step": 5,
|
||||
"unit": "feet"
|
||||
},
|
||||
"length_ft": {
|
||||
"min": float(coaster_stats['min_length_ft'] or 0),
|
||||
"max": float(coaster_stats['max_length_ft'] or 10000),
|
||||
"step": 100,
|
||||
"unit": "feet"
|
||||
},
|
||||
"speed_mph": {
|
||||
"min": float(coaster_stats['min_speed_mph'] or 0),
|
||||
"max": float(coaster_stats['max_speed_mph'] or 150),
|
||||
"step": 5,
|
||||
"unit": "mph"
|
||||
},
|
||||
"inversions": {
|
||||
"min": coaster_stats['min_inversions'] or 0,
|
||||
"max": coaster_stats['max_inversions'] or 20,
|
||||
"step": 1,
|
||||
"unit": "inversions"
|
||||
},
|
||||
"ride_time": {
|
||||
"min": coaster_stats['min_ride_time'] or 0,
|
||||
"max": coaster_stats['max_ride_time'] or 600,
|
||||
"step": 10,
|
||||
"unit": "seconds"
|
||||
},
|
||||
"max_drop_height_ft": {
|
||||
"min": float(coaster_stats['min_drop_height'] or 0),
|
||||
"max": float(coaster_stats['max_drop_height'] or 500),
|
||||
"step": 10,
|
||||
"unit": "feet"
|
||||
},
|
||||
"trains_count": {
|
||||
"min": coaster_stats['min_trains'] or 1,
|
||||
"max": coaster_stats['max_trains'] or 10,
|
||||
"step": 1,
|
||||
"unit": "trains"
|
||||
},
|
||||
"cars_per_train": {
|
||||
"min": coaster_stats['min_cars'] or 1,
|
||||
"max": coaster_stats['max_cars'] or 20,
|
||||
"step": 1,
|
||||
"unit": "cars"
|
||||
},
|
||||
"seats_per_car": {
|
||||
"min": coaster_stats['min_seats'] or 1,
|
||||
"max": coaster_stats['max_seats'] or 8,
|
||||
"step": 1,
|
||||
"unit": "seats"
|
||||
},
|
||||
"opening_year": {
|
||||
"min": ride_stats['min_year'] or 1800,
|
||||
"max": ride_stats['max_year'] or 2030,
|
||||
"step": 1,
|
||||
"unit": "year"
|
||||
},
|
||||
}
|
||||
|
||||
return Response({
|
||||
"categories": categories,
|
||||
"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",
|
||||
"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)"},
|
||||
],
|
||||
})
|
||||
|
||||
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",
|
||||
},
|
||||
"opening_year": {
|
||||
"min": 1800,
|
||||
"max": 2030,
|
||||
"step": 1,
|
||||
"unit": "year",
|
||||
},
|
||||
"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_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) -----------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@ from drf_spectacular.utils import (
|
||||
from config.django import base as settings
|
||||
|
||||
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
|
||||
from apps.core.services.media_url_service import MediaURLService
|
||||
|
||||
|
||||
# === PARK SERIALIZERS ===
|
||||
@@ -211,20 +212,20 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url if photo.image else None,
|
||||
"image_variants": (
|
||||
{
|
||||
"thumbnail": (
|
||||
f"{photo.image.url}/thumbnail" if photo.image else None
|
||||
),
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
}
|
||||
if photo.image
|
||||
else {}
|
||||
),
|
||||
"id": photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
|
||||
},
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
@@ -244,13 +245,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
if photo and photo.image:
|
||||
return {
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url,
|
||||
"id": photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{photo.image.url}/thumbnail",
|
||||
"medium": f"{photo.image.url}/medium",
|
||||
"large": f"{photo.image.url}/large",
|
||||
"public": f"{photo.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
|
||||
},
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
@@ -266,13 +273,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
# First try the explicitly set banner image
|
||||
if obj.banner_image and obj.banner_image.image:
|
||||
return {
|
||||
"id": obj.banner_image.id,
|
||||
"image_url": obj.banner_image.image.url,
|
||||
"id": obj.banner_image.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{obj.banner_image.image.url}/thumbnail",
|
||||
"medium": f"{obj.banner_image.image.url}/medium",
|
||||
"large": f"{obj.banner_image.image.url}/large",
|
||||
"public": f"{obj.banner_image.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "public"),
|
||||
},
|
||||
"caption": obj.banner_image.caption,
|
||||
"alt_text": obj.banner_image.alt_text,
|
||||
@@ -292,13 +305,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.id,
|
||||
"image_url": latest_photo.image.url,
|
||||
"id": latest_photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{latest_photo.image.url}/thumbnail",
|
||||
"medium": f"{latest_photo.image.url}/medium",
|
||||
"large": f"{latest_photo.image.url}/large",
|
||||
"public": f"{latest_photo.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
|
||||
},
|
||||
"caption": latest_photo.caption,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
@@ -315,13 +334,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
# First try the explicitly set card image
|
||||
if obj.card_image and obj.card_image.image:
|
||||
return {
|
||||
"id": obj.card_image.id,
|
||||
"image_url": obj.card_image.image.url,
|
||||
"id": obj.card_image.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{obj.card_image.image.url}/thumbnail",
|
||||
"medium": f"{obj.card_image.image.url}/medium",
|
||||
"large": f"{obj.card_image.image.url}/large",
|
||||
"public": f"{obj.card_image.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "public"),
|
||||
},
|
||||
"caption": obj.card_image.caption,
|
||||
"alt_text": obj.card_image.alt_text,
|
||||
@@ -341,13 +366,19 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.id,
|
||||
"image_url": latest_photo.image.url,
|
||||
"id": latest_photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": f"{latest_photo.image.url}/thumbnail",
|
||||
"medium": f"{latest_photo.image.url}/medium",
|
||||
"large": f"{latest_photo.image.url}/large",
|
||||
"public": f"{latest_photo.image.url}/public",
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
|
||||
},
|
||||
"caption": latest_photo.caption,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
|
||||
149
backend/apps/core/services/media_url_service.py
Normal file
149
backend/apps/core/services/media_url_service.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Media URL service for generating friendly URLs.
|
||||
|
||||
This service provides utilities for generating SEO-friendly URLs for media files
|
||||
while maintaining compatibility with Cloudflare Images.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict, Any
|
||||
from django.utils.text import slugify
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class MediaURLService:
|
||||
"""Service for generating and parsing friendly media URLs."""
|
||||
|
||||
@staticmethod
|
||||
def generate_friendly_filename(caption: str, photo_id: int, extension: str = "jpg") -> str:
|
||||
"""
|
||||
Generate a friendly filename from photo caption and ID.
|
||||
|
||||
Args:
|
||||
caption: Photo caption
|
||||
photo_id: Photo database ID
|
||||
extension: File extension (default: jpg)
|
||||
|
||||
Returns:
|
||||
Friendly filename like "beautiful-park-entrance-123.jpg"
|
||||
"""
|
||||
if caption:
|
||||
# Clean and slugify the caption
|
||||
slug = slugify(caption)
|
||||
# Limit length to avoid overly long URLs
|
||||
if len(slug) > 50:
|
||||
slug = slug[:50].rsplit('-', 1)[0] # Cut at word boundary
|
||||
return f"{slug}-{photo_id}.{extension}"
|
||||
else:
|
||||
return f"photo-{photo_id}.{extension}"
|
||||
|
||||
@staticmethod
|
||||
def generate_park_photo_url(park_slug: str, caption: str, photo_id: int, variant: str = "public") -> str:
|
||||
"""
|
||||
Generate a friendly URL for a park photo.
|
||||
|
||||
Args:
|
||||
park_slug: Park slug
|
||||
caption: Photo caption
|
||||
photo_id: Photo database ID
|
||||
variant: Image variant (public, thumbnail, medium, large)
|
||||
|
||||
Returns:
|
||||
Friendly URL like "/parks/cedar-point/photos/beautiful-entrance-123.jpg"
|
||||
"""
|
||||
filename = MediaURLService.generate_friendly_filename(caption, photo_id)
|
||||
|
||||
# Add variant to filename if not public
|
||||
if variant != "public":
|
||||
name, ext = filename.rsplit('.', 1)
|
||||
filename = f"{name}-{variant}.{ext}"
|
||||
|
||||
return f"/parks/{park_slug}/photos/{filename}"
|
||||
|
||||
@staticmethod
|
||||
def generate_ride_photo_url(park_slug: str, ride_slug: str, caption: str, photo_id: int, variant: str = "public") -> str:
|
||||
"""
|
||||
Generate a friendly URL for a ride photo.
|
||||
|
||||
Args:
|
||||
park_slug: Park slug
|
||||
ride_slug: Ride slug
|
||||
caption: Photo caption
|
||||
photo_id: Photo database ID
|
||||
variant: Image variant
|
||||
|
||||
Returns:
|
||||
Friendly URL like "/parks/cedar-point/rides/millennium-force/photos/first-drop-456.jpg"
|
||||
"""
|
||||
filename = MediaURLService.generate_friendly_filename(caption, photo_id)
|
||||
|
||||
if variant != "public":
|
||||
name, ext = filename.rsplit('.', 1)
|
||||
filename = f"{name}-{variant}.{ext}"
|
||||
|
||||
return f"/parks/{park_slug}/rides/{ride_slug}/photos/{filename}"
|
||||
|
||||
@staticmethod
|
||||
def parse_photo_filename(filename: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a friendly filename to extract photo ID and variant.
|
||||
|
||||
Args:
|
||||
filename: Filename like "beautiful-entrance-123-thumbnail.jpg"
|
||||
|
||||
Returns:
|
||||
Dict with photo_id and variant, or None if parsing fails
|
||||
"""
|
||||
# Remove extension
|
||||
name = filename.rsplit('.', 1)[0]
|
||||
|
||||
# Check for variant suffix
|
||||
variant = "public"
|
||||
variant_patterns = ["thumbnail", "medium", "large"]
|
||||
|
||||
for v in variant_patterns:
|
||||
if name.endswith(f"-{v}"):
|
||||
variant = v
|
||||
name = name[:-len(f"-{v}")]
|
||||
break
|
||||
|
||||
# Extract photo ID (should be the last number)
|
||||
match = re.search(r'-(\d+)$', name)
|
||||
if match:
|
||||
photo_id = int(match.group(1))
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"variant": variant
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_cloudflare_url_with_fallback(cloudflare_image, variant: str = "public") -> Optional[str]:
|
||||
"""
|
||||
Get Cloudflare URL with fallback handling.
|
||||
|
||||
Args:
|
||||
cloudflare_image: CloudflareImage instance
|
||||
variant: Desired variant
|
||||
|
||||
Returns:
|
||||
Cloudflare URL or None
|
||||
"""
|
||||
if not cloudflare_image:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try the specific variant first
|
||||
url = cloudflare_image.get_url(variant)
|
||||
if url:
|
||||
return url
|
||||
|
||||
# Fallback to public URL
|
||||
if variant != "public":
|
||||
return cloudflare_image.public_url
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user