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