mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 09:11:13 -05:00
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:
3
django/api/v1/endpoints/__init__.py
Normal file
3
django/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API v1 endpoints package.
|
||||
"""
|
||||
BIN
django/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/auth.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/companies.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/companies.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/moderation.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/moderation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/parks.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/parks.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/photos.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/photos.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/ride_models.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/ride_models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/rides.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/rides.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/search.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/search.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/api/v1/endpoints/__pycache__/versioning.cpython-313.pyc
Normal file
BIN
django/api/v1/endpoints/__pycache__/versioning.cpython-313.pyc
Normal file
Binary file not shown.
596
django/api/v1/endpoints/auth.py
Normal file
596
django/api/v1/endpoints/auth.py
Normal 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)}
|
||||
254
django/api/v1/endpoints/companies.py
Normal file
254
django/api/v1/endpoints/companies.py
Normal 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)
|
||||
496
django/api/v1/endpoints/moderation.py
Normal file
496
django/api/v1/endpoints/moderation.py
Normal 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,
|
||||
}
|
||||
362
django/api/v1/endpoints/parks.py
Normal file
362
django/api/v1/endpoints/parks.py
Normal 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)
|
||||
600
django/api/v1/endpoints/photos.py
Normal file
600
django/api/v1/endpoints/photos.py
Normal 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)}
|
||||
247
django/api/v1/endpoints/ride_models.py
Normal file
247
django/api/v1/endpoints/ride_models.py
Normal 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)
|
||||
360
django/api/v1/endpoints/rides.py
Normal file
360
django/api/v1/endpoints/rides.py
Normal 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
|
||||
438
django/api/v1/endpoints/search.py
Normal file
438
django/api/v1/endpoints/search.py
Normal 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))
|
||||
369
django/api/v1/endpoints/versioning.py
Normal file
369
django/api/v1/endpoints/versioning.py
Normal 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
|
||||
# }
|
||||
Reference in New Issue
Block a user