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