Files
thrillwiki_django_no_react/backend/apps/api/v1/parks/serializers.py
pacnpal 8069589b8a feat: Complete Phase 5 of Django Unicorn refactoring for park detail templates
- Refactored park detail template from HTMX/Alpine.js to Django Unicorn component
- Achieved ~97% reduction in template complexity
- Created ParkDetailView component with optimized data loading and reactive features
- Developed a responsive reactive template for park details
- Implemented server-side state management and reactive event handlers
- Enhanced performance with optimized database queries and loading states
- Comprehensive error handling and user experience improvements

docs: Update Django Unicorn refactoring plan with completed components and phases

- Documented installation and configuration of Django Unicorn
- Detailed completed work on park search component and refactoring strategy
- Outlined planned refactoring phases for future components
- Provided examples of component structure and usage

feat: Implement parks rides endpoint with comprehensive features

- Developed API endpoint GET /api/v1/parks/{park_slug}/rides/ for paginated ride listings
- Included filtering capabilities for categories and statuses
- Optimized database queries with select_related and prefetch_related
- Implemented serializer for comprehensive ride data output
- Added complete API documentation for frontend integration
2025-09-02 22:58:11 -04:00

1059 lines
46 KiB
Python

"""
Parks domain serializers for ThrillWiki API v1.
This module contains all serializers related to parks, park locations,
and park-specific functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from config.django import base as settings
# === PARK PHOTO SERIALIZERS ===
class ParkPhotoOutputSerializer(serializers.Serializer):
"""Output serializer for park photo details."""
id = serializers.IntegerField()
caption = serializers.CharField()
alt_text = serializers.CharField()
photo_type = serializers.CharField()
is_primary = serializers.BooleanField()
is_approved = serializers.BooleanField()
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class ParkPhotoCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating park photos."""
caption = serializers.CharField(required=False, allow_blank=True)
alt_text = serializers.CharField(required=False, allow_blank=True)
photo_type = serializers.CharField(required=False, default="exterior")
is_primary = serializers.BooleanField(required=False, default=False)
class ParkPhotoUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating park photos."""
caption = serializers.CharField(required=False, allow_blank=True)
alt_text = serializers.CharField(required=False, allow_blank=True)
photo_type = serializers.CharField(required=False)
is_primary = serializers.BooleanField(required=False)
class ParkPhotoListOutputSerializer(serializers.Serializer):
"""Output serializer for park photo list view."""
id = serializers.IntegerField()
caption = serializers.CharField()
photo_type = serializers.CharField()
is_primary = serializers.BooleanField()
is_approved = serializers.BooleanField()
created_at = serializers.DateTimeField()
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for bulk photo approval."""
photo_ids = serializers.ListField(child=serializers.IntegerField())
approve = serializers.BooleanField()
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
"""Output serializer for park photo statistics."""
total_photos = serializers.IntegerField()
approved_photos = serializers.IntegerField()
pending_photos = serializers.IntegerField()
primary_photos = serializers.IntegerField()
# === PARK RIDES LIST SERIALIZER ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Park Rides List Example",
summary="Example park rides list response",
description="List of rides at a specific park",
value={
"count": 15,
"results": [
{
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"category": "RC",
"status": "OPERATING",
"opening_date": "2018-05-05",
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
"banner_image": {
"id": 123,
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
},
"caption": "Steel Vengeance roller coaster",
"alt_text": "Hybrid roller coaster with wooden structure and steel track",
"photo_type": "exterior",
},
"ride_model": {
"id": 1,
"name": "I-Box Track",
"slug": "i-box-track",
"category": "RC",
"manufacturer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rocky-mountain-construction"
}
}
},
{
"id": 2,
"name": "Millennium Force",
"slug": "millennium-force",
"category": "RC",
"status": "OPERATING",
"opening_date": "2000-05-13",
"url": "https://thrillwiki.com/parks/cedar-point/rides/millennium-force/",
"banner_image": None,
"ride_model": {
"id": 2,
"name": "Hyper Coaster",
"slug": "hyper-coaster",
"category": "RC",
"manufacturer": {
"id": 2,
"name": "Intamin",
"slug": "intamin"
}
}
}
]
},
)
]
)
class ParkRidesListOutputSerializer(serializers.Serializer):
"""Output serializer for park rides list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
opening_date = serializers.DateField(allow_null=True)
url = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
ride_model = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/"
@extend_schema_field(serializers.DictField(allow_null=True))
def get_banner_image(self, obj):
"""Get the banner image for this ride with fallback to latest photo."""
# 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,
"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",
},
"caption": obj.banner_image.caption,
"alt_text": obj.banner_image.alt_text,
"photo_type": obj.banner_image.photo_type,
}
# Fallback to latest approved photo
from apps.rides.models import RidePhoto
try:
latest_photo = (
RidePhoto.objects.filter(
ride=obj, is_approved=True, image__isnull=False
)
.order_by("-created_at")
.first()
)
if latest_photo and latest_photo.image:
return {
"id": latest_photo.id,
"image_url": latest_photo.image.url,
"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",
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
"photo_type": latest_photo.photo_type,
"is_fallback": True,
}
except Exception:
pass
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_ride_model(self, obj):
"""Get the ride model information for this ride."""
if obj.ride_model:
return {
"id": obj.ride_model.id,
"name": obj.ride_model.name,
"slug": obj.ride_model.slug,
"category": obj.ride_model.category,
"manufacturer": {
"id": obj.ride_model.manufacturer.id,
"name": obj.ride_model.manufacturer.name,
"slug": obj.ride_model.manufacturer.slug,
} if obj.ride_model.manufacturer else None,
}
return None
# === PARK RIDE DETAIL SERIALIZER ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Park Ride Detail Example",
summary="Complete ride details with all attributes",
description="Comprehensive ride information including all fields, photos, and related data",
value={
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"description": "A hybrid roller coaster featuring RMC's I-Box track technology...",
"category": "RC",
"status": "OPERATING",
"post_closing_status": None,
"opening_date": "2018-05-05",
"closing_date": None,
"status_since": "2018-05-05",
"min_height_in": 48,
"max_height_in": None,
"capacity_per_hour": 1200,
"ride_duration_seconds": 150,
"average_rating": 9.2,
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
"park_url": "https://thrillwiki.com/parks/cedar-point/",
"park": {
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"url": "https://thrillwiki.com/parks/cedar-point/"
},
"park_area": {
"id": 5,
"name": "Frontier Town",
"slug": "frontier-town"
},
"manufacturer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rocky-mountain-construction",
"roles": ["MANUFACTURER"]
},
"designer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rocky-mountain-construction",
"roles": ["DESIGNER"]
},
"ride_model": {
"id": 1,
"name": "I-Box Track",
"slug": "i-box-track",
"category": "RC",
"description": "RMC's signature steel track system...",
"manufacturer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rocky-mountain-construction"
}
},
"banner_image": {
"id": 123,
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
},
"caption": "Steel Vengeance roller coaster",
"alt_text": "Hybrid roller coaster with wooden structure and steel track",
"photo_type": "exterior"
},
"card_image": None,
"photos": [
{
"id": 123,
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
},
"caption": "Steel Vengeance roller coaster",
"alt_text": "Hybrid roller coaster with wooden structure and steel track",
"photo_type": "exterior",
"is_primary": True,
"is_approved": True,
"created_at": "2023-01-01T12:00:00Z",
"uploaded_by": {
"id": 1,
"username": "photographer",
"display_name": "John Photographer"
}
}
],
"coaster_stats": {
"height_ft": 205.0,
"length_ft": 5740.0,
"speed_mph": 74.0,
"inversions": 4,
"ride_time_seconds": 150,
"track_type": "I-Box Steel Track",
"track_material": "HYBRID",
"roller_coaster_type": "SITDOWN",
"max_drop_height_ft": 200.0,
"launch_type": "CHAIN",
"train_style": "Traditional",
"trains_count": 3,
"cars_per_train": 6,
"seats_per_car": 4
}
}
)
]
)
class ParkRideDetailOutputSerializer(serializers.Serializer):
"""Comprehensive output serializer for park ride detail view with ALL attributes."""
# Core ride fields
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
description = serializers.CharField()
category = serializers.CharField()
status = serializers.CharField()
post_closing_status = serializers.CharField(allow_null=True)
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
status_since = serializers.DateField(allow_null=True)
min_height_in = serializers.IntegerField(allow_null=True)
max_height_in = serializers.IntegerField(allow_null=True)
capacity_per_hour = serializers.IntegerField(allow_null=True)
ride_duration_seconds = serializers.IntegerField(allow_null=True)
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True)
url = serializers.CharField()
park_url = serializers.CharField()
# Related objects
park = serializers.SerializerMethodField()
park_area = serializers.SerializerMethodField()
manufacturer = serializers.SerializerMethodField()
designer = serializers.SerializerMethodField()
ride_model = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
photos = serializers.SerializerMethodField()
coaster_stats = serializers.SerializerMethodField()
reviews = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_park(self, obj):
"""Get park information."""
return {
"id": obj.park.id,
"name": obj.park.name,
"slug": obj.park.slug,
"url": obj.park_url,
}
@extend_schema_field(serializers.DictField(allow_null=True))
def get_park_area(self, obj):
"""Get park area information."""
if obj.park_area:
return {
"id": obj.park_area.id,
"name": obj.park_area.name,
"slug": obj.park_area.slug,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_manufacturer(self, obj):
"""Get manufacturer information."""
if obj.manufacturer:
return {
"id": obj.manufacturer.id,
"name": obj.manufacturer.name,
"slug": obj.manufacturer.slug,
"roles": obj.manufacturer.roles,
"description": obj.manufacturer.description,
"website": obj.manufacturer.website,
"founded_year": obj.manufacturer.founded_year,
"website": obj.manufacturer.website,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_designer(self, obj):
"""Get designer information."""
if obj.designer:
return {
"id": obj.designer.id,
"name": obj.designer.name,
"slug": obj.designer.slug,
"roles": obj.designer.roles,
"description": obj.designer.description,
"website": obj.designer.website,
"founded_year": obj.designer.founded_year,
"url": obj.designer.url,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_ride_model(self, obj):
"""Get comprehensive ride model information."""
if obj.ride_model:
model_data = {
"id": obj.ride_model.id,
"name": obj.ride_model.name,
"slug": obj.ride_model.slug,
"category": obj.ride_model.category,
"description": obj.ride_model.description,
"manufacturer": {
"id": obj.ride_model.manufacturer.id,
"name": obj.ride_model.manufacturer.name,
"slug": obj.ride_model.manufacturer.slug,
} if obj.ride_model.manufacturer else None,
# Technical specifications
"typical_height_range_min_ft": float(obj.ride_model.typical_height_range_min_ft) if obj.ride_model.typical_height_range_min_ft else None,
"typical_height_range_max_ft": float(obj.ride_model.typical_height_range_max_ft) if obj.ride_model.typical_height_range_max_ft else None,
"typical_speed_range_min_mph": float(obj.ride_model.typical_speed_range_min_mph) if obj.ride_model.typical_speed_range_min_mph else None,
"typical_speed_range_max_mph": float(obj.ride_model.typical_speed_range_max_mph) if obj.ride_model.typical_speed_range_max_mph else None,
"typical_capacity_range_min": obj.ride_model.typical_capacity_range_min,
"typical_capacity_range_max": obj.ride_model.typical_capacity_range_max,
# Design characteristics
"track_type": obj.ride_model.track_type,
"support_structure": obj.ride_model.support_structure,
"train_configuration": obj.ride_model.train_configuration,
"restraint_system": obj.ride_model.restraint_system,
# Market information
"first_installation_year": obj.ride_model.first_installation_year,
"last_installation_year": obj.ride_model.last_installation_year,
"is_discontinued": obj.ride_model.is_discontinued,
"total_installations": obj.ride_model.total_installations,
"notable_features": obj.ride_model.notable_features,
"target_market": obj.ride_model.target_market,
# Display properties
"installation_years_range": obj.ride_model.installation_years_range,
"height_range_display": obj.ride_model.height_range_display,
"speed_range_display": obj.ride_model.speed_range_display,
"url": obj.ride_model.url,
}
return model_data
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_banner_image(self, obj):
"""Get banner image with Cloudflare variants."""
if obj.banner_image and obj.banner_image.image:
return {
"id": obj.banner_image.id,
"image_url": obj.banner_image.image.url,
"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",
},
"caption": obj.banner_image.caption,
"alt_text": obj.banner_image.alt_text,
"photo_type": obj.banner_image.photo_type,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_card_image(self, obj):
"""Get card image with Cloudflare variants."""
if obj.card_image and obj.card_image.image:
return {
"id": obj.card_image.id,
"image_url": obj.card_image.image.url,
"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",
},
"caption": obj.card_image.caption,
"alt_text": obj.card_image.alt_text,
"photo_type": obj.card_image.photo_type,
}
return None
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_photos(self, obj):
"""Get all approved photos for this ride."""
photos = []
for photo in obj.photos.filter(is_approved=True).select_related('uploaded_by', 'image'):
if photo.image:
photo_data = {
"id": photo.id,
"image_url": photo.image.url,
"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",
},
"caption": photo.caption,
"alt_text": photo.alt_text,
"photo_type": photo.photo_type,
"is_primary": photo.is_primary,
"is_approved": photo.is_approved,
"created_at": photo.created_at,
"date_taken": photo.date_taken,
"uploaded_by": {
"id": photo.uploaded_by.id,
"username": photo.uploaded_by.username,
"display_name": getattr(photo.uploaded_by, 'display_name', photo.uploaded_by.username),
} if photo.uploaded_by else None,
}
photos.append(photo_data)
return photos
@extend_schema_field(serializers.DictField(allow_null=True))
def get_coaster_stats(self, obj):
"""Get roller coaster statistics if available."""
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
stats = obj.coaster_stats
return {
"height_ft": float(stats.height_ft) if stats.height_ft else None,
"length_ft": float(stats.length_ft) if stats.length_ft else None,
"speed_mph": float(stats.speed_mph) if stats.speed_mph else None,
"inversions": stats.inversions,
"ride_time_seconds": stats.ride_time_seconds,
"track_type": stats.track_type,
"track_material": stats.track_material,
"roller_coaster_type": stats.roller_coaster_type,
"max_drop_height_ft": float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
"launch_type": stats.launch_type,
"train_style": stats.train_style,
"trains_count": stats.trains_count,
"cars_per_train": stats.cars_per_train,
"seats_per_car": stats.seats_per_car,
}
return None
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_reviews(self, obj):
"""Get the latest 10 reviews for this ride."""
try:
# Import here to avoid circular imports
from apps.rides.models import RideReview
reviews = []
latest_reviews = (
RideReview.objects.filter(ride=obj, is_published=True)
.select_related('user', 'user__profile')
.order_by('-created_at')[:10]
)
for review in latest_reviews:
# Get user avatar URL with fallback
avatar_url = None
if hasattr(review.user, 'profile') and review.user.profile and review.user.profile.avatar:
avatar_url = review.user.profile.avatar.url
else:
# Fallback to UI-Avatars
display_name = getattr(
review.user, 'display_name', review.user.username)
avatar_url = f"https://ui-avatars.com/api/?name={display_name}&size=200&background=0D8ABC&color=fff"
# Smart content truncation at word boundaries
content_snippet = review.content
if len(content_snippet) > 150:
content_snippet = content_snippet[:147] + "..."
# Try to break at word boundary
last_space = content_snippet.rfind(' ')
if last_space > 100: # Only break at word if it's not too short
content_snippet = content_snippet[:last_space] + "..."
review_data = {
"id": review.id,
"rating": float(review.rating) if review.rating else None,
"content": content_snippet,
"created_at": review.created_at,
"user": {
"id": review.user.id,
"username": review.user.username,
"display_name": getattr(review.user, 'display_name', review.user.username),
"avatar_url": avatar_url,
},
"is_verified_visit": getattr(review, 'is_verified_visit', False),
"visit_date": getattr(review, 'visit_date', None),
}
reviews.append(review_data)
return reviews
except Exception:
# If RideReview model doesn't exist or any other error, return empty list
return []
# === PARK DETAIL SERIALIZER ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Park Detail Example",
summary="Complete park details with all information",
description="Comprehensive park information including all fields, rides, photos, and related data",
value={
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"description": "Known as America's Roller Coast, Cedar Point is a 364-acre amusement park...",
"status": "OPERATING",
"park_type": "THEME_PARK",
"opening_date": "1870-01-01",
"closing_date": None,
"operating_season": "May through October",
"size_acres": 364.0,
"website": "https://www.cedarpoint.com",
"average_rating": 8.5,
"ride_count": 70,
"coaster_count": 17,
"url": "https://thrillwiki.com/parks/cedar-point/",
"location": {
"id": 1,
"formatted_address": "1 Cedar Point Dr, Sandusky, OH 44870, USA",
"coordinates": [41.4793, -82.6831],
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
"postal_code": "44870"
},
"operator": {
"id": 1,
"name": "Cedar Fair Entertainment Company",
"slug": "cedar-fair",
"roles": ["OPERATOR"],
"description": "Leading amusement park operator...",
"website": "https://www.cedarfair.com",
"founded_date": "1983-01-01",
"url": "https://thrillwiki.com/parks/operators/cedar-fair/"
},
"property_owner": None,
"areas": [
{
"id": 1,
"name": "Main Midway",
"slug": "main-midway",
"description": "The heart of Cedar Point..."
},
{
"id": 2,
"name": "Frontier Town",
"slug": "frontier-town",
"description": "Western-themed area..."
}
],
"banner_image": {
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/park456def789/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/park456def789/thumbnail",
"medium": "https://imagedelivery.net/account-hash/park456def789/medium",
"large": "https://imagedelivery.net/account-hash/park456def789/large",
"public": "https://imagedelivery.net/account-hash/park456def789/public"
},
"caption": "Cedar Point skyline",
"alt_text": "Aerial view of Cedar Point amusement park",
"photo_type": "aerial"
},
"photos": [
{
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/park456def789/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/park456def789/thumbnail",
"medium": "https://imagedelivery.net/account-hash/park456def789/medium",
"large": "https://imagedelivery.net/account-hash/park456def789/large",
"public": "https://imagedelivery.net/account-hash/park456def789/public"
},
"caption": "Cedar Point skyline",
"alt_text": "Aerial view of Cedar Point amusement park",
"photo_type": "aerial",
"is_primary": True,
"is_approved": True,
"created_at": "2023-01-01T12:00:00Z",
"uploaded_by": {
"id": 1,
"username": "photographer",
"display_name": "John Photographer"
}
}
],
"rides": [
{
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"category": "RC",
"status": "OPERATING",
"opening_date": "2018-05-05",
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
"banner_image": {
"id": 123,
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
},
"caption": "Steel Vengeance roller coaster",
"alt_text": "Hybrid roller coaster with wooden structure and steel track",
"photo_type": "exterior"
},
"park_area": {
"id": 2,
"name": "Frontier Town",
"slug": "frontier-town"
},
"ride_model": {
"id": 1,
"name": "I-Box Track",
"slug": "i-box-track",
"category": "RC",
"manufacturer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rocky-mountain-construction"
}
}
}
]
}
)
]
)
class ParkDetailOutputSerializer(serializers.Serializer):
"""Comprehensive output serializer for park detail view with ALL park information."""
# Core park fields
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
description = serializers.CharField()
status = serializers.CharField()
park_type = serializers.CharField()
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
operating_season = serializers.CharField()
size_acres = serializers.DecimalField(
max_digits=10, decimal_places=2, allow_null=True)
website = serializers.URLField()
timezone = serializers.CharField()
average_rating = serializers.DecimalField(
max_digits=3, decimal_places=2, allow_null=True)
ride_count = serializers.IntegerField(allow_null=True)
coaster_count = serializers.IntegerField(allow_null=True)
url = serializers.CharField()
created_at = serializers.DateTimeField(allow_null=True)
updated_at = serializers.DateTimeField()
# Related objects
location = serializers.SerializerMethodField()
operator = serializers.SerializerMethodField()
property_owner = serializers.SerializerMethodField()
areas = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
photos = serializers.SerializerMethodField()
rides = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField(allow_null=True))
def get_location(self, obj):
"""Get comprehensive location information."""
if hasattr(obj, 'location') and obj.location:
location = obj.location
return {
"id": location.id,
"address": location.street_address,
"city": location.city,
"state": location.state,
"postal_code": location.postal_code,
"country": location.country,
"continent": location.continent,
"latitude": location.latitude,
"longitude": location.longitude,
"formatted_address": location.formatted_address,
}
return None
@extend_schema_field(serializers.DictField())
def get_operator(self, obj):
"""Get operating company information."""
if obj.operator:
return {
"id": obj.operator.id,
"name": obj.operator.name,
"slug": obj.operator.slug,
"roles": obj.operator.roles,
"description": obj.operator.description,
"website": obj.operator.website,
"founded_year": obj.operator.founded_year,
"url": obj.operator.url,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_property_owner(self, obj):
"""Get property owner information if different from operator."""
if obj.property_owner:
return {
"id": obj.property_owner.id,
"name": obj.property_owner.name,
"slug": obj.property_owner.slug,
"roles": obj.property_owner.roles,
"description": obj.property_owner.description,
"website": obj.property_owner.website,
"founded_year": obj.property_owner.founded_year,
"url": obj.property_owner.url,
}
return None
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_areas(self, obj):
"""Get all park areas/themed sections."""
areas = []
for area in obj.areas.all():
area_data = {
"id": area.id,
"name": area.name,
"slug": area.slug,
"description": area.description,
"theme": getattr(area, 'theme', None),
"opening_date": getattr(area, 'opening_date', None),
}
areas.append(area_data)
return areas
@extend_schema_field(serializers.DictField(allow_null=True))
def get_banner_image(self, obj):
"""Get banner image with Cloudflare variants."""
if obj.banner_image and obj.banner_image.image:
return {
"id": obj.banner_image.id,
"image_url": obj.banner_image.image.url,
"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",
},
"caption": obj.banner_image.caption,
"alt_text": obj.banner_image.alt_text,
"photo_type": obj.banner_image.photo_type,
}
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_card_image(self, obj):
"""Get card image with Cloudflare variants."""
if obj.card_image and obj.card_image.image:
return {
"id": obj.card_image.id,
"image_url": obj.card_image.image.url,
"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",
},
"caption": obj.card_image.caption,
"alt_text": obj.card_image.alt_text,
"photo_type": obj.card_image.photo_type,
}
return None
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_photos(self, obj):
"""Get all approved photos for this park."""
photos = []
for photo in obj.photos.filter(is_approved=True).select_related('uploaded_by', 'image'):
if photo.image:
photo_data = {
"id": photo.id,
"image_url": photo.image.url,
"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",
},
"caption": photo.caption,
"alt_text": photo.alt_text,
"photo_type": photo.photo_type,
"is_primary": photo.is_primary,
"is_approved": photo.is_approved,
"created_at": photo.created_at,
"date_taken": getattr(photo, 'date_taken', None),
"uploaded_by": {
"id": photo.uploaded_by.id,
"username": photo.uploaded_by.username,
"display_name": getattr(photo.uploaded_by, 'display_name', photo.uploaded_by.username),
} if photo.uploaded_by else None,
}
photos.append(photo_data)
return photos
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_rides(self, obj):
"""Get all rides at this park with comprehensive information."""
rides = []
# Get all rides for this park with optimized queries
park_rides = obj.rides.select_related(
'park_area',
'ride_model',
'ride_model__manufacturer',
'manufacturer',
'designer',
'banner_image',
'card_image'
).prefetch_related(
'photos'
).filter(
status__in=['OPERATING', 'CLOSED_TEMP', 'SBNO', 'UNDER_CONSTRUCTION']
).order_by('name')
for ride in park_rides:
# Get banner image with fallback logic
banner_image = None
if ride.banner_image and ride.banner_image.image:
banner_image = {
"id": ride.banner_image.id,
"image_url": ride.banner_image.image.url,
"image_variants": {
"thumbnail": f"{ride.banner_image.image.url}/thumbnail",
"medium": f"{ride.banner_image.image.url}/medium",
"large": f"{ride.banner_image.image.url}/large",
"public": f"{ride.banner_image.image.url}/public",
},
"caption": ride.banner_image.caption,
"alt_text": ride.banner_image.alt_text,
"photo_type": ride.banner_image.photo_type,
}
else:
# Fallback to latest approved photo
try:
from apps.rides.models import RidePhoto
latest_photo = (
RidePhoto.objects.filter(
ride=ride, is_approved=True, image__isnull=False
)
.order_by("-created_at")
.first()
)
if latest_photo and latest_photo.image:
banner_image = {
"id": latest_photo.id,
"image_url": latest_photo.image.url,
"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",
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
"photo_type": latest_photo.photo_type,
"is_fallback": True,
}
except Exception:
pass
# Get park area information
park_area = None
if ride.park_area:
park_area = {
"id": ride.park_area.id,
"name": ride.park_area.name,
"slug": ride.park_area.slug,
}
# Get ride model information
ride_model = None
if ride.ride_model:
ride_model = {
"id": ride.ride_model.id,
"name": ride.ride_model.name,
"slug": ride.ride_model.slug,
"category": ride.ride_model.category,
"manufacturer": {
"id": ride.ride_model.manufacturer.id,
"name": ride.ride_model.manufacturer.name,
"slug": ride.ride_model.manufacturer.slug,
} if ride.ride_model.manufacturer else None,
}
# Build ride data
ride_data = {
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"category": ride.category,
"status": ride.status,
"opening_date": ride.opening_date,
"closing_date": ride.closing_date,
"url": f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/rides/{ride.slug}/",
"banner_image": banner_image,
"park_area": park_area,
"ride_model": ride_model,
"manufacturer": {
"id": ride.manufacturer.id,
"name": ride.manufacturer.name,
"slug": ride.manufacturer.slug,
} if ride.manufacturer else None,
"designer": {
"id": ride.designer.id,
"name": ride.designer.name,
"slug": ride.designer.slug,
} if ride.designer else None,
}
rides.append(ride_data)
return rides