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

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
# }