mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 10:11:08 -05:00
update
This commit is contained in:
@@ -1,330 +0,0 @@
|
||||
"""
|
||||
ThrillWiki API v1 serializers module.
|
||||
|
||||
This module provides a unified interface to all serializers across different domains
|
||||
while maintaining the modular structure for better organization and maintainability.
|
||||
"""
|
||||
|
||||
from .services import (
|
||||
HealthCheckOutputSerializer,
|
||||
PerformanceMetricsOutputSerializer,
|
||||
SimpleHealthOutputSerializer,
|
||||
EmailSendInputSerializer,
|
||||
EmailTemplateOutputSerializer,
|
||||
MapDataOutputSerializer,
|
||||
CoordinateInputSerializer,
|
||||
HistoryEventSerializer,
|
||||
HistoryEntryOutputSerializer,
|
||||
HistoryCreateInputSerializer,
|
||||
ModerationSubmissionSerializer,
|
||||
ModerationSubmissionOutputSerializer,
|
||||
RoadtripParkSerializer,
|
||||
RoadtripCreateInputSerializer,
|
||||
RoadtripOutputSerializer,
|
||||
GeocodeInputSerializer,
|
||||
GeocodeOutputSerializer,
|
||||
DistanceCalculationInputSerializer,
|
||||
DistanceCalculationOutputSerializer,
|
||||
) # noqa: F401
|
||||
from typing import Any, Dict, List
|
||||
import importlib
|
||||
|
||||
# --- Shared utilities and base classes ---
|
||||
from .shared import (
|
||||
FilterOptionSerializer,
|
||||
FilterRangeSerializer,
|
||||
StandardizedFilterMetadataSerializer,
|
||||
validate_filter_metadata_contract,
|
||||
ensure_filter_option_format,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Parks domain ---
|
||||
from .parks import (
|
||||
ParkListOutputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
ParkCreateInputSerializer,
|
||||
ParkUpdateInputSerializer,
|
||||
ParkFilterInputSerializer,
|
||||
ParkAreaDetailOutputSerializer,
|
||||
ParkAreaCreateInputSerializer,
|
||||
ParkAreaUpdateInputSerializer,
|
||||
ParkLocationOutputSerializer,
|
||||
ParkLocationCreateInputSerializer,
|
||||
ParkLocationUpdateInputSerializer,
|
||||
ParkSuggestionSerializer,
|
||||
ParkSuggestionOutputSerializer,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Companies and ride models domain ---
|
||||
from .companies import (
|
||||
CompanyDetailOutputSerializer,
|
||||
CompanyCreateInputSerializer,
|
||||
CompanyUpdateInputSerializer,
|
||||
RideModelDetailOutputSerializer,
|
||||
RideModelCreateInputSerializer,
|
||||
RideModelUpdateInputSerializer,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Rides domain ---
|
||||
from .rides import (
|
||||
RideParkOutputSerializer,
|
||||
RideModelOutputSerializer,
|
||||
RideListOutputSerializer,
|
||||
RideDetailOutputSerializer,
|
||||
RideCreateInputSerializer,
|
||||
RideUpdateInputSerializer,
|
||||
RideFilterInputSerializer,
|
||||
RollerCoasterStatsOutputSerializer,
|
||||
RollerCoasterStatsCreateInputSerializer,
|
||||
RollerCoasterStatsUpdateInputSerializer,
|
||||
RideLocationOutputSerializer,
|
||||
RideLocationCreateInputSerializer,
|
||||
RideLocationUpdateInputSerializer,
|
||||
RideReviewOutputSerializer,
|
||||
RideReviewCreateInputSerializer,
|
||||
RideReviewUpdateInputSerializer,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Accounts domain: try multiple likely locations, fall back to placeholders ---
|
||||
_ACCOUNTS_SYMBOLS: List[str] = [
|
||||
"UserProfileOutputSerializer",
|
||||
"UserProfileCreateInputSerializer",
|
||||
"UserProfileUpdateInputSerializer",
|
||||
"TopListOutputSerializer",
|
||||
"TopListCreateInputSerializer",
|
||||
"TopListUpdateInputSerializer",
|
||||
"TopListItemOutputSerializer",
|
||||
"TopListItemCreateInputSerializer",
|
||||
"TopListItemUpdateInputSerializer",
|
||||
"UserOutputSerializer",
|
||||
"LoginInputSerializer",
|
||||
"LoginOutputSerializer",
|
||||
"SignupInputSerializer",
|
||||
"SignupOutputSerializer",
|
||||
"PasswordResetInputSerializer",
|
||||
"PasswordResetOutputSerializer",
|
||||
"PasswordChangeInputSerializer",
|
||||
"PasswordChangeOutputSerializer",
|
||||
"LogoutOutputSerializer",
|
||||
"SocialProviderOutputSerializer",
|
||||
"AuthStatusOutputSerializer",
|
||||
]
|
||||
|
||||
|
||||
def _import_accounts_symbols() -> Dict[str, Any]:
|
||||
"""
|
||||
Try a list of candidate module paths and return a dict mapping expected symbol
|
||||
names to the objects found. If no candidate provides a symbol, the symbol maps to None.
|
||||
"""
|
||||
candidates = [
|
||||
f"{__package__}.accounts",
|
||||
f"{__package__}.auth",
|
||||
"apps.accounts.serializers",
|
||||
"apps.api.v1.auth.serializers",
|
||||
]
|
||||
|
||||
# Prepare default placeholders
|
||||
result: Dict[str, Any] = {name: None for name in _ACCOUNTS_SYMBOLS}
|
||||
|
||||
for modname in candidates:
|
||||
try:
|
||||
module = importlib.import_module(modname)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Fill in any symbols that exist on this module (don't require all)
|
||||
for name in _ACCOUNTS_SYMBOLS:
|
||||
if hasattr(module, name):
|
||||
result[name] = getattr(module, name)
|
||||
|
||||
# If we've found at least one real object (not all None), stop trying further candidates.
|
||||
if any(result[name] is not None for name in _ACCOUNTS_SYMBOLS):
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
_accounts = _import_accounts_symbols()
|
||||
|
||||
# Bind account symbols into the module namespace (only if they exist)
|
||||
for _name in _ACCOUNTS_SYMBOLS:
|
||||
if _accounts.get(_name) is not None:
|
||||
globals()[_name] = _accounts[_name]
|
||||
|
||||
# --- Services domain ---
|
||||
|
||||
# --- Optionally try importing other domain modules and inject serializer-like names ---
|
||||
_optional_domains = [
|
||||
"other",
|
||||
"media",
|
||||
"parks_media",
|
||||
"rides_media",
|
||||
"search",
|
||||
"history",
|
||||
]
|
||||
for domain in _optional_domains:
|
||||
modname = f"{__package__}.{domain}"
|
||||
try:
|
||||
module = importlib.import_module(modname)
|
||||
except Exception:
|
||||
continue
|
||||
# Inject any attribute that looks like a serializer or matches uppercase naming used by exported symbols
|
||||
for attr in dir(module):
|
||||
if attr.startswith("_"):
|
||||
continue
|
||||
# Heuristic: export classes/constants that end with 'Serializer' or are uppercase constants
|
||||
if (
|
||||
attr.endswith("Serializer")
|
||||
or attr.isupper()
|
||||
or attr.endswith("OutputSerializer")
|
||||
or attr.endswith("InputSerializer")
|
||||
):
|
||||
globals()[attr] = getattr(module, attr)
|
||||
|
||||
# --- Construct a conservative __all__ based on explicit lists and discovered serializer names ---
|
||||
_SHARED_EXPORTS = [
|
||||
"FilterOptionSerializer",
|
||||
"FilterRangeSerializer",
|
||||
"StandardizedFilterMetadataSerializer",
|
||||
"validate_filter_metadata_contract",
|
||||
"ensure_filter_option_format",
|
||||
]
|
||||
|
||||
_PARKS_EXPORTS = [
|
||||
"ParkListOutputSerializer",
|
||||
"ParkDetailOutputSerializer",
|
||||
"ParkCreateInputSerializer",
|
||||
"ParkUpdateInputSerializer",
|
||||
"ParkFilterInputSerializer",
|
||||
"ParkAreaDetailOutputSerializer",
|
||||
"ParkAreaCreateInputSerializer",
|
||||
"ParkAreaUpdateInputSerializer",
|
||||
"ParkLocationOutputSerializer",
|
||||
"ParkLocationCreateInputSerializer",
|
||||
"ParkLocationUpdateInputSerializer",
|
||||
"ParkSuggestionSerializer",
|
||||
"ParkSuggestionOutputSerializer",
|
||||
]
|
||||
|
||||
_COMPANIES_EXPORTS = [
|
||||
"CompanyDetailOutputSerializer",
|
||||
"CompanyCreateInputSerializer",
|
||||
"CompanyUpdateInputSerializer",
|
||||
"RideModelDetailOutputSerializer",
|
||||
"RideModelCreateInputSerializer",
|
||||
"RideModelUpdateInputSerializer",
|
||||
]
|
||||
|
||||
_RIDES_EXPORTS = [
|
||||
"RideParkOutputSerializer",
|
||||
"RideModelOutputSerializer",
|
||||
"RideListOutputSerializer",
|
||||
"RideDetailOutputSerializer",
|
||||
"RideCreateInputSerializer",
|
||||
"RideUpdateInputSerializer",
|
||||
"RideFilterInputSerializer",
|
||||
"RollerCoasterStatsOutputSerializer",
|
||||
"RollerCoasterStatsCreateInputSerializer",
|
||||
"RollerCoasterStatsUpdateInputSerializer",
|
||||
"RideLocationOutputSerializer",
|
||||
"RideLocationCreateInputSerializer",
|
||||
"RideLocationUpdateInputSerializer",
|
||||
"RideReviewOutputSerializer",
|
||||
"RideReviewCreateInputSerializer",
|
||||
"RideReviewUpdateInputSerializer",
|
||||
]
|
||||
|
||||
_SERVICES_EXPORTS = [
|
||||
"HealthCheckOutputSerializer",
|
||||
"PerformanceMetricsOutputSerializer",
|
||||
"SimpleHealthOutputSerializer",
|
||||
"EmailSendInputSerializer",
|
||||
"EmailTemplateOutputSerializer",
|
||||
"MapDataOutputSerializer",
|
||||
"CoordinateInputSerializer",
|
||||
"HistoryEventSerializer",
|
||||
"HistoryEntryOutputSerializer",
|
||||
"HistoryCreateInputSerializer",
|
||||
"ModerationSubmissionSerializer",
|
||||
"ModerationSubmissionOutputSerializer",
|
||||
"RoadtripParkSerializer",
|
||||
"RoadtripCreateInputSerializer",
|
||||
"RoadtripOutputSerializer",
|
||||
"GeocodeInputSerializer",
|
||||
"GeocodeOutputSerializer",
|
||||
"DistanceCalculationInputSerializer",
|
||||
"DistanceCalculationOutputSerializer",
|
||||
]
|
||||
|
||||
# Build a static __all__ list with only the serializers we know exist
|
||||
__all__ = [
|
||||
# Shared exports
|
||||
"FilterOptionSerializer",
|
||||
"FilterRangeSerializer",
|
||||
"StandardizedFilterMetadataSerializer",
|
||||
"validate_filter_metadata_contract",
|
||||
"ensure_filter_option_format",
|
||||
# Parks exports
|
||||
"ParkListOutputSerializer",
|
||||
"ParkDetailOutputSerializer",
|
||||
"ParkCreateInputSerializer",
|
||||
"ParkUpdateInputSerializer",
|
||||
"ParkFilterInputSerializer",
|
||||
"ParkAreaDetailOutputSerializer",
|
||||
"ParkAreaCreateInputSerializer",
|
||||
"ParkAreaUpdateInputSerializer",
|
||||
"ParkLocationOutputSerializer",
|
||||
"ParkLocationCreateInputSerializer",
|
||||
"ParkLocationUpdateInputSerializer",
|
||||
"ParkSuggestionSerializer",
|
||||
"ParkSuggestionOutputSerializer",
|
||||
# Companies exports
|
||||
"CompanyDetailOutputSerializer",
|
||||
"CompanyCreateInputSerializer",
|
||||
"CompanyUpdateInputSerializer",
|
||||
"RideModelDetailOutputSerializer",
|
||||
"RideModelCreateInputSerializer",
|
||||
"RideModelUpdateInputSerializer",
|
||||
# Rides exports
|
||||
"RideParkOutputSerializer",
|
||||
"RideModelOutputSerializer",
|
||||
"RideListOutputSerializer",
|
||||
"RideDetailOutputSerializer",
|
||||
"RideCreateInputSerializer",
|
||||
"RideUpdateInputSerializer",
|
||||
"RideFilterInputSerializer",
|
||||
"RollerCoasterStatsOutputSerializer",
|
||||
"RollerCoasterStatsCreateInputSerializer",
|
||||
"RollerCoasterStatsUpdateInputSerializer",
|
||||
"RideLocationOutputSerializer",
|
||||
"RideLocationCreateInputSerializer",
|
||||
"RideLocationUpdateInputSerializer",
|
||||
"RideReviewOutputSerializer",
|
||||
"RideReviewCreateInputSerializer",
|
||||
"RideReviewUpdateInputSerializer",
|
||||
# Services exports
|
||||
"HealthCheckOutputSerializer",
|
||||
"PerformanceMetricsOutputSerializer",
|
||||
"SimpleHealthOutputSerializer",
|
||||
"EmailSendInputSerializer",
|
||||
"EmailTemplateOutputSerializer",
|
||||
"MapDataOutputSerializer",
|
||||
"CoordinateInputSerializer",
|
||||
"HistoryEventSerializer",
|
||||
"HistoryEntryOutputSerializer",
|
||||
"HistoryCreateInputSerializer",
|
||||
"ModerationSubmissionSerializer",
|
||||
"ModerationSubmissionOutputSerializer",
|
||||
"RoadtripParkSerializer",
|
||||
"RoadtripCreateInputSerializer",
|
||||
"RoadtripOutputSerializer",
|
||||
"GeocodeInputSerializer",
|
||||
"GeocodeOutputSerializer",
|
||||
"DistanceCalculationInputSerializer",
|
||||
"DistanceCalculationOutputSerializer",
|
||||
]
|
||||
|
||||
# Add any accounts serializers that actually exist
|
||||
for name in _ACCOUNTS_SYMBOLS:
|
||||
if name in globals():
|
||||
__all__.append(name)
|
||||
@@ -1,904 +0,0 @@
|
||||
"""
|
||||
User accounts and settings serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to user account management,
|
||||
profile settings, preferences, privacy, notifications, and security.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.accounts.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
TopList,
|
||||
UserNotification,
|
||||
NotificationPreference,
|
||||
)
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# === USER PROFILE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Profile Example",
|
||||
summary="Complete user profile",
|
||||
description="Full user profile with all fields",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user profile data."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
avatar_variants = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"profile_id",
|
||||
"display_name",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"avatar_variants",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
]
|
||||
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the avatar URL with fallback to default letter-based avatar."""
|
||||
return obj.get_avatar_url()
|
||||
|
||||
def get_avatar_variants(self, obj):
|
||||
"""Get avatar variants for different use cases."""
|
||||
return obj.get_avatar_variants()
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Complete User Example",
|
||||
summary="Complete user with profile",
|
||||
description="Full user object with embedded profile",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class CompleteUserSerializer(serializers.ModelSerializer):
|
||||
"""Complete user serializer with profile data."""
|
||||
|
||||
profile = UserProfileSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"user_id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
"role",
|
||||
"theme_preference",
|
||||
"profile",
|
||||
]
|
||||
read_only_fields = ["user_id", "date_joined", "role"]
|
||||
|
||||
|
||||
# === USER SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Preferences Example",
|
||||
summary="User preferences and settings",
|
||||
description="User's preference settings",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
"email_notifications": True,
|
||||
"push_notifications": False,
|
||||
"privacy_level": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_statistics": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserPreferencesSerializer(serializers.Serializer):
|
||||
"""Serializer for user preferences and settings."""
|
||||
|
||||
theme_preference = RichChoiceFieldSerializer(
|
||||
choice_group="theme_preferences",
|
||||
domain="accounts",
|
||||
help_text="User's theme preference"
|
||||
)
|
||||
email_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Whether to receive email notifications"
|
||||
)
|
||||
push_notifications = serializers.BooleanField(
|
||||
default=False, help_text="Whether to receive push notifications"
|
||||
)
|
||||
privacy_level = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="public",
|
||||
help_text="Profile visibility level",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Whether to show email on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show real name on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show ride statistics on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow direct messages"
|
||||
)
|
||||
|
||||
|
||||
# === NOTIFICATION SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Settings Example",
|
||||
summary="User notification preferences",
|
||||
description="Detailed notification settings",
|
||||
value={
|
||||
"email_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"weekly_digest": False,
|
||||
"new_features": True,
|
||||
"security_alerts": True,
|
||||
},
|
||||
"push_notifications": {
|
||||
"new_reviews": False,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
},
|
||||
"in_app_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"system_announcements": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for detailed notification settings."""
|
||||
|
||||
class EmailNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
weekly_digest = serializers.BooleanField(default=False)
|
||||
new_features = serializers.BooleanField(default=True)
|
||||
security_alerts = serializers.BooleanField(default=True)
|
||||
|
||||
class PushNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=False)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
|
||||
class InAppNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
system_announcements = serializers.BooleanField(default=True)
|
||||
|
||||
email_notifications = EmailNotificationsSerializer()
|
||||
push_notifications = PushNotificationsSerializer()
|
||||
in_app_notifications = InAppNotificationsSerializer()
|
||||
|
||||
|
||||
# === PRIVACY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Privacy Settings Example",
|
||||
summary="User privacy settings",
|
||||
description="Detailed privacy and visibility settings",
|
||||
value={
|
||||
"profile_visibility": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_join_date": True,
|
||||
"show_statistics": True,
|
||||
"show_reviews": True,
|
||||
"show_photos": True,
|
||||
"show_top_lists": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
"allow_profile_comments": False,
|
||||
"search_visibility": True,
|
||||
"activity_visibility": "friends",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PrivacySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for privacy and visibility settings."""
|
||||
|
||||
profile_visibility = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="public",
|
||||
help_text="Overall profile visibility",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Show email address on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Show real name on profile"
|
||||
)
|
||||
show_join_date = serializers.BooleanField(
|
||||
default=True, help_text="Show join date on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Show ride statistics on profile"
|
||||
)
|
||||
show_reviews = serializers.BooleanField(
|
||||
default=True, help_text="Show reviews on profile"
|
||||
)
|
||||
show_photos = serializers.BooleanField(
|
||||
default=True, help_text="Show uploaded photos on profile"
|
||||
)
|
||||
show_top_lists = serializers.BooleanField(
|
||||
default=True, help_text="Show top lists on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send direct messages"
|
||||
)
|
||||
allow_profile_comments = serializers.BooleanField(
|
||||
default=False, help_text="Allow others to comment on profile"
|
||||
)
|
||||
search_visibility = serializers.BooleanField(
|
||||
default=True, help_text="Allow profile to appear in search results"
|
||||
)
|
||||
activity_visibility = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="friends",
|
||||
help_text="Who can see your activity feed",
|
||||
)
|
||||
|
||||
|
||||
# === SECURITY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Security Settings Example",
|
||||
summary="User security settings",
|
||||
description="Account security and authentication settings",
|
||||
value={
|
||||
"two_factor_enabled": False,
|
||||
"login_notifications": True,
|
||||
"session_timeout": 30,
|
||||
"require_password_change": False,
|
||||
"last_password_change": "2024-01-01T00:00:00Z",
|
||||
"active_sessions": 2,
|
||||
"login_history_retention": 90,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SecuritySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for security settings."""
|
||||
|
||||
two_factor_enabled = serializers.BooleanField(
|
||||
default=False, help_text="Whether two-factor authentication is enabled"
|
||||
)
|
||||
login_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Send notifications for new logins"
|
||||
)
|
||||
session_timeout = serializers.IntegerField(
|
||||
default=30, min_value=5, max_value=180, help_text="Session timeout in days"
|
||||
)
|
||||
require_password_change = serializers.BooleanField(
|
||||
default=False, help_text="Whether password change is required"
|
||||
)
|
||||
last_password_change = serializers.DateTimeField(
|
||||
read_only=True, help_text="When password was last changed"
|
||||
)
|
||||
active_sessions = serializers.IntegerField(
|
||||
read_only=True, help_text="Number of active sessions"
|
||||
)
|
||||
login_history_retention = serializers.IntegerField(
|
||||
default=90,
|
||||
min_value=30,
|
||||
max_value=365,
|
||||
help_text="How long to keep login history (days)",
|
||||
)
|
||||
|
||||
|
||||
# === USER STATISTICS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Statistics Example",
|
||||
summary="User activity statistics",
|
||||
description="Comprehensive user activity and contribution statistics",
|
||||
value={
|
||||
"ride_credits": {
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
"total_credits": 307,
|
||||
},
|
||||
"contributions": {
|
||||
"park_reviews": 25,
|
||||
"ride_reviews": 87,
|
||||
"photos_uploaded": 156,
|
||||
"top_lists_created": 8,
|
||||
"helpful_votes_received": 342,
|
||||
},
|
||||
"activity": {
|
||||
"days_active": 45,
|
||||
"last_active": "2024-01-15T10:30:00Z",
|
||||
"average_review_rating": 4.2,
|
||||
"most_reviewed_park": "Cedar Point",
|
||||
"favorite_ride_type": "Roller Coaster",
|
||||
},
|
||||
"achievements": {
|
||||
"first_review": True,
|
||||
"photo_contributor": True,
|
||||
"top_reviewer": False,
|
||||
"park_explorer": True,
|
||||
"coaster_enthusiast": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserStatisticsSerializer(serializers.Serializer):
|
||||
"""Serializer for user statistics and achievements."""
|
||||
|
||||
class RideCreditsSerializer(serializers.Serializer):
|
||||
coaster_credits = serializers.IntegerField()
|
||||
dark_ride_credits = serializers.IntegerField()
|
||||
flat_ride_credits = serializers.IntegerField()
|
||||
water_ride_credits = serializers.IntegerField()
|
||||
total_credits = serializers.IntegerField()
|
||||
|
||||
class ContributionsSerializer(serializers.Serializer):
|
||||
park_reviews = serializers.IntegerField()
|
||||
ride_reviews = serializers.IntegerField()
|
||||
photos_uploaded = serializers.IntegerField()
|
||||
top_lists_created = serializers.IntegerField()
|
||||
helpful_votes_received = serializers.IntegerField()
|
||||
|
||||
class ActivitySerializer(serializers.Serializer):
|
||||
days_active = serializers.IntegerField()
|
||||
last_active = serializers.DateTimeField()
|
||||
average_review_rating = serializers.FloatField()
|
||||
most_reviewed_park = serializers.CharField()
|
||||
favorite_ride_type = serializers.CharField()
|
||||
|
||||
class AchievementsSerializer(serializers.Serializer):
|
||||
first_review = serializers.BooleanField()
|
||||
photo_contributor = serializers.BooleanField()
|
||||
top_reviewer = serializers.BooleanField()
|
||||
park_explorer = serializers.BooleanField()
|
||||
coaster_enthusiast = serializers.BooleanField()
|
||||
|
||||
ride_credits = RideCreditsSerializer()
|
||||
contributions = ContributionsSerializer()
|
||||
activity = ActivitySerializer()
|
||||
achievements = AchievementsSerializer()
|
||||
|
||||
|
||||
# === TOP LISTS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Example",
|
||||
summary="User's top list",
|
||||
description="A user's ranked list of rides or parks",
|
||||
value={
|
||||
"id": 1,
|
||||
"title": "My Top 10 Roller Coasters",
|
||||
"category": "RC",
|
||||
"description": "My favorite roller coasters from around the world",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"items_count": 10,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user's top lists."""
|
||||
|
||||
items_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"category",
|
||||
"description",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"items_count",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_items_count(self, obj):
|
||||
"""Get the number of items in the list."""
|
||||
return obj.items.count()
|
||||
|
||||
|
||||
# === ACCOUNT UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Account Update Example",
|
||||
summary="Update account information",
|
||||
description="Update basic account information",
|
||||
value={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "newemail@example.com",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AccountUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating account information."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
]
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email uniqueness."""
|
||||
user = self.context["request"].user
|
||||
if User.objects.filter(email=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Email already in use")
|
||||
return value
|
||||
|
||||
|
||||
# === PROFILE UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Profile Update Example",
|
||||
summary="Update profile information",
|
||||
description="Update profile information and social links",
|
||||
value={
|
||||
"display_name": "New Display Name",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Updated bio text",
|
||||
"twitter": "https://twitter.com/newhandle",
|
||||
"instagram": "",
|
||||
"youtube": "https://youtube.com/newchannel",
|
||||
"discord": "newhandle#5678",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating profile information."""
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"display_name",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
]
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
# === THEME PREFERENCE SERIALIZER ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Theme Update Example",
|
||||
summary="Update theme preference",
|
||||
description="Update user's theme preference",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ThemePreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating theme preference."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["theme_preference"]
|
||||
|
||||
|
||||
# === NOTIFICATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Notification Example",
|
||||
summary="User notification",
|
||||
description="A notification sent to a user",
|
||||
value={
|
||||
"id": 1,
|
||||
"notification_type": "submission_approved",
|
||||
"title": "Your submission has been approved!",
|
||||
"message": "Your photo submission for Cedar Point has been approved and is now live on the site.",
|
||||
"priority": "normal",
|
||||
"is_read": False,
|
||||
"read_at": None,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"expires_at": None,
|
||||
"extra_data": {"submission_id": 123, "park_name": "Cedar Point"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserNotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user notifications."""
|
||||
|
||||
class Meta:
|
||||
model = UserNotification
|
||||
fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"is_read",
|
||||
"read_at",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Preferences Example",
|
||||
summary="User notification preferences",
|
||||
description="Comprehensive notification preferences for all channels",
|
||||
value={
|
||||
"submission_approved_email": True,
|
||||
"submission_approved_push": True,
|
||||
"submission_approved_inapp": True,
|
||||
"submission_rejected_email": True,
|
||||
"submission_rejected_push": True,
|
||||
"submission_rejected_inapp": True,
|
||||
"submission_pending_email": False,
|
||||
"submission_pending_push": False,
|
||||
"submission_pending_inapp": True,
|
||||
"review_reply_email": True,
|
||||
"review_reply_push": True,
|
||||
"review_reply_inapp": True,
|
||||
"review_helpful_email": False,
|
||||
"review_helpful_push": True,
|
||||
"review_helpful_inapp": True,
|
||||
"friend_request_email": True,
|
||||
"friend_request_push": True,
|
||||
"friend_request_inapp": True,
|
||||
"friend_accepted_email": False,
|
||||
"friend_accepted_push": True,
|
||||
"friend_accepted_inapp": True,
|
||||
"message_received_email": True,
|
||||
"message_received_push": True,
|
||||
"message_received_inapp": True,
|
||||
"system_announcement_email": True,
|
||||
"system_announcement_push": False,
|
||||
"system_announcement_inapp": True,
|
||||
"account_security_email": True,
|
||||
"account_security_push": True,
|
||||
"account_security_inapp": True,
|
||||
"feature_update_email": True,
|
||||
"feature_update_push": False,
|
||||
"feature_update_inapp": True,
|
||||
"achievement_unlocked_email": False,
|
||||
"achievement_unlocked_push": True,
|
||||
"achievement_unlocked_inapp": True,
|
||||
"milestone_reached_email": False,
|
||||
"milestone_reached_push": True,
|
||||
"milestone_reached_inapp": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification preferences."""
|
||||
|
||||
class Meta:
|
||||
model = NotificationPreference
|
||||
fields = [
|
||||
# Submission notifications
|
||||
"submission_approved_email",
|
||||
"submission_approved_push",
|
||||
"submission_approved_inapp",
|
||||
"submission_rejected_email",
|
||||
"submission_rejected_push",
|
||||
"submission_rejected_inapp",
|
||||
"submission_pending_email",
|
||||
"submission_pending_push",
|
||||
"submission_pending_inapp",
|
||||
# Review notifications
|
||||
"review_reply_email",
|
||||
"review_reply_push",
|
||||
"review_reply_inapp",
|
||||
"review_helpful_email",
|
||||
"review_helpful_push",
|
||||
"review_helpful_inapp",
|
||||
# Social notifications
|
||||
"friend_request_email",
|
||||
"friend_request_push",
|
||||
"friend_request_inapp",
|
||||
"friend_accepted_email",
|
||||
"friend_accepted_push",
|
||||
"friend_accepted_inapp",
|
||||
"message_received_email",
|
||||
"message_received_push",
|
||||
"message_received_inapp",
|
||||
# System notifications
|
||||
"system_announcement_email",
|
||||
"system_announcement_push",
|
||||
"system_announcement_inapp",
|
||||
"account_security_email",
|
||||
"account_security_push",
|
||||
"account_security_inapp",
|
||||
"feature_update_email",
|
||||
"feature_update_push",
|
||||
"feature_update_inapp",
|
||||
# Achievement notifications
|
||||
"achievement_unlocked_email",
|
||||
"achievement_unlocked_push",
|
||||
"achievement_unlocked_inapp",
|
||||
"milestone_reached_email",
|
||||
"milestone_reached_push",
|
||||
"milestone_reached_inapp",
|
||||
]
|
||||
|
||||
|
||||
# === NOTIFICATION ACTIONS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Mark Notifications Read Example",
|
||||
summary="Mark notifications as read",
|
||||
description="Mark specific notifications as read",
|
||||
value={"notification_ids": [1, 2, 3, 4, 5]},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MarkNotificationsReadSerializer(serializers.Serializer):
|
||||
"""Serializer for marking notifications as read."""
|
||||
|
||||
notification_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of notification IDs to mark as read",
|
||||
)
|
||||
|
||||
def validate_notification_ids(self, value):
|
||||
"""Validate that all notification IDs belong to the requesting user."""
|
||||
user = self.context["request"].user
|
||||
valid_ids = UserNotification.objects.filter(
|
||||
id__in=value, user=user
|
||||
).values_list("id", flat=True)
|
||||
|
||||
invalid_ids = set(value) - set(valid_ids)
|
||||
if invalid_ids:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid notification IDs: {list(invalid_ids)}"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Avatar Upload Example",
|
||||
summary="Upload user avatar",
|
||||
description="Upload a new avatar image",
|
||||
value={"avatar": "base64_encoded_image_data_or_file_upload"},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AvatarUploadSerializer(serializers.Serializer):
|
||||
"""Serializer for uploading user avatar."""
|
||||
|
||||
# Use FileField instead of ImageField to bypass Django's image validation
|
||||
avatar = serializers.FileField()
|
||||
|
||||
def validate_avatar(self, value):
|
||||
"""Validate avatar file."""
|
||||
if not value:
|
||||
raise serializers.ValidationError("No file provided")
|
||||
|
||||
# Check file size constraints (max 10MB for Cloudflare Images)
|
||||
if hasattr(value, 'size') and value.size > 10 * 1024 * 1024:
|
||||
raise serializers.ValidationError(
|
||||
"Image file too large. Maximum size is 10MB.")
|
||||
|
||||
# Try to validate with PIL
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
value.seek(0)
|
||||
image_data = value.read()
|
||||
value.seek(0) # Reset for later use
|
||||
|
||||
if len(image_data) == 0:
|
||||
raise serializers.ValidationError("File appears to be empty")
|
||||
|
||||
# Try to open with PIL
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Verify it's a valid image
|
||||
image.verify()
|
||||
|
||||
# Check image dimensions (max 12,000x12,000 for Cloudflare Images)
|
||||
if image.size[0] > 12000 or image.size[1] > 12000:
|
||||
raise serializers.ValidationError(
|
||||
"Image dimensions too large. Maximum is 12,000x12,000 pixels.")
|
||||
|
||||
# Check if it's a supported format
|
||||
if image.format not in ['JPEG', 'PNG', 'GIF', 'WEBP']:
|
||||
raise serializers.ValidationError(
|
||||
f"Unsupported image format: {image.format}. Supported formats: JPEG, PNG, GIF, WebP.")
|
||||
|
||||
except serializers.ValidationError:
|
||||
raise # Re-raise validation errors
|
||||
except Exception:
|
||||
# PIL validation failed, but let Cloudflare Images try to process it
|
||||
pass
|
||||
|
||||
return value
|
||||
@@ -1,497 +0,0 @@
|
||||
"""
|
||||
Authentication domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to user authentication,
|
||||
registration, password management, and social authentication.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model, authenticate
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# === USER SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Output Example",
|
||||
summary="Example user response",
|
||||
description="A typical user object in API responses",
|
||||
value={
|
||||
"id": 1,
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for user data."""
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined"]
|
||||
|
||||
|
||||
# === LOGIN SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Login Input Example",
|
||||
summary="Example login request",
|
||||
description="Login with username or email and password",
|
||||
value={
|
||||
"username": "thrillseeker",
|
||||
"password": "securepassword123",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class LoginInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for user login."""
|
||||
|
||||
username = serializers.CharField(
|
||||
max_length=150,
|
||||
help_text="Username or email address",
|
||||
)
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="User password",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate login credentials."""
|
||||
username = attrs.get("username")
|
||||
password = attrs.get("password")
|
||||
|
||||
if username and password:
|
||||
# Try to authenticate with the provided credentials
|
||||
user = authenticate(
|
||||
request=self.context.get("request"),
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if not user:
|
||||
# Try email-based authentication if username failed
|
||||
if "@" in username:
|
||||
try:
|
||||
user_obj = UserModel.objects.get(email=username)
|
||||
user = authenticate(
|
||||
request=self.context.get("request"),
|
||||
username=user_obj.username, # type: ignore[attr-defined]
|
||||
password=password,
|
||||
)
|
||||
except UserModel.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user:
|
||||
raise serializers.ValidationError("Invalid credentials")
|
||||
|
||||
if not user.is_active:
|
||||
raise serializers.ValidationError("Account is disabled")
|
||||
|
||||
attrs["user"] = user
|
||||
else:
|
||||
raise serializers.ValidationError("Must include username and password")
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Login Output Example",
|
||||
summary="Example login response",
|
||||
description="Successful login response with token and user data",
|
||||
value={
|
||||
"token": "abc123def456ghi789",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
},
|
||||
"message": "Login successful",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class LoginOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for login response."""
|
||||
|
||||
token = serializers.CharField(help_text="Authentication token")
|
||||
user = UserOutputSerializer(help_text="User information")
|
||||
message = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === SIGNUP SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Signup Input Example",
|
||||
summary="Example registration request",
|
||||
description="Register a new user account",
|
||||
value={
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"password": "securepassword123",
|
||||
"password_confirm": "securepassword123",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SignupInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for user registration."""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="User password",
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="Password confirmation",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"password",
|
||||
"password_confirm",
|
||||
"first_name",
|
||||
"last_name",
|
||||
]
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email uniqueness."""
|
||||
if UserModel.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError("Email already registered")
|
||||
return value
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username uniqueness."""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError("Username already taken")
|
||||
return value
|
||||
|
||||
def validate_password(self, value):
|
||||
"""Validate password strength."""
|
||||
try:
|
||||
validate_password(value)
|
||||
except DjangoValidationError as e:
|
||||
raise serializers.ValidationError(list(e.messages))
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError("Passwords do not match")
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create new user."""
|
||||
validated_data.pop("password_confirm")
|
||||
password = validated_data.pop("password")
|
||||
|
||||
user = UserModel.objects.create_user( # type: ignore[attr-defined]
|
||||
password=password,
|
||||
**validated_data,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Signup Output Example",
|
||||
summary="Example registration response",
|
||||
description="Successful registration response with token and user data",
|
||||
value={
|
||||
"token": "abc123def456ghi789",
|
||||
"user": {
|
||||
"id": 2,
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith",
|
||||
},
|
||||
"message": "Registration successful",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SignupOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for registration response."""
|
||||
|
||||
token = serializers.CharField(help_text="Authentication token")
|
||||
user = UserOutputSerializer(help_text="User information")
|
||||
message = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === LOGOUT SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Logout Output Example",
|
||||
summary="Example logout response",
|
||||
description="Successful logout response",
|
||||
value={
|
||||
"message": "Logout successful",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class LogoutOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for logout response."""
|
||||
|
||||
message = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === PASSWORD RESET SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Reset Input Example",
|
||||
summary="Example password reset request",
|
||||
description="Request password reset email",
|
||||
value={
|
||||
"email": "user@example.com",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordResetInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password reset request."""
|
||||
|
||||
email = serializers.EmailField(help_text="Email address for password reset")
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email exists."""
|
||||
if not UserModel.objects.filter(email=value).exists():
|
||||
# Don't reveal if email exists for security
|
||||
pass
|
||||
return value
|
||||
|
||||
def save(self, **kwargs): # type: ignore[override]
|
||||
"""Send password reset email."""
|
||||
email = self.validated_data["email"] # type: ignore[index]
|
||||
try:
|
||||
_user = UserModel.objects.get(email=email)
|
||||
# Here you would typically send a password reset email
|
||||
# For now, we'll just pass
|
||||
pass
|
||||
except UserModel.DoesNotExist:
|
||||
# Don't reveal if email exists for security
|
||||
pass
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Reset Output Example",
|
||||
summary="Example password reset response",
|
||||
description="Password reset email sent response",
|
||||
value={
|
||||
"detail": "Password reset email sent",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordResetOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password reset response."""
|
||||
|
||||
detail = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === PASSWORD CHANGE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Change Input Example",
|
||||
summary="Example password change request",
|
||||
description="Change current user's password",
|
||||
value={
|
||||
"old_password": "oldpassword123",
|
||||
"new_password": "newpassword456",
|
||||
"new_password_confirm": "newpassword456",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordChangeInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password change."""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="Current password",
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="New password",
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="New password confirmation",
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate current password."""
|
||||
user = self.context["request"].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Current password is incorrect")
|
||||
return value
|
||||
|
||||
def validate_new_password(self, value):
|
||||
"""Validate new password strength."""
|
||||
try:
|
||||
validate_password(value, user=self.context["request"].user)
|
||||
except DjangoValidationError as e:
|
||||
raise serializers.ValidationError(list(e.messages))
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
new_password = attrs.get("new_password")
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError("New passwords do not match")
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs): # type: ignore[override]
|
||||
"""Change user password."""
|
||||
user = self.context["request"].user
|
||||
user.set_password(self.validated_data["new_password"]) # type: ignore[index]
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Change Output Example",
|
||||
summary="Example password change response",
|
||||
description="Password changed successfully response",
|
||||
value={
|
||||
"detail": "Password changed successfully",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordChangeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password change response."""
|
||||
|
||||
detail = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === SOCIAL PROVIDER SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Social Provider Example",
|
||||
summary="Example social provider",
|
||||
description="Available social authentication provider",
|
||||
value={
|
||||
"id": "google",
|
||||
"name": "Google",
|
||||
"authUrl": "https://example.com/accounts/google/login/",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SocialProviderOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for social authentication providers."""
|
||||
|
||||
id = serializers.CharField(help_text="Provider ID")
|
||||
name = serializers.CharField(help_text="Provider display name")
|
||||
authUrl = serializers.URLField(help_text="Authentication URL")
|
||||
|
||||
|
||||
# === AUTH STATUS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Auth Status Authenticated Example",
|
||||
summary="Example authenticated status",
|
||||
description="Response when user is authenticated",
|
||||
value={
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
},
|
||||
},
|
||||
),
|
||||
OpenApiExample(
|
||||
"Auth Status Unauthenticated Example",
|
||||
summary="Example unauthenticated status",
|
||||
description="Response when user is not authenticated",
|
||||
value={
|
||||
"authenticated": False,
|
||||
"user": None,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
class AuthStatusOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for authentication status."""
|
||||
|
||||
authenticated = serializers.BooleanField(help_text="Whether user is authenticated")
|
||||
user = UserOutputSerializer(
|
||||
allow_null=True, help_text="User information if authenticated"
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
Companies and ride models domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to companies that operate parks
|
||||
or manufacture rides, as well as ride model serializers.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === COMPANY SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Company Example",
|
||||
summary="Example company response",
|
||||
description="A company that operates parks or manufactures rides",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Cedar Fair",
|
||||
"slug": "cedar-fair",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "Theme park operator based in Ohio",
|
||||
"website": "https://cedarfair.com",
|
||||
"founded_date": "1983-01-01",
|
||||
"rides_count": 0,
|
||||
"coasters_count": 0,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class CompanyDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for company details."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
roles = serializers.ListField(child=serializers.CharField())
|
||||
description = serializers.CharField()
|
||||
website = serializers.URLField()
|
||||
founded_date = serializers.DateField(allow_null=True)
|
||||
rides_count = serializers.IntegerField()
|
||||
coasters_count = serializers.IntegerField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class CompanyCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating companies."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
roles = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=ModelChoices.get_company_role_choices()),
|
||||
allow_empty=False,
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating companies."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
roles = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=ModelChoices.get_company_role_choices()),
|
||||
required=False,
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# === RIDE MODEL SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Model Example",
|
||||
summary="Example ride model response",
|
||||
description="A specific model/type of ride manufactured by a company",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Dive Coaster",
|
||||
"description": "A roller coaster featuring a near-vertical drop",
|
||||
"category": "RC",
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideModelDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model details."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
|
||||
# Manufacturer info
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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(), required=False)
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
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(), required=False)
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
@@ -1,186 +0,0 @@
|
||||
"""
|
||||
History domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for history tracking and timeline functionality
|
||||
using django-pghistory.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
|
||||
class ParkHistoryEventSerializer(serializers.Serializer):
|
||||
"""Serializer for park history events."""
|
||||
|
||||
pgh_id = serializers.IntegerField(read_only=True)
|
||||
pgh_created_at = serializers.DateTimeField(read_only=True)
|
||||
pgh_label = serializers.CharField(read_only=True)
|
||||
pgh_obj_id = serializers.IntegerField(read_only=True)
|
||||
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
|
||||
pgh_data = serializers.JSONField(read_only=True)
|
||||
event_type = serializers.SerializerMethodField()
|
||||
changes = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_event_type(self, obj) -> str:
|
||||
"""Get human-readable event type."""
|
||||
return obj.pgh_label.replace("_", " ").title()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_changes(self, obj) -> dict:
|
||||
"""Get changes made in this event."""
|
||||
if hasattr(obj, "pgh_diff") and obj.pgh_diff:
|
||||
return obj.pgh_diff
|
||||
return {}
|
||||
|
||||
|
||||
class RideHistoryEventSerializer(serializers.Serializer):
|
||||
"""Serializer for ride history events."""
|
||||
|
||||
pgh_id = serializers.IntegerField(read_only=True)
|
||||
pgh_created_at = serializers.DateTimeField(read_only=True)
|
||||
pgh_label = serializers.CharField(read_only=True)
|
||||
pgh_obj_id = serializers.IntegerField(read_only=True)
|
||||
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
|
||||
pgh_data = serializers.JSONField(read_only=True)
|
||||
event_type = serializers.SerializerMethodField()
|
||||
changes = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_event_type(self, obj) -> str:
|
||||
"""Get human-readable event type."""
|
||||
return obj.pgh_label.replace("_", " ").title()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_changes(self, obj) -> dict:
|
||||
"""Get changes made in this event."""
|
||||
if hasattr(obj, "pgh_diff") and obj.pgh_diff:
|
||||
return obj.pgh_diff
|
||||
return {}
|
||||
|
||||
|
||||
class HistorySummarySerializer(serializers.Serializer):
|
||||
"""Serializer for history summary information."""
|
||||
|
||||
total_events = serializers.IntegerField()
|
||||
first_recorded = serializers.DateTimeField(allow_null=True)
|
||||
last_modified = serializers.DateTimeField(allow_null=True)
|
||||
|
||||
|
||||
class ParkHistoryOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for complete park history."""
|
||||
|
||||
park = serializers.SerializerMethodField()
|
||||
current_state = serializers.SerializerMethodField()
|
||||
summary = HistorySummarySerializer()
|
||||
events = ParkHistoryEventSerializer(many=True)
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj) -> dict:
|
||||
"""Get basic park information."""
|
||||
park = obj.get("park")
|
||||
if park:
|
||||
return {
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"status": park.status,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_current_state(self, obj) -> dict:
|
||||
"""Get current park state."""
|
||||
park = obj.get("current_state")
|
||||
if park:
|
||||
return {
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"status": park.status,
|
||||
"opening_date": (
|
||||
park.opening_date.isoformat()
|
||||
if hasattr(park, "opening_date") and park.opening_date
|
||||
else None
|
||||
),
|
||||
"coaster_count": getattr(park, "coaster_count", 0),
|
||||
"ride_count": getattr(park, "ride_count", 0),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
class RideHistoryOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for complete ride history."""
|
||||
|
||||
ride = serializers.SerializerMethodField()
|
||||
current_state = serializers.SerializerMethodField()
|
||||
summary = HistorySummarySerializer()
|
||||
events = RideHistoryEventSerializer(many=True)
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj) -> dict:
|
||||
"""Get basic ride information."""
|
||||
ride = obj.get("ride")
|
||||
if ride:
|
||||
return {
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park_name": ride.park.name if hasattr(ride, "park") else None,
|
||||
"status": getattr(ride, "status", "UNKNOWN"),
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_current_state(self, obj) -> dict:
|
||||
"""Get current ride state."""
|
||||
ride = obj.get("current_state")
|
||||
if ride:
|
||||
return {
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park_name": ride.park.name if hasattr(ride, "park") else None,
|
||||
"status": getattr(ride, "status", "UNKNOWN"),
|
||||
"opening_date": (
|
||||
ride.opening_date.isoformat()
|
||||
if hasattr(ride, "opening_date") and ride.opening_date
|
||||
else None
|
||||
),
|
||||
"ride_type": getattr(ride, "ride_type", "Unknown"),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
class UnifiedHistoryTimelineSerializer(serializers.Serializer):
|
||||
"""Serializer for unified history timeline."""
|
||||
|
||||
summary = serializers.SerializerMethodField()
|
||||
events = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_summary(self, obj) -> dict:
|
||||
"""Get timeline summary."""
|
||||
return obj.get("summary", {})
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_events(self, obj) -> list:
|
||||
"""Get timeline events."""
|
||||
events = obj.get("events", [])
|
||||
event_data = []
|
||||
|
||||
for event in events:
|
||||
event_data.append(
|
||||
{
|
||||
"pgh_id": event.pgh_id,
|
||||
"pgh_created_at": event.pgh_created_at,
|
||||
"pgh_label": event.pgh_label,
|
||||
"pgh_model": event.pgh_model,
|
||||
"pgh_obj_id": event.pgh_obj_id,
|
||||
"pgh_context": event.pgh_context,
|
||||
"event_type": event.pgh_label.replace("_", " ").title(),
|
||||
"model_type": event.pgh_model.split(".")[-1].title(),
|
||||
}
|
||||
)
|
||||
|
||||
return event_data
|
||||
@@ -1,421 +0,0 @@
|
||||
"""
|
||||
Maps domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to map functionality,
|
||||
including location data, search results, and clustering.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
|
||||
# === MAP LOCATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Location Example",
|
||||
summary="Example map location response",
|
||||
description="A location point on the map",
|
||||
value={
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"status": "OPERATING",
|
||||
"location": {
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"average_rating": 4.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapLocationSerializer(serializers.Serializer):
|
||||
"""Serializer for individual map locations (parks and rides)."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField() # 'park' or 'ride'
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
status = serializers.CharField()
|
||||
|
||||
# Location details
|
||||
location = serializers.SerializerMethodField()
|
||||
|
||||
# Statistics
|
||||
stats = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
"country": obj.location.country,
|
||||
"formatted_address": obj.location.formatted_address,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get relevant statistics based on object type."""
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Cluster Example",
|
||||
summary="Example map cluster response",
|
||||
description="A cluster of locations on the map",
|
||||
value={
|
||||
"id": "cluster_1",
|
||||
"type": "cluster",
|
||||
"latitude": 41.5,
|
||||
"longitude": -82.7,
|
||||
"count": 5,
|
||||
"bounds": {
|
||||
"north": 41.6,
|
||||
"south": 41.4,
|
||||
"east": -82.6,
|
||||
"west": -82.8,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapClusterSerializer(serializers.Serializer):
|
||||
"""Serializer for map clusters."""
|
||||
|
||||
id = serializers.CharField()
|
||||
type = serializers.CharField(default="cluster")
|
||||
latitude = serializers.FloatField()
|
||||
longitude = serializers.FloatField()
|
||||
count = serializers.IntegerField()
|
||||
bounds = serializers.DictField()
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Locations Response Example",
|
||||
summary="Example map locations response",
|
||||
description="Response containing locations and optional clusters",
|
||||
value={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"locations": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"status": "OPERATING",
|
||||
}
|
||||
],
|
||||
"clusters": [],
|
||||
"bounds": {
|
||||
"north": 41.5,
|
||||
"south": 41.4,
|
||||
"east": -82.6,
|
||||
"west": -82.8,
|
||||
},
|
||||
"total_count": 1,
|
||||
"clustered": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapLocationsResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for map locations endpoint."""
|
||||
|
||||
status = serializers.CharField(default="success")
|
||||
locations = serializers.ListField(child=serializers.DictField())
|
||||
clusters = serializers.ListField(child=serializers.DictField(), default=list)
|
||||
bounds = serializers.DictField(default=dict)
|
||||
total_count = serializers.IntegerField(default=0)
|
||||
clustered = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
# === MAP SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Search Result Example",
|
||||
summary="Example map search result",
|
||||
description="A search result for map locations",
|
||||
value={
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"location": {
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
},
|
||||
"relevance_score": 0.95,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapSearchResultSerializer(serializers.Serializer):
|
||||
"""Serializer for map search results."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
location = serializers.SerializerMethodField()
|
||||
relevance_score = serializers.FloatField(required=False)
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
"country": obj.location.country,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Search Response Example",
|
||||
summary="Example map search response",
|
||||
description="Response containing search results",
|
||||
value={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
}
|
||||
],
|
||||
"query": "cedar point",
|
||||
"total_count": 1,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapSearchResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for map search endpoint."""
|
||||
|
||||
status = serializers.CharField(default="success")
|
||||
results = serializers.ListField(child=serializers.DictField())
|
||||
query = serializers.CharField()
|
||||
total_count = serializers.IntegerField(default=0)
|
||||
page = serializers.IntegerField(default=1)
|
||||
page_size = serializers.IntegerField(default=20)
|
||||
|
||||
|
||||
# === MAP DETAIL SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Location Detail Example",
|
||||
summary="Example map location detail response",
|
||||
description="Detailed information about a specific location",
|
||||
value={
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"description": "America's Roller Coast",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"status": "OPERATING",
|
||||
"location": {
|
||||
"street_address": "1 Cedar Point Dr",
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
"postal_code": "44870",
|
||||
"formatted_address": "1 Cedar Point Dr, Sandusky, Ohio, 44870, United States",
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"average_rating": 4.5,
|
||||
},
|
||||
"nearby_locations": [],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapLocationDetailSerializer(serializers.Serializer):
|
||||
"""Serializer for detailed map location information."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
status = serializers.CharField()
|
||||
|
||||
# Detailed location information
|
||||
location = serializers.SerializerMethodField()
|
||||
|
||||
# Statistics
|
||||
stats = serializers.SerializerMethodField()
|
||||
|
||||
# Nearby locations
|
||||
nearby_locations = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get detailed location information."""
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"street_address": obj.location.street_address,
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
"country": obj.location.country,
|
||||
"postal_code": obj.location.postal_code,
|
||||
"formatted_address": obj.location.formatted_address,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get detailed statistics based on object type."""
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"size_acres": float(obj.size_acres) if obj.size_acres else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_nearby_locations(self, obj) -> list:
|
||||
"""Get nearby locations (placeholder for now)."""
|
||||
# TODO: Implement nearby location logic
|
||||
return []
|
||||
|
||||
|
||||
# === INPUT SERIALIZERS ===
|
||||
|
||||
|
||||
class MapBoundsInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for map bounds queries."""
|
||||
|
||||
north = serializers.FloatField(min_value=-90, max_value=90)
|
||||
south = serializers.FloatField(min_value=-90, max_value=90)
|
||||
east = serializers.FloatField(min_value=-180, max_value=180)
|
||||
west = serializers.FloatField(min_value=-180, max_value=180)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that bounds make geographic sense."""
|
||||
if attrs["north"] <= attrs["south"]:
|
||||
raise serializers.ValidationError(
|
||||
"North bound must be greater than south bound"
|
||||
)
|
||||
|
||||
# Handle longitude wraparound (e.g., crossing the international date line)
|
||||
# For now, we'll require west < east for simplicity
|
||||
if attrs["west"] >= attrs["east"]:
|
||||
raise serializers.ValidationError("West bound must be less than east bound")
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class MapSearchInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for map search queries."""
|
||||
|
||||
q = serializers.CharField(min_length=1, max_length=255)
|
||||
types = serializers.CharField(required=False, allow_blank=True)
|
||||
bounds = MapBoundsInputSerializer(required=False)
|
||||
page = serializers.IntegerField(min_value=1, default=1)
|
||||
page_size = serializers.IntegerField(min_value=1, max_value=100, default=20)
|
||||
|
||||
def validate_types(self, value):
|
||||
"""Validate location types."""
|
||||
if not value:
|
||||
return []
|
||||
|
||||
valid_types = ["park", "ride"]
|
||||
types = [t.strip().lower() for t in value.split(",")]
|
||||
|
||||
for location_type in types:
|
||||
if location_type not in valid_types:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid location type: {location_type}. Valid types: {', '.join(valid_types)}"
|
||||
)
|
||||
|
||||
return types
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
Media domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for photo uploads, media management,
|
||||
and related media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
|
||||
# === MEDIA SERIALIZERS ===
|
||||
|
||||
|
||||
class PhotoUploadInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo uploads."""
|
||||
|
||||
file = serializers.ImageField()
|
||||
caption = serializers.CharField(
|
||||
max_length=500,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional caption for the photo",
|
||||
)
|
||||
alt_text = serializers.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Alt text for accessibility",
|
||||
)
|
||||
is_primary = serializers.BooleanField(
|
||||
default=False, help_text="Whether this should be the primary photo"
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Photo Detail Example",
|
||||
summary="Example photo detail response",
|
||||
description="A photo with full details",
|
||||
value={
|
||||
"id": 1,
|
||||
"url": "https://example.com/media/photos/ride123.jpg",
|
||||
"thumbnail_url": "https://example.com/media/thumbnails/ride123_thumb.jpg",
|
||||
"caption": "Amazing view of Steel Vengeance",
|
||||
"alt_text": "Steel Vengeance roller coaster with blue sky",
|
||||
"is_primary": True,
|
||||
"uploaded_at": "2024-08-15T10:30:00Z",
|
||||
"uploaded_by": {
|
||||
"id": 1,
|
||||
"username": "coaster_photographer",
|
||||
"display_name": "Coaster Photographer",
|
||||
},
|
||||
"content_type": "Ride",
|
||||
"object_id": 123,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PhotoDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for photo details."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
url = serializers.URLField()
|
||||
thumbnail_url = serializers.URLField(required=False)
|
||||
caption = serializers.CharField()
|
||||
alt_text = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
uploaded_at = serializers.DateTimeField()
|
||||
content_type = serializers.CharField()
|
||||
object_id = serializers.IntegerField()
|
||||
|
||||
# File metadata
|
||||
file_size = serializers.IntegerField()
|
||||
width = serializers.IntegerField()
|
||||
height = serializers.IntegerField()
|
||||
format = serializers.CharField()
|
||||
|
||||
# Uploader info
|
||||
uploaded_by = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_uploaded_by(self, obj) -> dict:
|
||||
"""Get uploader information."""
|
||||
return {
|
||||
"id": obj.uploaded_by.id,
|
||||
"username": obj.uploaded_by.username,
|
||||
"display_name": getattr(
|
||||
obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username
|
||||
)(),
|
||||
}
|
||||
|
||||
|
||||
class PhotoListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for photo list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
url = serializers.URLField()
|
||||
thumbnail_url = serializers.URLField(required=False)
|
||||
caption = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
uploaded_at = serializers.DateTimeField()
|
||||
uploaded_by = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_uploaded_by(self, obj) -> dict:
|
||||
"""Get uploader information."""
|
||||
return {
|
||||
"id": obj.uploaded_by.id,
|
||||
"username": obj.uploaded_by.username,
|
||||
}
|
||||
|
||||
|
||||
class PhotoUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating photos."""
|
||||
|
||||
caption = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
||||
alt_text = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
is_primary = serializers.BooleanField(required=False)
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
Statistics, health check, and miscellaneous domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for statistics, health checks, and other
|
||||
miscellaneous functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
)
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === STATISTICS SERIALIZERS ===
|
||||
|
||||
|
||||
class ParkStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park statistics."""
|
||||
|
||||
total_parks = serializers.IntegerField()
|
||||
operating_parks = serializers.IntegerField()
|
||||
closed_parks = serializers.IntegerField()
|
||||
under_construction = serializers.IntegerField()
|
||||
|
||||
# Averages
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
average_coaster_count = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
|
||||
# Top countries
|
||||
top_countries = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
# Recently added
|
||||
recently_added_count = serializers.IntegerField()
|
||||
|
||||
|
||||
class RideStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride statistics."""
|
||||
|
||||
total_rides = serializers.IntegerField()
|
||||
operating_rides = serializers.IntegerField()
|
||||
closed_rides = serializers.IntegerField()
|
||||
under_construction = serializers.IntegerField()
|
||||
|
||||
# By category
|
||||
rides_by_category = serializers.DictField()
|
||||
|
||||
# Averages
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
average_capacity = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, allow_null=True
|
||||
)
|
||||
|
||||
# Top manufacturers
|
||||
top_manufacturers = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
# Recently added
|
||||
recently_added_count = serializers.IntegerField()
|
||||
|
||||
|
||||
class ParkReviewOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
rating = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
content = serializers.CharField()
|
||||
visit_date = serializers.DateField()
|
||||
created_at = serializers.DateTimeField()
|
||||
|
||||
# User info (limited for privacy)
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_user(self, obj) -> dict:
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"display_name": obj.user.get_full_name() or obj.user.username,
|
||||
}
|
||||
|
||||
|
||||
# === HEALTH CHECK SERIALIZERS ===
|
||||
|
||||
|
||||
class HealthCheckOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for health check responses."""
|
||||
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="health_statuses",
|
||||
domain="core"
|
||||
)
|
||||
timestamp = serializers.DateTimeField()
|
||||
version = serializers.CharField()
|
||||
environment = serializers.CharField()
|
||||
response_time_ms = serializers.FloatField()
|
||||
checks = serializers.DictField()
|
||||
metrics = serializers.DictField()
|
||||
|
||||
|
||||
class PerformanceMetricsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for performance metrics."""
|
||||
|
||||
timestamp = serializers.DateTimeField()
|
||||
database_analysis = serializers.DictField()
|
||||
cache_performance = serializers.DictField()
|
||||
recent_slow_queries = serializers.ListField()
|
||||
|
||||
|
||||
class SimpleHealthOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for simple health check."""
|
||||
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="simple_health_statuses",
|
||||
domain="core"
|
||||
)
|
||||
timestamp = serializers.DateTimeField()
|
||||
error = serializers.CharField(required=False)
|
||||
@@ -1,728 +0,0 @@
|
||||
"""
|
||||
Parks domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to parks, park areas, park locations,
|
||||
and park search functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
from config.django import base as settings
|
||||
|
||||
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
|
||||
from apps.core.services.media_url_service import MediaURLService
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === PARK SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Park List Example",
|
||||
summary="Example park list response",
|
||||
description="A typical park in the list view",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"status": "OPERATING",
|
||||
"description": "America's Roller Coast",
|
||||
"average_rating": 4.5,
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"location": {
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
},
|
||||
"operator": {"id": 1, "name": "Cedar Fair", "slug": "cedar-fair"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="parks"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
coaster_count = serializers.IntegerField(allow_null=True)
|
||||
ride_count = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Location (simplified for list view)
|
||||
location = LocationOutputSerializer(allow_null=True)
|
||||
|
||||
# Operator info
|
||||
operator = CompanyOutputSerializer()
|
||||
|
||||
# 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 park."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Park Detail Example",
|
||||
summary="Example park detail response",
|
||||
description="A complete park detail response",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"status": "OPERATING",
|
||||
"description": "America's Roller Coast",
|
||||
"opening_date": "1870-01-01",
|
||||
"website": "https://cedarpoint.com",
|
||||
"size_acres": 364.0,
|
||||
"average_rating": 4.5,
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"location": {
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park detail view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="parks"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Details
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
operating_season = serializers.CharField()
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, allow_null=True
|
||||
)
|
||||
website = serializers.URLField()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
coaster_count = serializers.IntegerField(allow_null=True)
|
||||
ride_count = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Location (full details)
|
||||
location = LocationOutputSerializer(allow_null=True)
|
||||
|
||||
# Companies
|
||||
operator = CompanyOutputSerializer()
|
||||
property_owner = CompanyOutputSerializer(allow_null=True)
|
||||
|
||||
# Areas
|
||||
areas = serializers.SerializerMethodField()
|
||||
|
||||
# Photos
|
||||
photos = serializers.SerializerMethodField()
|
||||
primary_photo = serializers.SerializerMethodField()
|
||||
banner_image = serializers.SerializerMethodField()
|
||||
card_image = serializers.SerializerMethodField()
|
||||
|
||||
# URL
|
||||
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}/"
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_areas(self, obj):
|
||||
"""Get simplified area information."""
|
||||
if hasattr(obj, "areas"):
|
||||
return [
|
||||
{
|
||||
"id": area.id,
|
||||
"name": area.name,
|
||||
"slug": area.slug,
|
||||
"description": area.description,
|
||||
}
|
||||
for area in obj.areas.all()
|
||||
]
|
||||
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.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
|
||||
},
|
||||
"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.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "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.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "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.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "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.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "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.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "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:
|
||||
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:
|
||||
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."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_park_status_choices(), default="OPERATING"
|
||||
)
|
||||
|
||||
# Optional details
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
operating_season = serializers.CharField(
|
||||
max_length=255, required=False, allow_blank=True
|
||||
)
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Required operator
|
||||
operator_id = serializers.IntegerField()
|
||||
|
||||
# Optional property owner
|
||||
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field 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"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ParkUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating parks."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_park_status_choices(), required=False
|
||||
)
|
||||
|
||||
# Optional details
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
operating_season = serializers.CharField(
|
||||
max_length=255, required=False, allow_blank=True
|
||||
)
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Companies
|
||||
operator_id = serializers.IntegerField(required=False)
|
||||
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field 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"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ParkFilterInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for park filtering and search."""
|
||||
|
||||
# Search
|
||||
search = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Status filter
|
||||
status = serializers.MultipleChoiceField(
|
||||
choices=[],
|
||||
required=False, # Choices set dynamically
|
||||
)
|
||||
|
||||
# Location filters
|
||||
country = serializers.CharField(required=False, allow_blank=True)
|
||||
state = serializers.CharField(required=False, allow_blank=True)
|
||||
city = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Rating filter
|
||||
min_rating = serializers.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=10,
|
||||
)
|
||||
|
||||
# Size filter
|
||||
min_size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, min_value=0
|
||||
)
|
||||
max_size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, min_value=0
|
||||
)
|
||||
|
||||
# Company filters
|
||||
operator_id = serializers.IntegerField(required=False)
|
||||
property_owner_id = serializers.IntegerField(required=False)
|
||||
|
||||
# Ordering
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=[
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"average_rating",
|
||||
"-average_rating",
|
||||
"coaster_count",
|
||||
"-coaster_count",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
],
|
||||
required=False,
|
||||
default="name",
|
||||
)
|
||||
|
||||
|
||||
# === PARK AREA SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Park Area Example",
|
||||
summary="Example park area response",
|
||||
description="A themed area within a park",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Tomorrowland",
|
||||
"slug": "tomorrowland",
|
||||
"description": "A futuristic themed area",
|
||||
"park": {"id": 1, "name": "Magic Kingdom", "slug": "magic-kingdom"},
|
||||
"opening_date": "1971-10-01",
|
||||
"closing_date": None,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkAreaDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park areas."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
|
||||
# Park info
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.park.id,
|
||||
"name": obj.park.name,
|
||||
"slug": obj.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class ParkAreaCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating park areas."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
park_id = serializers.IntegerField()
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field 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"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ParkAreaUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating park areas."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field 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"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# === PARK LOCATION SERIALIZERS ===
|
||||
|
||||
|
||||
class ParkLocationOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park locations."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
address = serializers.CharField()
|
||||
city = serializers.CharField()
|
||||
state = serializers.CharField()
|
||||
country = serializers.CharField()
|
||||
postal_code = serializers.CharField()
|
||||
formatted_address = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.park.id,
|
||||
"name": obj.park.name,
|
||||
"slug": obj.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class ParkLocationCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating park locations."""
|
||||
|
||||
park_id = serializers.IntegerField()
|
||||
latitude = serializers.FloatField(required=False, allow_null=True)
|
||||
longitude = serializers.FloatField(required=False, allow_null=True)
|
||||
address = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
city = serializers.CharField(max_length=100)
|
||||
state = serializers.CharField(max_length=100)
|
||||
country = serializers.CharField(max_length=100)
|
||||
postal_code = serializers.CharField(max_length=20, allow_blank=True, default="")
|
||||
|
||||
|
||||
class ParkLocationUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating park locations."""
|
||||
|
||||
latitude = serializers.FloatField(required=False, allow_null=True)
|
||||
longitude = serializers.FloatField(required=False, allow_null=True)
|
||||
address = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
city = serializers.CharField(max_length=100, required=False)
|
||||
state = serializers.CharField(max_length=100, required=False)
|
||||
country = serializers.CharField(max_length=100, required=False)
|
||||
postal_code = serializers.CharField(max_length=20, allow_blank=True, required=False)
|
||||
|
||||
|
||||
# === PARKS SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
class ParkSuggestionSerializer(serializers.Serializer):
|
||||
"""Serializer for park search suggestions."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
location = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
coaster_count = serializers.IntegerField()
|
||||
|
||||
|
||||
class ParkSuggestionOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park suggestions."""
|
||||
|
||||
results = ParkSuggestionSerializer(many=True)
|
||||
query = serializers.CharField()
|
||||
count = serializers.IntegerField()
|
||||
@@ -1,116 +0,0 @@
|
||||
"""
|
||||
Park media serializers for ThrillWiki API.
|
||||
|
||||
This module contains serializers for park-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
|
||||
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for park photos."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
file_size = serializers.ReadOnlyField()
|
||||
dimensions = serializers.ReadOnlyField()
|
||||
park_slug = serializers.CharField(source="park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for park photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
Serializers for review-related API endpoints.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.parks.models.reviews import ParkReview
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user information in reviews."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["username", "display_name", "avatar_url"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the user's avatar URL."""
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar_url()
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
class LatestReviewSerializer(serializers.Serializer):
|
||||
"""Serializer for latest reviews combining park and ride reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField() # 'park' or 'ride'
|
||||
title = serializers.CharField()
|
||||
content_snippet = serializers.CharField()
|
||||
rating = serializers.IntegerField()
|
||||
created_at = serializers.DateTimeField()
|
||||
user = ReviewUserSerializer()
|
||||
|
||||
# Subject information (park or ride)
|
||||
subject_name = serializers.CharField()
|
||||
subject_slug = serializers.CharField()
|
||||
subject_url = serializers.CharField()
|
||||
|
||||
# Park information (for ride reviews)
|
||||
park_name = serializers.CharField(allow_null=True)
|
||||
park_slug = serializers.CharField(allow_null=True)
|
||||
park_url = serializers.CharField(allow_null=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Convert review instance to serialized representation."""
|
||||
if isinstance(instance, ParkReview):
|
||||
return {
|
||||
"id": instance.pk,
|
||||
"type": "park",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.park.name,
|
||||
"subject_slug": instance.park.slug,
|
||||
"subject_url": f"/parks/{instance.park.slug}/",
|
||||
"park_name": None,
|
||||
"park_slug": None,
|
||||
"park_url": None,
|
||||
}
|
||||
elif isinstance(instance, RideReview):
|
||||
return {
|
||||
"id": instance.pk,
|
||||
"type": "ride",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.ride.name,
|
||||
"subject_slug": instance.ride.slug,
|
||||
"subject_url": f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
|
||||
"park_name": instance.ride.park.name,
|
||||
"park_slug": instance.ride.park.slug,
|
||||
"park_url": f"/parks/{instance.ride.park.slug}/",
|
||||
}
|
||||
return {}
|
||||
|
||||
def _get_content_snippet(self, content, max_length=150):
|
||||
"""Get a snippet of the review content."""
|
||||
if len(content) <= max_length:
|
||||
return content
|
||||
|
||||
# Find the last complete word within the limit
|
||||
snippet = content[:max_length]
|
||||
last_space = snippet.rfind(" ")
|
||||
if last_space > 0:
|
||||
snippet = snippet[:last_space]
|
||||
|
||||
return snippet + "..."
|
||||
@@ -1,797 +0,0 @@
|
||||
"""
|
||||
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 config.django import base as settings
|
||||
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
# 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 = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Manufacturer info
|
||||
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
|
||||
|
||||
# Market info
|
||||
target_market = RichChoiceFieldSerializer(
|
||||
choice_group="target_markets",
|
||||
domain="rides"
|
||||
)
|
||||
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)
|
||||
|
||||
# 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 model."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
# 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 model."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
|
||||
|
||||
@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=ModelChoices.get_target_market_choices(),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
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=ModelChoices.get_target_market_choices(),
|
||||
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=ModelChoices.get_target_market_choices(),
|
||||
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=ModelChoices.get_technical_spec_category_choices()
|
||||
)
|
||||
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=ModelChoices.get_technical_spec_category_choices(),
|
||||
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=ModelChoices.get_photo_type_choices(),
|
||||
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=ModelChoices.get_photo_type_choices(),
|
||||
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"
|
||||
)
|
||||
@@ -1,219 +0,0 @@
|
||||
"""
|
||||
Serializers for ride review API endpoints.
|
||||
|
||||
This module contains serializers for ride review CRUD operations with Rich Choice Objects compliance.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from apps.accounts.models import User
|
||||
from apps.core.choices.serializers import RichChoiceSerializer
|
||||
|
||||
|
||||
class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user information in ride reviews."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "display_name", "avatar_url"]
|
||||
read_only_fields = fields
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the user's avatar URL."""
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar_url()
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Complete Ride Review",
|
||||
summary="Full ride review response",
|
||||
description="Example response showing all fields for a ride review",
|
||||
value={
|
||||
"id": 123,
|
||||
"title": "Amazing roller coaster experience!",
|
||||
"content": "This ride was absolutely incredible. The inversions were smooth and the theming was top-notch.",
|
||||
"rating": 9,
|
||||
"visit_date": "2023-06-15",
|
||||
"created_at": "2023-06-16T10:30:00Z",
|
||||
"updated_at": "2023-06-16T10:30:00Z",
|
||||
"is_published": True,
|
||||
"user": {
|
||||
"id": 456,
|
||||
"username": "coaster_fan",
|
||||
"display_name": "Coaster Fan",
|
||||
"avatar_url": "https://example.com/avatar.jpg"
|
||||
},
|
||||
"ride": {
|
||||
"id": 789,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance"
|
||||
},
|
||||
"park": {
|
||||
"id": 101,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point"
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideReviewOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for ride reviews."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
|
||||
# Ride information
|
||||
ride = serializers.SerializerMethodField()
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"ride",
|
||||
"park",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
"ride",
|
||||
"park",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj):
|
||||
"""Get ride information."""
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj):
|
||||
"""Get park information."""
|
||||
return {
|
||||
"id": obj.ride.park.id,
|
||||
"name": obj.ride.park.name,
|
||||
"slug": obj.ride.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class RideReviewCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating ride reviews."""
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class RideReviewUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating ride reviews."""
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class RideReviewListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for ride review lists."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"ride_name",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RideReviewStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride review statistics."""
|
||||
|
||||
total_reviews = serializers.IntegerField()
|
||||
published_reviews = serializers.IntegerField()
|
||||
pending_reviews = serializers.IntegerField()
|
||||
average_rating = serializers.FloatField(allow_null=True)
|
||||
rating_distribution = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Count of reviews by rating (1-10)"
|
||||
)
|
||||
recent_reviews = serializers.IntegerField()
|
||||
|
||||
|
||||
class RideReviewModerationInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for review moderation operations."""
|
||||
|
||||
review_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of review IDs to moderate"
|
||||
)
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
("publish", "Publish"),
|
||||
("unpublish", "Unpublish"),
|
||||
("delete", "Delete"),
|
||||
],
|
||||
help_text="Moderation action to perform"
|
||||
)
|
||||
moderation_notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional notes about the moderation action"
|
||||
)
|
||||
@@ -1,948 +0,0 @@
|
||||
"""
|
||||
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
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === 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()
|
||||
|
||||
# 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=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
|
||||
@@ -1,146 +0,0 @@
|
||||
"""
|
||||
Ride media serializers for ThrillWiki API.
|
||||
|
||||
This module contains serializers for ride-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
|
||||
class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for ride photos."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
file_size = serializers.ReadOnlyField()
|
||||
dimensions = serializers.ReadOnlyField()
|
||||
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_slug = serializers.CharField(source="ride.park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"photo_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for ride photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RidePhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
by_type = serializers.DictField(
|
||||
child=serializers.IntegerField(), help_text="Photo counts by type"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoTypeFilterSerializer(serializers.Serializer):
|
||||
"""Serializer for filtering photos by type."""
|
||||
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
required=False,
|
||||
help_text="Filter photos by type",
|
||||
)
|
||||
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
Search domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for entity search, location search,
|
||||
and other search functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from ..shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === CORE ENTITY SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
class EntitySearchInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for entity search requests."""
|
||||
|
||||
query = serializers.CharField(max_length=255, help_text="Search query string")
|
||||
entity_types = serializers.ListField(
|
||||
child=serializers.ChoiceField(
|
||||
choices=ModelChoices.get_entity_type_choices()
|
||||
),
|
||||
required=False,
|
||||
help_text="Types of entities to search for",
|
||||
)
|
||||
limit = serializers.IntegerField(
|
||||
default=10,
|
||||
min_value=1,
|
||||
max_value=50,
|
||||
help_text="Maximum number of results to return",
|
||||
)
|
||||
|
||||
|
||||
class EntitySearchResultSerializer(serializers.Serializer):
|
||||
"""Serializer for individual entity search results."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
type = RichChoiceFieldSerializer(
|
||||
choice_group="entity_types",
|
||||
domain="core"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
relevance_score = serializers.FloatField()
|
||||
|
||||
# Context-specific info — renamed to avoid overriding Serializer.context
|
||||
context_info = serializers.JSONField(
|
||||
help_text="Additional context based on entity type"
|
||||
)
|
||||
|
||||
|
||||
class EntitySearchOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for entity search results."""
|
||||
|
||||
query = serializers.CharField()
|
||||
total_results = serializers.IntegerField()
|
||||
results = EntitySearchResultSerializer(many=True)
|
||||
search_time_ms = serializers.FloatField()
|
||||
|
||||
|
||||
# === LOCATION SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
class LocationSearchResultSerializer(serializers.Serializer):
|
||||
"""Serializer for location search results."""
|
||||
|
||||
display_name = serializers.CharField()
|
||||
lat = serializers.FloatField()
|
||||
lon = serializers.FloatField()
|
||||
type = serializers.CharField()
|
||||
importance = serializers.FloatField()
|
||||
address = serializers.JSONField()
|
||||
|
||||
|
||||
class LocationSearchOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for location search."""
|
||||
|
||||
results = LocationSearchResultSerializer(many=True)
|
||||
query = serializers.CharField()
|
||||
count = serializers.IntegerField()
|
||||
|
||||
|
||||
class ReverseGeocodeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for reverse geocoding."""
|
||||
|
||||
display_name = serializers.CharField()
|
||||
lat = serializers.FloatField()
|
||||
lon = serializers.FloatField()
|
||||
address = serializers.JSONField()
|
||||
type = serializers.CharField()
|
||||
@@ -1,266 +0,0 @@
|
||||
"""
|
||||
Services domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for various services like email, maps,
|
||||
history tracking, moderation, and roadtrip planning.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
)
|
||||
|
||||
|
||||
# === HEALTH CHECK SERIALIZERS ===
|
||||
|
||||
|
||||
class HealthCheckOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for comprehensive health check responses."""
|
||||
|
||||
status = serializers.CharField(help_text="Overall health status")
|
||||
timestamp = serializers.DateTimeField(help_text="Timestamp of health check")
|
||||
version = serializers.CharField(help_text="Application version")
|
||||
environment = serializers.CharField(help_text="Environment name")
|
||||
response_time_ms = serializers.FloatField(help_text="Response time in milliseconds")
|
||||
checks = serializers.DictField(help_text="Individual health check results")
|
||||
metrics = serializers.DictField(help_text="System metrics")
|
||||
|
||||
|
||||
class PerformanceMetricsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for performance metrics responses."""
|
||||
|
||||
timestamp = serializers.DateTimeField(help_text="Timestamp of metrics collection")
|
||||
database_analysis = serializers.DictField(help_text="Database performance analysis")
|
||||
cache_performance = serializers.DictField(help_text="Cache performance metrics")
|
||||
recent_slow_queries = serializers.DictField(help_text="Recent slow query analysis")
|
||||
|
||||
|
||||
class SimpleHealthOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for simple health check responses."""
|
||||
|
||||
status = serializers.CharField(help_text="Simple health status")
|
||||
timestamp = serializers.DateTimeField(help_text="Timestamp of health check")
|
||||
error = serializers.CharField(
|
||||
required=False, help_text="Error message if unhealthy"
|
||||
)
|
||||
|
||||
|
||||
# === EMAIL SERVICE SERIALIZERS ===
|
||||
|
||||
|
||||
class EmailSendInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for sending emails."""
|
||||
|
||||
to = serializers.EmailField()
|
||||
subject = serializers.CharField(max_length=255)
|
||||
text = serializers.CharField()
|
||||
html = serializers.CharField(required=False)
|
||||
template = serializers.CharField(required=False)
|
||||
template_context = serializers.JSONField(required=False)
|
||||
|
||||
|
||||
class EmailTemplateOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for email templates."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
subject = serializers.CharField()
|
||||
text_template = serializers.CharField()
|
||||
html_template = serializers.CharField(required=False)
|
||||
|
||||
|
||||
# === MAP SERVICE SERIALIZERS ===
|
||||
|
||||
|
||||
class MapDataOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for map data."""
|
||||
|
||||
parks = serializers.ListField(child=serializers.DictField())
|
||||
rides = serializers.ListField(child=serializers.DictField())
|
||||
bounds = serializers.DictField()
|
||||
zoom_level = serializers.IntegerField()
|
||||
|
||||
|
||||
class CoordinateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for coordinate-based requests."""
|
||||
|
||||
latitude = serializers.FloatField(min_value=-90, max_value=90)
|
||||
longitude = serializers.FloatField(min_value=-180, max_value=180)
|
||||
radius_km = serializers.FloatField(min_value=0, max_value=1000, default=10)
|
||||
|
||||
|
||||
# === HISTORY SERIALIZERS ===
|
||||
|
||||
|
||||
class HistoryEventSerializer(serializers.Serializer):
|
||||
"""Base serializer for history events from pghistory."""
|
||||
|
||||
pgh_id = serializers.IntegerField(read_only=True)
|
||||
pgh_created_at = serializers.DateTimeField(read_only=True)
|
||||
pgh_label = serializers.CharField(read_only=True)
|
||||
pgh_obj_id = serializers.IntegerField(read_only=True)
|
||||
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
|
||||
pgh_diff = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_pgh_diff(self, obj) -> dict:
|
||||
"""Get diff from previous version if available."""
|
||||
if hasattr(obj, "diff_against_previous"):
|
||||
return obj.diff_against_previous()
|
||||
return {}
|
||||
|
||||
|
||||
class HistoryEntryOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for history entries."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
model_type = serializers.CharField()
|
||||
object_id = serializers.IntegerField()
|
||||
object_name = serializers.CharField()
|
||||
action = serializers.CharField()
|
||||
changes = serializers.JSONField()
|
||||
timestamp = serializers.DateTimeField()
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_user(self, obj) -> dict | None:
|
||||
if hasattr(obj, "user") and obj.user:
|
||||
return {
|
||||
"id": obj.user.id,
|
||||
"username": obj.user.username,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class HistoryCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating history entries."""
|
||||
|
||||
action = serializers.CharField(max_length=50)
|
||||
description = serializers.CharField(max_length=500)
|
||||
metadata = serializers.JSONField(required=False)
|
||||
|
||||
|
||||
# === MODERATION SERIALIZERS ===
|
||||
|
||||
|
||||
class ModerationSubmissionSerializer(serializers.Serializer):
|
||||
"""Serializer for moderation submissions."""
|
||||
|
||||
submission_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("EDIT", "Edit Submission"),
|
||||
("PHOTO", "Photo Submission"),
|
||||
("REVIEW", "Review Submission"),
|
||||
],
|
||||
help_text="Type of submission"
|
||||
)
|
||||
content_type = serializers.CharField(help_text="Content type being modified")
|
||||
object_id = serializers.IntegerField(help_text="ID of object being modified")
|
||||
changes = serializers.JSONField(help_text="Changes being submitted")
|
||||
reason = serializers.CharField(
|
||||
max_length=500,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Reason for the changes",
|
||||
)
|
||||
|
||||
|
||||
class ModerationSubmissionOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for moderation submission responses."""
|
||||
|
||||
status = serializers.CharField()
|
||||
message = serializers.CharField()
|
||||
submission_id = serializers.IntegerField(required=False)
|
||||
auto_approved = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
# === ROADTRIP SERIALIZERS ===
|
||||
|
||||
|
||||
class RoadtripParkSerializer(serializers.Serializer):
|
||||
"""Serializer for parks in roadtrip planning."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
latitude = serializers.FloatField()
|
||||
longitude = serializers.FloatField()
|
||||
coaster_count = serializers.IntegerField()
|
||||
status = serializers.CharField()
|
||||
|
||||
|
||||
class RoadtripCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating roadtrips."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
park_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
help_text="List of park IDs (2-10 parks)",
|
||||
)
|
||||
start_date = serializers.DateField(required=False)
|
||||
end_date = serializers.DateField(required=False)
|
||||
notes = serializers.CharField(max_length=1000, required=False, allow_blank=True)
|
||||
|
||||
def validate_park_ids(self, value):
|
||||
"""Validate park IDs."""
|
||||
if len(value) < 2:
|
||||
raise serializers.ValidationError("At least 2 parks are required")
|
||||
if len(value) > 10:
|
||||
raise serializers.ValidationError("Maximum 10 parks allowed")
|
||||
if len(set(value)) != len(value):
|
||||
raise serializers.ValidationError("Duplicate park IDs not allowed")
|
||||
return value
|
||||
|
||||
|
||||
class RoadtripOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for roadtrip responses."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
parks = RoadtripParkSerializer(many=True)
|
||||
total_distance_miles = serializers.FloatField()
|
||||
estimated_drive_time_hours = serializers.FloatField()
|
||||
route_coordinates = serializers.ListField(
|
||||
child=serializers.ListField(child=serializers.FloatField())
|
||||
)
|
||||
created_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class GeocodeInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for geocoding requests."""
|
||||
|
||||
address = serializers.CharField(max_length=500, help_text="Address to geocode")
|
||||
|
||||
|
||||
class GeocodeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for geocoding responses."""
|
||||
|
||||
status = serializers.CharField()
|
||||
coordinates = serializers.JSONField(required=False)
|
||||
formatted_address = serializers.CharField(required=False)
|
||||
|
||||
|
||||
# === DISTANCE CALCULATION SERIALIZERS ===
|
||||
class DistanceCalculationInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for distance calculation requests."""
|
||||
|
||||
park1_id = serializers.IntegerField(help_text="ID of first park")
|
||||
park2_id = serializers.IntegerField(help_text="ID of second park")
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that park IDs are different."""
|
||||
if attrs["park1_id"] == attrs["park2_id"]:
|
||||
raise serializers.ValidationError("Park IDs must be different")
|
||||
return attrs
|
||||
|
||||
|
||||
class DistanceCalculationOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for distance calculation responses."""
|
||||
|
||||
status = serializers.CharField()
|
||||
distance_miles = serializers.FloatField(required=False)
|
||||
distance_km = serializers.FloatField(required=False)
|
||||
drive_time_hours = serializers.FloatField(required=False)
|
||||
message = serializers.CharField(required=False)
|
||||
@@ -1,657 +0,0 @@
|
||||
"""
|
||||
Shared Contract Serializers for ThrillWiki API
|
||||
|
||||
This module contains standardized serializers that enforce consistent formats
|
||||
across all API responses, ensuring they match frontend TypeScript interfaces exactly.
|
||||
|
||||
These serializers prevent contract violations by providing a single source of truth
|
||||
for common data structures used throughout the API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
class FilterOptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard filter option format - matches frontend TypeScript exactly.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
selected?: boolean;
|
||||
}
|
||||
"""
|
||||
value = serializers.CharField(
|
||||
help_text="The actual value used for filtering"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable display label"
|
||||
)
|
||||
count = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Number of items matching this filter option"
|
||||
)
|
||||
selected = serializers.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this option is currently selected"
|
||||
)
|
||||
|
||||
|
||||
class FilterRangeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard range filter format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface FilterRange {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
}
|
||||
"""
|
||||
min = serializers.FloatField(
|
||||
allow_null=True,
|
||||
help_text="Minimum value for the range"
|
||||
)
|
||||
max = serializers.FloatField(
|
||||
allow_null=True,
|
||||
help_text="Maximum value for the range"
|
||||
)
|
||||
step = serializers.FloatField(
|
||||
default=1.0,
|
||||
help_text="Step size for range inputs"
|
||||
)
|
||||
unit = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Unit of measurement (e.g., 'feet', 'mph', 'stars')"
|
||||
)
|
||||
|
||||
|
||||
class BooleanFilterSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard boolean filter format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface BooleanFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
"""
|
||||
key = serializers.CharField(
|
||||
help_text="The filter parameter key"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable label for the filter"
|
||||
)
|
||||
description = serializers.CharField(
|
||||
help_text="Description of what this filter does"
|
||||
)
|
||||
|
||||
|
||||
class OrderingOptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard ordering option format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface OrderingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
"""
|
||||
value = serializers.CharField(
|
||||
help_text="The ordering parameter value"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable label for the ordering option"
|
||||
)
|
||||
|
||||
|
||||
class StandardizedFilterMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Matches frontend TypeScript interface exactly.
|
||||
|
||||
This serializer ensures all filter metadata responses follow the same structure
|
||||
that the frontend expects, preventing runtime type errors.
|
||||
"""
|
||||
categorical = serializers.DictField(
|
||||
child=FilterOptionSerializer(many=True),
|
||||
help_text="Categorical filter options with value/label/count structure"
|
||||
)
|
||||
ranges = serializers.DictField(
|
||||
child=FilterRangeSerializer(),
|
||||
help_text="Range filter metadata with min/max/step/unit"
|
||||
)
|
||||
total_count = serializers.IntegerField(
|
||||
help_text="Total number of items in the filtered dataset"
|
||||
)
|
||||
ordering_options = FilterOptionSerializer(
|
||||
many=True,
|
||||
required=False,
|
||||
help_text="Available ordering options"
|
||||
)
|
||||
boolean_filters = BooleanFilterSerializer(
|
||||
many=True,
|
||||
required=False,
|
||||
help_text="Available boolean filter options"
|
||||
)
|
||||
|
||||
|
||||
class PaginationMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard pagination metadata format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface PaginationMetadata {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
"""
|
||||
count = serializers.IntegerField(
|
||||
help_text="Total number of items across all pages"
|
||||
)
|
||||
next = serializers.URLField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="URL for the next page of results"
|
||||
)
|
||||
previous = serializers.URLField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="URL for the previous page of results"
|
||||
)
|
||||
page_size = serializers.IntegerField(
|
||||
help_text="Number of items per page"
|
||||
)
|
||||
current_page = serializers.IntegerField(
|
||||
help_text="Current page number (1-indexed)"
|
||||
)
|
||||
total_pages = serializers.IntegerField(
|
||||
help_text="Total number of pages"
|
||||
)
|
||||
|
||||
|
||||
class ApiResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard API response wrapper.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
errors?: ValidationError;
|
||||
}
|
||||
"""
|
||||
success = serializers.BooleanField(
|
||||
help_text="Whether the request was successful"
|
||||
)
|
||||
response_data = serializers.JSONField(
|
||||
required=False,
|
||||
help_text="Response data (structure varies by endpoint)",
|
||||
source='data'
|
||||
)
|
||||
message = serializers.CharField(
|
||||
required=False,
|
||||
help_text="Human-readable message about the operation"
|
||||
)
|
||||
response_errors = serializers.DictField(
|
||||
required=False,
|
||||
help_text="Validation errors (field -> error messages)",
|
||||
source='errors'
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard error response format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ApiError {
|
||||
status: "error";
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
request_user?: string;
|
||||
};
|
||||
data: null;
|
||||
}
|
||||
"""
|
||||
status = serializers.CharField(
|
||||
default="error",
|
||||
help_text="Response status indicator"
|
||||
)
|
||||
error = serializers.DictField(
|
||||
help_text="Error details"
|
||||
)
|
||||
response_data = serializers.JSONField(
|
||||
default=None,
|
||||
allow_null=True,
|
||||
help_text="Always null for error responses",
|
||||
source='data'
|
||||
)
|
||||
|
||||
|
||||
class LocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard location format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Location {
|
||||
city: string;
|
||||
state?: string;
|
||||
country: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
"""
|
||||
city = serializers.CharField(
|
||||
help_text="City name"
|
||||
)
|
||||
state = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="State/province name"
|
||||
)
|
||||
country = serializers.CharField(
|
||||
help_text="Country name"
|
||||
)
|
||||
address = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Street address"
|
||||
)
|
||||
latitude = serializers.FloatField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Latitude coordinate"
|
||||
)
|
||||
longitude = serializers.FloatField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Longitude coordinate"
|
||||
)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
LocationOutputSerializer = LocationSerializer
|
||||
|
||||
|
||||
class CompanyOutputSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard company output format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
roles?: string[];
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Company ID"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Company name"
|
||||
)
|
||||
slug = serializers.SlugField(
|
||||
help_text="URL-friendly identifier"
|
||||
)
|
||||
roles = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
help_text="Company roles (manufacturer, operator, etc.)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class ModelChoices:
|
||||
"""
|
||||
Utility class to provide model choices for serializers using Rich Choice Objects.
|
||||
This prevents circular imports while providing access to model choices from the registry.
|
||||
|
||||
NO FALLBACKS - All choices must be properly defined in Rich Choice Objects.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_park_status_choices():
|
||||
"""Get park status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("statuses", "parks")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_status_choices():
|
||||
"""Get ride status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("statuses", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_company_role_choices():
|
||||
"""Get company role choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
# Get rides domain company roles (MANUFACTURER, DESIGNER)
|
||||
rides_choices = get_choices("company_roles", "rides")
|
||||
# Get parks domain company roles (OPERATOR, PROPERTY_OWNER)
|
||||
parks_choices = get_choices("company_roles", "parks")
|
||||
all_choices = list(rides_choices) + list(parks_choices)
|
||||
return [(choice.value, choice.label) for choice in all_choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_category_choices():
|
||||
"""Get ride category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_post_closing_choices():
|
||||
"""Get ride post-closing status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("post_closing_statuses", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_track_choices():
|
||||
"""Get coaster track material choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("track_materials", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_type_choices():
|
||||
"""Get coaster type choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("coaster_types", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_launch_choices():
|
||||
"""Get launch system choices from Rich Choice registry (legacy method)."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("propulsion_systems", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_propulsion_system_choices():
|
||||
"""Get propulsion system choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("propulsion_systems", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_photo_type_choices():
|
||||
"""Get photo type choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("photo_types", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_spec_category_choices():
|
||||
"""Get technical specification category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("spec_categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_technical_spec_category_choices():
|
||||
"""Get technical specification category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("spec_categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_target_market_choices():
|
||||
"""Get target market choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("target_markets", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_entity_type_choices():
|
||||
"""Get entity type choices for search functionality."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("entity_types", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_health_status_choices():
|
||||
"""Get health check status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("health_statuses", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_simple_health_status_choices():
|
||||
"""Get simple health check status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("simple_health_statuses", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
|
||||
class EntityReferenceSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard entity reference format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Entity {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Unique identifier"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Display name"
|
||||
)
|
||||
slug = serializers.SlugField(
|
||||
help_text="URL-friendly identifier"
|
||||
)
|
||||
|
||||
|
||||
class ImageVariantsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard image variants format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ImageVariants {
|
||||
thumbnail: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
avatar?: string;
|
||||
}
|
||||
"""
|
||||
thumbnail = serializers.URLField(
|
||||
help_text="Thumbnail size image URL"
|
||||
)
|
||||
medium = serializers.URLField(
|
||||
help_text="Medium size image URL"
|
||||
)
|
||||
large = serializers.URLField(
|
||||
help_text="Large size image URL"
|
||||
)
|
||||
avatar = serializers.URLField(
|
||||
required=False,
|
||||
help_text="Avatar size image URL (for user avatars)"
|
||||
)
|
||||
|
||||
|
||||
class PhotoSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard photo format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Photo {
|
||||
id: number;
|
||||
image_variants: ImageVariants;
|
||||
alt_text?: string;
|
||||
image_url?: string;
|
||||
caption?: string;
|
||||
photo_type?: string;
|
||||
uploaded_by?: UserInfo;
|
||||
uploaded_at?: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Photo ID"
|
||||
)
|
||||
image_variants = ImageVariantsSerializer(
|
||||
help_text="Available image size variants"
|
||||
)
|
||||
alt_text = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Alternative text for accessibility"
|
||||
)
|
||||
image_url = serializers.URLField(
|
||||
required=False,
|
||||
help_text="Primary image URL (for compatibility)"
|
||||
)
|
||||
caption = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Photo caption"
|
||||
)
|
||||
photo_type = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Type/category of photo"
|
||||
)
|
||||
uploaded_by = EntityReferenceSerializer(
|
||||
required=False,
|
||||
help_text="User who uploaded the photo"
|
||||
)
|
||||
uploaded_at = serializers.DateTimeField(
|
||||
required=False,
|
||||
help_text="When the photo was uploaded"
|
||||
)
|
||||
|
||||
|
||||
class UserInfoSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard user info format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="User ID"
|
||||
)
|
||||
username = serializers.CharField(
|
||||
help_text="Username"
|
||||
)
|
||||
display_name = serializers.CharField(
|
||||
help_text="Display name"
|
||||
)
|
||||
avatar_url = serializers.URLField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="User avatar URL"
|
||||
)
|
||||
|
||||
|
||||
def validate_filter_metadata_contract(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate that filter metadata follows the expected contract.
|
||||
|
||||
This function can be used in views to ensure filter metadata
|
||||
matches the frontend TypeScript interface before returning it.
|
||||
|
||||
Args:
|
||||
data: Filter metadata dictionary
|
||||
|
||||
Returns:
|
||||
Validated and potentially transformed data
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: If data doesn't match contract
|
||||
"""
|
||||
serializer = StandardizedFilterMetadataSerializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
# Return validated_data directly - it's already a dict
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Ensure a list of filter options follows the expected format.
|
||||
|
||||
This utility function converts various input formats to the standard
|
||||
FilterOption format expected by the frontend.
|
||||
|
||||
Args:
|
||||
options: List of options in various formats
|
||||
|
||||
Returns:
|
||||
List of options in standard format
|
||||
"""
|
||||
standardized = []
|
||||
|
||||
for option in options:
|
||||
if isinstance(option, dict):
|
||||
# Already in correct format or close to it
|
||||
standardized_option = {
|
||||
'value': str(option.get('value', option.get('id', ''))),
|
||||
'label': option.get('label', option.get('name', str(option.get('value', '')))),
|
||||
'count': option.get('count'),
|
||||
'selected': option.get('selected', False)
|
||||
}
|
||||
elif hasattr(option, 'value') and hasattr(option, 'label'):
|
||||
# RichChoice object format
|
||||
standardized_option = {
|
||||
'value': str(option.value),
|
||||
'label': str(option.label),
|
||||
'count': None,
|
||||
'selected': False
|
||||
}
|
||||
else:
|
||||
# Simple value - use as both value and label
|
||||
standardized_option = {
|
||||
'value': str(option),
|
||||
'label': str(option),
|
||||
'count': None,
|
||||
'selected': False
|
||||
}
|
||||
|
||||
standardized.append(standardized_option)
|
||||
|
||||
return standardized
|
||||
|
||||
|
||||
def ensure_range_format(range_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Ensure range data follows the expected format.
|
||||
|
||||
Args:
|
||||
range_data: Range data dictionary
|
||||
|
||||
Returns:
|
||||
Range data in standard format
|
||||
"""
|
||||
return {
|
||||
'min': range_data.get('min'),
|
||||
'max': range_data.get('max'),
|
||||
'step': range_data.get('step', 1.0),
|
||||
'unit': range_data.get('unit')
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
Statistics serializers for ThrillWiki API.
|
||||
|
||||
Provides serialization for platform statistics data.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class StatsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for platform statistics response.
|
||||
|
||||
This serializer defines the structure of the statistics API response,
|
||||
including all the various counts and breakdowns available.
|
||||
"""
|
||||
|
||||
# Core entity counts
|
||||
total_parks = serializers.IntegerField(
|
||||
help_text="Total number of parks in the database"
|
||||
)
|
||||
total_rides = serializers.IntegerField(
|
||||
help_text="Total number of rides in the database"
|
||||
)
|
||||
total_manufacturers = serializers.IntegerField(
|
||||
help_text="Total number of ride manufacturers"
|
||||
)
|
||||
total_operators = serializers.IntegerField(
|
||||
help_text="Total number of park operators"
|
||||
)
|
||||
total_designers = serializers.IntegerField(
|
||||
help_text="Total number of ride designers"
|
||||
)
|
||||
total_property_owners = serializers.IntegerField(
|
||||
help_text="Total number of property owners"
|
||||
)
|
||||
total_roller_coasters = serializers.IntegerField(
|
||||
help_text="Total number of roller coasters with detailed stats"
|
||||
)
|
||||
|
||||
# Photo counts
|
||||
total_photos = serializers.IntegerField(
|
||||
help_text="Total number of photos (parks + rides combined)"
|
||||
)
|
||||
total_park_photos = serializers.IntegerField(
|
||||
help_text="Total number of park photos"
|
||||
)
|
||||
total_ride_photos = serializers.IntegerField(
|
||||
help_text="Total number of ride photos"
|
||||
)
|
||||
|
||||
# Review counts
|
||||
total_reviews = serializers.IntegerField(
|
||||
help_text="Total number of reviews (parks + rides)"
|
||||
)
|
||||
total_park_reviews = serializers.IntegerField(
|
||||
help_text="Total number of park reviews"
|
||||
)
|
||||
total_ride_reviews = serializers.IntegerField(
|
||||
help_text="Total number of ride reviews"
|
||||
)
|
||||
|
||||
# Ride category counts (optional fields since they depend on data)
|
||||
roller_coasters = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as roller coasters"
|
||||
)
|
||||
dark_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as dark rides"
|
||||
)
|
||||
flat_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as flat rides"
|
||||
)
|
||||
water_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as water rides"
|
||||
)
|
||||
transport_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as transport rides"
|
||||
)
|
||||
other_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as other"
|
||||
)
|
||||
|
||||
# Park status counts (optional fields since they depend on data)
|
||||
operating_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of currently operating parks"
|
||||
)
|
||||
temporarily_closed_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of temporarily closed parks"
|
||||
)
|
||||
permanently_closed_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of permanently closed parks"
|
||||
)
|
||||
under_construction_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of parks under construction"
|
||||
)
|
||||
demolished_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of demolished parks"
|
||||
)
|
||||
relocated_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of relocated parks"
|
||||
)
|
||||
|
||||
# Ride status counts (optional fields since they depend on data)
|
||||
operating_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of currently operating rides"
|
||||
)
|
||||
temporarily_closed_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of temporarily closed rides"
|
||||
)
|
||||
sbno_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides standing but not operating"
|
||||
)
|
||||
closing_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides in the process of closing"
|
||||
)
|
||||
permanently_closed_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of permanently closed rides"
|
||||
)
|
||||
under_construction_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides under construction"
|
||||
)
|
||||
demolished_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of demolished rides"
|
||||
)
|
||||
relocated_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of relocated rides"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
last_updated = serializers.CharField(
|
||||
help_text="ISO timestamp when these statistics were last calculated"
|
||||
)
|
||||
relative_last_updated = serializers.CharField(
|
||||
help_text="Human-readable relative time since last update (e.g., '2 minutes ago')"
|
||||
)
|
||||
Reference in New Issue
Block a user