mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
- Implement CRUD operations for ride credits, allowing users to log rides, track counts, and view statistics. - Create endpoints for managing user-created ranked lists of parks, rides, or coasters with custom rankings and notes. - Introduce pagination for both ride credits and top lists. - Ensure proper authentication and authorization for modifying user-specific data. - Add serialization methods for ride credits and top lists to return structured data. - Include error handling and logging for better traceability of operations.
1224 lines
38 KiB
Python
1224 lines
38 KiB
Python
"""
|
|
Pydantic schemas for API v1 endpoints.
|
|
|
|
These schemas define the structure of request and response data for the REST API.
|
|
"""
|
|
from datetime import date, datetime
|
|
from typing import Optional, List
|
|
from decimal import Decimal
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from uuid import UUID
|
|
|
|
|
|
# ============================================================================
|
|
# Base Schemas
|
|
# ============================================================================
|
|
|
|
class TimestampSchema(BaseModel):
|
|
"""Base schema with timestamps."""
|
|
created: datetime
|
|
modified: datetime
|
|
|
|
|
|
# ============================================================================
|
|
# Company Schemas
|
|
# ============================================================================
|
|
|
|
class CompanyBase(BaseModel):
|
|
"""Base company schema with common fields."""
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
company_types: List[str] = Field(default_factory=list)
|
|
founded_date: Optional[date] = None
|
|
founded_date_precision: str = Field(default='day')
|
|
closed_date: Optional[date] = None
|
|
closed_date_precision: str = Field(default='day')
|
|
website: Optional[str] = None
|
|
logo_image_id: Optional[str] = None
|
|
logo_image_url: Optional[str] = None
|
|
|
|
|
|
class CompanyCreate(CompanyBase):
|
|
"""Schema for creating a company."""
|
|
pass
|
|
|
|
|
|
class CompanyUpdate(BaseModel):
|
|
"""Schema for updating a company (all fields optional)."""
|
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
company_types: Optional[List[str]] = None
|
|
founded_date: Optional[date] = None
|
|
founded_date_precision: Optional[str] = None
|
|
closed_date: Optional[date] = None
|
|
closed_date_precision: Optional[str] = None
|
|
website: Optional[str] = None
|
|
logo_image_id: Optional[str] = None
|
|
logo_image_url: Optional[str] = None
|
|
|
|
|
|
class CompanyOut(CompanyBase, TimestampSchema):
|
|
"""Schema for company output."""
|
|
id: UUID
|
|
slug: str
|
|
park_count: int
|
|
ride_count: int
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# RideModel Schemas
|
|
# ============================================================================
|
|
|
|
class RideModelBase(BaseModel):
|
|
"""Base ride model schema."""
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
manufacturer_id: UUID
|
|
model_type: str
|
|
typical_height: Optional[Decimal] = None
|
|
typical_speed: Optional[Decimal] = None
|
|
typical_capacity: Optional[int] = None
|
|
image_id: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
class RideModelCreate(RideModelBase):
|
|
"""Schema for creating a ride model."""
|
|
pass
|
|
|
|
|
|
class RideModelUpdate(BaseModel):
|
|
"""Schema for updating a ride model (all fields optional)."""
|
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
manufacturer_id: Optional[UUID] = None
|
|
model_type: Optional[str] = None
|
|
typical_height: Optional[Decimal] = None
|
|
typical_speed: Optional[Decimal] = None
|
|
typical_capacity: Optional[int] = None
|
|
image_id: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
class RideModelOut(RideModelBase, TimestampSchema):
|
|
"""Schema for ride model output."""
|
|
id: UUID
|
|
slug: str
|
|
installation_count: int
|
|
manufacturer_name: Optional[str] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# Park Schemas
|
|
# ============================================================================
|
|
|
|
class ParkBase(BaseModel):
|
|
"""Base park schema."""
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
park_type: str
|
|
status: str = Field(default='operating')
|
|
opening_date: Optional[date] = None
|
|
opening_date_precision: str = Field(default='day')
|
|
closing_date: Optional[date] = None
|
|
closing_date_precision: str = Field(default='day')
|
|
latitude: Optional[Decimal] = None
|
|
longitude: Optional[Decimal] = None
|
|
operator_id: Optional[UUID] = None
|
|
website: Optional[str] = None
|
|
banner_image_id: Optional[str] = None
|
|
banner_image_url: Optional[str] = None
|
|
logo_image_id: Optional[str] = None
|
|
logo_image_url: Optional[str] = None
|
|
|
|
|
|
class ParkCreate(ParkBase):
|
|
"""Schema for creating a park."""
|
|
pass
|
|
|
|
|
|
class ParkUpdate(BaseModel):
|
|
"""Schema for updating a park (all fields optional)."""
|
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
park_type: Optional[str] = None
|
|
status: Optional[str] = None
|
|
opening_date: Optional[date] = None
|
|
opening_date_precision: Optional[str] = None
|
|
closing_date: Optional[date] = None
|
|
closing_date_precision: Optional[str] = None
|
|
latitude: Optional[Decimal] = None
|
|
longitude: Optional[Decimal] = None
|
|
operator_id: Optional[UUID] = None
|
|
website: Optional[str] = None
|
|
banner_image_id: Optional[str] = None
|
|
banner_image_url: Optional[str] = None
|
|
logo_image_id: Optional[str] = None
|
|
logo_image_url: Optional[str] = None
|
|
|
|
|
|
class ParkOut(ParkBase, TimestampSchema):
|
|
"""Schema for park output."""
|
|
id: UUID
|
|
slug: str
|
|
ride_count: int
|
|
coaster_count: int
|
|
operator_name: Optional[str] = None
|
|
coordinates: Optional[tuple[float, float]] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# Ride Schemas
|
|
# ============================================================================
|
|
|
|
class RideBase(BaseModel):
|
|
"""Base ride schema."""
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
park_id: UUID
|
|
ride_category: str
|
|
ride_type: Optional[str] = None
|
|
is_coaster: bool = Field(default=False)
|
|
status: str = Field(default='operating')
|
|
opening_date: Optional[date] = None
|
|
opening_date_precision: str = Field(default='day')
|
|
closing_date: Optional[date] = None
|
|
closing_date_precision: str = Field(default='day')
|
|
manufacturer_id: Optional[UUID] = None
|
|
model_id: Optional[UUID] = None
|
|
height: Optional[Decimal] = None
|
|
speed: Optional[Decimal] = None
|
|
length: Optional[Decimal] = None
|
|
duration: Optional[int] = None
|
|
inversions: Optional[int] = None
|
|
capacity: Optional[int] = None
|
|
image_id: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
class RideCreate(RideBase):
|
|
"""Schema for creating a ride."""
|
|
pass
|
|
|
|
|
|
class RideUpdate(BaseModel):
|
|
"""Schema for updating a ride (all fields optional)."""
|
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
park_id: Optional[UUID] = None
|
|
ride_category: Optional[str] = None
|
|
ride_type: Optional[str] = None
|
|
is_coaster: Optional[bool] = None
|
|
status: Optional[str] = None
|
|
opening_date: Optional[date] = None
|
|
opening_date_precision: Optional[str] = None
|
|
closing_date: Optional[date] = None
|
|
closing_date_precision: Optional[str] = None
|
|
manufacturer_id: Optional[UUID] = None
|
|
model_id: Optional[UUID] = None
|
|
height: Optional[Decimal] = None
|
|
speed: Optional[Decimal] = None
|
|
length: Optional[Decimal] = None
|
|
duration: Optional[int] = None
|
|
inversions: Optional[int] = None
|
|
capacity: Optional[int] = None
|
|
image_id: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
class RideOut(RideBase, TimestampSchema):
|
|
"""Schema for ride output."""
|
|
id: UUID
|
|
slug: str
|
|
park_name: Optional[str] = None
|
|
manufacturer_name: Optional[str] = None
|
|
model_name: Optional[str] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# Pagination Schemas
|
|
# ============================================================================
|
|
|
|
class PaginatedResponse(BaseModel):
|
|
"""Generic paginated response schema."""
|
|
items: List
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
class CompanyListOut(BaseModel):
|
|
"""Paginated company list response."""
|
|
items: List[CompanyOut]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
class RideModelListOut(BaseModel):
|
|
"""Paginated ride model list response."""
|
|
items: List[RideModelOut]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
class ParkListOut(BaseModel):
|
|
"""Paginated park list response."""
|
|
items: List[ParkOut]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
class RideListOut(BaseModel):
|
|
"""Paginated ride list response."""
|
|
items: List[RideOut]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
# ============================================================================
|
|
# Error Schemas
|
|
# ============================================================================
|
|
|
|
class ErrorResponse(BaseModel):
|
|
"""Standard error response schema."""
|
|
detail: str
|
|
code: Optional[str] = None
|
|
|
|
|
|
class ValidationErrorResponse(BaseModel):
|
|
"""Validation error response schema."""
|
|
detail: str
|
|
errors: Optional[List[dict]] = None
|
|
|
|
|
|
# ============================================================================
|
|
# Moderation Schemas
|
|
# ============================================================================
|
|
|
|
class SubmissionItemBase(BaseModel):
|
|
"""Base submission item schema."""
|
|
field_name: str = Field(..., min_length=1, max_length=100)
|
|
field_label: Optional[str] = None
|
|
old_value: Optional[dict] = None
|
|
new_value: Optional[dict] = None
|
|
change_type: str = Field(default='modify')
|
|
is_required: bool = Field(default=False)
|
|
order: int = Field(default=0)
|
|
|
|
|
|
class SubmissionItemCreate(SubmissionItemBase):
|
|
"""Schema for creating a submission item."""
|
|
pass
|
|
|
|
|
|
class SubmissionItemOut(SubmissionItemBase, TimestampSchema):
|
|
"""Schema for submission item output."""
|
|
id: UUID
|
|
submission_id: UUID
|
|
status: str
|
|
reviewed_by_id: Optional[UUID] = None
|
|
reviewed_by_email: Optional[str] = None
|
|
reviewed_at: Optional[datetime] = None
|
|
rejection_reason: Optional[str] = None
|
|
old_value_display: str
|
|
new_value_display: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ContentSubmissionBase(BaseModel):
|
|
"""Base content submission schema."""
|
|
submission_type: str
|
|
title: str = Field(..., min_length=1, max_length=255)
|
|
description: Optional[str] = None
|
|
entity_type: str
|
|
entity_id: UUID
|
|
|
|
|
|
class ContentSubmissionCreate(BaseModel):
|
|
"""Schema for creating a content submission."""
|
|
entity_type: str = Field(..., description="Entity type (park, ride, company, ridemodel)")
|
|
entity_id: UUID = Field(..., description="ID of entity being modified")
|
|
submission_type: str = Field(..., description="Operation type (create, update, delete)")
|
|
title: str = Field(..., min_length=1, max_length=255, description="Brief description")
|
|
description: Optional[str] = Field(None, description="Detailed description")
|
|
items: List[SubmissionItemCreate] = Field(..., min_items=1, description="List of changes")
|
|
metadata: Optional[dict] = Field(default_factory=dict)
|
|
auto_submit: bool = Field(default=True, description="Auto-submit for review")
|
|
|
|
|
|
class ContentSubmissionOut(TimestampSchema):
|
|
"""Schema for content submission output."""
|
|
id: UUID
|
|
status: str
|
|
submission_type: str
|
|
title: str
|
|
description: Optional[str] = None
|
|
entity_type: str
|
|
entity_id: UUID
|
|
user_id: UUID
|
|
user_email: str
|
|
locked_by_id: Optional[UUID] = None
|
|
locked_by_email: Optional[str] = None
|
|
locked_at: Optional[datetime] = None
|
|
reviewed_by_id: Optional[UUID] = None
|
|
reviewed_by_email: Optional[str] = None
|
|
reviewed_at: Optional[datetime] = None
|
|
rejection_reason: Optional[str] = None
|
|
source: str
|
|
metadata: dict
|
|
items_count: int
|
|
approved_items_count: int
|
|
rejected_items_count: int
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ContentSubmissionDetail(ContentSubmissionOut):
|
|
"""Detailed submission with items."""
|
|
items: List[SubmissionItemOut]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class StartReviewRequest(BaseModel):
|
|
"""Schema for starting a review."""
|
|
pass # No additional fields needed
|
|
|
|
|
|
class ApproveRequest(BaseModel):
|
|
"""Schema for approving a submission."""
|
|
pass # No additional fields needed
|
|
|
|
|
|
class ApproveSelectiveRequest(BaseModel):
|
|
"""Schema for selective approval."""
|
|
item_ids: List[UUID] = Field(..., min_items=1, description="List of item IDs to approve")
|
|
|
|
|
|
class RejectRequest(BaseModel):
|
|
"""Schema for rejecting a submission."""
|
|
reason: str = Field(..., min_length=1, description="Reason for rejection")
|
|
|
|
|
|
class RejectSelectiveRequest(BaseModel):
|
|
"""Schema for selective rejection."""
|
|
item_ids: List[UUID] = Field(..., min_items=1, description="List of item IDs to reject")
|
|
reason: Optional[str] = Field(None, description="Reason for rejection")
|
|
|
|
|
|
class ApprovalResponse(BaseModel):
|
|
"""Response for approval operations."""
|
|
success: bool
|
|
message: str
|
|
submission: ContentSubmissionOut
|
|
|
|
|
|
class SelectiveApprovalResponse(BaseModel):
|
|
"""Response for selective approval."""
|
|
success: bool
|
|
message: str
|
|
approved: int
|
|
total: int
|
|
pending: int
|
|
submission_approved: bool
|
|
|
|
|
|
class SelectiveRejectionResponse(BaseModel):
|
|
"""Response for selective rejection."""
|
|
success: bool
|
|
message: str
|
|
rejected: int
|
|
total: int
|
|
pending: int
|
|
submission_complete: bool
|
|
|
|
|
|
class SubmissionListOut(BaseModel):
|
|
"""Paginated submission list response."""
|
|
items: List[ContentSubmissionOut]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
# ============================================================================
|
|
# Versioning Schemas
|
|
# ============================================================================
|
|
|
|
class EntityVersionSchema(TimestampSchema):
|
|
"""Schema for entity version output."""
|
|
id: UUID
|
|
entity_type: str
|
|
entity_id: UUID
|
|
entity_name: str
|
|
version_number: int
|
|
change_type: str
|
|
snapshot: dict
|
|
changed_fields: dict
|
|
changed_by_id: Optional[UUID] = None
|
|
changed_by_email: Optional[str] = None
|
|
submission_id: Optional[UUID] = None
|
|
comment: Optional[str] = None
|
|
diff_summary: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class VersionHistoryResponseSchema(BaseModel):
|
|
"""Response schema for version history."""
|
|
entity_id: str
|
|
entity_type: str
|
|
entity_name: str
|
|
total_versions: int
|
|
versions: List[EntityVersionSchema]
|
|
|
|
|
|
class VersionDiffSchema(BaseModel):
|
|
"""Schema for version diff response."""
|
|
entity_id: str
|
|
entity_type: str
|
|
entity_name: str
|
|
version_number: int
|
|
version_date: datetime
|
|
differences: dict
|
|
changed_field_count: int
|
|
|
|
|
|
class VersionComparisonSchema(BaseModel):
|
|
"""Schema for comparing two versions."""
|
|
version1: EntityVersionSchema
|
|
version2: EntityVersionSchema
|
|
differences: dict
|
|
changed_field_count: int
|
|
|
|
|
|
# ============================================================================
|
|
# Generic Utility Schemas
|
|
# ============================================================================
|
|
|
|
class MessageSchema(BaseModel):
|
|
"""Generic message response."""
|
|
message: str
|
|
success: bool = True
|
|
|
|
|
|
class ErrorSchema(BaseModel):
|
|
"""Standard error response."""
|
|
error: str
|
|
detail: Optional[str] = None
|
|
|
|
|
|
# ============================================================================
|
|
# Authentication Schemas
|
|
# ============================================================================
|
|
|
|
class UserBase(BaseModel):
|
|
"""Base user schema."""
|
|
email: str = Field(..., description="Email address")
|
|
username: Optional[str] = Field(None, description="Username")
|
|
first_name: Optional[str] = Field(None, max_length=150)
|
|
last_name: Optional[str] = Field(None, max_length=150)
|
|
|
|
|
|
class UserRegisterRequest(BaseModel):
|
|
"""Schema for user registration."""
|
|
email: str = Field(..., description="Email address")
|
|
password: str = Field(..., min_length=8, description="Password (min 8 characters)")
|
|
password_confirm: str = Field(..., description="Password confirmation")
|
|
username: Optional[str] = Field(None, description="Username (auto-generated if not provided)")
|
|
first_name: Optional[str] = Field(None, max_length=150)
|
|
last_name: Optional[str] = Field(None, max_length=150)
|
|
|
|
@field_validator('password_confirm')
|
|
def passwords_match(cls, v, info):
|
|
if 'password' in info.data and v != info.data['password']:
|
|
raise ValueError('Passwords do not match')
|
|
return v
|
|
|
|
|
|
class UserLoginRequest(BaseModel):
|
|
"""Schema for user login."""
|
|
email: str = Field(..., description="Email address")
|
|
password: str = Field(..., description="Password")
|
|
mfa_token: Optional[str] = Field(None, description="MFA token if enabled")
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
"""Schema for token response."""
|
|
access: str = Field(..., description="JWT access token")
|
|
refresh: str = Field(..., description="JWT refresh token")
|
|
token_type: str = Field(default="Bearer")
|
|
|
|
|
|
class TokenRefreshRequest(BaseModel):
|
|
"""Schema for token refresh."""
|
|
refresh: str = Field(..., description="Refresh token")
|
|
|
|
|
|
class UserProfileOut(BaseModel):
|
|
"""Schema for user profile output."""
|
|
id: UUID
|
|
email: str
|
|
username: str
|
|
first_name: str
|
|
last_name: str
|
|
display_name: str
|
|
avatar_url: Optional[str] = None
|
|
bio: Optional[str] = None
|
|
reputation_score: int
|
|
mfa_enabled: bool
|
|
banned: bool
|
|
date_joined: datetime
|
|
last_login: Optional[datetime] = None
|
|
oauth_provider: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class UserProfileUpdate(BaseModel):
|
|
"""Schema for updating user profile."""
|
|
first_name: Optional[str] = Field(None, max_length=150)
|
|
last_name: Optional[str] = Field(None, max_length=150)
|
|
username: Optional[str] = Field(None, max_length=150)
|
|
bio: Optional[str] = Field(None, max_length=500)
|
|
avatar_url: Optional[str] = None
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
"""Schema for password change."""
|
|
old_password: str = Field(..., description="Current password")
|
|
new_password: str = Field(..., min_length=8, description="New password")
|
|
new_password_confirm: str = Field(..., description="New password confirmation")
|
|
|
|
@field_validator('new_password_confirm')
|
|
def passwords_match(cls, v, info):
|
|
if 'new_password' in info.data and v != info.data['new_password']:
|
|
raise ValueError('Passwords do not match')
|
|
return v
|
|
|
|
|
|
class ResetPasswordRequest(BaseModel):
|
|
"""Schema for password reset."""
|
|
email: str = Field(..., description="Email address")
|
|
|
|
|
|
class ResetPasswordConfirm(BaseModel):
|
|
"""Schema for password reset confirmation."""
|
|
token: str = Field(..., description="Reset token")
|
|
password: str = Field(..., min_length=8, description="New password")
|
|
password_confirm: str = Field(..., description="Password confirmation")
|
|
|
|
@field_validator('password_confirm')
|
|
def passwords_match(cls, v, info):
|
|
if 'password' in info.data and v != info.data['password']:
|
|
raise ValueError('Passwords do not match')
|
|
return v
|
|
|
|
|
|
class UserRoleOut(BaseModel):
|
|
"""Schema for user role output."""
|
|
role: str
|
|
is_moderator: bool
|
|
is_admin: bool
|
|
granted_at: datetime
|
|
granted_by_email: Optional[str] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class UserPermissionsOut(BaseModel):
|
|
"""Schema for user permissions."""
|
|
can_submit: bool
|
|
can_moderate: bool
|
|
can_admin: bool
|
|
can_edit_own: bool
|
|
can_delete_own: bool
|
|
|
|
|
|
class UserStatsOut(BaseModel):
|
|
"""Schema for user statistics."""
|
|
total_submissions: int
|
|
approved_submissions: int
|
|
reputation_score: int
|
|
member_since: datetime
|
|
last_active: Optional[datetime] = None
|
|
|
|
|
|
class UserProfilePreferencesOut(BaseModel):
|
|
"""Schema for user preferences."""
|
|
email_notifications: bool
|
|
email_on_submission_approved: bool
|
|
email_on_submission_rejected: bool
|
|
profile_public: bool
|
|
show_email: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class UserProfilePreferencesUpdate(BaseModel):
|
|
"""Schema for updating user preferences."""
|
|
email_notifications: Optional[bool] = None
|
|
email_on_submission_approved: Optional[bool] = None
|
|
email_on_submission_rejected: Optional[bool] = None
|
|
profile_public: Optional[bool] = None
|
|
show_email: Optional[bool] = None
|
|
|
|
|
|
class TOTPEnableResponse(BaseModel):
|
|
"""Schema for TOTP enable response."""
|
|
secret: str = Field(..., description="TOTP secret key")
|
|
qr_code_url: str = Field(..., description="QR code URL for authenticator apps")
|
|
backup_codes: List[str] = Field(default_factory=list, description="Backup codes")
|
|
|
|
|
|
class TOTPConfirmRequest(BaseModel):
|
|
"""Schema for TOTP confirmation."""
|
|
token: str = Field(..., min_length=6, max_length=6, description="6-digit TOTP token")
|
|
|
|
|
|
class TOTPVerifyRequest(BaseModel):
|
|
"""Schema for TOTP verification."""
|
|
token: str = Field(..., min_length=6, max_length=6, description="6-digit TOTP token")
|
|
|
|
|
|
class BanUserRequest(BaseModel):
|
|
"""Schema for banning a user."""
|
|
user_id: UUID = Field(..., description="User ID to ban")
|
|
reason: str = Field(..., min_length=1, description="Reason for ban")
|
|
|
|
|
|
class UnbanUserRequest(BaseModel):
|
|
"""Schema for unbanning a user."""
|
|
user_id: UUID = Field(..., description="User ID to unban")
|
|
|
|
|
|
class AssignRoleRequest(BaseModel):
|
|
"""Schema for assigning a role."""
|
|
user_id: UUID = Field(..., description="User ID")
|
|
role: str = Field(..., description="Role to assign (user, moderator, admin)")
|
|
|
|
|
|
class UserListOut(BaseModel):
|
|
"""Paginated user list response."""
|
|
items: List[UserProfileOut]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
# ============================================================================
|
|
# Photo/Media Schemas
|
|
# ============================================================================
|
|
|
|
class PhotoBase(BaseModel):
|
|
"""Base photo schema."""
|
|
title: Optional[str] = Field(None, max_length=255)
|
|
description: Optional[str] = None
|
|
credit: Optional[str] = Field(None, max_length=255, description="Photo credit/attribution")
|
|
photo_type: str = Field(default='gallery', description="Type: main, gallery, banner, logo, thumbnail, other")
|
|
is_visible: bool = Field(default=True)
|
|
|
|
|
|
class PhotoUploadRequest(PhotoBase):
|
|
"""Schema for photo upload request (form data)."""
|
|
entity_type: Optional[str] = Field(None, description="Entity type to attach to")
|
|
entity_id: Optional[UUID] = Field(None, description="Entity ID to attach to")
|
|
|
|
|
|
class PhotoUpdate(BaseModel):
|
|
"""Schema for updating photo metadata."""
|
|
title: Optional[str] = Field(None, max_length=255)
|
|
description: Optional[str] = None
|
|
credit: Optional[str] = Field(None, max_length=255)
|
|
photo_type: Optional[str] = None
|
|
is_visible: Optional[bool] = None
|
|
display_order: Optional[int] = None
|
|
|
|
|
|
class PhotoOut(PhotoBase, TimestampSchema):
|
|
"""Schema for photo output."""
|
|
id: UUID
|
|
cloudflare_image_id: str
|
|
cloudflare_url: str
|
|
uploaded_by_id: UUID
|
|
uploaded_by_email: Optional[str] = None
|
|
moderation_status: str
|
|
moderated_by_id: Optional[UUID] = None
|
|
moderated_by_email: Optional[str] = None
|
|
moderated_at: Optional[datetime] = None
|
|
moderation_notes: Optional[str] = None
|
|
entity_type: Optional[str] = None
|
|
entity_id: Optional[str] = None
|
|
entity_name: Optional[str] = None
|
|
width: int
|
|
height: int
|
|
file_size: int
|
|
mime_type: str
|
|
display_order: int
|
|
|
|
# Generated URLs for different variants
|
|
thumbnail_url: Optional[str] = None
|
|
banner_url: Optional[str] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class PhotoListOut(BaseModel):
|
|
"""Paginated photo list response."""
|
|
items: List[PhotoOut]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
class PhotoUploadResponse(BaseModel):
|
|
"""Response for photo upload."""
|
|
success: bool
|
|
message: str
|
|
photo: PhotoOut
|
|
|
|
|
|
class PhotoModerateRequest(BaseModel):
|
|
"""Schema for moderating a photo."""
|
|
status: str = Field(..., description="Status: approved, rejected, flagged")
|
|
notes: Optional[str] = Field(None, description="Moderation notes")
|
|
|
|
|
|
class PhotoReorderRequest(BaseModel):
|
|
"""Schema for reordering photos."""
|
|
photo_ids: List[int] = Field(..., min_items=1, description="Ordered list of photo IDs")
|
|
photo_type: Optional[str] = Field(None, description="Optional photo type filter")
|
|
|
|
|
|
class PhotoAttachRequest(BaseModel):
|
|
"""Schema for attaching photo to entity."""
|
|
photo_id: UUID = Field(..., description="Photo ID to attach")
|
|
photo_type: Optional[str] = Field('gallery', description="Photo type")
|
|
|
|
|
|
class PhotoStatsOut(BaseModel):
|
|
"""Statistics about photos."""
|
|
total_photos: int
|
|
pending_photos: int
|
|
approved_photos: int
|
|
rejected_photos: int
|
|
flagged_photos: int
|
|
total_size_mb: float
|
|
|
|
|
|
# ============================================================================
|
|
# Search Schemas
|
|
# ============================================================================
|
|
|
|
class SearchResultBase(BaseModel):
|
|
"""Base schema for search results."""
|
|
id: UUID
|
|
name: str
|
|
slug: str
|
|
entity_type: str
|
|
description: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
class CompanySearchResult(SearchResultBase):
|
|
"""Company search result."""
|
|
company_types: List[str] = Field(default_factory=list)
|
|
park_count: int = 0
|
|
ride_count: int = 0
|
|
|
|
|
|
class RideModelSearchResult(SearchResultBase):
|
|
"""Ride model search result."""
|
|
manufacturer_name: str
|
|
model_type: str
|
|
installation_count: int = 0
|
|
|
|
|
|
class ParkSearchResult(SearchResultBase):
|
|
"""Park search result."""
|
|
park_type: str
|
|
status: str
|
|
operator_name: Optional[str] = None
|
|
ride_count: int = 0
|
|
coaster_count: int = 0
|
|
coordinates: Optional[tuple[float, float]] = None
|
|
|
|
|
|
class RideSearchResult(SearchResultBase):
|
|
"""Ride search result."""
|
|
park_name: str
|
|
park_slug: str
|
|
manufacturer_name: Optional[str] = None
|
|
ride_category: str
|
|
status: str
|
|
is_coaster: bool
|
|
|
|
|
|
class GlobalSearchResponse(BaseModel):
|
|
"""Response for global search across all entities."""
|
|
query: str
|
|
total_results: int
|
|
companies: List[CompanySearchResult] = Field(default_factory=list)
|
|
ride_models: List[RideModelSearchResult] = Field(default_factory=list)
|
|
parks: List[ParkSearchResult] = Field(default_factory=list)
|
|
rides: List[RideSearchResult] = Field(default_factory=list)
|
|
|
|
|
|
class AutocompleteItem(BaseModel):
|
|
"""Single autocomplete suggestion."""
|
|
id: UUID
|
|
name: str
|
|
slug: str
|
|
entity_type: str
|
|
park_name: Optional[str] = None # For rides
|
|
manufacturer_name: Optional[str] = None # For ride models
|
|
|
|
|
|
class AutocompleteResponse(BaseModel):
|
|
"""Response for autocomplete suggestions."""
|
|
query: str
|
|
suggestions: List[AutocompleteItem]
|
|
|
|
|
|
class SearchFilters(BaseModel):
|
|
"""Base filters for search operations."""
|
|
q: str = Field(..., min_length=2, max_length=200, description="Search query")
|
|
entity_types: Optional[List[str]] = Field(None, description="Filter by entity types")
|
|
limit: int = Field(20, ge=1, le=100, description="Maximum results per entity type")
|
|
|
|
|
|
class CompanySearchFilters(BaseModel):
|
|
"""Filters for company search."""
|
|
q: str = Field(..., min_length=2, max_length=200, description="Search query")
|
|
company_types: Optional[List[str]] = Field(None, description="Filter by company types")
|
|
founded_after: Optional[date] = Field(None, description="Founded after date")
|
|
founded_before: Optional[date] = Field(None, description="Founded before date")
|
|
limit: int = Field(20, ge=1, le=100)
|
|
|
|
|
|
class RideModelSearchFilters(BaseModel):
|
|
"""Filters for ride model search."""
|
|
q: str = Field(..., min_length=2, max_length=200, description="Search query")
|
|
manufacturer_id: Optional[UUID] = Field(None, description="Filter by manufacturer")
|
|
model_type: Optional[str] = Field(None, description="Filter by model type")
|
|
limit: int = Field(20, ge=1, le=100)
|
|
|
|
|
|
class ParkSearchFilters(BaseModel):
|
|
"""Filters for park search."""
|
|
q: str = Field(..., min_length=2, max_length=200, description="Search query")
|
|
status: Optional[str] = Field(None, description="Filter by status")
|
|
park_type: Optional[str] = Field(None, description="Filter by park type")
|
|
operator_id: Optional[UUID] = Field(None, description="Filter by operator")
|
|
opening_after: Optional[date] = Field(None, description="Opened after date")
|
|
opening_before: Optional[date] = Field(None, description="Opened before date")
|
|
latitude: Optional[float] = Field(None, description="Search center latitude")
|
|
longitude: Optional[float] = Field(None, description="Search center longitude")
|
|
radius: Optional[float] = Field(None, ge=0, le=500, description="Search radius in km")
|
|
limit: int = Field(20, ge=1, le=100)
|
|
|
|
|
|
class RideSearchFilters(BaseModel):
|
|
"""Filters for ride search."""
|
|
q: str = Field(..., min_length=2, max_length=200, description="Search query")
|
|
park_id: Optional[UUID] = Field(None, description="Filter by park")
|
|
manufacturer_id: Optional[UUID] = Field(None, description="Filter by manufacturer")
|
|
model_id: Optional[UUID] = Field(None, description="Filter by model")
|
|
status: Optional[str] = Field(None, description="Filter by status")
|
|
ride_category: Optional[str] = Field(None, description="Filter by category")
|
|
is_coaster: Optional[bool] = Field(None, description="Filter coasters only")
|
|
opening_after: Optional[date] = Field(None, description="Opened after date")
|
|
opening_before: Optional[date] = Field(None, description="Opened before date")
|
|
min_height: Optional[Decimal] = Field(None, description="Minimum height in feet")
|
|
max_height: Optional[Decimal] = Field(None, description="Maximum height in feet")
|
|
min_speed: Optional[Decimal] = Field(None, description="Minimum speed in mph")
|
|
max_speed: Optional[Decimal] = Field(None, description="Maximum speed in mph")
|
|
limit: int = Field(20, ge=1, le=100)
|
|
|
|
|
|
# ============================================================================
|
|
# 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")
|