""" Rides domain serializers for ThrillWiki API v1. This module contains all serializers related to rides, roller coaster statistics, ride locations, and ride reviews. """ from drf_spectacular.utils import ( OpenApiExample, extend_schema_field, extend_schema_serializer, ) from rest_framework import serializers from apps.core.choices.serializers import RichChoiceFieldSerializer 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() url = serializers.SerializerMethodField() @extend_schema_field(serializers.URLField()) def get_url(self, obj) -> str: """Generate the frontend URL for this park.""" return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/" 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 = RichChoiceFieldSerializer(choice_group="categories", domain="rides") status = RichChoiceFieldSerializer(choice_group="statuses", domain="rides") 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 = RichChoiceFieldSerializer(choice_group="categories", domain="rides") status = RichChoiceFieldSerializer(choice_group="statuses", domain="rides") post_closing_status = RichChoiceFieldSerializer( choice_group="post_closing_statuses", domain="rides", 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() # Former names (name history) former_names = serializers.SerializerMethodField() # Coaster statistics - includes both imperial and metric units for frontend flexibility coaster_statistics = serializers.SerializerMethodField() # Metric unit fields for frontend (converted from imperial) height_meters = serializers.SerializerMethodField() length_meters = serializers.SerializerMethodField() max_speed_kmh = serializers.SerializerMethodField() drop_meters = serializers.SerializerMethodField() # Technical specifications list technical_specifications = 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.public_url if photo.image else None, "image_variants": ( { "thumbnail": (f"{photo.image.public_url}/thumbnail" if photo.image else None), "medium": f"{photo.image.public_url}/medium" if photo.image else None, "large": f"{photo.image.public_url}/large" if photo.image else None, "public": f"{photo.image.public_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.public_url, "image_variants": { "thumbnail": f"{photo.image.public_url}/thumbnail", "medium": f"{photo.image.public_url}/medium", "large": f"{photo.image.public_url}/large", "public": f"{photo.image.public_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.public_url, "image_variants": { "thumbnail": f"{obj.banner_image.image.public_url}/thumbnail", "medium": f"{obj.banner_image.image.public_url}/medium", "large": f"{obj.banner_image.image.public_url}/large", "public": f"{obj.banner_image.image.public_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.public_url, "image_variants": { "thumbnail": f"{latest_photo.image.public_url}/thumbnail", "medium": f"{latest_photo.image.public_url}/medium", "large": f"{latest_photo.image.public_url}/large", "public": f"{latest_photo.image.public_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.public_url, "image_variants": { "thumbnail": f"{obj.card_image.image.public_url}/thumbnail", "medium": f"{obj.card_image.image.public_url}/medium", "large": f"{obj.card_image.image.public_url}/large", "public": f"{obj.card_image.image.public_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.public_url, "image_variants": { "thumbnail": f"{latest_photo.image.public_url}/thumbnail", "medium": f"{latest_photo.image.public_url}/medium", "large": f"{latest_photo.image.public_url}/large", "public": f"{latest_photo.image.public_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.ListField(child=serializers.DictField())) def get_former_names(self, obj): """Get the former names (name history) for this ride.""" from apps.rides.models import RideNameHistory former_names = RideNameHistory.objects.filter(ride=obj).order_by("-to_year", "-from_year") return [ { "id": entry.id, "former_name": entry.former_name, "from_year": entry.from_year, "to_year": entry.to_year, "reason": entry.reason, } for entry in former_names ] @extend_schema_field(serializers.DictField(allow_null=True)) def get_coaster_statistics(self, obj): """Get coaster statistics with both imperial and metric units.""" try: if hasattr(obj, "coaster_stats") and obj.coaster_stats: stats = obj.coaster_stats return { # Imperial units (stored in DB) "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, "max_drop_height_ft": float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None, # Metric conversions for frontend "height_meters": round(float(stats.height_ft) * 0.3048, 2) if stats.height_ft else None, "length_meters": round(float(stats.length_ft) * 0.3048, 2) if stats.length_ft else None, "max_speed_kmh": round(float(stats.speed_mph) * 1.60934, 2) if stats.speed_mph else None, "drop_meters": round(float(stats.max_drop_height_ft) * 0.3048, 2) if stats.max_drop_height_ft else None, # Other stats "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, "propulsion_system": stats.propulsion_system, "train_style": stats.train_style, "trains_count": stats.trains_count, "cars_per_train": stats.cars_per_train, "seats_per_car": stats.seats_per_car, } except AttributeError: pass return None @extend_schema_field(serializers.FloatField(allow_null=True)) def get_height_meters(self, obj): """Convert height from feet to meters for frontend.""" try: if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.height_ft: return round(float(obj.coaster_stats.height_ft) * 0.3048, 2) except (AttributeError, TypeError): pass return None @extend_schema_field(serializers.FloatField(allow_null=True)) def get_length_meters(self, obj): """Convert length from feet to meters for frontend.""" try: if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.length_ft: return round(float(obj.coaster_stats.length_ft) * 0.3048, 2) except (AttributeError, TypeError): pass return None @extend_schema_field(serializers.FloatField(allow_null=True)) def get_max_speed_kmh(self, obj): """Convert max speed from mph to km/h for frontend.""" try: if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.speed_mph: return round(float(obj.coaster_stats.speed_mph) * 1.60934, 2) except (AttributeError, TypeError): pass return None @extend_schema_field(serializers.FloatField(allow_null=True)) def get_drop_meters(self, obj): """Convert drop height from feet to meters for frontend.""" try: if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.max_drop_height_ft: return round(float(obj.coaster_stats.max_drop_height_ft) * 0.3048, 2) except (AttributeError, TypeError): pass return None @extend_schema_field(serializers.ListField(child=serializers.DictField())) def get_technical_specifications(self, obj): """Get technical specifications list for this ride.""" try: from apps.rides.models import RideTechnicalSpec specs = RideTechnicalSpec.objects.filter(ride=obj).order_by("category", "name") return [ { "id": spec.id, "name": spec.name, "value": spec.value, "unit": spec.unit, "category": spec.category, } for spec in specs ] except Exception: return [] 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: RidePhoto.objects.get(id=value) # The ride will be validated in the view return value except RidePhoto.DoesNotExist: raise serializers.ValidationError("Photo not found") from None 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: RidePhoto.objects.get(id=value) # The ride will be validated in the view return value except RidePhoto.DoesNotExist: raise serializers.ValidationError("Photo not found") from None 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=ModelChoices.get_ride_category_choices()) status = serializers.ChoiceField(choices=ModelChoices.get_ride_status_choices(), default="OPERATING") # 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") # Park area validation when park changes park_id = attrs.get("park_id") park_area_id = attrs.get("park_area_id") if park_id and park_area_id: try: from apps.parks.models import ParkArea park_area = ParkArea.objects.get(id=park_area_id) if park_area.park_id != park_id: raise serializers.ValidationError( f"Park area '{park_area.name}' does not belong to the selected park" ) except Exception: # If models aren't available or area doesn't exist, let the view handle it pass 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=ModelChoices.get_ride_category_choices(), required=False) status = serializers.ChoiceField(choices=ModelChoices.get_ride_status_choices(), required=False) 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=ModelChoices.get_ride_category_choices(), required=False) # Status filter status = serializers.MultipleChoiceField( choices=ModelChoices.get_ride_status_choices(), required=False, ) # 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", "propulsion_system": "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 = RichChoiceFieldSerializer(choice_group="track_materials", domain="rides") roller_coaster_type = RichChoiceFieldSerializer(choice_group="coaster_types", domain="rides") max_drop_height_ft = serializers.DecimalField(max_digits=6, decimal_places=2, allow_null=True) propulsion_system = RichChoiceFieldSerializer(choice_group="propulsion_systems", domain="rides") 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) propulsion_system = serializers.ChoiceField(choices=ModelChoices.get_propulsion_system_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) propulsion_system = serializers.ChoiceField(choices=ModelChoices.get_propulsion_system_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 # === RIDE NAME HISTORY SERIALIZERS === class RideNameHistoryOutputSerializer(serializers.Serializer): """Output serializer for ride name history (former names).""" id = serializers.IntegerField() former_name = serializers.CharField() from_year = serializers.IntegerField(allow_null=True) to_year = serializers.IntegerField(allow_null=True) reason = serializers.CharField() created_at = serializers.DateTimeField() class RideNameHistoryCreateInputSerializer(serializers.Serializer): """Input serializer for creating ride name history entries.""" former_name = serializers.CharField(max_length=200) from_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100) to_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100) reason = serializers.CharField(max_length=500, required=False, allow_blank=True, default="") def validate(self, attrs): """Validate year range.""" from_year = attrs.get("from_year") to_year = attrs.get("to_year") if from_year and to_year and from_year > to_year: raise serializers.ValidationError("From year cannot be after to year") return attrs