Files
thrilltrack-explorer/django-backend/api/v1/schemas.py

1566 lines
49 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
# ============================================================================
# Ride Name History Schemas
# ============================================================================
class RideNameHistoryOut(BaseModel):
"""Schema for ride name history output."""
id: UUID
former_name: str
from_year: Optional[int] = None
to_year: Optional[int] = None
date_changed: Optional[date] = None
date_changed_precision: Optional[str] = None
reason: Optional[str] = None
order_index: Optional[int] = None
created_at: datetime
updated_at: datetime
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
# ============================================================================
# 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")
# ============================================================================
# History/Versioning Schemas
# ============================================================================
class HistoryEventSchema(BaseModel):
"""Schema for a single history event."""
id: int
timestamp: datetime
operation: str # 'INSERT' or 'UPDATE'
snapshot: dict
changed_fields: Optional[dict] = None
change_summary: str
can_rollback: bool
class Config:
from_attributes = True
class HistoryListResponse(BaseModel):
"""Response for list history endpoint."""
entity_id: UUID
entity_type: str
entity_name: str
total_events: int
accessible_events: int
access_limited: bool
access_reason: str
events: List[HistoryEventSchema]
pagination: dict
class HistoryEventDetailSchema(BaseModel):
"""Detailed event with rollback preview."""
id: int
timestamp: datetime
operation: str
entity_id: UUID
entity_type: str
entity_name: str
snapshot: dict
changed_fields: Optional[dict] = None
metadata: dict
can_rollback: bool
rollback_preview: Optional[dict] = None
class Config:
from_attributes = True
class HistoryComparisonSchema(BaseModel):
"""Response for event comparison."""
entity_id: UUID
entity_type: str
entity_name: str
event1: dict
event2: dict
differences: dict
changed_field_count: int
unchanged_field_count: int
time_between: str
class HistoryDiffCurrentSchema(BaseModel):
"""Response for comparing event with current state."""
entity_id: UUID
entity_type: str
entity_name: str
event: dict
current_state: dict
differences: dict
changed_field_count: int
time_since: str
class FieldHistorySchema(BaseModel):
"""Response for field-specific history."""
entity_id: UUID
entity_type: str
entity_name: str
field: str
field_type: str
history: List[dict]
total_changes: int
first_value: Optional[str] = None
current_value: Optional[str] = None
class HistoryActivitySummarySchema(BaseModel):
"""Response for activity summary."""
entity_id: UUID
entity_type: str
entity_name: str
total_events: int
accessible_events: int
summary: dict
most_changed_fields: Optional[List[dict]] = None
recent_activity: List[dict]
class RollbackRequestSchema(BaseModel):
"""Request body for rollback operation."""
fields: Optional[List[str]] = None
comment: str = ""
create_backup: bool = True
class RollbackResponseSchema(BaseModel):
"""Response for rollback operation."""
success: bool
message: str
entity_id: UUID
rollback_event_id: int
new_event_id: Optional[int]
fields_changed: dict
backup_event_id: Optional[int]
# ============================================================================
# Timeline Event Schemas
# ============================================================================
class EntityTimelineEventOut(BaseModel):
"""Schema for timeline event output."""
id: UUID
entity_id: UUID
entity_type: str
event_type: str
event_date: date
event_date_precision: Optional[str] = None
title: str
description: Optional[str] = None
from_entity_id: Optional[UUID] = None
to_entity_id: Optional[UUID] = None
from_location_id: Optional[UUID] = None
from_location_name: Optional[str] = None
to_location_id: Optional[UUID] = None
to_location_name: Optional[str] = None
from_value: Optional[str] = None
to_value: Optional[str] = None
is_public: bool
display_order: Optional[int] = None
created_by_id: Optional[UUID] = None
created_by_email: Optional[str] = None
approved_by_id: Optional[UUID] = None
approved_by_email: Optional[str] = None
submission_id: Optional[UUID] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class EntityTimelineEventCreate(BaseModel):
"""Schema for creating a timeline event."""
entity_id: UUID = Field(..., description="ID of entity this event belongs to")
entity_type: str = Field(..., description="Type of entity (park, ride, company, ridemodel)")
event_type: str = Field(..., max_length=100, description="Type of event (opening, closing, relocation, etc.)")
event_date: date = Field(..., description="Date of the event")
event_date_precision: Optional[str] = Field('day', description="Precision: day, month, year, decade")
title: str = Field(..., min_length=1, max_length=255, description="Event title")
description: Optional[str] = Field(None, description="Detailed description")
from_entity_id: Optional[UUID] = Field(None, description="Source entity ID (for transfers/relocations)")
to_entity_id: Optional[UUID] = Field(None, description="Destination entity ID (for transfers/relocations)")
from_location_id: Optional[UUID] = Field(None, description="Source park ID (for relocations)")
to_location_id: Optional[UUID] = Field(None, description="Destination park ID (for relocations)")
from_value: Optional[str] = Field(None, description="Original value (for changes)")
to_value: Optional[str] = Field(None, description="New value (for changes)")
is_public: bool = Field(True, description="Whether event is publicly visible")
display_order: Optional[int] = Field(None, description="Display order")
@field_validator('entity_type')
def validate_entity_type(cls, v):
if v not in ['park', 'ride', 'company', 'ridemodel']:
raise ValueError('entity_type must be park, ride, company, or ridemodel')
return v
@field_validator('event_date_precision')
def validate_precision(cls, v):
if v and v not in ['day', 'month', 'year', 'decade']:
raise ValueError('event_date_precision must be day, month, year, or decade')
return v
class EntityTimelineEventUpdate(BaseModel):
"""Schema for updating a timeline event."""
event_type: Optional[str] = Field(None, max_length=100)
event_date: Optional[date] = None
event_date_precision: Optional[str] = None
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
from_entity_id: Optional[UUID] = None
to_entity_id: Optional[UUID] = None
from_location_id: Optional[UUID] = None
to_location_id: Optional[UUID] = None
from_value: Optional[str] = None
to_value: Optional[str] = None
is_public: Optional[bool] = None
display_order: Optional[int] = None
class EntityTimelineEventListOut(BaseModel):
"""Paginated timeline event list response."""
items: List[EntityTimelineEventOut]
total: int
page: int
page_size: int
total_pages: int
class TimelineStatsOut(BaseModel):
"""Statistics about timeline events."""
total_events: int
public_events: int
event_types: dict # {event_type: count}
earliest_event: Optional[date] = None
latest_event: Optional[date] = None
# ============================================================================
# Report Schemas
# ============================================================================
class ReportCreate(BaseModel):
"""Schema for creating a report."""
entity_type: str = Field(..., max_length=50, description="Type of entity being reported")
entity_id: UUID = Field(..., description="ID of entity being reported")
report_type: str = Field(..., description="Type of report: inappropriate, inaccurate, spam, duplicate, copyright, other")
description: str = Field(..., min_length=1, description="Description of the issue")
@field_validator('report_type')
def validate_report_type(cls, v):
valid_types = ['inappropriate', 'inaccurate', 'spam', 'duplicate', 'copyright', 'other']
if v not in valid_types:
raise ValueError(f'report_type must be one of: {", ".join(valid_types)}')
return v
class ReportUpdate(BaseModel):
"""Schema for updating a report (moderators only)."""
status: Optional[str] = Field(None, description="Status: pending, reviewing, resolved, dismissed")
reviewed_by_id: Optional[UUID] = None
reviewed_at: Optional[datetime] = None
resolution_notes: Optional[str] = Field(None, description="Moderation notes")
@field_validator('status')
def validate_status(cls, v):
if v and v not in ['pending', 'reviewing', 'resolved', 'dismissed']:
raise ValueError('status must be pending, reviewing, resolved, or dismissed')
return v
class ReportOut(BaseModel):
"""Schema for report output."""
id: UUID
entity_type: str
entity_id: UUID
report_type: str
description: str
status: str
reported_by_id: UUID
reported_by_email: Optional[str] = None
reviewed_by_id: Optional[UUID] = None
reviewed_by_email: Optional[str] = None
reviewed_at: Optional[datetime] = None
resolution_notes: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ReportListOut(BaseModel):
"""Paginated report list response."""
items: List[ReportOut]
total: int
page: int
page_size: int
total_pages: int
class ReportStatsOut(BaseModel):
"""Statistics about reports."""
total_reports: int
pending_reports: int
reviewing_reports: int
resolved_reports: int
dismissed_reports: int
reports_by_type: dict # {report_type: count}
reports_by_entity_type: dict # {entity_type: count}
average_resolution_time_hours: Optional[float] = None
# ============================================================================
# Contact Schemas
# ============================================================================
class ContactSubmissionCreate(BaseModel):
"""Schema for creating a contact submission."""
name: str = Field(..., min_length=1, max_length=255, description="Contact name")
email: str = Field(..., description="Contact email address")
subject: str = Field(..., min_length=1, max_length=255, description="Subject")
message: str = Field(..., min_length=10, description="Message (min 10 characters)")
category: str = Field(..., description="Category: general, bug, feature, abuse, data, account, other")
@field_validator('category')
def validate_category(cls, v):
valid_categories = ['general', 'bug', 'feature', 'abuse', 'data', 'account', 'other']
if v not in valid_categories:
raise ValueError(f'category must be one of: {", ".join(valid_categories)}')
return v
class ContactSubmissionUpdate(BaseModel):
"""Schema for updating a contact submission (moderators only)."""
status: Optional[str] = Field(None, description="Status: pending, in_progress, resolved, archived")
assigned_to_id: Optional[UUID] = Field(None, description="User ID to assign to")
admin_notes: Optional[str] = Field(None, description="Internal admin notes")
@field_validator('status')
def validate_status(cls, v):
if v and v not in ['pending', 'in_progress', 'resolved', 'archived']:
raise ValueError('status must be pending, in_progress, resolved, or archived')
return v
class ContactSubmissionOut(BaseModel):
"""Schema for contact submission output."""
id: UUID
ticket_number: str
name: str
email: str
subject: str
message: str
category: str
status: str
user_id: Optional[UUID] = None
user_email: Optional[str] = None
assigned_to_id: Optional[UUID] = None
assigned_to_email: Optional[str] = None
admin_notes: Optional[str] = None
resolved_at: Optional[datetime] = None
resolved_by_id: Optional[UUID] = None
resolved_by_email: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ContactSubmissionListOut(BaseModel):
"""Paginated contact submission list response."""
items: List[ContactSubmissionOut]
total: int
page: int
page_size: int
total_pages: int
class ContactSubmissionStatsOut(BaseModel):
"""Statistics about contact submissions."""
total_submissions: int
pending_submissions: int
in_progress_submissions: int
resolved_submissions: int
archived_submissions: int
submissions_by_category: dict # {category: count}
average_resolution_time_hours: Optional[float] = None
recent_submissions: List[ContactSubmissionOut]