Add email templates for user notifications and account management

- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
This commit is contained in:
pacnpal
2025-11-08 15:34:04 -05:00
parent 9c46ef8b03
commit d6ff4cc3a3
335 changed files with 61926 additions and 73 deletions

3
django/api/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
REST API package for ThrillWiki Django backend.
"""

Binary file not shown.

View File

@@ -0,0 +1,3 @@
"""
API v1 package.
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

158
django/api/v1/api.py Normal file
View File

@@ -0,0 +1,158 @@
"""
Main API v1 router.
This module combines all endpoint routers and provides the main API interface.
"""
from ninja import NinjaAPI
from ninja.security import django_auth
from .endpoints.companies import router as companies_router
from .endpoints.ride_models import router as ride_models_router
from .endpoints.parks import router as parks_router
from .endpoints.rides import router as rides_router
from .endpoints.moderation import router as moderation_router
from .endpoints.versioning import router as versioning_router
from .endpoints.auth import router as auth_router
from .endpoints.photos import router as photos_router
from .endpoints.search import router as search_router
# Create the main API instance
api = NinjaAPI(
title="ThrillWiki API",
version="1.0.0",
description="""
# ThrillWiki REST API
A comprehensive API for amusement park, ride, and company data.
## Features
- **Companies**: Manufacturers, operators, and designers in the amusement industry
- **Ride Models**: Specific ride models from manufacturers
- **Parks**: Theme parks, amusement parks, water parks, and FECs
- **Rides**: Individual rides and roller coasters
## Authentication
The API uses JWT (JSON Web Token) authentication for secure access.
### Getting Started
1. Register: `POST /api/v1/auth/register`
2. Login: `POST /api/v1/auth/login` (returns access & refresh tokens)
3. Use token: Include `Authorization: Bearer <access_token>` header in requests
4. Refresh: `POST /api/v1/auth/token/refresh` when access token expires
### Permissions
- **Public**: Read operations (GET) on entities
- **Authenticated**: Create submissions, manage own profile
- **Moderator**: Approve/reject submissions, moderate content
- **Admin**: Full access, user management, role assignment
### Optional: Multi-Factor Authentication (MFA)
Users can enable TOTP-based 2FA for enhanced security:
1. Enable: `POST /api/v1/auth/mfa/enable`
2. Confirm: `POST /api/v1/auth/mfa/confirm`
3. Login with MFA: Include `mfa_token` in login request
## Pagination
List endpoints return paginated results:
- Default page size: 50 items
- Use `page` parameter to navigate (e.g., `?page=2`)
## Filtering & Search
Most list endpoints support filtering and search parameters.
See individual endpoint documentation for available filters.
## Geographic Search
The parks endpoint includes a special `/parks/nearby/` endpoint for geographic searches:
- **Production (PostGIS)**: Uses accurate distance-based queries
- **Local Development (SQLite)**: Uses bounding box approximation
## Rate Limiting
Rate limiting will be implemented in future versions.
## Data Format
All dates are in ISO 8601 format (YYYY-MM-DD).
All timestamps are in ISO 8601 format with timezone.
UUIDs are used for all entity IDs.
""",
docs_url="/docs",
openapi_url="/openapi.json",
)
# Add authentication router
api.add_router("/auth", auth_router)
# Add routers for each entity
api.add_router("/companies", companies_router)
api.add_router("/ride-models", ride_models_router)
api.add_router("/parks", parks_router)
api.add_router("/rides", rides_router)
# Add moderation router
api.add_router("/moderation", moderation_router)
# Add versioning router
api.add_router("", versioning_router) # Versioning endpoints are nested under entity paths
# Add photos router
api.add_router("", photos_router) # Photos endpoints include both /photos and entity-nested routes
# Add search router
api.add_router("/search", search_router)
# Health check endpoint
@api.get("/health", tags=["System"], summary="Health check")
def health_check(request):
"""
Health check endpoint.
Returns system status and API version.
"""
return {
"status": "healthy",
"version": "1.0.0",
"api": "ThrillWiki API v1"
}
# API info endpoint
@api.get("/info", tags=["System"], summary="API information")
def api_info(request):
"""
Get API information and statistics.
Returns basic API metadata and available endpoints.
"""
from apps.entities.models import Company, RideModel, Park, Ride
return {
"version": "1.0.0",
"title": "ThrillWiki API",
"endpoints": {
"auth": "/api/v1/auth/",
"companies": "/api/v1/companies/",
"ride_models": "/api/v1/ride-models/",
"parks": "/api/v1/parks/",
"rides": "/api/v1/rides/",
"moderation": "/api/v1/moderation/",
"photos": "/api/v1/photos/",
"search": "/api/v1/search/",
},
"statistics": {
"companies": Company.objects.count(),
"ride_models": RideModel.objects.count(),
"parks": Park.objects.count(),
"rides": Ride.objects.count(),
"coasters": Ride.objects.filter(is_coaster=True).count(),
},
"documentation": "/api/v1/docs",
"openapi_schema": "/api/v1/openapi.json",
}

View File

@@ -0,0 +1,3 @@
"""
API v1 endpoints package.
"""

View File

