mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-03-26 20:39:29 -04:00
- 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
1059 lines
46 KiB
Python
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
|