""" Rides domain serializers for ThrillWiki API v1. This module contains all serializers related to rides, roller coaster statistics, ride locations, and ride reviews. """ from rest_framework import serializers from drf_spectacular.utils import ( extend_schema_serializer, extend_schema_field, OpenApiExample, ) from config.django import base as settings from .shared import ModelChoices # === RIDE SERIALIZERS === class RideParkOutputSerializer(serializers.Serializer): """Output serializer for ride's park data.""" id = serializers.IntegerField() name = serializers.CharField() slug = serializers.CharField() class RideModelOutputSerializer(serializers.Serializer): """Output serializer for ride model data.""" id = serializers.IntegerField() name = serializers.CharField() description = serializers.CharField() category = serializers.CharField() manufacturer = serializers.SerializerMethodField() @extend_schema_field(serializers.DictField(allow_null=True)) def get_manufacturer(self, obj) -> dict | None: if obj.manufacturer: return { "id": obj.manufacturer.id, "name": obj.manufacturer.name, "slug": obj.manufacturer.slug, } return None @extend_schema_serializer( examples=[ OpenApiExample( "Ride List Example", summary="Example ride list response", description="A typical ride in the list view", value={ "id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance", "category": "ROLLER_COASTER", "status": "OPERATING", "description": "Hybrid roller coaster", "park": {"id": 1, "name": "Cedar Point", "slug": "cedar-point"}, "average_rating": 4.8, "capacity_per_hour": 1200, "opening_date": "2018-05-05", }, ) ] ) class RideListOutputSerializer(serializers.Serializer): """Output serializer for ride list view.""" id = serializers.IntegerField() name = serializers.CharField() slug = serializers.CharField() category = serializers.CharField() status = serializers.CharField() description = serializers.CharField() # Park info park = RideParkOutputSerializer() # Statistics average_rating = serializers.DecimalField( max_digits=3, decimal_places=2, allow_null=True ) capacity_per_hour = serializers.IntegerField(allow_null=True) # Dates opening_date = serializers.DateField(allow_null=True) closing_date = serializers.DateField(allow_null=True) # URL url = serializers.SerializerMethodField() # Metadata created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() @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_serializer( examples=[ OpenApiExample( "Ride Detail Example", summary="Example ride detail response", description="A complete ride detail response", value={ "id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance", "category": "ROLLER_COASTER", "status": "OPERATING", "description": "Hybrid roller coaster featuring RMC I-Box track", "park": {"id": 1, "name": "Cedar Point", "slug": "cedar-point"}, "opening_date": "2018-05-05", "min_height_in": 48, "capacity_per_hour": 1200, "ride_duration_seconds": 150, "average_rating": 4.8, "manufacturer": { "id": 1, "name": "Rocky Mountain Construction", "slug": "rocky-mountain-construction", }, "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": "Amazing roller coaster photo", "is_primary": True, "photo_type": "exterior" } ], "primary_photo": { "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": "Amazing roller coaster photo", "photo_type": "exterior" } }, ) ] ) class RideDetailOutputSerializer(serializers.Serializer): """Output serializer for ride detail view.""" id = serializers.IntegerField() name = serializers.CharField() slug = serializers.CharField() category = serializers.CharField() status = serializers.CharField() post_closing_status = serializers.CharField(allow_null=True) description = serializers.CharField() # Park info park = RideParkOutputSerializer() park_area = serializers.SerializerMethodField() # Dates opening_date = serializers.DateField(allow_null=True) closing_date = serializers.DateField(allow_null=True) status_since = serializers.DateField(allow_null=True) # Physical specs 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) # Statistics average_rating = serializers.DecimalField( max_digits=3, decimal_places=2, allow_null=True ) # Companies manufacturer = serializers.SerializerMethodField() designer = serializers.SerializerMethodField() # Model ride_model = RideModelOutputSerializer(allow_null=True) # Photos photos = serializers.SerializerMethodField() primary_photo = serializers.SerializerMethodField() banner_image = serializers.SerializerMethodField() card_image = serializers.SerializerMethodField() # URL url = serializers.SerializerMethodField() # Metadata created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() @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_park_area(self, obj) -> dict | None: 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) -> dict | None: if obj.manufacturer: return { "id": obj.manufacturer.id, "name": obj.manufacturer.name, "slug": obj.manufacturer.slug, } return None @extend_schema_field(serializers.DictField(allow_null=True)) def get_designer(self, obj) -> dict | None: if obj.designer: return { "id": obj.designer.id, "name": obj.designer.name, "slug": obj.designer.slug, } return None @extend_schema_field(serializers.ListField(child=serializers.DictField())) def get_photos(self, obj): """Get all approved photos for this ride.""" from apps.rides.models import RidePhoto photos = RidePhoto.objects.filter( ride=obj, is_approved=True ).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos return [ { "id": photo.id, "image_url": photo.image.url if photo.image else None, "image_variants": { "thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None, "medium": f"{photo.image.url}/medium" if photo.image else None, "large": f"{photo.image.url}/large" if photo.image else None, "public": f"{photo.image.url}/public" if photo.image else None, } if photo.image else {}, "caption": photo.caption, "alt_text": photo.alt_text, "is_primary": photo.is_primary, "photo_type": photo.photo_type, } for photo in photos ] @extend_schema_field(serializers.DictField(allow_null=True)) def get_primary_photo(self, obj): """Get the primary photo for this ride.""" from apps.rides.models import RidePhoto try: photo = RidePhoto.objects.filter( ride=obj, is_primary=True, is_approved=True ).first() if photo and photo.image: return { "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, } except Exception: pass return None @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_card_image(self, obj): """Get the card image for this ride with fallback to latest photo.""" # First try the explicitly set card image if obj.card_image and obj.card_image.image: return { "id": obj.card_image.id, "image_url": obj.card_image.image.url, "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, } # 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 class RideImageSettingsInputSerializer(serializers.Serializer): """Input serializer for setting ride banner and card images.""" banner_image_id = serializers.IntegerField(required=False, allow_null=True) card_image_id = serializers.IntegerField(required=False, allow_null=True) def validate_banner_image_id(self, value): """Validate that the banner image belongs to the same ride.""" if value is not None: from apps.rides.models import RidePhoto try: photo = RidePhoto.objects.get(id=value) # The ride will be validated in the view return value except RidePhoto.DoesNotExist: raise serializers.ValidationError("Photo not found") return value def validate_card_image_id(self, value): """Validate that the card image belongs to the same ride.""" if value is not None: from apps.rides.models import RidePhoto try: photo = RidePhoto.objects.get(id=value) # The ride will be validated in the view return value except RidePhoto.DoesNotExist: raise serializers.ValidationError("Photo not found") return value class RideCreateInputSerializer(serializers.Serializer): """Input serializer for creating rides.""" name = serializers.CharField(max_length=255) description = serializers.CharField(allow_blank=True, default="") category = serializers.ChoiceField(choices=[]) # Choices set dynamically status = serializers.ChoiceField( choices=[], default="OPERATING" ) # Choices set dynamically # Required park park_id = serializers.IntegerField() # Optional area park_area_id = serializers.IntegerField(required=False, allow_null=True) # Optional dates opening_date = serializers.DateField(required=False, allow_null=True) closing_date = serializers.DateField(required=False, allow_null=True) status_since = serializers.DateField(required=False, allow_null=True) # Optional specs min_height_in = serializers.IntegerField( required=False, allow_null=True, min_value=30, max_value=90 ) max_height_in = serializers.IntegerField( required=False, allow_null=True, min_value=30, max_value=90 ) capacity_per_hour = serializers.IntegerField( required=False, allow_null=True, min_value=1 ) ride_duration_seconds = serializers.IntegerField( required=False, allow_null=True, min_value=1 ) # Optional companies manufacturer_id = serializers.IntegerField(required=False, allow_null=True) designer_id = serializers.IntegerField(required=False, allow_null=True) # Optional model ride_model_id = serializers.IntegerField(required=False, allow_null=True) def validate(self, attrs): """Cross-field validation.""" # Date validation opening_date = attrs.get("opening_date") closing_date = attrs.get("closing_date") if opening_date and closing_date and closing_date < opening_date: raise serializers.ValidationError( "Closing date cannot be before opening date" ) # Height validation min_height = attrs.get("min_height_in") max_height = attrs.get("max_height_in") if min_height and max_height and min_height > max_height: raise serializers.ValidationError( "Minimum height cannot be greater than maximum height" ) return attrs class RideUpdateInputSerializer(serializers.Serializer): """Input serializer for updating rides.""" name = serializers.CharField(max_length=255, required=False) description = serializers.CharField(allow_blank=True, required=False) category = serializers.ChoiceField( choices=[], required=False ) # Choices set dynamically status = serializers.ChoiceField( choices=[], required=False ) # Choices set dynamically post_closing_status = serializers.ChoiceField( choices=ModelChoices.get_ride_post_closing_choices(), required=False, allow_null=True, ) # Park and area park_id = serializers.IntegerField(required=False) park_area_id = serializers.IntegerField(required=False, allow_null=True) # Dates opening_date = serializers.DateField(required=False, allow_null=True) closing_date = serializers.DateField(required=False, allow_null=True) status_since = serializers.DateField(required=False, allow_null=True) # Specs min_height_in = serializers.IntegerField( required=False, allow_null=True, min_value=30, max_value=90 ) max_height_in = serializers.IntegerField( required=False, allow_null=True, min_value=30, max_value=90 ) capacity_per_hour = serializers.IntegerField( required=False, allow_null=True, min_value=1 ) ride_duration_seconds = serializers.IntegerField( required=False, allow_null=True, min_value=1 ) # Companies manufacturer_id = serializers.IntegerField(required=False, allow_null=True) designer_id = serializers.IntegerField(required=False, allow_null=True) # Model ride_model_id = serializers.IntegerField(required=False, allow_null=True) def validate(self, attrs): """Cross-field validation.""" # Date validation opening_date = attrs.get("opening_date") closing_date = attrs.get("closing_date") if opening_date and closing_date and closing_date < opening_date: raise serializers.ValidationError( "Closing date cannot be before opening date" ) # Height validation min_height = attrs.get("min_height_in") max_height = attrs.get("max_height_in") if min_height and max_height and min_height > max_height: raise serializers.ValidationError( "Minimum height cannot be greater than maximum height" ) return attrs class RideFilterInputSerializer(serializers.Serializer): """Input serializer for ride filtering and search.""" # Search search = serializers.CharField(required=False, allow_blank=True) # Category filter category = serializers.MultipleChoiceField( choices=[], required=False ) # Choices set dynamically # Status filter status = serializers.MultipleChoiceField( choices=[], required=False, # Choices set dynamically ) # Park filter park_id = serializers.IntegerField(required=False) park_slug = serializers.CharField(required=False, allow_blank=True) # Company filters manufacturer_id = serializers.IntegerField(required=False) designer_id = serializers.IntegerField(required=False) # Rating filter min_rating = serializers.DecimalField( max_digits=3, decimal_places=2, required=False, min_value=1, max_value=10, ) # Height filters min_height_requirement = serializers.IntegerField(required=False) max_height_requirement = serializers.IntegerField(required=False) # Capacity filter min_capacity = serializers.IntegerField(required=False) # Ordering ordering = serializers.ChoiceField( choices=[ "name", "-name", "opening_date", "-opening_date", "average_rating", "-average_rating", "capacity_per_hour", "-capacity_per_hour", "created_at", "-created_at", ], required=False, default="name", ) # === ROLLER COASTER STATS SERIALIZERS === @extend_schema_serializer( examples=[ OpenApiExample( "Roller Coaster Stats Example", summary="Example roller coaster statistics", description="Detailed statistics for a roller coaster", value={ "id": 1, "ride": {"id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance"}, "height_ft": 205.0, "length_ft": 5740.0, "speed_mph": 74.0, "inversions": 4, "ride_time_seconds": 150, "track_material": "HYBRID", "roller_coaster_type": "SITDOWN", "launch_type": "CHAIN", }, ) ] ) class RollerCoasterStatsOutputSerializer(serializers.Serializer): """Output serializer for roller coaster statistics.""" id = serializers.IntegerField() height_ft = serializers.DecimalField( max_digits=6, decimal_places=2, allow_null=True ) length_ft = serializers.DecimalField( max_digits=7, decimal_places=2, allow_null=True ) speed_mph = serializers.DecimalField( max_digits=5, decimal_places=2, allow_null=True ) inversions = serializers.IntegerField() ride_time_seconds = serializers.IntegerField(allow_null=True) track_type = serializers.CharField() track_material = serializers.CharField() roller_coaster_type = serializers.CharField() max_drop_height_ft = serializers.DecimalField( max_digits=6, decimal_places=2, allow_null=True ) launch_type = serializers.CharField() train_style = serializers.CharField() trains_count = serializers.IntegerField(allow_null=True) cars_per_train = serializers.IntegerField(allow_null=True) seats_per_car = serializers.IntegerField(allow_null=True) # Ride info ride = serializers.SerializerMethodField() @extend_schema_field(serializers.DictField()) def get_ride(self, obj) -> dict: return { "id": obj.ride.id, "name": obj.ride.name, "slug": obj.ride.slug, } class RollerCoasterStatsCreateInputSerializer(serializers.Serializer): """Input serializer for creating roller coaster statistics.""" ride_id = serializers.IntegerField() height_ft = serializers.DecimalField( max_digits=6, decimal_places=2, required=False, allow_null=True ) length_ft = serializers.DecimalField( max_digits=7, decimal_places=2, required=False, allow_null=True ) speed_mph = serializers.DecimalField( max_digits=5, decimal_places=2, required=False, allow_null=True ) inversions = serializers.IntegerField(default=0) ride_time_seconds = serializers.IntegerField(required=False, allow_null=True) track_type = serializers.CharField(max_length=255, allow_blank=True, default="") track_material = serializers.ChoiceField( choices=ModelChoices.get_coaster_track_choices(), default="STEEL" ) roller_coaster_type = serializers.ChoiceField( choices=ModelChoices.get_coaster_type_choices(), default="SITDOWN" ) max_drop_height_ft = serializers.DecimalField( max_digits=6, decimal_places=2, required=False, allow_null=True ) launch_type = serializers.ChoiceField( choices=ModelChoices.get_launch_choices(), default="CHAIN" ) train_style = serializers.CharField(max_length=255, allow_blank=True, default="") trains_count = serializers.IntegerField(required=False, allow_null=True) cars_per_train = serializers.IntegerField(required=False, allow_null=True) seats_per_car = serializers.IntegerField(required=False, allow_null=True) class RollerCoasterStatsUpdateInputSerializer(serializers.Serializer): """Input serializer for updating roller coaster statistics.""" height_ft = serializers.DecimalField( max_digits=6, decimal_places=2, required=False, allow_null=True ) length_ft = serializers.DecimalField( max_digits=7, decimal_places=2, required=False, allow_null=True ) speed_mph = serializers.DecimalField( max_digits=5, decimal_places=2, required=False, allow_null=True ) inversions = serializers.IntegerField(required=False) ride_time_seconds = serializers.IntegerField(required=False, allow_null=True) track_type = serializers.CharField(max_length=255, allow_blank=True, required=False) track_material = serializers.ChoiceField( choices=ModelChoices.get_coaster_track_choices(), required=False ) roller_coaster_type = serializers.ChoiceField( choices=ModelChoices.get_coaster_type_choices(), required=False ) max_drop_height_ft = serializers.DecimalField( max_digits=6, decimal_places=2, required=False, allow_null=True ) launch_type = serializers.ChoiceField( choices=ModelChoices.get_launch_choices(), required=False ) train_style = serializers.CharField( max_length=255, allow_blank=True, required=False ) trains_count = serializers.IntegerField(required=False, allow_null=True) cars_per_train = serializers.IntegerField(required=False, allow_null=True) seats_per_car = serializers.IntegerField(required=False, allow_null=True) # === RIDE LOCATION SERIALIZERS === class RideLocationOutputSerializer(serializers.Serializer): """Output serializer for ride locations.""" id = serializers.IntegerField() latitude = serializers.FloatField(allow_null=True) longitude = serializers.FloatField(allow_null=True) coordinates = serializers.CharField() # Ride info ride = serializers.SerializerMethodField() @extend_schema_field(serializers.DictField()) def get_ride(self, obj) -> dict: return { "id": obj.ride.id, "name": obj.ride.name, "slug": obj.ride.slug, } class RideLocationCreateInputSerializer(serializers.Serializer): """Input serializer for creating ride locations.""" ride_id = serializers.IntegerField() latitude = serializers.FloatField(required=False, allow_null=True) longitude = serializers.FloatField(required=False, allow_null=True) class RideLocationUpdateInputSerializer(serializers.Serializer): """Input serializer for updating ride locations.""" latitude = serializers.FloatField(required=False, allow_null=True) longitude = serializers.FloatField(required=False, allow_null=True) # === RIDE REVIEW SERIALIZERS === @extend_schema_serializer( examples=[ OpenApiExample( "Ride Review Example", summary="Example ride review response", description="A user review of a ride", value={ "id": 1, "rating": 9, "title": "Amazing coaster!", "content": "This ride was incredible, the airtime was fantastic.", "visit_date": "2024-08-15", "ride": {"id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance"}, "user": {"username": "coaster_fan", "display_name": "Coaster Fan"}, "created_at": "2024-08-16T10:30:00Z", "is_published": True, }, ) ] ) class RideReviewOutputSerializer(serializers.Serializer): """Output serializer for ride reviews.""" id = serializers.IntegerField() rating = serializers.IntegerField() title = serializers.CharField() content = serializers.CharField() visit_date = serializers.DateField() created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() is_published = serializers.BooleanField() # Ride info ride = serializers.SerializerMethodField() # User info (limited for privacy) user = serializers.SerializerMethodField() @extend_schema_field(serializers.DictField()) def get_ride(self, obj) -> dict: return { "id": obj.ride.id, "name": obj.ride.name, "slug": obj.ride.slug, } @extend_schema_field(serializers.DictField()) def get_user(self, obj) -> dict: return { "username": obj.user.username, "display_name": obj.user.get_display_name(), } class RideReviewCreateInputSerializer(serializers.Serializer): """Input serializer for creating ride reviews.""" ride_id = serializers.IntegerField() rating = serializers.IntegerField(min_value=1, max_value=10) title = serializers.CharField(max_length=200) content = serializers.CharField() visit_date = serializers.DateField() def validate_visit_date(self, value): """Validate visit date is not in the future.""" from django.utils import timezone if value > timezone.now().date(): raise serializers.ValidationError("Visit date cannot be in the future") return value class RideReviewUpdateInputSerializer(serializers.Serializer): """Input serializer for updating ride reviews.""" rating = serializers.IntegerField(min_value=1, max_value=10, required=False) title = serializers.CharField(max_length=200, required=False) content = serializers.CharField(required=False) visit_date = serializers.DateField(required=False) def validate_visit_date(self, value): """Validate visit date is not in the future.""" from django.utils import timezone if value and value > timezone.now().date(): raise serializers.ValidationError("Visit date cannot be in the future") return value