@@ -0,0 +1,596 @@
"""
Authentication API endpoints.
Provides endpoints for:
- User registration and login
- JWT token management
- MFA/2FA
- Password management
- User profile and preferences
- User administration
"""
from typing import List, Optional
from django.http import HttpRequest
from django.core.exceptions import ValidationError, PermissionDenied
from django.db.models import Q
from ninja import Router
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
import logging
from apps.users.models import User, UserRole, UserProfile
from apps.users.services import (
AuthenticationService,
MFAService,
RoleService,
UserManagementService
)
from apps.users.permissions import (
jwt_auth,
require_auth,
require_admin,
get_permission_checker
)
from api.v1.schemas import (
UserRegisterRequest,
UserLoginRequest,
TokenResponse,
TokenRefreshRequest,
UserProfileOut,
UserProfileUpdate,
ChangePasswordRequest,
ResetPasswordRequest,
TOTPEnableResponse,
TOTPConfirmRequest,
TOTPVerifyRequest,
UserRoleOut,
UserPermissionsOut,
UserStatsOut,
UserProfilePreferencesOut,
UserProfilePreferencesUpdate,
BanUserRequest,
UnbanUserRequest,
AssignRoleRequest,
UserListOut,
MessageSchema,
ErrorSchema,
)
router = Router(tags=["Authentication"])
logger = logging.getLogger(__name__)
# ============================================================================
# Public Authentication Endpoints
# ============================================================================
@router.post("/register", response={201: UserProfileOut, 400: ErrorSchema})
def register(request: HttpRequest, data: UserRegisterRequest):
"""
Register a new user account.
- **email**: User's email address (required)
- **password**: Password (min 8 characters, required)
- **password_confirm**: Password confirmation (required)
- **username**: Username (optional, auto-generated if not provided)
- **first_name**: First name (optional)
- **last_name**: Last name (optional)
Returns the created user profile and automatically logs in the user.
"""
try:
# Register user
user = AuthenticationService.register_user(
email=data.email,
password=data.password,
username=data.username,
first_name=data.first_name or '',
last_name=data.last_name or ''
)
logger.info(f"New user registered: {user.email}")
return 201, user
except ValidationError as e:
error_msg = str(e.message_dict) if hasattr(e, 'message_dict') else str(e)
return 400, {"error": "Registration failed", "detail": error_msg}
except Exception as e:
logger.error(f"Registration error: {e}")
return 400, {"error": "Registration failed", "detail": str(e)}
@router.post("/login", response={200: TokenResponse, 401: ErrorSchema})
def login(request: HttpRequest, data: UserLoginRequest):
"""
Login with email and password.
- **email**: User's email address
- **password**: Password
- **mfa_token**: MFA token (required if MFA is enabled)
Returns JWT access and refresh tokens on successful authentication.
"""
try:
# Authenticate user
user = AuthenticationService.authenticate_user(data.email, data.password)
if not user:
return 401, {"error": "Invalid credentials", "detail": "Email or password is incorrect"}
# Check MFA if enabled
if user.mfa_enabled:
if not data.mfa_token:
return 401, {"error": "MFA required", "detail": "Please provide MFA token"}
if not MFAService.verify_totp(user, data.mfa_token):
return 401, {"error": "Invalid MFA token", "detail": "The MFA token is invalid"}
# Generate tokens
refresh = RefreshToken.for_user(user)
return 200, {
"access": str(refresh.access_token),
"refresh": str(refresh),
"token_type": "Bearer"
}
except ValidationError as e:
return 401, {"error": "Authentication failed", "detail": str(e)}
except Exception as e:
logger.error(f"Login error: {e}")
return 401, {"error": "Authentication failed", "detail": str(e)}
@router.post("/token/refresh", response={200: TokenResponse, 401: ErrorSchema})
def refresh_token(request: HttpRequest, data: TokenRefreshRequest):
"""
Refresh JWT access token using refresh token.
- **refresh**: Refresh token
Returns new access token and optionally a new refresh token.
"""
try:
refresh = RefreshToken(data.refresh)
return 200, {
"access": str(refresh.access_token),
"refresh": str(refresh),
"token_type": "Bearer"
}
except TokenError as e:
return 401, {"error": "Invalid token", "detail": str(e)}
except Exception as e:
logger.error(f"Token refresh error: {e}")
return 401, {"error": "Token refresh failed", "detail": str(e)}
@router.post("/logout", auth=jwt_auth, response={200: MessageSchema})
@require_auth
def logout(request: HttpRequest):
"""
Logout (blacklist refresh token).
Note: Requires authentication. The client should also discard the access token.
"""
# Note: Token blacklisting is handled by djangorestframework-simplejwt
# when BLACKLIST_AFTER_ROTATION is True in settings
return 200, {"message": "Logged out successfully", "success": True}
# ============================================================================
# User Profile Endpoints
# ============================================================================
@router.get("/me", auth=jwt_auth, response={200: UserProfileOut, 401: ErrorSchema})
@require_auth
def get_my_profile(request: HttpRequest):
"""
Get current user's profile.
Returns detailed profile information for the authenticated user.
"""
user = request.auth
return 200, user
@router.patch("/me", auth=jwt_auth, response={200: UserProfileOut, 400: ErrorSchema})
@require_auth
def update_my_profile(request: HttpRequest, data: UserProfileUpdate):
"""
Update current user's profile.
- **first_name**: First name (optional)
- **last_name**: Last name (optional)
- **username**: Username (optional)
- **bio**: User biography (optional, max 500 characters)
- **avatar_url**: Avatar image URL (optional)
"""
try:
user = request.auth
# Prepare update data
update_data = data.dict(exclude_unset=True)
# Update profile
updated_user = UserManagementService.update_profile(user, **update_data)
return 200, updated_user
except ValidationError as e:
return 400, {"error": "Update failed", "detail": str(e)}
except Exception as e:
logger.error(f"Profile update error: {e}")
return 400, {"error": "Update failed", "detail": str(e)}
@router.get("/me/role", auth=jwt_auth, response={200: UserRoleOut, 404: ErrorSchema})
@require_auth
def get_my_role(request: HttpRequest):
"""
Get current user's role.
Returns role information including permissions.
"""
try:
user = request.auth
role = user.role
response_data = {
"role": role.role,
"is_moderator": role.is_moderator,
"is_admin": role.is_admin,
"granted_at": role.granted_at,
"granted_by_email": role.granted_by.email if role.granted_by else None
}
return 200, response_data
except UserRole.DoesNotExist:
return 404, {"error": "Role not found", "detail": "User role not assigned"}
@router.get("/me/permissions", auth=jwt_auth, response={200: UserPermissionsOut})
@require_auth
def get_my_permissions(request: HttpRequest):
"""
Get current user's permissions.
Returns a summary of what the user can do.
"""
user = request.auth
permissions = RoleService.get_user_permissions(user)
return 200, permissions
@router.get("/me/stats", auth=jwt_auth, response={200: UserStatsOut})
@require_auth
def get_my_stats(request: HttpRequest):
"""
Get current user's statistics.
Returns submission stats, reputation score, and activity information.
"""
user = request.auth
stats = UserManagementService.get_user_stats(user)
return 200, stats
# ============================================================================
# User Preferences Endpoints
# ============================================================================
@router.get("/me/preferences", auth=jwt_auth, response={200: UserProfilePreferencesOut})
@require_auth
def get_my_preferences(request: HttpRequest):
"""
Get current user's preferences.
Returns notification and privacy preferences.
"""
user = request.auth
profile = user.profile
return 200, profile
@router.patch("/me/preferences", auth=jwt_auth, response={200: UserProfilePreferencesOut, 400: ErrorSchema})
@require_auth
def update_my_preferences(request: HttpRequest, data: UserProfilePreferencesUpdate):
"""
Update current user's preferences.
- **email_notifications**: Receive email notifications
- **email_on_submission_approved**: Email when submissions approved
- **email_on_submission_rejected**: Email when submissions rejected
- **profile_public**: Make profile publicly visible
- **show_email**: Show email on public profile
"""
try:
user = request.auth
# Prepare update data
update_data = data.dict(exclude_unset=True)
# Update preferences
updated_profile = UserManagementService.update_preferences(user, **update_data)
return 200, updated_profile
except Exception as e:
logger.error(f"Preferences update error: {e}")
return 400, {"error": "Update failed", "detail": str(e)}
# ============================================================================
# Password Management Endpoints
# ============================================================================
@router.post("/password/change", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema})
@require_auth
def change_password(request: HttpRequest, data: ChangePasswordRequest):
"""
Change current user's password.
- **old_password**: Current password (required)
- **new_password**: New password (min 8 characters, required)
- **new_password_confirm**: New password confirmation (required)
"""
try:
user = request.auth
AuthenticationService.change_password(
user=user,
old_password=data.old_password,
new_password=data.new_password
)
return 200, {"message": "Password changed successfully", "success": True}
except ValidationError as e:
error_msg = str(e.message_dict) if hasattr(e, 'message_dict') else str(e)
return 400, {"error": "Password change failed", "detail": error_msg}
@router.post("/password/reset", response={200: MessageSchema})
def request_password_reset(request: HttpRequest, data: ResetPasswordRequest):
"""
Request password reset email.
- **email**: User's email address
Note: This is a placeholder. In production, this should send a reset email.
For now, it returns success regardless of whether the email exists.
"""
# TODO: Implement email sending with password reset token
# For security, always return success even if email doesn't exist
return 200, {
"message": "If the email exists, a password reset link has been sent",
"success": True
}
# ============================================================================
# MFA/2FA Endpoints
# ============================================================================
@router.post("/mfa/enable", auth=jwt_auth, response={200: TOTPEnableResponse, 400: ErrorSchema})
@require_auth
def enable_mfa(request: HttpRequest):
"""
Enable MFA/2FA for current user.
Returns TOTP secret and QR code URL for authenticator apps.
User must confirm with a valid token to complete setup.
"""
try:
user = request.auth
# Create TOTP device
device = MFAService.enable_totp(user)
# Generate QR code URL
issuer = "ThrillWiki"
qr_url = device.config_url
return 200, {
"secret": device.key,
"qr_code_url": qr_url,
"backup_codes": [] # TODO: Generate backup codes
}
except Exception as e:
logger.error(f"MFA enable error: {e}")
return 400, {"error": "MFA setup failed", "detail": str(e)}
@router.post("/mfa/confirm", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema})
@require_auth
def confirm_mfa(request: HttpRequest, data: TOTPConfirmRequest):
"""
Confirm MFA setup with verification token.
- **token**: 6-digit TOTP token from authenticator app
Completes MFA setup after verifying the token is valid.
"""
try:
user = request.auth
MFAService.confirm_totp(user, data.token)
return 200, {"message": "MFA enabled successfully", "success": True}
except ValidationError as e:
return 400, {"error": "Confirmation failed", "detail": str(e)}
@router.post("/mfa/disable", auth=jwt_auth, response={200: MessageSchema})
@require_auth
def disable_mfa(request: HttpRequest):
"""
Disable MFA/2FA for current user.
Removes all TOTP devices and disables MFA requirement.
"""
user = request.auth
MFAService.disable_totp(user)
return 200, {"message": "MFA disabled successfully", "success": True}
@router.post("/mfa/verify", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema})
@require_auth
def verify_mfa_token(request: HttpRequest, data: TOTPVerifyRequest):
"""
Verify MFA token (for testing).
- **token**: 6-digit TOTP token
Returns whether the token is valid.
"""
user = request.auth
if MFAService.verify_totp(user, data.token):
return 200, {"message": "Token is valid", "success": True}
else:
return 400, {"error": "Invalid token", "detail": "The token is not valid"}
# ============================================================================
# User Management Endpoints (Admin Only)
# ============================================================================
@router.get("/users", auth=jwt_auth, response={200: UserListOut, 403: ErrorSchema})
@require_admin
def list_users(
request: HttpRequest,
page: int = 1,
page_size: int = 50,
search: Optional[str] = None,
role: Optional[str] = None,
banned: Optional[bool] = None
):
"""
List all users (admin only).
- **page**: Page number (default: 1)
- **page_size**: Items per page (default: 50, max: 100)
- **search**: Search by email or username
- **role**: Filter by role (user, moderator, admin)
- **banned**: Filter by banned status
"""
# Build query
queryset = User.objects.select_related('role').all()
# Apply filters
if search:
queryset = queryset.filter(
Q(email__icontains=search) |
Q(username__icontains=search) |
Q(first_name__icontains=search) |
Q(last_name__icontains=search)
)
if role:
queryset = queryset.filter(role__role=role)
if banned is not None:
queryset = queryset.filter(banned=banned)
# Pagination
page_size = min(page_size, 100) # Max 100 items per page
total = queryset.count()
total_pages = (total + page_size - 1) // page_size
start = (page - 1) * page_size
end = start + page_size
users = list(queryset[start:end])
return 200, {
"items": users,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages
}
@router.get("/users/{user_id}", auth=jwt_auth, response={200: UserProfileOut, 404: ErrorSchema})
@require_admin
def get_user(request: HttpRequest, user_id: str):
"""
Get user by ID (admin only).
Returns detailed profile information for the specified user.
"""
try:
user = User.objects.get(id=user_id)
return 200, user
except User.DoesNotExist:
return 404, {"error": "User not found", "detail": f"No user with ID {user_id}"}
@router.post("/users/ban", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema})
@require_admin
def ban_user(request: HttpRequest, data: BanUserRequest):
"""
Ban a user (admin only).
- **user_id**: User ID to ban
- **reason**: Reason for ban
"""
try:
user = User.objects.get(id=data.user_id)
admin = request.auth
UserManagementService.ban_user(user, data.reason, admin)
return 200, {"message": f"User {user.email} has been banned", "success": True}
except User.DoesNotExist:
return 400, {"error": "User not found", "detail": f"No user with ID {data.user_id}"}
@router.post("/users/unban", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema})
@require_admin
def unban_user(request: HttpRequest, data: UnbanUserRequest):
"""
Unban a user (admin only).
- **user_id**: User ID to unban
"""
try:
user = User.objects.get(id=data.user_id)
UserManagementService.unban_user(user)
return 200, {"message": f"User {user.email} has been unbanned", "success": True}
except User.DoesNotExist:
return 400, {"error": "User not found", "detail": f"No user with ID {data.user_id}"}
@router.post("/users/assign-role", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema})
@require_admin
def assign_role(request: HttpRequest, data: AssignRoleRequest):
"""
Assign role to user (admin only).
- **user_id**: User ID
- **role**: Role to assign (user, moderator, admin)
"""
try:
user = User.objects.get(id=data.user_id)
admin = request.auth
RoleService.assign_role(user, data.role, admin)
return 200, {"message": f"Role '{data.role}' assigned to {user.email}", "success": True}
except User.DoesNotExist:
return 400, {"error": "User not found", "detail": f"No user with ID {data.user_id}"}
except ValidationError as e:
return 400, {"error": "Invalid role", "detail": str(e)}

View File

