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:
pacnpal
2025-08-31 16:45:47 -04:00
parent 91906e0d57
commit 0fd6dc2560
12 changed files with 1530 additions and 380 deletions

View File

@@ -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)"},
],
})

View File

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

View File

@@ -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) -----------------------------------------

View File

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

View 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