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