feat(rides): populate slugs for existing RideModel records and ensure uniqueness

- Added migration 0011 to populate unique slugs for existing RideModel records based on manufacturer and model names.
- Implemented logic to ensure slug uniqueness during population.
- Added reverse migration to clear slugs if needed.

feat(rides): enforce unique slugs for RideModel

- Created migration 0012 to alter the slug field in RideModel to be unique.
- Updated the slug field to include help text and a maximum length of 255 characters.

docs: integrate Cloudflare Images into rides and parks models

- Updated RidePhoto and ParkPhoto models to use CloudflareImagesField for image storage.
- Enhanced API serializers for rides and parks to support Cloudflare Images, including new fields for image URLs and variants.
- Provided comprehensive OpenAPI schema metadata for new fields.
- Documented database migrations for the integration.
- Detailed configuration settings for Cloudflare Images.
- Updated API response formats to include Cloudflare Images URLs and variants.
- Added examples for uploading photos via API and outlined testing procedures.
This commit is contained in:
pacnpal
2025-08-28 15:12:39 -04:00
parent 715e284b3e
commit 67db0aa46e
34 changed files with 6002 additions and 894 deletions

View File

@@ -96,6 +96,31 @@ class ParkListOutputSerializer(serializers.Serializer):
"country": "United States",
},
"operator": {"id": 1, "name": "Cedar Fair", "slug": "cedar-fair"},
"photos": [
{
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"caption": "Beautiful park entrance",
"is_primary": True
}
],
"primary_photo": {
"id": 456,
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"caption": "Beautiful park entrance"
}
},
)
]
@@ -135,6 +160,12 @@ class ParkDetailOutputSerializer(serializers.Serializer):
# Areas
areas = serializers.SerializerMethodField()
# Photos
photos = serializers.SerializerMethodField()
primary_photo = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_areas(self, obj):
"""Get simplified area information."""
@@ -150,11 +181,191 @@ class ParkDetailOutputSerializer(serializers.Serializer):
]
return []
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_photos(self, obj):
"""Get all approved photos for this park."""
from apps.parks.models import ParkPhoto
photos = ParkPhoto.objects.filter(
park=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,
}
for photo in photos
]
@extend_schema_field(serializers.DictField(allow_null=True))
def get_primary_photo(self, obj):
"""Get the primary photo for this park."""
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.filter(
park=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,
}
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 park 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,
}
# Fallback to latest approved photo
from apps.parks.models import ParkPhoto
try:
latest_photo = ParkPhoto.objects.filter(
park=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,
"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 park 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,
}
# Fallback to latest approved photo
from apps.parks.models import ParkPhoto
try:
latest_photo = ParkPhoto.objects.filter(
park=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,
"is_fallback": True,
}
except Exception:
pass
return None
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class ParkImageSettingsInputSerializer(serializers.Serializer):
"""Input serializer for setting park 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 park."""
if value is not None:
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
# The park will be validated in the view
return value
except ParkPhoto.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 park."""
if value is not None:
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
# The park will be validated in the view
return value
except ParkPhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
return value
class ParkCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating parks."""

View File

