Files
thrilltrack-explorer/django/api/v1/schemas.py
pacnpal e38a9aaa41 Add ride credits and top lists endpoints for API v1
- Implement CRUD operations for ride credits, allowing users to log rides, track counts, and view statistics.
- Create endpoints for managing user-created ranked lists of parks, rides, or coasters with custom rankings and notes.
- Introduce pagination for both ride credits and top lists.
- Ensure proper authentication and authorization for modifying user-specific data.
- Add serialization methods for ride credits and top lists to return structured data.
- Include error handling and logging for better traceability of operations.
2025-11-08 16:02:11 -05:00

1224 lines
38 KiB
Python

"""
Pydantic schemas for API v1 endpoints.
These schemas define the structure of request and response data for the REST API.
"""
from datetime import date, datetime
from typing import Optional, List
from decimal import Decimal
from pydantic import BaseModel, Field, field_validator
from uuid import UUID
# ============================================================================
# Base Schemas
# ============================================================================
class TimestampSchema(BaseModel):
"""Base schema with timestamps."""
created: datetime
modified: datetime
# ============================================================================
# Company Schemas
# ============================================================================
class CompanyBase(BaseModel):
"""Base company schema with common fields."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
company_types: List[str] = Field(default_factory=list)
founded_date: Optional[date] = None
founded_date_precision: str = Field(default='day')
closed_date: Optional[date] = None
closed_date_precision: str = Field(default='day')
website: Optional[str] = None
logo_image_id: Optional[str] = None
logo_image_url: Optional[str] = None
class CompanyCreate(CompanyBase):
"""Schema for creating a company."""
pass
class CompanyUpdate(BaseModel):
"""Schema for updating a company (all fields optional)."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
company_types: Optional[List[str]] = None
founded_date: Optional[date] = None
founded_date_precision: Optional[str] = None
closed_date: Optional[date] = None
closed_date_precision: Optional[str] = None
website: Optional[str] = None
logo_image_id: Optional[str] = None
logo_image_url: Optional[str] = None
class CompanyOut(CompanyBase, TimestampSchema):
"""Schema for company output."""
id: UUID
slug: str
park_count: int
ride_count: int
class Config:
from_attributes = True
# ============================================================================
# RideModel Schemas
# ============================================================================
class RideModelBase(BaseModel):
"""Base ride model schema."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
manufacturer_id: UUID
model_type: str
typical_height: Optional[Decimal] = None
typical_speed: Optional[Decimal] = None
typical_capacity: Optional[int] = None
image_id: Optional[str] = None
image_url: Optional[str] = None
class RideModelCreate(RideModelBase):
"""Schema for creating a ride model."""
pass
class RideModelUpdate(BaseModel):
"""Schema for updating a ride model (all fields optional)."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
manufacturer_id: Optional[UUID] = None
model_type: Optional[str] = None
typical_height: Optional[Decimal] = None
typical_speed: Optional[Decimal] = None
typical_capacity: Optional[int] = None
image_id: Optional[str] = None
image_url: Optional[str] = None
class RideModelOut(RideModelBase, TimestampSchema):
"""Schema for ride model output."""
id: UUID
slug: str
installation_count: int
manufacturer_name: Optional[str] = None
class Config:
from_attributes = True
# ============================================================================
# Park Schemas
# ============================================================================
class ParkBase(BaseModel):
"""Base park schema."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
park_type: str
status: str = Field(default='operating')
opening_date: Optional[date] = None
opening_date_precision: str = Field(default='day')
closing_date: Optional[date] = None
closing_date_precision: str = Field(default='day')
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
operator_id: Optional[UUID] = None
website: Optional[str] = None
banner_image_id: Optional[str] = None
banner_image_url: Optional[str] = None
logo_image_id: Optional[str] = None
logo_image_url: Optional[str] = None
class ParkCreate(ParkBase):
"""Schema for creating a park."""
pass
class ParkUpdate(BaseModel):
"""Schema for updating a park (all fields optional)."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
park_type: Optional[str] = None
status: Optional[str] = None
opening_date: Optional[date] = None
opening_date_precision: Optional[str] = None
closing_date: Optional[date] = None
closing_date_precision: Optional[str] = None
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
operator_id: Optional[UUID] = None
website: Optional[str] = None
banner_image_id: Optional[str] = None
banner_image_url: Optional[str] = None
logo_image_id: Optional[str] = None
logo_image_url: Optional[str] = None
class ParkOut(ParkBase, TimestampSchema):
"""Schema for park output."""
id: UUID
slug: str
ride_count: int
coaster_count: int
operator_name: Optional[str] = None
coordinates: Optional[tuple[float, float]] = None
class Config:
from_attributes = True
# ============================================================================
# Ride Schemas
# ============================================================================
class RideBase(BaseModel):
"""Base ride schema."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
park_id: UUID
ride_category: str
ride_type: Optional[str] = None
is_coaster: bool = Field(default=False)
status: str = Field(default='operating')
opening_date: Optional[date] = None
opening_date_precision: str = Field(default='day')
closing_date: Optional[date] = None
closing_date_precision: str = Field(default='day')
manufacturer_id: Optional[UUID] = None
model_id: Optional[UUID] = None
height: Optional[Decimal] = None
speed: Optional[Decimal] = None
length: Optional[Decimal] = None
duration: Optional[int] = None
inversions: Optional[int] = None
capacity: Optional[int] = None
image_id: Optional[str] = None
image_url: Optional[str] = None
class RideCreate(RideBase):
"""Schema for creating a ride."""
pass
class RideUpdate(BaseModel):
"""Schema for updating a ride (all fields optional)."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
park_id: Optional[UUID] = None
ride_category: Optional[str] = None
ride_type: Optional[str] = None
is_coaster: Optional[bool] = None
status: Optional[str] = None
opening_date: Optional[date] = None
opening_date_precision: Optional[str] = None
closing_date: Optional[date] = None
closing_date_precision: Optional[str] = None
manufacturer_id: Optional[UUID] = None
model_id: Optional[UUID] = None
height: Optional[Decimal] = None
speed: Optional[Decimal] = None
length: Optional[Decimal] = None
duration: Optional[int] = None
inversions: Optional[int] = None
capacity: Optional[int] = None
image_id: Optional[str] = None
image_url: Optional[str] = None
class RideOut(RideBase, TimestampSchema):
"""Schema for ride output."""
id: UUID
slug: str
park_name: Optional[str] = None
manufacturer_name: Optional[str] = None
model_name: Optional[str] = None
class Config:
from_attributes = True
# ============================================================================
# Pagination Schemas
# ============================================================================
class PaginatedResponse(BaseModel):
"""Generic paginated response schema."""
items: List
total: int
page: int
page_size: int
total_pages: int
class CompanyListOut(BaseModel):
"""Paginated company list response."""
items: List[CompanyOut]
total: int
page: int
page_size: int
total_pages: int
class RideModelListOut(BaseModel):
"""Paginated ride model list response."""
items: List[RideModelOut]
total: int
page: int
page_size: int
total_pages: int
class ParkListOut(BaseModel):
"""Paginated park list response."""
items: List[ParkOut]
total: int
page: int
page_size: int
total_pages: int
class RideListOut(BaseModel):
"""Paginated ride list response."""
items: List[RideOut]
total: int
page: int
page_size: int
total_pages: int
# ============================================================================
# Error Schemas
# ============================================================================
class ErrorResponse(BaseModel):
"""Standard error response schema."""
detail: str
code: Optional[str] = None
class ValidationErrorResponse(BaseModel):
"""Validation error response schema."""
detail: str
errors: Optional[List[dict]] = None
# ============================================================================
# Moderation Schemas
# ============================================================================
class SubmissionItemBase(BaseModel):
"""Base submission item schema."""
field_name: str = Field(..., min_length=1, max_length=100)
field_label: Optional[str] = None
old_value: Optional[dict] = None
new_value: Optional[dict] = None
change_type: str = Field(default='modify')
is_required: bool = Field(default=False)
order: int = Field(default=0)
class SubmissionItemCreate(SubmissionItemBase):
"""Schema for creating a submission item."""
pass
class SubmissionItemOut(SubmissionItemBase, TimestampSchema):
"""Schema for submission item output."""
id: UUID
submission_id: UUID
status: str
reviewed_by_id: Optional[UUID] = None
reviewed_by_email: Optional[str] = None
reviewed_at: Optional[datetime] = None
rejection_reason: Optional[str] = None
old_value_display: str
new_value_display: str
class Config:
from_attributes = True
class ContentSubmissionBase(BaseModel):
"""Base content submission schema."""
submission_type: str
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
entity_type: str
entity_id: UUID
class ContentSubmissionCreate(BaseModel):
"""Schema for creating a content submission."""
entity_type: str = Field(..., description="Entity type (park, ride, company, ridemodel)")
entity_id: UUID = Field(..., description="ID of entity being modified")
submission_type: str = Field(..., description="Operation type (create, update, delete)")
title: str = Field(..., min_length=1, max_length=255, description="Brief description")
description: Optional[str] = Field(None, description="Detailed description")
items: List[SubmissionItemCreate] = Field(..., min_items=1, description="List of changes")
metadata: Optional[dict] = Field(default_factory=dict)
auto_submit: bool = Field(default=True, description="Auto-submit for review")
class ContentSubmissionOut(TimestampSchema):
"""Schema for content submission output."""
id: UUID
status: str
submission_type: str
title: str
description: Optional[str] = None
entity_type: str
entity_id: UUID
user_id: UUID
user_email: str
locked_by_id: Optional[UUID] = None
locked_by_email: Optional[str] = None
locked_at: Optional[datetime] = None
reviewed_by_id: Optional[UUID] = None
reviewed_by_email: Optional[str] = None
reviewed_at: Optional[datetime] = None
rejection_reason: Optional[str] = None
source: str
metadata: dict
items_count: int
approved_items_count: int
rejected_items_count: int
class Config:
from_attributes = True
class ContentSubmissionDetail(ContentSubmissionOut):
"""Detailed submission with items."""
items: List[SubmissionItemOut]
class Config:
from_attributes = True
class StartReviewRequest(BaseModel):
"""Schema for starting a review."""
pass # No additional fields needed
class ApproveRequest(BaseModel):
"""Schema for approving a submission."""
pass # No additional fields needed
class ApproveSelectiveRequest(BaseModel):
"""Schema for selective approval."""
item_ids: List[UUID] = Field(..., min_items=1, description="List of item IDs to approve")
class RejectRequest(BaseModel):
"""Schema for rejecting a submission."""
reason: str = Field(..., min_length=1, description="Reason for rejection")
class RejectSelectiveRequest(BaseModel):
"""Schema for selective rejection."""
item_ids: List[UUID] = Field(..., min_items=1, description="List of item IDs to reject")
reason: Optional[str] = Field(None, description="Reason for rejection")
class ApprovalResponse(BaseModel):
"""Response for approval operations."""
success: bool
message: str
submission: ContentSubmissionOut
class SelectiveApprovalResponse(BaseModel):
"""Response for selective approval."""
success: bool
message: str
approved: int
total: int
pending: int
submission_approved: bool
class SelectiveRejectionResponse(BaseModel):
"""Response for selective rejection."""
success: bool
message: str
rejected: int
total: int
pending: int
submission_complete: bool
class SubmissionListOut(BaseModel):
"""Paginated submission list response."""
items: List[ContentSubmissionOut]
total: int
page: int
page_size: int
total_pages: int
# ============================================================================
# Versioning Schemas
# ============================================================================
class EntityVersionSchema(TimestampSchema):
"""Schema for entity version output."""
id: UUID
entity_type: str
entity_id: UUID
entity_name: str
version_number: int
change_type: str
snapshot: dict
changed_fields: dict
changed_by_id: Optional[UUID] = None
changed_by_email: Optional[str] = None
submission_id: Optional[UUID] = None
comment: Optional[str] = None
diff_summary: str
class Config:
from_attributes = True
class VersionHistoryResponseSchema(BaseModel):
"""Response schema for version history."""
entity_id: str
entity_type: str
entity_name: str
total_versions: int
versions: List[EntityVersionSchema]
class VersionDiffSchema(BaseModel):
"""Schema for version diff response."""
entity_id: str
entity_type: str
entity_name: str
version_number: int
version_date: datetime
differences: dict
changed_field_count: int
class VersionComparisonSchema(BaseModel):
"""Schema for comparing two versions."""
version1: EntityVersionSchema
version2: EntityVersionSchema
differences: dict
changed_field_count: int
# ============================================================================
# Generic Utility Schemas
# ============================================================================
class MessageSchema(BaseModel):
"""Generic message response."""
message: str
success: bool = True
class ErrorSchema(BaseModel):
"""Standard error response."""
error: str
detail: Optional[str] = None
# ============================================================================
# Authentication Schemas
# ============================================================================
class UserBase(BaseModel):
"""Base user schema."""
email: str = Field(..., description="Email address")
username: Optional[str] = Field(None, description="Username")
first_name: Optional[str] = Field(None, max_length=150)
last_name: Optional[str] = Field(None, max_length=150)
class UserRegisterRequest(BaseModel):
"""Schema for user registration."""
email: str = Field(..., description="Email address")
password: str = Field(..., min_length=8, description="Password (min 8 characters)")
password_confirm: str = Field(..., description="Password confirmation")
username: Optional[str] = Field(None, description="Username (auto-generated if not provided)")
first_name: Optional[str] = Field(None, max_length=150)
last_name: Optional[str] = Field(None, max_length=150)
@field_validator('password_confirm')
def passwords_match(cls, v, info):
if 'password' in info.data and v != info.data['password']:
raise ValueError('Passwords do not match')
return v
class UserLoginRequest(BaseModel):
"""Schema for user login."""
email: str = Field(..., description="Email address")
password: str = Field(..., description="Password")
mfa_token: Optional[str] = Field(None, description="MFA token if enabled")
class TokenResponse(BaseModel):
"""Schema for token response."""
access: str = Field(..., description="JWT access token")
refresh: str = Field(..., description="JWT refresh token")
token_type: str = Field(default="Bearer")
class TokenRefreshRequest(BaseModel):
"""Schema for token refresh."""
refresh: str = Field(..., description="Refresh token")
class UserProfileOut(BaseModel):
"""Schema for user profile output."""
id: UUID
email: str
username: str
first_name: str
last_name: str
display_name: str
avatar_url: Optional[str] = None
bio: Optional[str] = None
reputation_score: int
mfa_enabled: bool
banned: bool
date_joined: datetime
last_login: Optional[datetime] = None
oauth_provider: str
class Config:
from_attributes = True
class UserProfileUpdate(BaseModel):
"""Schema for updating user profile."""
first_name: Optional[str] = Field(None, max_length=150)
last_name: Optional[str] = Field(None, max_length=150)
username: Optional[str] = Field(None, max_length=150)
bio: Optional[str] = Field(None, max_length=500)
avatar_url: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""Schema for password change."""
old_password: str = Field(..., description="Current password")
new_password: str = Field(..., min_length=8, description="New password")
new_password_confirm: str = Field(..., description="New password confirmation")
@field_validator('new_password_confirm')
def passwords_match(cls, v, info):
if 'new_password' in info.data and v != info.data['new_password']:
raise ValueError('Passwords do not match')
return v
class ResetPasswordRequest(BaseModel):
"""Schema for password reset."""
email: str = Field(..., description="Email address")
class ResetPasswordConfirm(BaseModel):
"""Schema for password reset confirmation."""
token: str = Field(..., description="Reset token")
password: str = Field(..., min_length=8, description="New password")
password_confirm: str = Field(..., description="Password confirmation")
@field_validator('password_confirm')
def passwords_match(cls, v, info):
if 'password' in info.data and v != info.data['password']:
raise ValueError('Passwords do not match')
return v
class UserRoleOut(BaseModel):
"""Schema for user role output."""
role: str
is_moderator: bool
is_admin: bool
granted_at: datetime
granted_by_email: Optional[str] = None
class Config:
from_attributes = True
class UserPermissionsOut(BaseModel):
"""Schema for user permissions."""
can_submit: bool
can_moderate: bool
can_admin: bool
can_edit_own: bool
can_delete_own: bool
class UserStatsOut(BaseModel):
"""Schema for user statistics."""
total_submissions: int
approved_submissions: int
reputation_score: int
member_since: datetime
last_active: Optional[datetime] = None
class UserProfilePreferencesOut(BaseModel):
"""Schema for user preferences."""
email_notifications: bool
email_on_submission_approved: bool
email_on_submission_rejected: bool
profile_public: bool
show_email: bool
class Config:
from_attributes = True
class UserProfilePreferencesUpdate(BaseModel):
"""Schema for updating user preferences."""
email_notifications: Optional[bool] = None
email_on_submission_approved: Optional[bool] = None
email_on_submission_rejected: Optional[bool] = None
profile_public: Optional[bool] = None
show_email: Optional[bool] = None
class TOTPEnableResponse(BaseModel):
"""Schema for TOTP enable response."""
secret: str = Field(..., description="TOTP secret key")
qr_code_url: str = Field(..., description="QR code URL for authenticator apps")
backup_codes: List[str] = Field(default_factory=list, description="Backup codes")
class TOTPConfirmRequest(BaseModel):
"""Schema for TOTP confirmation."""
token: str = Field(..., min_length=6, max_length=6, description="6-digit TOTP token")
class TOTPVerifyRequest(BaseModel):
"""Schema for TOTP verification."""
token: str = Field(..., min_length=6, max_length=6, description="6-digit TOTP token")
class BanUserRequest(BaseModel):
"""Schema for banning a user."""
user_id: UUID = Field(..., description="User ID to ban")
reason: str = Field(..., min_length=1, description="Reason for ban")
class UnbanUserRequest(BaseModel):
"""Schema for unbanning a user."""
user_id: UUID = Field(..., description="User ID to unban")
class AssignRoleRequest(BaseModel):
"""Schema for assigning a role."""
user_id: UUID = Field(..., description="User ID")
role: str = Field(..., description="Role to assign (user, moderator, admin)")
class UserListOut(BaseModel):
"""Paginated user list response."""
items: List[UserProfileOut]
total: int
page: int
page_size: int
total_pages: int
# ============================================================================
# Photo/Media Schemas
# ============================================================================
class PhotoBase(BaseModel):
"""Base photo schema."""
title: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
credit: Optional[str] = Field(None, max_length=255, description="Photo credit/attribution")
photo_type: str = Field(default='gallery', description="Type: main, gallery, banner, logo, thumbnail, other")
is_visible: bool = Field(default=True)
class PhotoUploadRequest(PhotoBase):
"""Schema for photo upload request (form data)."""
entity_type: Optional[str] = Field(None, description="Entity type to attach to")
entity_id: Optional[UUID] = Field(None, description="Entity ID to attach to")
class PhotoUpdate(BaseModel):
"""Schema for updating photo metadata."""
title: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
credit: Optional[str] = Field(None, max_length=255)
photo_type: Optional[str] = None
is_visible: Optional[bool] = None
display_order: Optional[int] = None
class PhotoOut(PhotoBase, TimestampSchema):
"""Schema for photo output."""
id: UUID
cloudflare_image_id: str
cloudflare_url: str
uploaded_by_id: UUID
uploaded_by_email: Optional[str] = None
moderation_status: str
moderated_by_id: Optional[UUID] = None
moderated_by_email: Optional[str] = None
moderated_at: Optional[datetime] = None
moderation_notes: Optional[str] = None
entity_type: Optional[str] = None
entity_id: Optional[str] = None
entity_name: Optional[str] = None
width: int
height: int
file_size: int
mime_type: str
display_order: int
# Generated URLs for different variants
thumbnail_url: Optional[str] = None
banner_url: Optional[str] = None
class Config:
from_attributes = True
class PhotoListOut(BaseModel):
"""Paginated photo list response."""
items: List[PhotoOut]
total: int
page: int
page_size: int
total_pages: int
class PhotoUploadResponse(BaseModel):
"""Response for photo upload."""
success: bool
message: str
photo: PhotoOut
class PhotoModerateRequest(BaseModel):
"""Schema for moderating a photo."""
status: str = Field(..., description="Status: approved, rejected, flagged")
notes: Optional[str] = Field(None, description="Moderation notes")
class PhotoReorderRequest(BaseModel):
"""Schema for reordering photos."""
photo_ids: List[int] = Field(..., min_items=1, description="Ordered list of photo IDs")
photo_type: Optional[str] = Field(None, description="Optional photo type filter")
class PhotoAttachRequest(BaseModel):
"""Schema for attaching photo to entity."""
photo_id: UUID = Field(..., description="Photo ID to attach")
photo_type: Optional[str] = Field('gallery', description="Photo type")
class PhotoStatsOut(BaseModel):
"""Statistics about photos."""
total_photos: int
pending_photos: int
approved_photos: int
rejected_photos: int
flagged_photos: int
total_size_mb: float
# ============================================================================
# Search Schemas
# ============================================================================
class SearchResultBase(BaseModel):
"""Base schema for search results."""
id: UUID
name: str
slug: str
entity_type: str
description: Optional[str] = None
image_url: Optional[str] = None
class CompanySearchResult(SearchResultBase):
"""Company search result."""
company_types: List[str] = Field(default_factory=list)
park_count: int = 0
ride_count: int = 0
class RideModelSearchResult(SearchResultBase):
"""Ride model search result."""
manufacturer_name: str
model_type: str
installation_count: int = 0
class ParkSearchResult(SearchResultBase):
"""Park search result."""
park_type: str
status: str
operator_name: Optional[str] = None
ride_count: int = 0
coaster_count: int = 0
coordinates: Optional[tuple[float, float]] = None
class RideSearchResult(SearchResultBase):
"""Ride search result."""
park_name: str
park_slug: str
manufacturer_name: Optional[str] = None
ride_category: str
status: str
is_coaster: bool
class GlobalSearchResponse(BaseModel):
"""Response for global search across all entities."""
query: str
total_results: int
companies: List[CompanySearchResult] = Field(default_factory=list)
ride_models: List[RideModelSearchResult] = Field(default_factory=list)
parks: List[ParkSearchResult] = Field(default_factory=list)
rides: List[RideSearchResult] = Field(default_factory=list)
class AutocompleteItem(BaseModel):
"""Single autocomplete suggestion."""
id: UUID
name: str
slug: str
entity_type: str
park_name: Optional[str] = None # For rides
manufacturer_name: Optional[str] = None # For ride models
class AutocompleteResponse(BaseModel):
"""Response for autocomplete suggestions."""
query: str
suggestions: List[AutocompleteItem]
class SearchFilters(BaseModel):
"""Base filters for search operations."""
q: str = Field(..., min_length=2, max_length=200, description="Search query")
entity_types: Optional[List[str]] = Field(None, description="Filter by entity types")
limit: int = Field(20, ge=1, le=100, description="Maximum results per entity type")
class CompanySearchFilters(BaseModel):
"""Filters for company search."""
q: str = Field(..., min_length=2, max_length=200, description="Search query")
company_types: Optional[List[str]] = Field(None, description="Filter by company types")
founded_after: Optional[date] = Field(None, description="Founded after date")
founded_before: Optional[date] = Field(None, description="Founded before date")
limit: int = Field(20, ge=1, le=100)
class RideModelSearchFilters(BaseModel):
"""Filters for ride model search."""
q: str = Field(..., min_length=2, max_length=200, description="Search query")
manufacturer_id: Optional[UUID] = Field(None, description="Filter by manufacturer")
model_type: Optional[str] = Field(None, description="Filter by model type")
limit: int = Field(20, ge=1, le=100)
class ParkSearchFilters(BaseModel):
"""Filters for park search."""
q: str = Field(..., min_length=2, max_length=200, description="Search query")
status: Optional[str] = Field(None, description="Filter by status")
park_type: Optional[str] = Field(None, description="Filter by park type")
operator_id: Optional[UUID] = Field(None, description="Filter by operator")
opening_after: Optional[date] = Field(None, description="Opened after date")
opening_before: Optional[date] = Field(None, description="Opened before date")
latitude: Optional[float] = Field(None, description="Search center latitude")
longitude: Optional[float] = Field(None, description="Search center longitude")
radius: Optional[float] = Field(None, ge=0, le=500, description="Search radius in km")
limit: int = Field(20, ge=1, le=100)
class RideSearchFilters(BaseModel):
"""Filters for ride search."""
q: str = Field(..., min_length=2, max_length=200, description="Search query")
park_id: Optional[UUID] = Field(None, description="Filter by park")
manufacturer_id: Optional[UUID] = Field(None, description="Filter by manufacturer")
model_id: Optional[UUID] = Field(None, description="Filter by model")
status: Optional[str] = Field(None, description="Filter by status")
ride_category: Optional[str] = Field(None, description="Filter by category")
is_coaster: Optional[bool] = Field(None, description="Filter coasters only")
opening_after: Optional[date] = Field(None, description="Opened after date")
opening_before: Optional[date] = Field(None, description="Opened before date")
min_height: Optional[Decimal] = Field(None, description="Minimum height in feet")
max_height: Optional[Decimal] = Field(None, description="Maximum height in feet")
min_speed: Optional[Decimal] = Field(None, description="Minimum speed in mph")
max_speed: Optional[Decimal] = Field(None, description="Maximum speed in mph")
limit: int = Field(20, ge=1, le=100)
# ============================================================================
# Review Schemas
# ============================================================================
class UserSchema(BaseModel):
"""Minimal user schema for embedding in other schemas."""
id: UUID
username: str
display_name: str
avatar_url: Optional[str] = None
reputation_score: int
class Config:
from_attributes = True
class ReviewCreateSchema(BaseModel):
"""Schema for creating a review."""
entity_type: str = Field(..., description="Entity type: 'park' or 'ride'")
entity_id: UUID = Field(..., description="ID of park or ride being reviewed")
title: str = Field(..., min_length=1, max_length=200, description="Review title")
content: str = Field(..., min_length=10, description="Review content (min 10 characters)")
rating: int = Field(..., ge=1, le=5, description="Rating from 1 to 5 stars")
visit_date: Optional[date] = Field(None, description="Date of visit")
wait_time_minutes: Optional[int] = Field(None, ge=0, description="Wait time in minutes")
@field_validator('entity_type')
def validate_entity_type(cls, v):
if v not in ['park', 'ride']:
raise ValueError('entity_type must be "park" or "ride"')
return v
class ReviewUpdateSchema(BaseModel):
"""Schema for updating a review."""
title: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, min_length=10)
rating: Optional[int] = Field(None, ge=1, le=5)
visit_date: Optional[date] = None
wait_time_minutes: Optional[int] = Field(None, ge=0)
class VoteRequest(BaseModel):
"""Schema for voting on a review."""
is_helpful: bool = Field(..., description="True if helpful, False if not helpful")
class ReviewOut(TimestampSchema):
"""Schema for review output."""
id: int
user: UserSchema
entity_type: str
entity_id: UUID
entity_name: str
title: str
content: str
rating: int
visit_date: Optional[date]
wait_time_minutes: Optional[int]
helpful_votes: int
total_votes: int
helpful_percentage: Optional[float]
moderation_status: str
moderated_at: Optional[datetime]
moderated_by_email: Optional[str]
photo_count: int
user_vote: Optional[bool] = None # Current user's vote if authenticated
class Config:
from_attributes = True
class VoteResponse(BaseModel):
"""Response for vote action."""
success: bool
review_id: int
helpful_votes: int
total_votes: int
helpful_percentage: Optional[float]
class ReviewListOut(BaseModel):
"""Paginated review list response."""
items: List[ReviewOut]
total: int
page: int
page_size: int
total_pages: int
class ReviewStatsOut(BaseModel):
"""Statistics about reviews for an entity."""
average_rating: float
total_reviews: int
rating_distribution: dict # {1: count, 2: count, 3: count, 4: count, 5: count}
# ============================================================================
# Ride Credit Schemas
# ============================================================================
class RideCreditCreateSchema(BaseModel):
"""Schema for creating a ride credit."""
ride_id: UUID = Field(..., description="ID of ride")
first_ride_date: Optional[date] = Field(None, description="Date of first ride")
ride_count: int = Field(1, ge=1, description="Number of times ridden")
notes: Optional[str] = Field(None, max_length=500, description="Notes about the ride")
class RideCreditUpdateSchema(BaseModel):
"""Schema for updating a ride credit."""
first_ride_date: Optional[date] = None
ride_count: Optional[int] = Field(None, ge=1)
notes: Optional[str] = Field(None, max_length=500)
class RideCreditOut(TimestampSchema):
"""Schema for ride credit output."""
id: UUID
user: UserSchema
ride_id: UUID
ride_name: str
ride_slug: str
park_id: UUID
park_name: str
park_slug: str
is_coaster: bool
first_ride_date: Optional[date]
ride_count: int
notes: str
class Config:
from_attributes = True
class RideCreditListOut(BaseModel):
"""Paginated ride credit list response."""
items: List[RideCreditOut]
total: int
page: int
page_size: int
total_pages: int
class RideCreditStatsOut(BaseModel):
"""Statistics about user's ride credits."""
total_rides: int # Sum of all ride_counts
total_credits: int # Count of unique rides
unique_parks: int
coaster_count: int
first_credit_date: Optional[date]
last_credit_date: Optional[date]
top_park: Optional[str]
top_park_count: int
recent_credits: List[RideCreditOut]
# ============================================================================
# Top List Schemas
# ============================================================================
class TopListCreateSchema(BaseModel):
"""Schema for creating a top list."""
list_type: str = Field(..., description="List type: 'parks', 'rides', or 'coasters'")
title: str = Field(..., min_length=1, max_length=200, description="List title")
description: Optional[str] = Field(None, max_length=1000, description="List description")
is_public: bool = Field(True, description="Whether list is publicly visible")
@field_validator('list_type')
def validate_list_type(cls, v):
if v not in ['parks', 'rides', 'coasters']:
raise ValueError('list_type must be "parks", "rides", or "coasters"')
return v
class TopListUpdateSchema(BaseModel):
"""Schema for updating a top list."""
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
is_public: Optional[bool] = None
class TopListItemCreateSchema(BaseModel):
"""Schema for creating a top list item."""
entity_type: str = Field(..., description="Entity type: 'park' or 'ride'")
entity_id: UUID = Field(..., description="ID of park or ride")
position: Optional[int] = Field(None, ge=1, description="Position in list (1 = top)")
notes: Optional[str] = Field(None, max_length=500, description="Notes about this item")
@field_validator('entity_type')
def validate_entity_type(cls, v):
if v not in ['park', 'ride']:
raise ValueError('entity_type must be "park" or "ride"')
return v
class TopListItemUpdateSchema(BaseModel):
"""Schema for updating a top list item."""
position: Optional[int] = Field(None, ge=1, description="New position in list")
notes: Optional[str] = Field(None, max_length=500)
class TopListItemOut(TimestampSchema):
"""Schema for top list item output."""
id: UUID
position: int
entity_type: str
entity_id: UUID
entity_name: str
entity_slug: str
entity_image_url: Optional[str]
park_name: Optional[str] # For rides, show which park
notes: str
class Config:
from_attributes = True
class TopListOut(TimestampSchema):
"""Schema for top list output."""
id: UUID
user: UserSchema
list_type: str
title: str
description: str
is_public: bool
item_count: int
class Config:
from_attributes = True
class TopListDetailOut(TopListOut):
"""Detailed top list with items."""
items: List[TopListItemOut]
class Config:
from_attributes = True
class TopListListOut(BaseModel):
"""Paginated top list response."""
items: List[TopListOut]
total: int
page: int
page_size: int
total_pages: int
class ReorderItemsRequest(BaseModel):
"""Schema for reordering list items."""
item_positions: dict = Field(..., description="Map of item_id to new_position")