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