@@ -0,0 +1,254 @@
"""
Company endpoints for API v1.
Provides CRUD operations for Company entities with filtering and search.
"""
from typing import List, Optional
from uuid import UUID
from django.shortcuts import get_object_or_404
from django.db.models import Q
from ninja import Router, Query
from ninja.pagination import paginate, PageNumberPagination
from apps.entities.models import Company
from ..schemas import (
CompanyCreate,
CompanyUpdate,
CompanyOut,
CompanyListOut,
ErrorResponse
)
router = Router(tags=["Companies"])
class CompanyPagination(PageNumberPagination):
"""Custom pagination for companies."""
page_size = 50
@router.get(
"/",
response={200: List[CompanyOut]},
summary="List companies",
description="Get a paginated list of companies with optional filtering"
)
@paginate(CompanyPagination)
def list_companies(
request,
search: Optional[str] = Query(None, description="Search by company name"),
company_type: Optional[str] = Query(None, description="Filter by company type"),
location_id: Optional[UUID] = Query(None, description="Filter by location"),
ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)")
):
"""
List all companies with optional filters.
**Filters:**
- search: Search company names (case-insensitive partial match)
- company_type: Filter by specific company type
- location_id: Filter by headquarters location
- ordering: Sort results (default: -created)
**Returns:** Paginated list of companies
"""
queryset = Company.objects.all()
# Apply search filter
if search:
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Apply company type filter
if company_type:
queryset = queryset.filter(company_types__contains=[company_type])
# Apply location filter
if location_id:
queryset = queryset.filter(location_id=location_id)
# Apply ordering
valid_order_fields = ['name', 'created', 'modified', 'founded_date', 'park_count', 'ride_count']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
return queryset
@router.get(
"/{company_id}",
response={200: CompanyOut, 404: ErrorResponse},
summary="Get company",
description="Retrieve a single company by ID"
)
def get_company(request, company_id: UUID):
"""
Get a company by ID.
**Parameters:**
- company_id: UUID of the company
**Returns:** Company details
"""
company = get_object_or_404(Company, id=company_id)
return company
@router.post(
"/",
response={201: CompanyOut, 400: ErrorResponse},
summary="Create company",
description="Create a new company (requires authentication)"
)
def create_company(request, payload: CompanyCreate):
"""
Create a new company.
**Authentication:** Required
**Parameters:**
- payload: Company data
**Returns:** Created company
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
company = Company.objects.create(**payload.dict())
return 201, company
@router.put(
"/{company_id}",
response={200: CompanyOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Update company",
description="Update an existing company (requires authentication)"
)
def update_company(request, company_id: UUID, payload: CompanyUpdate):
"""
Update a company.
**Authentication:** Required
**Parameters:**
- company_id: UUID of the company
- payload: Updated company data
**Returns:** Updated company
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
company = get_object_or_404(Company, id=company_id)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(company, key, value)
company.save()
return company
@router.patch(
"/{company_id}",
response={200: CompanyOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Partial update company",
description="Partially update an existing company (requires authentication)"
)
def partial_update_company(request, company_id: UUID, payload: CompanyUpdate):
"""
Partially update a company.
**Authentication:** Required
**Parameters:**
- company_id: UUID of the company
- payload: Fields to update
**Returns:** Updated company
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
company = get_object_or_404(Company, id=company_id)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(company, key, value)
company.save()
return company
@router.delete(
"/{company_id}",
response={204: None, 404: ErrorResponse},
summary="Delete company",
description="Delete a company (requires authentication)"
)
def delete_company(request, company_id: UUID):
"""
Delete a company.
**Authentication:** Required
**Parameters:**
- company_id: UUID of the company
**Returns:** No content (204)
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
company = get_object_or_404(Company, id=company_id)
company.delete()
return 204, None
@router.get(
"/{company_id}/parks",
response={200: List[dict], 404: ErrorResponse},
summary="Get company parks",
description="Get all parks operated by a company"
)
def get_company_parks(request, company_id: UUID):
"""
Get parks operated by a company.
**Parameters:**
- company_id: UUID of the company
**Returns:** List of parks
"""
company = get_object_or_404(Company, id=company_id)
parks = company.operated_parks.all().values('id', 'name', 'slug', 'status', 'park_type')
return list(parks)
@router.get(
"/{company_id}/rides",
response={200: List[dict], 404: ErrorResponse},
summary="Get company rides",
description="Get all rides manufactured by a company"
)
def get_company_rides(request, company_id: UUID):
"""
Get rides manufactured by a company.
**Parameters:**
- company_id: UUID of the company
**Returns:** List of rides
"""
company = get_object_or_404(Company, id=company_id)
rides = company.manufactured_rides.all().values('id', 'name', 'slug', 'status', 'ride_category')
return list(rides)

View File

@@ -0,0 +1,496 @@
"""
Moderation API endpoints.
Provides REST API for content submission and moderation workflow.
"""
from typing import List, Optional
from uuid import UUID
from ninja import Router
from django.shortcuts import get_object_or_404
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError, PermissionDenied
from apps.moderation.models import ContentSubmission, SubmissionItem
from apps.moderation.services import ModerationService
from api.v1.schemas import (
ContentSubmissionCreate,
ContentSubmissionOut,
ContentSubmissionDetail,
SubmissionListOut,
StartReviewRequest,
ApproveRequest,
ApproveSelectiveRequest,
RejectRequest,
RejectSelectiveRequest,
ApprovalResponse,
SelectiveApprovalResponse,
SelectiveRejectionResponse,
ErrorResponse,
)
router = Router(tags=['Moderation'])
# ============================================================================
# Helper Functions
# ============================================================================
def _submission_to_dict(submission: ContentSubmission) -> dict:
"""Convert submission model to dict for schema."""
return {
'id': submission.id,
'status': submission.status,
'submission_type': submission.submission_type,
'title': submission.title,
'description': submission.description or '',
'entity_type': submission.entity_type.model,
'entity_id': submission.entity_id,
'user_id': submission.user.id,
'user_email': submission.user.email,
'locked_by_id': submission.locked_by.id if submission.locked_by else None,
'locked_by_email': submission.locked_by.email if submission.locked_by else None,
'locked_at': submission.locked_at,
'reviewed_by_id': submission.reviewed_by.id if submission.reviewed_by else None,
'reviewed_by_email': submission.reviewed_by.email if submission.reviewed_by else None,
'reviewed_at': submission.reviewed_at,
'rejection_reason': submission.rejection_reason or '',
'source': submission.source,
'metadata': submission.metadata,
'items_count': submission.get_items_count(),
'approved_items_count': submission.get_approved_items_count(),
'rejected_items_count': submission.get_rejected_items_count(),
'created': submission.created,
'modified': submission.modified,
}
def _item_to_dict(item: SubmissionItem) -> dict:
"""Convert submission item model to dict for schema."""
return {
'id': item.id,
'submission_id': item.submission.id,
'field_name': item.field_name,
'field_label': item.field_label or item.field_name,
'old_value': item.old_value,
'new_value': item.new_value,
'change_type': item.change_type,
'is_required': item.is_required,
'order': item.order,
'status': item.status,
'reviewed_by_id': item.reviewed_by.id if item.reviewed_by else None,
'reviewed_by_email': item.reviewed_by.email if item.reviewed_by else None,
'reviewed_at': item.reviewed_at,
'rejection_reason': item.rejection_reason or '',
'old_value_display': item.old_value_display,
'new_value_display': item.new_value_display,
'created': item.created,
'modified': item.modified,
}
def _get_entity(entity_type: str, entity_id: UUID):
"""Get entity instance from type string and ID."""
# Map entity type strings to models
type_map = {
'park': 'entities.Park',
'ride': 'entities.Ride',
'company': 'entities.Company',
'ridemodel': 'entities.RideModel',
}
app_label, model = type_map.get(entity_type.lower(), '').split('.')
content_type = ContentType.objects.get(app_label=app_label, model=model.lower())
model_class = content_type.model_class()
return get_object_or_404(model_class, id=entity_id)
# ============================================================================
# Submission Endpoints
# ============================================================================
@router.post('/submissions', response={201: ContentSubmissionOut, 400: ErrorResponse, 401: ErrorResponse})
def create_submission(request, data: ContentSubmissionCreate):
"""
Create a new content submission.
Creates a submission with multiple items representing field changes.
If auto_submit is True, the submission is immediately moved to pending state.
"""
# TODO: Require authentication
# For now, use a test user or get from request
from apps.users.models import User
user = User.objects.first() # TEMP: Get first user for testing
if not user:
return 401, {'detail': 'Authentication required'}
try:
# Get entity
entity = _get_entity(data.entity_type, data.entity_id)
# Prepare items data
items_data = [
{
'field_name': item.field_name,
'field_label': item.field_label,
'old_value': item.old_value,
'new_value': item.new_value,
'change_type': item.change_type,
'is_required': item.is_required,
'order': item.order,
}
for item in data.items
]
# Create submission
submission = ModerationService.create_submission(
user=user,
entity=entity,
submission_type=data.submission_type,
title=data.title,
description=data.description or '',
items_data=items_data,
metadata=data.metadata,
auto_submit=data.auto_submit,
source='api'
)
return 201, _submission_to_dict(submission)
except Exception as e:
return 400, {'detail': str(e)}
@router.get('/submissions', response=SubmissionListOut)
def list_submissions(
request,
status: Optional[str] = None,
page: int = 1,
page_size: int = 50
):
"""
List content submissions with optional filtering.
Query Parameters:
- status: Filter by status (draft, pending, reviewing, approved, rejected)
- page: Page number (default: 1)
- page_size: Items per page (default: 50, max: 100)
"""
# Validate page_size
page_size = min(page_size, 100)
offset = (page - 1) * page_size
# Get submissions
submissions = ModerationService.get_queue(
status=status,
limit=page_size,
offset=offset
)
# Get total count
total_queryset = ContentSubmission.objects.all()
if status:
total_queryset = total_queryset.filter(status=status)
total = total_queryset.count()
# Calculate total pages
total_pages = (total + page_size - 1) // page_size
# Convert to dicts
items = [_submission_to_dict(sub) for sub in submissions]
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': total_pages,
}
@router.get('/submissions/{submission_id}', response={200: ContentSubmissionDetail, 404: ErrorResponse})
def get_submission(request, submission_id: UUID):
"""
Get detailed submission information with all items.
"""
try:
submission = ModerationService.get_submission_details(submission_id)
# Convert to dict with items
data = _submission_to_dict(submission)
data['items'] = [_item_to_dict(item) for item in submission.items.all()]
return 200, data
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
@router.delete('/submissions/{submission_id}', response={204: None, 403: ErrorResponse, 404: ErrorResponse})
def delete_submission(request, submission_id: UUID):
"""
Delete a submission (only if draft/pending and owned by user).
"""
# TODO: Get current user from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
ModerationService.delete_submission(submission_id, user)
return 204, None
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
# ============================================================================
# Review Endpoints
# ============================================================================
@router.post(
'/submissions/{submission_id}/start-review',
response={200: ContentSubmissionOut, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def start_review(request, submission_id: UUID, data: StartReviewRequest):
"""
Start reviewing a submission (lock it for 15 minutes).
Only moderators can start reviews.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
submission = ModerationService.start_review(submission_id, user)
return 200, _submission_to_dict(submission)
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/approve',
response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def approve_submission(request, submission_id: UUID, data: ApproveRequest):
"""
Approve an entire submission and apply all changes.
Uses atomic transactions - all changes are applied or none are.
Only moderators can approve submissions.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
submission = ModerationService.approve_submission(submission_id, user)
return 200, {
'success': True,
'message': 'Submission approved successfully',
'submission': _submission_to_dict(submission)
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/approve-selective',
response={200: SelectiveApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def approve_selective(request, submission_id: UUID, data: ApproveSelectiveRequest):
"""
Approve only specific items in a submission.
Allows moderators to approve some changes while leaving others pending or rejected.
Uses atomic transactions for data integrity.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
result = ModerationService.approve_selective(
submission_id,
user,
[str(item_id) for item_id in data.item_ids]
)
return 200, {
'success': True,
'message': f"Approved {result['approved']} of {result['total']} items",
**result
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/reject',
response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def reject_submission(request, submission_id: UUID, data: RejectRequest):
"""
Reject an entire submission.
All pending items are rejected with the provided reason.
Only moderators can reject submissions.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
submission = ModerationService.reject_submission(submission_id, user, data.reason)
return 200, {
'success': True,
'message': 'Submission rejected',
'submission': _submission_to_dict(submission)
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/reject-selective',
response={200: SelectiveRejectionResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}
)
def reject_selective(request, submission_id: UUID, data: RejectSelectiveRequest):
"""
Reject only specific items in a submission.
Allows moderators to reject some changes while leaving others pending or approved.
"""
# TODO: Get current user (moderator) from request
from apps.users.models import User
user = User.objects.first() # TEMP
try:
result = ModerationService.reject_selective(
submission_id,
user,
[str(item_id) for item_id in data.item_ids],
data.reason or ''
)
return 200, {
'success': True,
'message': f"Rejected {result['rejected']} of {result['total']} items",
**result
}
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
except PermissionDenied as e:
return 403, {'detail': str(e)}
except ValidationError as e:
return 400, {'detail': str(e)}
@router.post(
'/submissions/{submission_id}/unlock',
response={200: ContentSubmissionOut, 404: ErrorResponse}
)
def unlock_submission(request, submission_id: UUID):
"""
Manually unlock a submission.
Removes the review lock. Can be used by moderators or automatically by cleanup tasks.
"""
try:
submission = ModerationService.unlock_submission(submission_id)
return 200, _submission_to_dict(submission)
except ContentSubmission.DoesNotExist:
return 404, {'detail': 'Submission not found'}
# ============================================================================
# Queue Endpoints
# ============================================================================
@router.get('/queue/pending', response=SubmissionListOut)
def get_pending_queue(request, page: int = 1, page_size: int = 50):
"""
Get pending submissions queue.
Returns all submissions awaiting review.
"""
return list_submissions(request, status='pending', page=page, page_size=page_size)
@router.get('/queue/reviewing', response=SubmissionListOut)
def get_reviewing_queue(request, page: int = 1, page_size: int = 50):
"""
Get submissions currently under review.
Returns all submissions being reviewed by moderators.
"""
return list_submissions(request, status='reviewing', page=page, page_size=page_size)
@router.get('/queue/my-submissions', response=SubmissionListOut)
def get_my_submissions(request, page: int = 1, page_size: int = 50):
"""
Get current user's submissions.
Returns all submissions created by the authenticated user.
"""
# TODO: Get current user from request
from apps.users.models import User
user = User.objects.first() # TEMP
# Validate page_size
page_size = min(page_size, 100)
offset = (page - 1) * page_size
# Get user's submissions
submissions = ModerationService.get_queue(
user=user,
limit=page_size,
offset=offset
)
# Get total count
total = ContentSubmission.objects.filter(user=user).count()
# Calculate total pages
total_pages = (total + page_size - 1) // page_size
# Convert to dicts
items = [_submission_to_dict(sub) for sub in submissions]
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': total_pages,
}

View File

@@ -0,0 +1,362 @@
"""
Park endpoints for API v1.
Provides CRUD operations for Park entities with filtering, search, and geographic queries.
Supports both SQLite (lat/lng) and PostGIS (location_point) modes.
"""
from typing import List, Optional
from uuid import UUID
from decimal import Decimal
from django.shortcuts import get_object_or_404
from django.db.models import Q
from django.conf import settings
from ninja import Router, Query
from ninja.pagination import paginate, PageNumberPagination
import math
from apps.entities.models import Park, Company, _using_postgis
from ..schemas import (
ParkCreate,
ParkUpdate,
ParkOut,
ParkListOut,
ErrorResponse
)
router = Router(tags=["Parks"])
class ParkPagination(PageNumberPagination):
"""Custom pagination for parks."""
page_size = 50
@router.get(
"/",
response={200: List[ParkOut]},
summary="List parks",
description="Get a paginated list of parks with optional filtering"
)
@paginate(ParkPagination)
def list_parks(
request,
search: Optional[str] = Query(None, description="Search by park name"),
park_type: Optional[str] = Query(None, description="Filter by park type"),
status: Optional[str] = Query(None, description="Filter by status"),
operator_id: Optional[UUID] = Query(None, description="Filter by operator"),
ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)")
):
"""
List all parks with optional filters.
**Filters:**
- search: Search park names (case-insensitive partial match)
- park_type: Filter by park type
- status: Filter by operational status
- operator_id: Filter by operator company
- ordering: Sort results (default: -created)
**Returns:** Paginated list of parks
"""
queryset = Park.objects.select_related('operator').all()
# Apply search filter
if search:
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Apply park type filter
if park_type:
queryset = queryset.filter(park_type=park_type)
# Apply status filter
if status:
queryset = queryset.filter(status=status)
# Apply operator filter
if operator_id:
queryset = queryset.filter(operator_id=operator_id)
# Apply ordering
valid_order_fields = ['name', 'created', 'modified', 'opening_date', 'ride_count', 'coaster_count']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
# Annotate with operator name
for park in queryset:
park.operator_name = park.operator.name if park.operator else None
return queryset
@router.get(
"/{park_id}",
response={200: ParkOut, 404: ErrorResponse},
summary="Get park",
description="Retrieve a single park by ID"
)
def get_park(request, park_id: UUID):
"""
Get a park by ID.
**Parameters:**
- park_id: UUID of the park
**Returns:** Park details
"""
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
park.operator_name = park.operator.name if park.operator else None
park.coordinates = park.coordinates
return park
@router.get(
"/nearby/",
response={200: List[ParkOut]},
summary="Find nearby parks",
description="Find parks within a radius of given coordinates. Uses PostGIS in production, bounding box in SQLite."
)
def find_nearby_parks(
request,
latitude: float = Query(..., description="Latitude coordinate"),
longitude: float = Query(..., description="Longitude coordinate"),
radius: float = Query(50, description="Search radius in kilometers"),
limit: int = Query(50, description="Maximum number of results")
):
"""
Find parks near a geographic point.
**Geographic Search Modes:**
- **PostGIS (Production)**: Uses accurate distance-based search with location_point field
- **SQLite (Local Dev)**: Uses bounding box approximation with latitude/longitude fields
**Parameters:**
- latitude: Center point latitude
- longitude: Center point longitude
- radius: Search radius in kilometers (default: 50)
- limit: Maximum results to return (default: 50)
**Returns:** List of nearby parks
"""
if _using_postgis:
# Use PostGIS for accurate distance-based search
try:
from django.contrib.gis.measure import D
from django.contrib.gis.geos import Point
user_point = Point(longitude, latitude, srid=4326)
nearby_parks = Park.objects.filter(
location_point__distance_lte=(user_point, D(km=radius))
).select_related('operator')[:limit]
except Exception as e:
return {"detail": f"Geographic search error: {str(e)}"}, 500
else:
# Use bounding box approximation for SQLite
# Calculate rough bounding box (1 degree ≈ 111 km at equator)
lat_offset = radius / 111.0
lng_offset = radius / (111.0 * math.cos(math.radians(latitude)))
min_lat = latitude - lat_offset
max_lat = latitude + lat_offset
min_lng = longitude - lng_offset
max_lng = longitude + lng_offset
nearby_parks = Park.objects.filter(
latitude__gte=Decimal(str(min_lat)),
latitude__lte=Decimal(str(max_lat)),
longitude__gte=Decimal(str(min_lng)),
longitude__lte=Decimal(str(max_lng))
).select_related('operator')[:limit]
# Annotate results
results = []
for park in nearby_parks:
park.operator_name = park.operator.name if park.operator else None
park.coordinates = park.coordinates
results.append(park)
return results
@router.post(
"/",
response={201: ParkOut, 400: ErrorResponse},
summary="Create park",
description="Create a new park (requires authentication)"
)
def create_park(request, payload: ParkCreate):
"""
Create a new park.
**Authentication:** Required
**Parameters:**
- payload: Park data
**Returns:** Created park
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
data = payload.dict()
# Extract coordinates to use set_location method
latitude = data.pop('latitude', None)
longitude = data.pop('longitude', None)
park = Park.objects.create(**data)
# Set location using helper method (handles both SQLite and PostGIS)
if latitude is not None and longitude is not None:
park.set_location(longitude, latitude)
park.save()
park.coordinates = park.coordinates
if park.operator:
park.operator_name = park.operator.name
return 201, park
@router.put(
"/{park_id}",
response={200: ParkOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Update park",
description="Update an existing park (requires authentication)"
)
def update_park(request, park_id: UUID, payload: ParkUpdate):
"""
Update a park.
**Authentication:** Required
**Parameters:**
- park_id: UUID of the park
- payload: Updated park data
**Returns:** Updated park
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
data = payload.dict(exclude_unset=True)
# Handle coordinates separately
latitude = data.pop('latitude', None)
longitude = data.pop('longitude', None)
# Update other fields
for key, value in data.items():
setattr(park, key, value)
# Update location if coordinates provided
if latitude is not None and longitude is not None:
park.set_location(longitude, latitude)
park.save()
park.operator_name = park.operator.name if park.operator else None
park.coordinates = park.coordinates
return park
@router.patch(
"/{park_id}",
response={200: ParkOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Partial update park",
description="Partially update an existing park (requires authentication)"
)
def partial_update_park(request, park_id: UUID, payload: ParkUpdate):
"""
Partially update a park.
**Authentication:** Required
**Parameters:**
- park_id: UUID of the park
- payload: Fields to update
**Returns:** Updated park
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
data = payload.dict(exclude_unset=True)
# Handle coordinates separately
latitude = data.pop('latitude', None)
longitude = data.pop('longitude', None)
# Update other fields
for key, value in data.items():
setattr(park, key, value)
# Update location if coordinates provided
if latitude is not None and longitude is not None:
park.set_location(longitude, latitude)
park.save()
park.operator_name = park.operator.name if park.operator else None
park.coordinates = park.coordinates
return park
@router.delete(
"/{park_id}",
response={204: None, 404: ErrorResponse},
summary="Delete park",
description="Delete a park (requires authentication)"
)
def delete_park(request, park_id: UUID):
"""
Delete a park.
**Authentication:** Required
**Parameters:**
- park_id: UUID of the park
**Returns:** No content (204)
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
park = get_object_or_404(Park, id=park_id)
park.delete()
return 204, None
@router.get(
"/{park_id}/rides",
response={200: List[dict], 404: ErrorResponse},
summary="Get park rides",
description="Get all rides at a park"
)
def get_park_rides(request, park_id: UUID):
"""
Get all rides at a park.
**Parameters:**
- park_id: UUID of the park
**Returns:** List of rides
"""
park = get_object_or_404(Park, id=park_id)
rides = park.rides.select_related('manufacturer').all().values(
'id', 'name', 'slug', 'status', 'ride_category', 'is_coaster', 'manufacturer__name'
)
return list(rides)

View File

@@ -0,0 +1,600 @@
"""
Photo management API endpoints.
Provides endpoints for photo upload, management, moderation, and entity attachment.
"""
import logging
from typing import List, Optional
from uuid import UUID
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import Q, Count, Sum
from django.http import HttpRequest
from ninja import Router, File, Form
from ninja.files import UploadedFile
from ninja.pagination import paginate
from api.v1.schemas import (
PhotoOut,
PhotoListOut,
PhotoUpdate,
PhotoUploadResponse,
PhotoModerateRequest,
PhotoReorderRequest,
PhotoAttachRequest,
PhotoStatsOut,
MessageSchema,
ErrorSchema,
)
from apps.media.models import Photo
from apps.media.services import PhotoService, CloudFlareError
from apps.media.validators import validate_image
from apps.users.permissions import jwt_auth, require_moderator, require_admin
from apps.entities.models import Park, Ride, Company, RideModel
logger = logging.getLogger(__name__)
router = Router(tags=["Photos"])
photo_service = PhotoService()
# ============================================================================
# Helper Functions
# ============================================================================
def serialize_photo(photo: Photo) -> dict:
"""
Serialize a Photo instance to dict for API response.
Args:
photo: Photo instance
Returns:
Dict with photo data
"""
# Get entity info if attached
entity_type = None
entity_id = None
entity_name = None
if photo.content_type and photo.object_id:
entity = photo.content_object
entity_type = photo.content_type.model
entity_id = str(photo.object_id)
entity_name = getattr(entity, 'name', str(entity)) if entity else None
# Generate variant URLs
cloudflare_service = photo_service.cloudflare
thumbnail_url = cloudflare_service.get_image_url(photo.cloudflare_image_id, 'thumbnail')
banner_url = cloudflare_service.get_image_url(photo.cloudflare_image_id, 'banner')
return {
'id': photo.id,
'cloudflare_image_id': photo.cloudflare_image_id,
'cloudflare_url': photo.cloudflare_url,
'title': photo.title,
'description': photo.description,
'credit': photo.credit,
'photo_type': photo.photo_type,
'is_visible': photo.is_visible,
'uploaded_by_id': photo.uploaded_by_id,
'uploaded_by_email': photo.uploaded_by.email if photo.uploaded_by else None,
'moderation_status': photo.moderation_status,
'moderated_by_id': photo.moderated_by_id,
'moderated_by_email': photo.moderated_by.email if photo.moderated_by else None,
'moderated_at': photo.moderated_at,
'moderation_notes': photo.moderation_notes,
'entity_type': entity_type,
'entity_id': entity_id,
'entity_name': entity_name,
'width': photo.width,
'height': photo.height,
'file_size': photo.file_size,
'mime_type': photo.mime_type,
'display_order': photo.display_order,
'thumbnail_url': thumbnail_url,
'banner_url': banner_url,
'created': photo.created_at,
'modified': photo.modified_at,
}
def get_entity_by_type(entity_type: str, entity_id: UUID):
"""
Get entity instance by type and ID.
Args:
entity_type: Entity type (park, ride, company, ridemodel)
entity_id: Entity UUID
Returns:
Entity instance
Raises:
ValueError: If entity type is invalid or not found
"""
entity_map = {
'park': Park,
'ride': Ride,
'company': Company,
'ridemodel': RideModel,
}
model = entity_map.get(entity_type.lower())
if not model:
raise ValueError(f"Invalid entity type: {entity_type}")
try:
return model.objects.get(id=entity_id)
except model.DoesNotExist:
raise ValueError(f"{entity_type} with ID {entity_id} not found")
# ============================================================================
# Public Endpoints
# ============================================================================
@router.get("/photos", response=List[PhotoOut], auth=None)
@paginate
def list_photos(
request: HttpRequest,
status: Optional[str] = None,
photo_type: Optional[str] = None,
entity_type: Optional[str] = None,
entity_id: Optional[UUID] = None,
):
"""
List approved photos (public endpoint).
Query Parameters:
- status: Filter by moderation status (defaults to 'approved')
- photo_type: Filter by photo type
- entity_type: Filter by entity type
- entity_id: Filter by entity ID
"""
queryset = Photo.objects.select_related(
'uploaded_by', 'moderated_by', 'content_type'
)
# Default to approved photos for public
if status:
queryset = queryset.filter(moderation_status=status)
else:
queryset = queryset.approved()
if photo_type:
queryset = queryset.filter(photo_type=photo_type)
if entity_type and entity_id:
try:
entity = get_entity_by_type(entity_type, entity_id)
content_type = ContentType.objects.get_for_model(entity)
queryset = queryset.filter(
content_type=content_type,
object_id=entity_id
)
except ValueError as e:
return []
queryset = queryset.filter(is_visible=True).order_by('display_order', '-created_at')
return queryset
@router.get("/photos/{photo_id}", response=PhotoOut, auth=None)
def get_photo(request: HttpRequest, photo_id: UUID):
"""
Get photo details by ID (public endpoint).
Only returns approved photos for non-authenticated users.
"""
try:
photo = Photo.objects.select_related(
'uploaded_by', 'moderated_by', 'content_type'
).get(id=photo_id)
# Only show approved photos to public
if not request.auth and photo.moderation_status != 'approved':
return 404, {"detail": "Photo not found"}
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.get("/{entity_type}/{entity_id}/photos", response=List[PhotoOut], auth=None)
def get_entity_photos(
request: HttpRequest,
entity_type: str,
entity_id: UUID,
photo_type: Optional[str] = None,
):
"""
Get photos for a specific entity (public endpoint).
Path Parameters:
- entity_type: Entity type (park, ride, company, ridemodel)
- entity_id: Entity UUID
Query Parameters:
- photo_type: Filter by photo type
"""
try:
entity = get_entity_by_type(entity_type, entity_id)
photos = photo_service.get_entity_photos(
entity,
photo_type=photo_type,
approved_only=not request.auth
)
return [serialize_photo(photo) for photo in photos]
except ValueError as e:
return 404, {"detail": str(e)}
# ============================================================================
# Authenticated Endpoints
# ============================================================================
@router.post("/photos/upload", response=PhotoUploadResponse, auth=jwt_auth)
def upload_photo(
request: HttpRequest,
file: UploadedFile = File(...),
title: Optional[str] = Form(None),
description: Optional[str] = Form(None),
credit: Optional[str] = Form(None),
photo_type: str = Form('gallery'),
entity_type: Optional[str] = Form(None),
entity_id: Optional[str] = Form(None),
):
"""
Upload a new photo.
Requires authentication. Photo enters moderation queue.
Form Data:
- file: Image file (required)
- title: Photo title
- description: Photo description
- credit: Photo credit/attribution
- photo_type: Type of photo (main, gallery, banner, logo, thumbnail, other)
- entity_type: Entity type to attach to (optional)
- entity_id: Entity ID to attach to (optional)
"""
user = request.auth
try:
# Validate image
validate_image(file, photo_type)
# Get entity if provided
entity = None
if entity_type and entity_id:
try:
entity = get_entity_by_type(entity_type, UUID(entity_id))
except (ValueError, TypeError) as e:
return 400, {"detail": f"Invalid entity: {str(e)}"}
# Create photo
photo = photo_service.create_photo(
file=file,
user=user,
entity=entity,
photo_type=photo_type,
title=title or file.name,
description=description or '',
credit=credit or '',
is_visible=True,
)
return {
'success': True,
'message': 'Photo uploaded successfully and pending moderation',
'photo': serialize_photo(photo),
}
except DjangoValidationError as e:
return 400, {"detail": str(e)}
except CloudFlareError as e:
logger.error(f"CloudFlare upload failed: {str(e)}")
return 500, {"detail": "Failed to upload image"}
except Exception as e:
logger.error(f"Photo upload failed: {str(e)}")
return 500, {"detail": "An error occurred during upload"}
@router.patch("/photos/{photo_id}", response=PhotoOut, auth=jwt_auth)
def update_photo(
request: HttpRequest,
photo_id: UUID,
payload: PhotoUpdate,
):
"""
Update photo metadata.
Users can only update their own photos.
Moderators can update any photo.
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
# Check permissions
if photo.uploaded_by_id != user.id and not user.is_moderator:
return 403, {"detail": "Permission denied"}
# Update fields
update_fields = []
if payload.title is not None:
photo.title = payload.title
update_fields.append('title')
if payload.description is not None:
photo.description = payload.description
update_fields.append('description')
if payload.credit is not None:
photo.credit = payload.credit
update_fields.append('credit')
if payload.photo_type is not None:
photo.photo_type = payload.photo_type
update_fields.append('photo_type')
if payload.is_visible is not None:
photo.is_visible = payload.is_visible
update_fields.append('is_visible')
if payload.display_order is not None:
photo.display_order = payload.display_order
update_fields.append('display_order')
if update_fields:
photo.save(update_fields=update_fields)
logger.info(f"Photo {photo_id} updated by user {user.id}")
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.delete("/photos/{photo_id}", response=MessageSchema, auth=jwt_auth)
def delete_photo(request: HttpRequest, photo_id: UUID):
"""
Delete own photo.
Users can only delete their own photos.
Photos are soft-deleted and removed from CloudFlare.
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
# Check permissions
if photo.uploaded_by_id != user.id and not user.is_moderator:
return 403, {"detail": "Permission denied"}
photo_service.delete_photo(photo)
return {
'success': True,
'message': 'Photo deleted successfully',
}
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post("/{entity_type}/{entity_id}/photos", response=MessageSchema, auth=jwt_auth)
def attach_photo_to_entity(
request: HttpRequest,
entity_type: str,
entity_id: UUID,
payload: PhotoAttachRequest,
):
"""
Attach an existing photo to an entity.
Requires authentication.
"""
user = request.auth
try:
# Get entity
entity = get_entity_by_type(entity_type, entity_id)
# Get photo
photo = Photo.objects.get(id=payload.photo_id)
# Check permissions (can only attach own photos unless moderator)
if photo.uploaded_by_id != user.id and not user.is_moderator:
return 403, {"detail": "Permission denied"}
# Attach photo
photo_service.attach_to_entity(photo, entity)
# Update photo type if provided
if payload.photo_type:
photo.photo_type = payload.photo_type
photo.save(update_fields=['photo_type'])
return {
'success': True,
'message': f'Photo attached to {entity_type} successfully',
}
except ValueError as e:
return 400, {"detail": str(e)}
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
# ============================================================================
# Moderator Endpoints
# ============================================================================
@router.get("/photos/pending", response=List[PhotoOut], auth=require_moderator)
@paginate
def list_pending_photos(request: HttpRequest):
"""
List photos pending moderation (moderators only).
"""
queryset = Photo.objects.select_related(
'uploaded_by', 'moderated_by', 'content_type'
).pending().order_by('-created_at')
return queryset
@router.post("/photos/{photo_id}/approve", response=PhotoOut, auth=require_moderator)
def approve_photo(request: HttpRequest, photo_id: UUID):
"""
Approve a photo (moderators only).
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
photo = photo_service.moderate_photo(
photo=photo,
status='approved',
moderator=user,
)
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post("/photos/{photo_id}/reject", response=PhotoOut, auth=require_moderator)
def reject_photo(
request: HttpRequest,
photo_id: UUID,
payload: PhotoModerateRequest,
):
"""
Reject a photo (moderators only).
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
photo = photo_service.moderate_photo(
photo=photo,
status='rejected',
moderator=user,
notes=payload.notes or '',
)
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post("/photos/{photo_id}/flag", response=PhotoOut, auth=require_moderator)
def flag_photo(
request: HttpRequest,
photo_id: UUID,
payload: PhotoModerateRequest,
):
"""
Flag a photo for review (moderators only).
"""
user = request.auth
try:
photo = Photo.objects.get(id=photo_id)
photo = photo_service.moderate_photo(
photo=photo,
status='flagged',
moderator=user,
notes=payload.notes or '',
)
return serialize_photo(photo)
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.get("/photos/stats", response=PhotoStatsOut, auth=require_moderator)
def get_photo_stats(request: HttpRequest):
"""
Get photo statistics (moderators only).
"""
stats = Photo.objects.aggregate(
total=Count('id'),
pending=Count('id', filter=Q(moderation_status='pending')),
approved=Count('id', filter=Q(moderation_status='approved')),
rejected=Count('id', filter=Q(moderation_status='rejected')),
flagged=Count('id', filter=Q(moderation_status='flagged')),
total_size=Sum('file_size'),
)
return {
'total_photos': stats['total'] or 0,
'pending_photos': stats['pending'] or 0,
'approved_photos': stats['approved'] or 0,
'rejected_photos': stats['rejected'] or 0,
'flagged_photos': stats['flagged'] or 0,
'total_size_mb': round((stats['total_size'] or 0) / (1024 * 1024), 2),
}
# ============================================================================
# Admin Endpoints
# ============================================================================
@router.delete("/photos/{photo_id}/admin", response=MessageSchema, auth=require_admin)
def admin_delete_photo(request: HttpRequest, photo_id: UUID):
"""
Force delete any photo (admins only).
Permanently removes photo from database and CloudFlare.
"""
try:
photo = Photo.objects.get(id=photo_id)
photo_service.delete_photo(photo, delete_from_cloudflare=True)
logger.info(f"Photo {photo_id} force deleted by admin {request.auth.id}")
return {
'success': True,
'message': 'Photo permanently deleted',
}
except Photo.DoesNotExist:
return 404, {"detail": "Photo not found"}
@router.post(
"/{entity_type}/{entity_id}/photos/reorder",
response=MessageSchema,
auth=require_admin
)
def reorder_entity_photos(
request: HttpRequest,
entity_type: str,
entity_id: UUID,
payload: PhotoReorderRequest,
):
"""
Reorder photos for an entity (admins only).
"""
try:
entity = get_entity_by_type(entity_type, entity_id)
photo_service.reorder_photos(
entity=entity,
photo_ids=payload.photo_ids,
photo_type=payload.photo_type,
)
return {
'success': True,
'message': 'Photos reordered successfully',
}
except ValueError as e:
return 400, {"detail": str(e)}

View File

@@ -0,0 +1,247 @@
"""
Ride Model endpoints for API v1.
Provides CRUD operations for RideModel entities with filtering and search.
"""
from typing import List, Optional
from uuid import UUID
from django.shortcuts import get_object_or_404
from django.db.models import Q
from ninja import Router, Query
from ninja.pagination import paginate, PageNumberPagination
from apps.entities.models import RideModel, Company
from ..schemas import (
RideModelCreate,
RideModelUpdate,
RideModelOut,
RideModelListOut,
ErrorResponse
)
router = Router(tags=["Ride Models"])
class RideModelPagination(PageNumberPagination):
"""Custom pagination for ride models."""
page_size = 50
@router.get(
"/",
response={200: List[RideModelOut]},
summary="List ride models",
description="Get a paginated list of ride models with optional filtering"
)
@paginate(RideModelPagination)
def list_ride_models(
request,
search: Optional[str] = Query(None, description="Search by model name"),
manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"),
model_type: Optional[str] = Query(None, description="Filter by model type"),
ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)")
):
"""
List all ride models with optional filters.
**Filters:**
- search: Search model names (case-insensitive partial match)
- manufacturer_id: Filter by manufacturer
- model_type: Filter by model type
- ordering: Sort results (default: -created)
**Returns:** Paginated list of ride models
"""
queryset = RideModel.objects.select_related('manufacturer').all()
# Apply search filter
if search:
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Apply manufacturer filter
if manufacturer_id:
queryset = queryset.filter(manufacturer_id=manufacturer_id)
# Apply model type filter
if model_type:
queryset = queryset.filter(model_type=model_type)
# Apply ordering
valid_order_fields = ['name', 'created', 'modified', 'installation_count']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
# Annotate with manufacturer name
for model in queryset:
model.manufacturer_name = model.manufacturer.name if model.manufacturer else None
return queryset
@router.get(
"/{model_id}",
response={200: RideModelOut, 404: ErrorResponse},
summary="Get ride model",
description="Retrieve a single ride model by ID"
)
def get_ride_model(request, model_id: UUID):
"""
Get a ride model by ID.
**Parameters:**
- model_id: UUID of the ride model
**Returns:** Ride model details
"""
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
model.manufacturer_name = model.manufacturer.name if model.manufacturer else None
return model
@router.post(
"/",
response={201: RideModelOut, 400: ErrorResponse, 404: ErrorResponse},
summary="Create ride model",
description="Create a new ride model (requires authentication)"
)
def create_ride_model(request, payload: RideModelCreate):
"""
Create a new ride model.
**Authentication:** Required
**Parameters:**
- payload: Ride model data
**Returns:** Created ride model
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
# Verify manufacturer exists
manufacturer = get_object_or_404(Company, id=payload.manufacturer_id)
model = RideModel.objects.create(**payload.dict())
model.manufacturer_name = manufacturer.name
return 201, model
@router.put(
"/{model_id}",
response={200: RideModelOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Update ride model",
description="Update an existing ride model (requires authentication)"
)
def update_ride_model(request, model_id: UUID, payload: RideModelUpdate):
"""
Update a ride model.
**Authentication:** Required
**Parameters:**
- model_id: UUID of the ride model
- payload: Updated ride model data
**Returns:** Updated ride model
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(model, key, value)
model.save()
model.manufacturer_name = model.manufacturer.name if model.manufacturer else None
return model
@router.patch(
"/{model_id}",
response={200: RideModelOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Partial update ride model",
description="Partially update an existing ride model (requires authentication)"
)
def partial_update_ride_model(request, model_id: UUID, payload: RideModelUpdate):
"""
Partially update a ride model.
**Authentication:** Required
**Parameters:**
- model_id: UUID of the ride model
- payload: Fields to update
**Returns:** Updated ride model
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(model, key, value)
model.save()
model.manufacturer_name = model.manufacturer.name if model.manufacturer else None
return model
@router.delete(
"/{model_id}",
response={204: None, 404: ErrorResponse},
summary="Delete ride model",
description="Delete a ride model (requires authentication)"
)
def delete_ride_model(request, model_id: UUID):
"""
Delete a ride model.
**Authentication:** Required
**Parameters:**
- model_id: UUID of the ride model
**Returns:** No content (204)
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
model = get_object_or_404(RideModel, id=model_id)
model.delete()
return 204, None
@router.get(
"/{model_id}/installations",
response={200: List[dict], 404: ErrorResponse},
summary="Get ride model installations",
description="Get all ride installations of this model"
)
def get_ride_model_installations(request, model_id: UUID):
"""
Get all installations of a ride model.
**Parameters:**
- model_id: UUID of the ride model
**Returns:** List of rides using this model
"""
model = get_object_or_404(RideModel, id=model_id)
rides = model.rides.select_related('park').all().values(
'id', 'name', 'slug', 'status', 'park__name', 'park__id'
)
return list(rides)

View File

@@ -0,0 +1,360 @@
"""
Ride endpoints for API v1.
Provides CRUD operations for Ride entities with filtering and search.
"""
from typing import List, Optional
from uuid import UUID
from django.shortcuts import get_object_or_404
from django.db.models import Q
from ninja import Router, Query
from ninja.pagination import paginate, PageNumberPagination
from apps.entities.models import Ride, Park, Company, RideModel
from ..schemas import (
RideCreate,
RideUpdate,
RideOut,
RideListOut,
ErrorResponse
)
router = Router(tags=["Rides"])
class RidePagination(PageNumberPagination):
"""Custom pagination for rides."""
page_size = 50
@router.get(
"/",
response={200: List[RideOut]},
summary="List rides",
description="Get a paginated list of rides with optional filtering"
)
@paginate(RidePagination)
def list_rides(
request,
search: Optional[str] = Query(None, description="Search by ride name"),
park_id: Optional[UUID] = Query(None, description="Filter by park"),
ride_category: Optional[str] = Query(None, description="Filter by ride category"),
status: Optional[str] = Query(None, description="Filter by status"),
is_coaster: Optional[bool] = Query(None, description="Filter for roller coasters only"),
manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"),
ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)")
):
"""
List all rides with optional filters.
**Filters:**
- search: Search ride names (case-insensitive partial match)
- park_id: Filter by park
- ride_category: Filter by ride category
- status: Filter by operational status
- is_coaster: Filter for roller coasters (true/false)
- manufacturer_id: Filter by manufacturer
- ordering: Sort results (default: -created)
**Returns:** Paginated list of rides
"""
queryset = Ride.objects.select_related('park', 'manufacturer', 'model').all()
# Apply search filter
if search:
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Apply park filter
if park_id:
queryset = queryset.filter(park_id=park_id)
# Apply ride category filter
if ride_category:
queryset = queryset.filter(ride_category=ride_category)
# Apply status filter
if status:
queryset = queryset.filter(status=status)
# Apply coaster filter
if is_coaster is not None:
queryset = queryset.filter(is_coaster=is_coaster)
# Apply manufacturer filter
if manufacturer_id:
queryset = queryset.filter(manufacturer_id=manufacturer_id)
# Apply ordering
valid_order_fields = ['name', 'created', 'modified', 'opening_date', 'height', 'speed', 'length']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
# Annotate with related names
for ride in queryset:
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return queryset
@router.get(
"/{ride_id}",
response={200: RideOut, 404: ErrorResponse},
summary="Get ride",
description="Retrieve a single ride by ID"
)
def get_ride(request, ride_id: UUID):
"""
Get a ride by ID.
**Parameters:**
- ride_id: UUID of the ride
**Returns:** Ride details
"""
ride = get_object_or_404(
Ride.objects.select_related('park', 'manufacturer', 'model'),
id=ride_id
)
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return ride
@router.post(
"/",
response={201: RideOut, 400: ErrorResponse, 404: ErrorResponse},
summary="Create ride",
description="Create a new ride (requires authentication)"
)
def create_ride(request, payload: RideCreate):
"""
Create a new ride.
**Authentication:** Required
**Parameters:**
- payload: Ride data
**Returns:** Created ride
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
# Verify park exists
park = get_object_or_404(Park, id=payload.park_id)
# Verify manufacturer if provided
if payload.manufacturer_id:
get_object_or_404(Company, id=payload.manufacturer_id)
# Verify model if provided
if payload.model_id:
get_object_or_404(RideModel, id=payload.model_id)
ride = Ride.objects.create(**payload.dict())
# Reload with related objects
ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id)
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return 201, ride
@router.put(
"/{ride_id}",
response={200: RideOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Update ride",
description="Update an existing ride (requires authentication)"
)
def update_ride(request, ride_id: UUID, payload: RideUpdate):
"""
Update a ride.
**Authentication:** Required
**Parameters:**
- ride_id: UUID of the ride
- payload: Updated ride data
**Returns:** Updated ride
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
ride = get_object_or_404(
Ride.objects.select_related('park', 'manufacturer', 'model'),
id=ride_id
)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(ride, key, value)
ride.save()
# Reload to get updated relationships
ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id)
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return ride
@router.patch(
"/{ride_id}",
response={200: RideOut, 404: ErrorResponse, 400: ErrorResponse},
summary="Partial update ride",
description="Partially update an existing ride (requires authentication)"
)
def partial_update_ride(request, ride_id: UUID, payload: RideUpdate):
"""
Partially update a ride.
**Authentication:** Required
**Parameters:**
- ride_id: UUID of the ride
- payload: Fields to update
**Returns:** Updated ride
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
ride = get_object_or_404(
Ride.objects.select_related('park', 'manufacturer', 'model'),
id=ride_id
)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(ride, key, value)
ride.save()
# Reload to get updated relationships
ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id)
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return ride
@router.delete(
"/{ride_id}",
response={204: None, 404: ErrorResponse},
summary="Delete ride",
description="Delete a ride (requires authentication)"
)
def delete_ride(request, ride_id: UUID):
"""
Delete a ride.
**Authentication:** Required
**Parameters:**
- ride_id: UUID of the ride
**Returns:** No content (204)
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
ride = get_object_or_404(Ride, id=ride_id)
ride.delete()
return 204, None
@router.get(
"/coasters/",
response={200: List[RideOut]},
summary="List roller coasters",
description="Get a paginated list of roller coasters only"
)
@paginate(RidePagination)
def list_coasters(
request,
search: Optional[str] = Query(None, description="Search by ride name"),
park_id: Optional[UUID] = Query(None, description="Filter by park"),
status: Optional[str] = Query(None, description="Filter by status"),
manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"),
min_height: Optional[float] = Query(None, description="Minimum height in feet"),
min_speed: Optional[float] = Query(None, description="Minimum speed in mph"),
ordering: Optional[str] = Query("-height", description="Sort by field (prefix with - for descending)")
):
"""
List only roller coasters with optional filters.
**Filters:**
- search: Search coaster names
- park_id: Filter by park
- status: Filter by operational status
- manufacturer_id: Filter by manufacturer
- min_height: Minimum height filter
- min_speed: Minimum speed filter
- ordering: Sort results (default: -height)
**Returns:** Paginated list of roller coasters
"""
queryset = Ride.objects.filter(is_coaster=True).select_related(
'park', 'manufacturer', 'model'
)
# Apply search filter
if search:
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Apply park filter
if park_id:
queryset = queryset.filter(park_id=park_id)
# Apply status filter
if status:
queryset = queryset.filter(status=status)
# Apply manufacturer filter
if manufacturer_id:
queryset = queryset.filter(manufacturer_id=manufacturer_id)
# Apply height filter
if min_height is not None:
queryset = queryset.filter(height__gte=min_height)
# Apply speed filter
if min_speed is not None:
queryset = queryset.filter(speed__gte=min_speed)
# Apply ordering
valid_order_fields = ['name', 'height', 'speed', 'length', 'opening_date', 'inversions']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-height')
# Annotate with related names
for ride in queryset:
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return queryset

View File

@@ -0,0 +1,438 @@
"""
Search and autocomplete endpoints for ThrillWiki API.
Provides full-text search and filtering across all entity types.
"""
from typing import List, Optional
from uuid import UUID
from datetime import date
from decimal import Decimal
from django.http import HttpRequest
from ninja import Router, Query
from apps.entities.search import SearchService
from apps.users.permissions import jwt_auth
from api.v1.schemas import (
GlobalSearchResponse,
CompanySearchResult,
RideModelSearchResult,
ParkSearchResult,
RideSearchResult,
AutocompleteResponse,
AutocompleteItem,
ErrorResponse,
)
router = Router(tags=["Search"])
search_service = SearchService()
# ============================================================================
# Helper Functions
# ============================================================================
def _company_to_search_result(company) -> CompanySearchResult:
"""Convert Company model to search result."""
return CompanySearchResult(
id=company.id,
name=company.name,
slug=company.slug,
entity_type='company',
description=company.description,
image_url=company.logo_image_url or None,
company_types=company.company_types or [],
park_count=company.park_count,
ride_count=company.ride_count,
)
def _ride_model_to_search_result(model) -> RideModelSearchResult:
"""Convert RideModel to search result."""
return RideModelSearchResult(
id=model.id,
name=model.name,
slug=model.slug,
entity_type='ride_model',
description=model.description,
image_url=model.image_url or None,
manufacturer_name=model.manufacturer.name if model.manufacturer else '',
model_type=model.model_type,
installation_count=model.installation_count,
)
def _park_to_search_result(park) -> ParkSearchResult:
"""Convert Park model to search result."""
return ParkSearchResult(
id=park.id,
name=park.name,
slug=park.slug,
entity_type='park',
description=park.description,
image_url=park.banner_image_url or park.logo_image_url or None,
park_type=park.park_type,
status=park.status,
operator_name=park.operator.name if park.operator else None,
ride_count=park.ride_count,
coaster_count=park.coaster_count,
coordinates=park.coordinates,
)
def _ride_to_search_result(ride) -> RideSearchResult:
"""Convert Ride model to search result."""
return RideSearchResult(
id=ride.id,
name=ride.name,
slug=ride.slug,
entity_type='ride',
description=ride.description,
image_url=ride.image_url or None,
park_name=ride.park.name if ride.park else '',
park_slug=ride.park.slug if ride.park else '',
manufacturer_name=ride.manufacturer.name if ride.manufacturer else None,
ride_category=ride.ride_category,
status=ride.status,
is_coaster=ride.is_coaster,
)
# ============================================================================
# Search Endpoints
# ============================================================================
@router.get(
"",
response={200: GlobalSearchResponse, 400: ErrorResponse},
summary="Global search across all entities"
)
def search_all(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
entity_types: Optional[List[str]] = Query(None, description="Filter by entity types (company, ride_model, park, ride)"),
limit: int = Query(20, ge=1, le=100, description="Maximum results per entity type"),
):
"""
Search across all entity types with full-text search.
- **q**: Search query (minimum 2 characters)
- **entity_types**: Optional list of entity types to search (defaults to all)
- **limit**: Maximum results per entity type (1-100, default 20)
Returns results grouped by entity type.
"""
try:
results = search_service.search_all(
query=q,
entity_types=entity_types,
limit=limit
)
# Convert to schema objects
response_data = {
'query': q,
'total_results': 0,
'companies': [],
'ride_models': [],
'parks': [],
'rides': [],
}
if 'companies' in results:
response_data['companies'] = [
_company_to_search_result(c) for c in results['companies']
]
response_data['total_results'] += len(response_data['companies'])
if 'ride_models' in results:
response_data['ride_models'] = [
_ride_model_to_search_result(m) for m in results['ride_models']
]
response_data['total_results'] += len(response_data['ride_models'])
if 'parks' in results:
response_data['parks'] = [
_park_to_search_result(p) for p in results['parks']
]
response_data['total_results'] += len(response_data['parks'])
if 'rides' in results:
response_data['rides'] = [
_ride_to_search_result(r) for r in results['rides']
]
response_data['total_results'] += len(response_data['rides'])
return GlobalSearchResponse(**response_data)
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/companies",
response={200: List[CompanySearchResult], 400: ErrorResponse},
summary="Search companies"
)
def search_companies(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
company_types: Optional[List[str]] = Query(None, description="Filter by company types"),
founded_after: Optional[date] = Query(None, description="Founded after date"),
founded_before: Optional[date] = Query(None, description="Founded before date"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search companies with optional filters.
- **q**: Search query
- **company_types**: Filter by types (manufacturer, operator, designer, etc.)
- **founded_after/before**: Filter by founding date range
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if company_types:
filters['company_types'] = company_types
if founded_after:
filters['founded_after'] = founded_after
if founded_before:
filters['founded_before'] = founded_before
results = search_service.search_companies(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_company_to_search_result(c) for c in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/ride-models",
response={200: List[RideModelSearchResult], 400: ErrorResponse},
summary="Search ride models"
)
def search_ride_models(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"),
model_type: Optional[str] = Query(None, description="Filter by model type"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search ride models with optional filters.
- **q**: Search query
- **manufacturer_id**: Filter by specific manufacturer
- **model_type**: Filter by model type
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if manufacturer_id:
filters['manufacturer_id'] = manufacturer_id
if model_type:
filters['model_type'] = model_type
results = search_service.search_ride_models(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_ride_model_to_search_result(m) for m in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/parks",
response={200: List[ParkSearchResult], 400: ErrorResponse},
summary="Search parks"
)
def search_parks(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
status: Optional[str] = Query(None, description="Filter by status"),
park_type: Optional[str] = Query(None, description="Filter by park type"),
operator_id: Optional[UUID] = Query(None, description="Filter by operator"),
opening_after: Optional[date] = Query(None, description="Opened after date"),
opening_before: Optional[date] = Query(None, description="Opened before date"),
latitude: Optional[float] = Query(None, description="Search center latitude"),
longitude: Optional[float] = Query(None, description="Search center longitude"),
radius: Optional[float] = Query(None, ge=0, le=500, description="Search radius in km (PostGIS only)"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search parks with optional filters including location-based search.
- **q**: Search query
- **status**: Filter by operational status
- **park_type**: Filter by park type
- **operator_id**: Filter by operator company
- **opening_after/before**: Filter by opening date range
- **latitude/longitude/radius**: Location-based filtering (PostGIS only)
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if status:
filters['status'] = status
if park_type:
filters['park_type'] = park_type
if operator_id:
filters['operator_id'] = operator_id
if opening_after:
filters['opening_after'] = opening_after
if opening_before:
filters['opening_before'] = opening_before
# Location-based search (PostGIS only)
if latitude is not None and longitude is not None and radius is not None:
filters['location'] = (longitude, latitude)
filters['radius'] = radius
results = search_service.search_parks(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_park_to_search_result(p) for p in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
@router.get(
"/rides",
response={200: List[RideSearchResult], 400: ErrorResponse},
summary="Search rides"
)
def search_rides(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=200, description="Search query"),
park_id: Optional[UUID] = Query(None, description="Filter by park"),
manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"),
model_id: Optional[UUID] = Query(None, description="Filter by model"),
status: Optional[str] = Query(None, description="Filter by status"),
ride_category: Optional[str] = Query(None, description="Filter by category"),
is_coaster: Optional[bool] = Query(None, description="Filter coasters only"),
opening_after: Optional[date] = Query(None, description="Opened after date"),
opening_before: Optional[date] = Query(None, description="Opened before date"),
min_height: Optional[Decimal] = Query(None, description="Minimum height in feet"),
max_height: Optional[Decimal] = Query(None, description="Maximum height in feet"),
min_speed: Optional[Decimal] = Query(None, description="Minimum speed in mph"),
max_speed: Optional[Decimal] = Query(None, description="Maximum speed in mph"),
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
):
"""
Search rides with extensive filtering options.
- **q**: Search query
- **park_id**: Filter by specific park
- **manufacturer_id**: Filter by manufacturer
- **model_id**: Filter by specific ride model
- **status**: Filter by operational status
- **ride_category**: Filter by category (roller_coaster, flat_ride, etc.)
- **is_coaster**: Filter to show only coasters
- **opening_after/before**: Filter by opening date range
- **min_height/max_height**: Filter by height range (feet)
- **min_speed/max_speed**: Filter by speed range (mph)
- **limit**: Maximum results (1-100, default 20)
"""
try:
filters = {}
if park_id:
filters['park_id'] = park_id
if manufacturer_id:
filters['manufacturer_id'] = manufacturer_id
if model_id:
filters['model_id'] = model_id
if status:
filters['status'] = status
if ride_category:
filters['ride_category'] = ride_category
if is_coaster is not None:
filters['is_coaster'] = is_coaster
if opening_after:
filters['opening_after'] = opening_after
if opening_before:
filters['opening_before'] = opening_before
if min_height:
filters['min_height'] = min_height
if max_height:
filters['max_height'] = max_height
if min_speed:
filters['min_speed'] = min_speed
if max_speed:
filters['max_speed'] = max_speed
results = search_service.search_rides(
query=q,
filters=filters if filters else None,
limit=limit
)
return [_ride_to_search_result(r) for r in results]
except Exception as e:
return 400, ErrorResponse(detail=str(e))
# ============================================================================
# Autocomplete Endpoint
# ============================================================================
@router.get(
"/autocomplete",
response={200: AutocompleteResponse, 400: ErrorResponse},
summary="Autocomplete suggestions"
)
def autocomplete(
request: HttpRequest,
q: str = Query(..., min_length=2, max_length=100, description="Partial search query"),
entity_type: Optional[str] = Query(None, description="Filter by entity type (company, park, ride, ride_model)"),
limit: int = Query(10, ge=1, le=20, description="Maximum suggestions"),
):
"""
Get autocomplete suggestions for search.
- **q**: Partial query (minimum 2 characters)
- **entity_type**: Optional entity type filter
- **limit**: Maximum suggestions (1-20, default 10)
Returns quick name-based suggestions for autocomplete UIs.
"""
try:
suggestions = search_service.autocomplete(
query=q,
entity_type=entity_type,
limit=limit
)
# Convert to schema objects
items = [
AutocompleteItem(
id=s['id'],
name=s['name'],
slug=s['slug'],
entity_type=s['entity_type'],
park_name=s.get('park_name'),
manufacturer_name=s.get('manufacturer_name'),
)
for s in suggestions
]
return AutocompleteResponse(
query=q,
suggestions=items
)
except Exception as e:
return 400, ErrorResponse(detail=str(e))

View File

@@ -0,0 +1,369 @@
"""
Versioning API endpoints for ThrillWiki.
Provides REST API for:
- Version history for entities
- Specific version details
- Comparing versions
- Diff with current state
- Version restoration (optional)
"""
from typing import List
from uuid import UUID
from django.shortcuts import get_object_or_404
from django.http import Http404
from ninja import Router
from apps.entities.models import Park, Ride, Company, RideModel
from apps.versioning.models import EntityVersion
from apps.versioning.services import VersionService
from api.v1.schemas import (
EntityVersionSchema,
VersionHistoryResponseSchema,
VersionDiffSchema,
VersionComparisonSchema,
ErrorSchema,
MessageSchema
)
router = Router(tags=['Versioning'])
# Park Versions
@router.get(
'/parks/{park_id}/versions',
response={200: VersionHistoryResponseSchema, 404: ErrorSchema},
summary="Get park version history"
)
def get_park_versions(request, park_id: UUID, limit: int = 50):
"""
Get version history for a park.
Returns up to `limit` versions in reverse chronological order (newest first).
"""
park = get_object_or_404(Park, id=park_id)
versions = VersionService.get_version_history(park, limit=limit)
return {
'entity_id': str(park.id),
'entity_type': 'park',
'entity_name': park.name,
'total_versions': VersionService.get_version_count(park),
'versions': [
EntityVersionSchema.from_orm(v) for v in versions
]
}
@router.get(
'/parks/{park_id}/versions/{version_number}',
response={200: EntityVersionSchema, 404: ErrorSchema},
summary="Get specific park version"
)
def get_park_version(request, park_id: UUID, version_number: int):
"""Get a specific version of a park by version number."""
park = get_object_or_404(Park, id=park_id)
version = VersionService.get_version_by_number(park, version_number)
if not version:
raise Http404("Version not found")
return EntityVersionSchema.from_orm(version)
@router.get(
'/parks/{park_id}/versions/{version_number}/diff',
response={200: VersionDiffSchema, 404: ErrorSchema},
summary="Compare park version with current"
)
def get_park_version_diff(request, park_id: UUID, version_number: int):
"""
Compare a specific version with the current park state.
Returns the differences between the version and current values.
"""
park = get_object_or_404(Park, id=park_id)
version = VersionService.get_version_by_number(park, version_number)
if not version:
raise Http404("Version not found")
diff = VersionService.get_diff_with_current(version)
return {
'entity_id': str(park.id),
'entity_type': 'park',
'entity_name': park.name,
'version_number': version.version_number,
'version_date': version.created,
'differences': diff['differences'],
'changed_field_count': diff['changed_field_count']
}
# Ride Versions
@router.get(
'/rides/{ride_id}/versions',
response={200: VersionHistoryResponseSchema, 404: ErrorSchema},
summary="Get ride version history"
)
def get_ride_versions(request, ride_id: UUID, limit: int = 50):
"""Get version history for a ride."""
ride = get_object_or_404(Ride, id=ride_id)
versions = VersionService.get_version_history(ride, limit=limit)
return {
'entity_id': str(ride.id),
'entity_type': 'ride',
'entity_name': ride.name,
'total_versions': VersionService.get_version_count(ride),
'versions': [
EntityVersionSchema.from_orm(v) for v in versions
]
}
@router.get(
'/rides/{ride_id}/versions/{version_number}',
response={200: EntityVersionSchema, 404: ErrorSchema},
summary="Get specific ride version"
)
def get_ride_version(request, ride_id: UUID, version_number: int):
"""Get a specific version of a ride by version number."""
ride = get_object_or_404(Ride, id=ride_id)
version = VersionService.get_version_by_number(ride, version_number)
if not version:
raise Http404("Version not found")
return EntityVersionSchema.from_orm(version)
@router.get(
'/rides/{ride_id}/versions/{version_number}/diff',
response={200: VersionDiffSchema, 404: ErrorSchema},
summary="Compare ride version with current"
)
def get_ride_version_diff(request, ride_id: UUID, version_number: int):
"""Compare a specific version with the current ride state."""
ride = get_object_or_404(Ride, id=ride_id)
version = VersionService.get_version_by_number(ride, version_number)
if not version:
raise Http404("Version not found")
diff = VersionService.get_diff_with_current(version)
return {
'entity_id': str(ride.id),
'entity_type': 'ride',
'entity_name': ride.name,
'version_number': version.version_number,
'version_date': version.created,
'differences': diff['differences'],
'changed_field_count': diff['changed_field_count']
}
# Company Versions
@router.get(
'/companies/{company_id}/versions',
response={200: VersionHistoryResponseSchema, 404: ErrorSchema},
summary="Get company version history"
)
def get_company_versions(request, company_id: UUID, limit: int = 50):
"""Get version history for a company."""
company = get_object_or_404(Company, id=company_id)
versions = VersionService.get_version_history(company, limit=limit)
return {
'entity_id': str(company.id),
'entity_type': 'company',
'entity_name': company.name,
'total_versions': VersionService.get_version_count(company),
'versions': [
EntityVersionSchema.from_orm(v) for v in versions
]
}
@router.get(
'/companies/{company_id}/versions/{version_number}',
response={200: EntityVersionSchema, 404: ErrorSchema},
summary="Get specific company version"
)
def get_company_version(request, company_id: UUID, version_number: int):
"""Get a specific version of a company by version number."""
company = get_object_or_404(Company, id=company_id)
version = VersionService.get_version_by_number(company, version_number)
if not version:
raise Http404("Version not found")
return EntityVersionSchema.from_orm(version)
@router.get(
'/companies/{company_id}/versions/{version_number}/diff',
response={200: VersionDiffSchema, 404: ErrorSchema},
summary="Compare company version with current"
)
def get_company_version_diff(request, company_id: UUID, version_number: int):
"""Compare a specific version with the current company state."""
company = get_object_or_404(Company, id=company_id)
version = VersionService.get_version_by_number(company, version_number)
if not version:
raise Http404("Version not found")
diff = VersionService.get_diff_with_current(version)
return {
'entity_id': str(company.id),
'entity_type': 'company',
'entity_name': company.name,
'version_number': version.version_number,
'version_date': version.created,
'differences': diff['differences'],
'changed_field_count': diff['changed_field_count']
}
# Ride Model Versions
@router.get(
'/ride-models/{model_id}/versions',
response={200: VersionHistoryResponseSchema, 404: ErrorSchema},
summary="Get ride model version history"
)
def get_ride_model_versions(request, model_id: UUID, limit: int = 50):
"""Get version history for a ride model."""
model = get_object_or_404(RideModel, id=model_id)
versions = VersionService.get_version_history(model, limit=limit)
return {
'entity_id': str(model.id),
'entity_type': 'ride_model',
'entity_name': str(model),
'total_versions': VersionService.get_version_count(model),
'versions': [
EntityVersionSchema.from_orm(v) for v in versions
]
}
@router.get(
'/ride-models/{model_id}/versions/{version_number}',
response={200: EntityVersionSchema, 404: ErrorSchema},
summary="Get specific ride model version"
)
def get_ride_model_version(request, model_id: UUID, version_number: int):
"""Get a specific version of a ride model by version number."""
model = get_object_or_404(RideModel, id=model_id)
version = VersionService.get_version_by_number(model, version_number)
if not version:
raise Http404("Version not found")
return EntityVersionSchema.from_orm(version)
@router.get(
'/ride-models/{model_id}/versions/{version_number}/diff',
response={200: VersionDiffSchema, 404: ErrorSchema},
summary="Compare ride model version with current"
)
def get_ride_model_version_diff(request, model_id: UUID, version_number: int):
"""Compare a specific version with the current ride model state."""
model = get_object_or_404(RideModel, id=model_id)
version = VersionService.get_version_by_number(model, version_number)
if not version:
raise Http404("Version not found")
diff = VersionService.get_diff_with_current(version)
return {
'entity_id': str(model.id),
'entity_type': 'ride_model',
'entity_name': str(model),
'version_number': version.version_number,
'version_date': version.created,
'differences': diff['differences'],
'changed_field_count': diff['changed_field_count']
}
# Generic Version Endpoints
@router.get(
'/versions/{version_id}',
response={200: EntityVersionSchema, 404: ErrorSchema},
summary="Get version by ID"
)
def get_version(request, version_id: UUID):
"""Get a specific version by its ID."""
version = get_object_or_404(EntityVersion, id=version_id)
return EntityVersionSchema.from_orm(version)
@router.get(
'/versions/{version_id}/compare/{other_version_id}',
response={200: VersionComparisonSchema, 404: ErrorSchema},
summary="Compare two versions"
)
def compare_versions(request, version_id: UUID, other_version_id: UUID):
"""
Compare two versions of the same entity.
Both versions must be for the same entity.
"""
version1 = get_object_or_404(EntityVersion, id=version_id)
version2 = get_object_or_404(EntityVersion, id=other_version_id)
comparison = VersionService.compare_versions(version1, version2)
return {
'version1': EntityVersionSchema.from_orm(version1),
'version2': EntityVersionSchema.from_orm(version2),
'differences': comparison['differences'],
'changed_field_count': comparison['changed_field_count']
}
# Optional: Version Restoration
# Uncomment if you want to enable version restoration via API
# @router.post(
# '/versions/{version_id}/restore',
# response={200: MessageSchema, 404: ErrorSchema},
# summary="Restore a version"
# )
# def restore_version(request, version_id: UUID):
# """
# Restore an entity to a previous version.
#
# This creates a new version with change_type='restored'.
# Requires authentication and appropriate permissions.
# """
# version = get_object_or_404(EntityVersion, id=version_id)
#
# # Check authentication
# if not request.user.is_authenticated:
# return 401, {'error': 'Authentication required'}
#
# # Restore version
# restored_version = VersionService.restore_version(
# version,
# user=request.user,
# comment='Restored via API'
# )
#
# return {
# 'message': f'Successfully restored to version {version.version_number}',
# 'new_version_number': restored_version.version_number
# }

969
django/api/v1/schemas.py Normal file
View File

@@ -0,0 +1,969 @@
"""
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)