mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 14:11:09 -05:00
- Added migration to transition avatar data from CloudflareImageField to ForeignKey structure in UserProfile. - Fixed UserProfileEvent avatar field to align with new avatar structure. - Created serializers for social authentication, including connected and available providers. - Developed request logging middleware for comprehensive request/response logging. - Updated moderation and parks migrations to remove outdated triggers and adjust foreign key relationships. - Enhanced rides migrations to ensure proper handling of image uploads and triggers. - Introduced a test script for the 3-step avatar upload process, ensuring functionality with Cloudflare. - Documented the fix for avatar upload issues, detailing root cause, implementation, and verification steps. - Implemented automatic deletion of Cloudflare images upon avatar, park, and ride photo changes or removals.
917 lines
32 KiB
Python
917 lines
32 KiB
Python
"""
|
|
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:
|
|
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:
|
|
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"
|
|
)
|
|
|
|
# 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=[], 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
|