""" 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")