@@ -0,0 +1,808 @@
"""
RideModel serializers for ThrillWiki API v1.
This module contains all serializers related to ride models, variants,
technical specifications, and related functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from .shared import ModelChoices
# Use dynamic imports to avoid circular import issues
def get_ride_model_classes():
"""Get ride model classes dynamically to avoid import issues."""
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
# === RIDE MODEL SERIALIZERS ===
class RideModelManufacturerOutputSerializer(serializers.Serializer):
"""Output serializer for ride model's manufacturer data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
class RideModelPhotoOutputSerializer(serializers.Serializer):
"""Output serializer for ride model photos."""
id = serializers.IntegerField()
image_url = serializers.SerializerMethodField()
caption = serializers.CharField()
alt_text = serializers.CharField()
photo_type = serializers.CharField()
is_primary = serializers.BooleanField()
photographer = serializers.CharField()
source = serializers.CharField()
@extend_schema_field(serializers.URLField(allow_null=True))
def get_image_url(self, obj):
"""Get the image URL."""
if obj.image:
return obj.image.url
return None
class RideModelTechnicalSpecOutputSerializer(serializers.Serializer):
"""Output serializer for ride model technical specifications."""
id = serializers.IntegerField()
spec_category = serializers.CharField()
spec_name = serializers.CharField()
spec_value = serializers.CharField()
spec_unit = serializers.CharField()
notes = serializers.CharField()
class RideModelVariantOutputSerializer(serializers.Serializer):
"""Output serializer for ride model variants."""
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True)
distinguishing_features = serializers.CharField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride Model List Example",
summary="Example ride model list response",
description="A typical ride model in the list view",
value={
"id": 1,
"name": "Hyper Coaster",
"slug": "bolliger-mabillard-hyper-coaster",
"category": "RC",
"description": "High-speed steel roller coaster with airtime hills",
"manufacturer": {
"id": 1,
"name": "Bolliger & Mabillard",
"slug": "bolliger-mabillard"
},
"target_market": "THRILL",
"is_discontinued": False,
"total_installations": 15,
"first_installation_year": 1999,
"height_range_display": "200-325 ft",
"speed_range_display": "70-95 mph",
"primary_image": {
"id": 123,
"image_url": "https://example.com/image.jpg",
"caption": "B&M Hyper Coaster",
"photo_type": "PROMOTIONAL"
}
},
)
]
)
class RideModelListOutputSerializer(serializers.Serializer):
"""Output serializer for ride model list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
description = serializers.CharField()
# Manufacturer info
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
# Market info
target_market = serializers.CharField()
is_discontinued = serializers.BooleanField()
total_installations = serializers.IntegerField()
first_installation_year = serializers.IntegerField(allow_null=True)
last_installation_year = serializers.IntegerField(allow_null=True)
# Display properties
height_range_display = serializers.CharField()
speed_range_display = serializers.CharField()
installation_years_range = serializers.CharField()
# Primary image
primary_image = RideModelPhotoOutputSerializer(allow_null=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride Model Detail Example",
summary="Example ride model detail response",
description="A complete ride model detail response",
value={
"id": 1,
"name": "Hyper Coaster",
"slug": "bolliger-mabillard-hyper-coaster",
"category": "RC",
"description": "High-speed steel roller coaster featuring airtime hills and smooth ride experience",
"manufacturer": {
"id": 1,
"name": "Bolliger & Mabillard",
"slug": "bolliger-mabillard"
},
"typical_height_range_min_ft": 200.0,
"typical_height_range_max_ft": 325.0,
"typical_speed_range_min_mph": 70.0,
"typical_speed_range_max_mph": 95.0,
"typical_capacity_range_min": 1200,
"typical_capacity_range_max": 1800,
"track_type": "Tubular Steel",
"support_structure": "Steel",
"train_configuration": "2-3 trains, 7-9 cars per train, 4 seats per car",
"restraint_system": "Clamshell lap bar",
"target_market": "THRILL",
"is_discontinued": False,
"total_installations": 15,
"first_installation_year": 1999,
"notable_features": "Airtime hills, smooth ride, high capacity",
"photos": [
{
"id": 123,
"image_url": "https://example.com/image.jpg",
"caption": "B&M Hyper Coaster",
"photo_type": "PROMOTIONAL",
"is_primary": True
}
],
"variants": [
{
"id": 1,
"name": "Mega Coaster",
"description": "200-299 ft height variant",
"min_height_ft": 200.0,
"max_height_ft": 299.0
}
],
"technical_specs": [
{
"id": 1,
"spec_category": "DIMENSIONS",
"spec_name": "Track Width",
"spec_value": "1435",
"spec_unit": "mm"
}
],
"installations": [
{
"id": 1,
"name": "Nitro",
"park_name": "Six Flags Great Adventure",
"opening_date": "2001-04-07"
}
]
},
)
]
)
class RideModelDetailOutputSerializer(serializers.Serializer):
"""Output serializer for ride model detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
description = serializers.CharField()
# Manufacturer info
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
# Technical specifications
typical_height_range_min_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
typical_height_range_max_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
typical_speed_range_min_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(allow_null=True)
typical_capacity_range_max = serializers.IntegerField(allow_null=True)
# Design characteristics
track_type = serializers.CharField()
support_structure = serializers.CharField()
train_configuration = serializers.CharField()
restraint_system = serializers.CharField()
# Market information
first_installation_year = serializers.IntegerField(allow_null=True)
last_installation_year = serializers.IntegerField(allow_null=True)
is_discontinued = serializers.BooleanField()
total_installations = serializers.IntegerField()
# Design features
notable_features = serializers.CharField()
target_market = serializers.CharField()
# Display properties
height_range_display = serializers.CharField()
speed_range_display = serializers.CharField()
installation_years_range = serializers.CharField()
# SEO metadata
meta_title = serializers.CharField()
meta_description = serializers.CharField()
# Related data
photos = RideModelPhotoOutputSerializer(many=True)
variants = RideModelVariantOutputSerializer(many=True)
technical_specs = RideModelTechnicalSpecOutputSerializer(many=True)
installations = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_installations(self, obj):
"""Get ride installations using this model."""
from django.apps import apps
Ride = apps.get_model('rides', 'Ride')
installations = Ride.objects.filter(ride_model=obj).select_related('park')[:10]
return [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"park_name": ride.park.name,
"park_slug": ride.park.slug,
"opening_date": ride.opening_date,
"status": ride.status,
}
for ride in installations
]
class RideModelCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride models."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(
choices=ModelChoices.get_ride_category_choices(),
allow_blank=True,
default=""
)
# Required manufacturer
manufacturer_id = serializers.IntegerField()
# Technical specifications
typical_height_range_min_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_height_range_max_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_min_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_max = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, default="")
support_structure = serializers.CharField(
max_length=100, allow_blank=True, default="")
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, default="")
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, default="")
# Market information
first_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
last_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
is_discontinued = serializers.BooleanField(default=False)
# Design features
notable_features = serializers.CharField(allow_blank=True, default="")
target_market = serializers.ChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
],
allow_blank=True,
default=""
)
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("typical_height_range_min_ft")
max_height = attrs.get("typical_height_range_max_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("typical_speed_range_min_mph")
max_speed = attrs.get("typical_speed_range_max_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
# Capacity range validation
min_capacity = attrs.get("typical_capacity_range_min")
max_capacity = attrs.get("typical_capacity_range_max")
if min_capacity and max_capacity and min_capacity > max_capacity:
raise serializers.ValidationError(
"Minimum capacity cannot be greater than maximum capacity"
)
# Installation years validation
first_year = attrs.get("first_installation_year")
last_year = attrs.get("last_installation_year")
if first_year and last_year and first_year > last_year:
raise serializers.ValidationError(
"First installation year cannot be after last installation year"
)
return attrs
class RideModelUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride models."""
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(),
allow_blank=True,
required=False
)
# Manufacturer
manufacturer_id = serializers.IntegerField(required=False)
# Technical specifications
typical_height_range_min_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_height_range_max_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_min_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_max = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, required=False)
support_structure = serializers.CharField(
max_length=100, allow_blank=True, required=False)
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, required=False)
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, required=False)
# Market information
first_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
last_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
is_discontinued = serializers.BooleanField(required=False)
# Design features
notable_features = serializers.CharField(allow_blank=True, required=False)
target_market = serializers.ChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
],
allow_blank=True,
required=False
)
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("typical_height_range_min_ft")
max_height = attrs.get("typical_height_range_max_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("typical_speed_range_min_mph")
max_speed = attrs.get("typical_speed_range_max_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
# Capacity range validation
min_capacity = attrs.get("typical_capacity_range_min")
max_capacity = attrs.get("typical_capacity_range_max")
if min_capacity and max_capacity and min_capacity > max_capacity:
raise serializers.ValidationError(
"Minimum capacity cannot be greater than maximum capacity"
)
# Installation years validation
first_year = attrs.get("first_installation_year")
last_year = attrs.get("last_installation_year")
if first_year and last_year and first_year > last_year:
raise serializers.ValidationError(
"First installation year cannot be after last installation year"
)
return attrs
class RideModelFilterInputSerializer(serializers.Serializer):
"""Input serializer for ride model 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
)
# Manufacturer filter
manufacturer_id = serializers.IntegerField(required=False)
manufacturer_slug = serializers.CharField(required=False, allow_blank=True)
# Market filter
target_market = serializers.MultipleChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
],
required=False
)
# Status filter
is_discontinued = serializers.BooleanField(required=False)
# Year filters
first_installation_year_min = serializers.IntegerField(required=False)
first_installation_year_max = serializers.IntegerField(required=False)
# Installation count filter
min_installations = serializers.IntegerField(required=False, min_value=0)
# Height filters
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False
)
# Speed filters
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False
)
# Ordering
ordering = serializers.ChoiceField(
choices=[
"name",
"-name",
"manufacturer__name",
"-manufacturer__name",
"first_installation_year",
"-first_installation_year",
"total_installations",
"-total_installations",
"created_at",
"-created_at",
],
required=False,
default="manufacturer__name,name",
)
# === RIDE MODEL VARIANT SERIALIZERS ===
class RideModelVariantCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride model variants."""
ride_model_id = serializers.IntegerField()
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
# Variant-specific specifications
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
# Distinguishing features
distinguishing_features = serializers.CharField(allow_blank=True, default="")
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("min_height_ft")
max_height = attrs.get("max_height_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("min_speed_mph")
max_speed = attrs.get("max_speed_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
return attrs
class RideModelVariantUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride model variants."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
# Variant-specific specifications
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
# Distinguishing features
distinguishing_features = serializers.CharField(allow_blank=True, required=False)
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("min_height_ft")
max_height = attrs.get("max_height_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("min_speed_mph")
max_speed = attrs.get("max_speed_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
return attrs
# === RIDE MODEL TECHNICAL SPEC SERIALIZERS ===
class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride model technical specifications."""
ride_model_id = serializers.IntegerField()
spec_category = serializers.ChoiceField(
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
]
)
spec_name = serializers.CharField(max_length=100)
spec_value = serializers.CharField(max_length=255)
spec_unit = serializers.CharField(max_length=20, allow_blank=True, default="")
notes = serializers.CharField(allow_blank=True, default="")
class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride model technical specifications."""
spec_category = serializers.ChoiceField(
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
],
required=False
)
spec_name = serializers.CharField(max_length=100, required=False)
spec_value = serializers.CharField(max_length=255, required=False)
spec_unit = serializers.CharField(max_length=20, allow_blank=True, required=False)
notes = serializers.CharField(allow_blank=True, required=False)
# === RIDE MODEL PHOTO SERIALIZERS ===
class RideModelPhotoCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride model photos."""
ride_model_id = serializers.IntegerField()
image = serializers.ImageField()
caption = serializers.CharField(max_length=500, allow_blank=True, default="")
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
photo_type = serializers.ChoiceField(
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
],
default='PROMOTIONAL'
)
is_primary = serializers.BooleanField(default=False)
photographer = serializers.CharField(max_length=255, allow_blank=True, default="")
source = serializers.CharField(max_length=255, allow_blank=True, default="")
copyright_info = serializers.CharField(max_length=255, allow_blank=True, default="")
class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride model photos."""
caption = serializers.CharField(max_length=500, allow_blank=True, required=False)
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
photo_type = serializers.ChoiceField(
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
],
required=False
)
is_primary = serializers.BooleanField(required=False)
photographer = serializers.CharField(
max_length=255, allow_blank=True, required=False)
source = serializers.CharField(max_length=255, allow_blank=True, required=False)
copyright_info = serializers.CharField(
max_length=255, allow_blank=True, required=False)
# === RIDE MODEL STATS SERIALIZERS ===
class RideModelStatsOutputSerializer(serializers.Serializer):
"""Output serializer for ride model statistics."""
total_models = serializers.IntegerField()
total_installations = serializers.IntegerField()
active_manufacturers = serializers.IntegerField()
discontinued_models = serializers.IntegerField()
by_category = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by category"
)
by_target_market = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by target market"
)
by_manufacturer = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by manufacturer"
)
recent_models = serializers.IntegerField(
help_text="Models created in the last 30 days"
)

View File

@@ -119,6 +119,33 @@ class RideListOutputSerializer(serializers.Serializer):
"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"
}
},
)
]
@@ -161,6 +188,12 @@ class RideDetailOutputSerializer(serializers.Serializer):
# Model
ride_model = RideModelOutputSerializer(allow_null=True)
# Photos
photos = serializers.SerializerMethodField()
primary_photo = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@@ -195,6 +228,192 @@ class RideDetailOutputSerializer(serializers.Serializer):
}
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."""

View File

@@ -102,6 +102,22 @@ class ModelChoices:
("SBNO", "Standing But Not Operating"),
]
@staticmethod
def get_ride_category_choices():
try:
from apps.rides.models import CATEGORY_CHOICES
return CATEGORY_CHOICES
except ImportError:
return [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
class LocationOutputSerializer(serializers.Serializer):
"""Shared serializer for location data."""