Files
thrilltrack-explorer/django/api/v1/schemas.py
pacnpal d6ff4cc3a3 Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
2025-11-08 15:34:04 -05:00

970 lines
30 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)