mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 19:51:14 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
3
django-backend/api/__init__.py
Normal file
3
django-backend/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
REST API package for ThrillWiki Django backend.
|
||||
"""
|
||||
3
django-backend/api/v1/__init__.py
Normal file
3
django-backend/api/v1/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API v1 package.
|
||||
"""
|
||||
182
django-backend/api/v1/api.py
Normal file
182
django-backend/api/v1/api.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Main API v1 router.
|
||||
|
||||
This module combines all endpoint routers and provides the main API interface.
|
||||
"""
|
||||
from ninja import NinjaAPI
|
||||
from ninja.security import django_auth
|
||||
|
||||
from .endpoints.companies import router as companies_router
|
||||
from .endpoints.ride_models import router as ride_models_router
|
||||
from .endpoints.parks import router as parks_router
|
||||
from .endpoints.rides import router as rides_router
|
||||
from .endpoints.moderation import router as moderation_router
|
||||
from .endpoints.auth import router as auth_router
|
||||
from .endpoints.photos import router as photos_router
|
||||
from .endpoints.search import router as search_router
|
||||
from .endpoints.reviews import router as reviews_router
|
||||
from .endpoints.ride_credits import router as ride_credits_router
|
||||
from .endpoints.top_lists import router as top_lists_router
|
||||
from .endpoints.history import router as history_router
|
||||
from .endpoints.timeline import router as timeline_router
|
||||
from .endpoints.reports import router as reports_router
|
||||
from .endpoints.seo import router as seo_router
|
||||
from .endpoints.contact import router as contact_router
|
||||
|
||||
|
||||
# Create the main API instance
|
||||
api = NinjaAPI(
|
||||
title="ThrillWiki API",
|
||||
version="1.0.0",
|
||||
description="""
|
||||
# ThrillWiki REST API
|
||||
|
||||
A comprehensive API for amusement park, ride, and company data.
|
||||
|
||||
## Features
|
||||
|
||||
- **Companies**: Manufacturers, operators, and designers in the amusement industry
|
||||
- **Ride Models**: Specific ride models from manufacturers
|
||||
- **Parks**: Theme parks, amusement parks, water parks, and FECs
|
||||
- **Rides**: Individual rides and roller coasters
|
||||
|
||||
## Authentication
|
||||
|
||||
The API uses JWT (JSON Web Token) authentication for secure access.
|
||||
|
||||
### Getting Started
|
||||
1. Register: `POST /api/v1/auth/register`
|
||||
2. Login: `POST /api/v1/auth/login` (returns access & refresh tokens)
|
||||
3. Use token: Include `Authorization: Bearer <access_token>` header in requests
|
||||
4. Refresh: `POST /api/v1/auth/token/refresh` when access token expires
|
||||
|
||||
### Permissions
|
||||
- **Public**: Read operations (GET) on entities
|
||||
- **Authenticated**: Create submissions, manage own profile
|
||||
- **Moderator**: Approve/reject submissions, moderate content
|
||||
- **Admin**: Full access, user management, role assignment
|
||||
|
||||
### Optional: Multi-Factor Authentication (MFA)
|
||||
Users can enable TOTP-based 2FA for enhanced security:
|
||||
1. Enable: `POST /api/v1/auth/mfa/enable`
|
||||
2. Confirm: `POST /api/v1/auth/mfa/confirm`
|
||||
3. Login with MFA: Include `mfa_token` in login request
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints return paginated results:
|
||||
- Default page size: 50 items
|
||||
- Use `page` parameter to navigate (e.g., `?page=2`)
|
||||
|
||||
## Filtering & Search
|
||||
|
||||
Most list endpoints support filtering and search parameters.
|
||||
See individual endpoint documentation for available filters.
|
||||
|
||||
## Geographic Search
|
||||
|
||||
The parks endpoint includes a special `/parks/nearby/` endpoint for geographic searches:
|
||||
- **Production (PostGIS)**: Uses accurate distance-based queries
|
||||
- **Local Development (SQLite)**: Uses bounding box approximation
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Rate limiting will be implemented in future versions.
|
||||
|
||||
## Data Format
|
||||
|
||||
All dates are in ISO 8601 format (YYYY-MM-DD).
|
||||
All timestamps are in ISO 8601 format with timezone.
|
||||
UUIDs are used for all entity IDs.
|
||||
""",
|
||||
docs_url="/docs",
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
# Add authentication router
|
||||
api.add_router("/auth", auth_router)
|
||||
|
||||
# Add routers for each entity
|
||||
api.add_router("/companies", companies_router)
|
||||
api.add_router("/ride-models", ride_models_router)
|
||||
api.add_router("/parks", parks_router)
|
||||
api.add_router("/rides", rides_router)
|
||||
|
||||
# Add moderation router
|
||||
api.add_router("/moderation", moderation_router)
|
||||
|
||||
# Add photos router
|
||||
api.add_router("", photos_router) # Photos endpoints include both /photos and entity-nested routes
|
||||
|
||||
# Add search router
|
||||
api.add_router("/search", search_router)
|
||||
|
||||
# Add user interaction routers
|
||||
api.add_router("/reviews", reviews_router)
|
||||
api.add_router("/ride-credits", ride_credits_router)
|
||||
api.add_router("/top-lists", top_lists_router)
|
||||
|
||||
# Add history router
|
||||
api.add_router("/history", history_router)
|
||||
|
||||
# Add timeline router
|
||||
api.add_router("/timeline", timeline_router)
|
||||
|
||||
# Add reports router
|
||||
api.add_router("/reports", reports_router)
|
||||
|
||||
# Add SEO router
|
||||
api.add_router("/seo", seo_router)
|
||||
|
||||
# Add contact router
|
||||
api.add_router("/contact", contact_router)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@api.get("/health", tags=["System"], summary="Health check")
|
||||
def health_check(request):
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns system status and API version.
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"api": "ThrillWiki API v1"
|
||||
}
|
||||
|
||||
|
||||
# API info endpoint
|
||||
@api.get("/info", tags=["System"], summary="API information")
|
||||
def api_info(request):
|
||||
"""
|
||||
Get API information and statistics.
|
||||
|
||||
Returns basic API metadata and available endpoints.
|
||||
"""
|
||||
from apps.entities.models import Company, RideModel, Park, Ride
|
||||
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"title": "ThrillWiki API",
|
||||
"endpoints": {
|
||||
"auth": "/api/v1/auth/",
|
||||
"companies": "/api/v1/companies/",
|
||||
"ride_models": "/api/v1/ride-models/",
|
||||
"parks": "/api/v1/parks/",
|
||||
"rides": "/api/v1/rides/",
|
||||
"moderation": "/api/v1/moderation/",
|
||||
"photos": "/api/v1/photos/",
|
||||
"search": "/api/v1/search/",
|
||||
},
|
||||
"statistics": {
|
||||
"companies": Company.objects.count(),
|
||||
"ride_models": RideModel.objects.count(),
|
||||
"parks": Park.objects.count(),
|
||||
"rides": Ride.objects.count(),
|
||||
"coasters": Ride.objects.filter(is_coaster=True).count(),
|
||||
},
|
||||
"documentation": "/api/v1/docs",
|
||||
"openapi_schema": "/api/v1/openapi.json",
|
||||
}
|
||||
3
django-backend/api/v1/endpoints/__init__.py
Normal file
3
django-backend/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API v1 endpoints package.
|
||||
"""
|
||||
596
django-backend/api/v1/endpoints/auth.py
Normal file
596
django-backend/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)}
|
||||
650
django-backend/api/v1/endpoints/companies.py
Normal file
650
django-backend/api/v1/endpoints/companies.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
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 apps.entities.services.company_submission import CompanySubmissionService
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
CompanyCreate,
|
||||
CompanyUpdate,
|
||||
CompanyOut,
|
||||
CompanyListOut,
|
||||
ErrorResponse,
|
||||
HistoryListResponse,
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
HistoryDiffCurrentSchema,
|
||||
FieldHistorySchema,
|
||||
HistoryActivitySummarySchema,
|
||||
RollbackRequestSchema,
|
||||
RollbackResponseSchema,
|
||||
ErrorSchema
|
||||
)
|
||||
from ..services.history_service import HistoryService
|
||||
from django.core.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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, 202: dict, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Create company",
|
||||
description="Create a new company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def create_company(request, payload: CompanyCreate):
|
||||
"""
|
||||
Create a new company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- payload: Company data (name, company_types, headquarters, etc.)
|
||||
|
||||
**Returns:** Created company (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Company created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All companies flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Create company through Sacred Pipeline
|
||||
submission, company = CompanySubmissionService.create_entity_submission(
|
||||
user=user,
|
||||
data=payload.dict(),
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, Company was created immediately
|
||||
if company:
|
||||
logger.info(f"Company created (moderator): {company.id} by {user.email}")
|
||||
return 201, company
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Company submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company submission pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{company_id}",
|
||||
response={200: CompanyOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Update company",
|
||||
description="Update an existing company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def update_company(request, company_id: UUID, payload: CompanyUpdate):
|
||||
"""
|
||||
Update a company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- company_id: UUID of the company
|
||||
- payload: Updated company data
|
||||
|
||||
**Returns:** Updated company (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update company through Sacred Pipeline
|
||||
submission, updated_company = CompanySubmissionService.update_entity_submission(
|
||||
entity=company,
|
||||
user=user,
|
||||
update_data=data,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, company was updated immediately
|
||||
if updated_company:
|
||||
logger.info(f"Company updated (moderator): {updated_company.id} by {user.email}")
|
||||
return 200, updated_company
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Company update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{company_id}",
|
||||
response={200: CompanyOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Partial update company",
|
||||
description="Partially update an existing company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def partial_update_company(request, company_id: UUID, payload: CompanyUpdate):
|
||||
"""
|
||||
Partially update a company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- company_id: UUID of the company
|
||||
- payload: Fields to update (only provided fields are updated)
|
||||
|
||||
**Returns:** Updated company (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update company through Sacred Pipeline
|
||||
submission, updated_company = CompanySubmissionService.update_entity_submission(
|
||||
entity=company,
|
||||
user=user,
|
||||
update_data=data,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, company was updated immediately
|
||||
if updated_company:
|
||||
logger.info(f"Company partially updated (moderator): {updated_company.id} by {user.email}")
|
||||
return 200, updated_company
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Company partial update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error partially updating company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{company_id}",
|
||||
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Delete company",
|
||||
description="Delete a company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def delete_company(request, company_id: UUID):
|
||||
"""
|
||||
Delete a company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- company_id: UUID of the company
|
||||
|
||||
**Returns:** Deletion confirmation (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Company hard-deleted immediately (removed from database)
|
||||
- Regular users: Deletion request created, enters moderation queue
|
||||
|
||||
**Deletion Strategy:**
|
||||
- Hard Delete: Removes company from database (Company has no status field for soft delete)
|
||||
|
||||
**Note:** All deletions flow through ContentSubmission pipeline for moderation.
|
||||
**Warning:** Deleting a company may affect related parks and rides.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
# Delete company through Sacred Pipeline (hard delete - no status field)
|
||||
submission, deleted = CompanySubmissionService.delete_entity_submission(
|
||||
entity=company,
|
||||
user=user,
|
||||
deletion_type='hard', # Company has no status field
|
||||
deletion_reason='',
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, deletion was applied immediately
|
||||
if deleted:
|
||||
logger.info(f"Company deleted (moderator): {company_id} by {user.email}")
|
||||
return 200, {
|
||||
'message': 'Company deleted successfully',
|
||||
'entity_id': str(company_id),
|
||||
'deletion_type': 'hard'
|
||||
}
|
||||
|
||||
# Regular user: deletion pending moderation
|
||||
logger.info(f"Company deletion submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company deletion request pending moderation. You will be notified when it is approved.',
|
||||
'entity_id': str(company_id)
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get company history",
|
||||
description="Get historical changes for a company"
|
||||
)
|
||||
def get_company_history(
|
||||
request,
|
||||
company_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)")
|
||||
):
|
||||
"""Get history for a company."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify company exists
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
# Parse dates if provided
|
||||
date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None
|
||||
date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None
|
||||
|
||||
# Get history
|
||||
offset = (page - 1) * page_size
|
||||
events, accessible_count = HistoryService.get_history(
|
||||
'company', str(company_id), request.user,
|
||||
date_from=date_from_obj, date_to=date_to_obj,
|
||||
limit=page_size, offset=offset
|
||||
)
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_events.append({
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'change_summary': event.get('change_summary', ''),
|
||||
'can_rollback': HistoryService.can_rollback(request.user)
|
||||
})
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (accessible_count + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
'total_events': accessible_count,
|
||||
'accessible_events': accessible_count,
|
||||
'access_limited': HistoryService.is_access_limited(request.user),
|
||||
'access_reason': HistoryService.get_access_reason(request.user),
|
||||
'events': formatted_events,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': total_pages,
|
||||
'total_items': accessible_count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific company history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_company_history_event(request, company_id: UUID, event_id: int):
|
||||
"""Get a specific history event for a company."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
event = HistoryService.get_event('company', event_id, request.user)
|
||||
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
return {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two company history events",
|
||||
description="Compare two historical events for a company"
|
||||
)
|
||||
def compare_company_history(
|
||||
request,
|
||||
company_id: UUID,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a company."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'company', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
'event1': comparison['event1'],
|
||||
'event2': comparison['event2'],
|
||||
'differences': comparison['differences'],
|
||||
'changed_field_count': comparison['changed_field_count'],
|
||||
'unchanged_field_count': comparison['unchanged_field_count'],
|
||||
'time_between': comparison['time_between']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/{event_id}/diff-current/',
|
||||
response={200: HistoryDiffCurrentSchema, 404: ErrorSchema},
|
||||
summary="Compare historical event with current state",
|
||||
description="Compare a historical event with the current company state"
|
||||
)
|
||||
def diff_company_history_with_current(request, company_id: UUID, event_id: int):
|
||||
"""Compare historical event with current company state."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'company', event_id, company, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
'event': diff['event'],
|
||||
'current_state': diff['current_state'],
|
||||
'differences': diff['differences'],
|
||||
'changed_field_count': diff['changed_field_count'],
|
||||
'time_since': diff['time_since']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{company_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback company to historical state",
|
||||
description="Rollback company to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_company(request, company_id: UUID, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback company to a historical state.
|
||||
|
||||
**Permission:** Moderators, Admins, Superusers only
|
||||
"""
|
||||
# Check authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {"error": "Authentication required"}
|
||||
|
||||
# Check rollback permission
|
||||
if not HistoryService.can_rollback(request.user):
|
||||
return 403, {"error": "Only moderators and administrators can perform rollbacks"}
|
||||
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
company, 'company', event_id, request.user,
|
||||
fields=payload.fields,
|
||||
comment=payload.comment,
|
||||
create_backup=payload.create_backup
|
||||
)
|
||||
return result
|
||||
except (ValueError, PermissionError) as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific company field"
|
||||
)
|
||||
def get_company_field_history(request, company_id: UUID, field_name: str):
|
||||
"""Get history of changes to a specific company field."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'company', str(company_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get company activity summary",
|
||||
description="Get activity summary for a company"
|
||||
)
|
||||
def get_company_activity_summary(request, company_id: UUID):
|
||||
"""Get activity summary for a company."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'company', str(company_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
**summary
|
||||
}
|
||||
330
django-backend/api/v1/endpoints/contact.py
Normal file
330
django-backend/api/v1/endpoints/contact.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Contact submission API endpoints.
|
||||
Handles user contact form submissions and admin management.
|
||||
"""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from ninja import Router
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from apps.contact.models import ContactSubmission
|
||||
from apps.contact.tasks import send_contact_confirmation_email, notify_admins_new_contact, send_contact_resolution_email
|
||||
from apps.users.permissions import require_role
|
||||
from api.v1.schemas import (
|
||||
ContactSubmissionCreate,
|
||||
ContactSubmissionUpdate,
|
||||
ContactSubmissionOut,
|
||||
ContactSubmissionListOut,
|
||||
ContactSubmissionStatsOut,
|
||||
MessageSchema,
|
||||
ErrorSchema,
|
||||
)
|
||||
|
||||
|
||||
router = Router(tags=["Contact"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Public Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/submit", response={200: ContactSubmissionOut, 400: ErrorSchema})
|
||||
def submit_contact_form(request, data: ContactSubmissionCreate):
|
||||
"""
|
||||
Submit a contact form.
|
||||
Available to both authenticated and anonymous users.
|
||||
"""
|
||||
try:
|
||||
# Create the contact submission
|
||||
contact = ContactSubmission.objects.create(
|
||||
name=data.name,
|
||||
email=data.email,
|
||||
subject=data.subject,
|
||||
message=data.message,
|
||||
category=data.category,
|
||||
user=request.auth if request.auth else None
|
||||
)
|
||||
|
||||
# Send confirmation email to user
|
||||
send_contact_confirmation_email.delay(str(contact.id))
|
||||
|
||||
# Notify admins
|
||||
notify_admins_new_contact.delay(str(contact.id))
|
||||
|
||||
# Prepare response
|
||||
response = ContactSubmissionOut.from_orm(contact)
|
||||
response.user_email = contact.user.email if contact.user else None
|
||||
|
||||
return 200, response
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to submit contact form", "detail": str(e)}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Admin/Moderator Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response={200: ContactSubmissionListOut, 403: ErrorSchema})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def list_contact_submissions(
|
||||
request,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
assigned_to_me: bool = False,
|
||||
search: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
List contact submissions (moderators/admins only).
|
||||
Supports filtering and pagination.
|
||||
"""
|
||||
queryset = ContactSubmission.objects.all()
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
if category:
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
if assigned_to_me and request.auth:
|
||||
queryset = queryset.filter(assigned_to=request.auth)
|
||||
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(ticket_number__icontains=search) |
|
||||
Q(name__icontains=search) |
|
||||
Q(email__icontains=search) |
|
||||
Q(subject__icontains=search) |
|
||||
Q(message__icontains=search)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = queryset.count()
|
||||
|
||||
# Apply pagination
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
contacts = queryset[start:end]
|
||||
|
||||
# Prepare response
|
||||
items = []
|
||||
for contact in contacts:
|
||||
item = ContactSubmissionOut.from_orm(contact)
|
||||
item.user_email = contact.user.email if contact.user else None
|
||||
item.assigned_to_email = contact.assigned_to.email if contact.assigned_to else None
|
||||
item.resolved_by_email = contact.resolved_by.email if contact.resolved_by else None
|
||||
items.append(item)
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{contact_id}", response={200: ContactSubmissionOut, 404: ErrorSchema, 403: ErrorSchema})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def get_contact_submission(request, contact_id: UUID):
|
||||
"""
|
||||
Get a specific contact submission by ID (moderators/admins only).
|
||||
"""
|
||||
try:
|
||||
contact = ContactSubmission.objects.get(id=contact_id)
|
||||
|
||||
response = ContactSubmissionOut.from_orm(contact)
|
||||
response.user_email = contact.user.email if contact.user else None
|
||||
response.assigned_to_email = contact.assigned_to.email if contact.assigned_to else None
|
||||
response.resolved_by_email = contact.resolved_by.email if contact.resolved_by else None
|
||||
|
||||
return 200, response
|
||||
|
||||
except ContactSubmission.DoesNotExist:
|
||||
return 404, {"error": "Contact submission not found"}
|
||||
|
||||
|
||||
@router.patch("/{contact_id}", response={200: ContactSubmissionOut, 404: ErrorSchema, 400: ErrorSchema, 403: ErrorSchema})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def update_contact_submission(request, contact_id: UUID, data: ContactSubmissionUpdate):
|
||||
"""
|
||||
Update a contact submission (moderators/admins only).
|
||||
Used to change status, assign, or add notes.
|
||||
"""
|
||||
try:
|
||||
contact = ContactSubmission.objects.get(id=contact_id)
|
||||
|
||||
# Track if status changed to resolved
|
||||
status_changed_to_resolved = False
|
||||
old_status = contact.status
|
||||
|
||||
# Update fields
|
||||
if data.status is not None:
|
||||
contact.status = data.status
|
||||
if data.status == 'resolved' and old_status != 'resolved':
|
||||
status_changed_to_resolved = True
|
||||
contact.resolved_by = request.auth
|
||||
contact.resolved_at = timezone.now()
|
||||
|
||||
if data.assigned_to_id is not None:
|
||||
from apps.users.models import User
|
||||
try:
|
||||
contact.assigned_to = User.objects.get(id=data.assigned_to_id)
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "Invalid user ID for assignment"}
|
||||
|
||||
if data.admin_notes is not None:
|
||||
contact.admin_notes = data.admin_notes
|
||||
|
||||
contact.save()
|
||||
|
||||
# Send resolution email if status changed to resolved
|
||||
if status_changed_to_resolved:
|
||||
send_contact_resolution_email.delay(str(contact.id))
|
||||
|
||||
# Prepare response
|
||||
response = ContactSubmissionOut.from_orm(contact)
|
||||
response.user_email = contact.user.email if contact.user else None
|
||||
response.assigned_to_email = contact.assigned_to.email if contact.assigned_to else None
|
||||
response.resolved_by_email = contact.resolved_by.email if contact.resolved_by else None
|
||||
|
||||
return 200, response
|
||||
|
||||
except ContactSubmission.DoesNotExist:
|
||||
return 404, {"error": "Contact submission not found"}
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to update contact submission", "detail": str(e)}
|
||||
|
||||
|
||||
@router.post("/{contact_id}/assign-to-me", response={200: MessageSchema, 404: ErrorSchema, 403: ErrorSchema})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def assign_to_me(request, contact_id: UUID):
|
||||
"""
|
||||
Assign a contact submission to the current user (moderators/admins only).
|
||||
"""
|
||||
try:
|
||||
contact = ContactSubmission.objects.get(id=contact_id)
|
||||
contact.assigned_to = request.auth
|
||||
contact.save()
|
||||
|
||||
return 200, {
|
||||
"message": f"Contact submission {contact.ticket_number} assigned to you",
|
||||
"success": True
|
||||
}
|
||||
|
||||
except ContactSubmission.DoesNotExist:
|
||||
return 404, {"error": "Contact submission not found"}
|
||||
|
||||
|
||||
@router.post("/{contact_id}/mark-resolved", response={200: MessageSchema, 404: ErrorSchema, 403: ErrorSchema})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def mark_resolved(request, contact_id: UUID):
|
||||
"""
|
||||
Mark a contact submission as resolved (moderators/admins only).
|
||||
"""
|
||||
try:
|
||||
contact = ContactSubmission.objects.get(id=contact_id)
|
||||
|
||||
if contact.status == 'resolved':
|
||||
return 200, {
|
||||
"message": f"Contact submission {contact.ticket_number} is already resolved",
|
||||
"success": True
|
||||
}
|
||||
|
||||
contact.status = 'resolved'
|
||||
contact.resolved_by = request.auth
|
||||
contact.resolved_at = timezone.now()
|
||||
contact.save()
|
||||
|
||||
# Send resolution email
|
||||
send_contact_resolution_email.delay(str(contact.id))
|
||||
|
||||
return 200, {
|
||||
"message": f"Contact submission {contact.ticket_number} marked as resolved",
|
||||
"success": True
|
||||
}
|
||||
|
||||
except ContactSubmission.DoesNotExist:
|
||||
return 404, {"error": "Contact submission not found"}
|
||||
|
||||
|
||||
@router.get("/stats/overview", response={200: ContactSubmissionStatsOut, 403: ErrorSchema})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def get_contact_stats(request):
|
||||
"""
|
||||
Get contact submission statistics (moderators/admins only).
|
||||
"""
|
||||
# Get counts by status
|
||||
total_submissions = ContactSubmission.objects.count()
|
||||
pending_submissions = ContactSubmission.objects.filter(status='pending').count()
|
||||
in_progress_submissions = ContactSubmission.objects.filter(status='in_progress').count()
|
||||
resolved_submissions = ContactSubmission.objects.filter(status='resolved').count()
|
||||
archived_submissions = ContactSubmission.objects.filter(status='archived').count()
|
||||
|
||||
# Get counts by category
|
||||
submissions_by_category = dict(
|
||||
ContactSubmission.objects.values('category').annotate(
|
||||
count=Count('id')
|
||||
).values_list('category', 'count')
|
||||
)
|
||||
|
||||
# Calculate average resolution time
|
||||
resolved_contacts = ContactSubmission.objects.filter(
|
||||
status='resolved',
|
||||
resolved_at__isnull=False
|
||||
).exclude(created_at=None)
|
||||
|
||||
avg_resolution_time = None
|
||||
if resolved_contacts.exists():
|
||||
total_time = sum([
|
||||
(contact.resolved_at - contact.created_at).total_seconds() / 3600
|
||||
for contact in resolved_contacts
|
||||
])
|
||||
avg_resolution_time = total_time / resolved_contacts.count()
|
||||
|
||||
# Get recent submissions
|
||||
recent = ContactSubmission.objects.order_by('-created_at')[:5]
|
||||
recent_submissions = []
|
||||
for contact in recent:
|
||||
item = ContactSubmissionOut.from_orm(contact)
|
||||
item.user_email = contact.user.email if contact.user else None
|
||||
item.assigned_to_email = contact.assigned_to.email if contact.assigned_to else None
|
||||
item.resolved_by_email = contact.resolved_by.email if contact.resolved_by else None
|
||||
recent_submissions.append(item)
|
||||
|
||||
return {
|
||||
"total_submissions": total_submissions,
|
||||
"pending_submissions": pending_submissions,
|
||||
"in_progress_submissions": in_progress_submissions,
|
||||
"resolved_submissions": resolved_submissions,
|
||||
"archived_submissions": archived_submissions,
|
||||
"submissions_by_category": submissions_by_category,
|
||||
"average_resolution_time_hours": avg_resolution_time,
|
||||
"recent_submissions": recent_submissions
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", response={200: MessageSchema, 404: ErrorSchema, 403: ErrorSchema})
|
||||
@require_role(['admin'])
|
||||
def delete_contact_submission(request, contact_id: UUID):
|
||||
"""
|
||||
Delete a contact submission (admins only).
|
||||
Use with caution - typically should archive instead.
|
||||
"""
|
||||
try:
|
||||
contact = ContactSubmission.objects.get(id=contact_id)
|
||||
ticket_number = contact.ticket_number
|
||||
contact.delete()
|
||||
|
||||
return 200, {
|
||||
"message": f"Contact submission {ticket_number} deleted",
|
||||
"success": True
|
||||
}
|
||||
|
||||
except ContactSubmission.DoesNotExist:
|
||||
return 404, {"error": "Contact submission not found"}
|
||||
100
django-backend/api/v1/endpoints/history.py
Normal file
100
django-backend/api/v1/endpoints/history.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Generic history endpoints for all entity types.
|
||||
|
||||
Provides cross-entity history operations and utilities.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import Http404
|
||||
from ninja import Router, Query
|
||||
|
||||
from api.v1.services.history_service import HistoryService
|
||||
from api.v1.schemas import (
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
ErrorSchema
|
||||
)
|
||||
|
||||
router = Router(tags=['History'])
|
||||
|
||||
|
||||
@router.get(
|
||||
'/events/{event_id}',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get event by ID",
|
||||
description="Retrieve any historical event by its ID (requires entity_type parameter)"
|
||||
)
|
||||
def get_event_by_id(
|
||||
request,
|
||||
event_id: int,
|
||||
entity_type: str = Query(..., description="Entity type (park, ride, company, ridemodel, review)")
|
||||
):
|
||||
"""Get a specific historical event by ID."""
|
||||
try:
|
||||
event = HistoryService.get_event(entity_type, event_id, request.user)
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
# Get entity info for response
|
||||
entity_id = str(event['entity_id'])
|
||||
entity_name = event.get('entity_name', 'Unknown')
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type,
|
||||
'entity_name': entity_name,
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None # Could add rollback preview logic if needed
|
||||
}
|
||||
|
||||
return response_data
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/compare',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two events",
|
||||
description="Compare two historical events (must be same entity)"
|
||||
)
|
||||
def compare_events(
|
||||
request,
|
||||
entity_type: str = Query(..., description="Entity type (park, ride, company, ridemodel, review)"),
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events."""
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
entity_type, event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found or not accessible"}
|
||||
|
||||
# Format response
|
||||
response_data = {
|
||||
'entity_id': comparison['entity_id'],
|
||||
'entity_type': entity_type,
|
||||
'entity_name': comparison.get('entity_name', 'Unknown'),
|
||||
'event1': comparison['event1'],
|
||||
'event2': comparison['event2'],
|
||||
'differences': comparison['differences'],
|
||||
'changed_field_count': comparison['changed_field_count'],
|
||||
'unchanged_field_count': comparison['unchanged_field_count'],
|
||||
'time_between': comparison['time_between']
|
||||
}
|
||||
|
||||
return response_data
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
550
django-backend/api/v1/endpoints/moderation.py
Normal file
550
django-backend/api/v1/endpoints/moderation.py
Normal file
@@ -0,0 +1,550 @@
|
||||
"""
|
||||
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 apps.users.permissions import jwt_auth, require_auth
|
||||
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}, auth=jwt_auth)
|
||||
@require_auth
|
||||
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.
|
||||
|
||||
**Authentication:** Required
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
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}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def delete_submission(request, submission_id: UUID):
|
||||
"""
|
||||
Delete a submission (only if draft/pending and owned by user).
|
||||
|
||||
**Authentication:** Required
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
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},
|
||||
auth=jwt_auth
|
||||
)
|
||||
@require_auth
|
||||
def start_review(request, submission_id: UUID, data: StartReviewRequest):
|
||||
"""
|
||||
Start reviewing a submission (lock it for 15 minutes).
|
||||
|
||||
Only moderators can start reviews.
|
||||
|
||||
**Authentication:** Required (Moderator role)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
# Check moderator permission
|
||||
if not hasattr(user, 'role') or not user.role.is_moderator:
|
||||
return 403, {'detail': 'Moderator permission required'}
|
||||
|
||||
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},
|
||||
auth=jwt_auth
|
||||
)
|
||||
@require_auth
|
||||
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.
|
||||
|
||||
**Authentication:** Required (Moderator role)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
# Check moderator permission
|
||||
if not hasattr(user, 'role') or not user.role.is_moderator:
|
||||
return 403, {'detail': 'Moderator permission required'}
|
||||
|
||||
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},
|
||||
auth=jwt_auth
|
||||
)
|
||||
@require_auth
|
||||
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.
|
||||
|
||||
**Authentication:** Required (Moderator role)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
# Check moderator permission
|
||||
if not hasattr(user, 'role') or not user.role.is_moderator:
|
||||
return 403, {'detail': 'Moderator permission required'}
|
||||
|
||||
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},
|
||||
auth=jwt_auth
|
||||
)
|
||||
@require_auth
|
||||
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.
|
||||
|
||||
**Authentication:** Required (Moderator role)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
# Check moderator permission
|
||||
if not hasattr(user, 'role') or not user.role.is_moderator:
|
||||
return 403, {'detail': 'Moderator permission required'}
|
||||
|
||||
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},
|
||||
auth=jwt_auth
|
||||
)
|
||||
@require_auth
|
||||
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.
|
||||
|
||||
**Authentication:** Required (Moderator role)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
# Check moderator permission
|
||||
if not hasattr(user, 'role') or not user.role.is_moderator:
|
||||
return 403, {'detail': 'Moderator permission required'}
|
||||
|
||||
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, auth=jwt_auth)
|
||||
@require_auth
|
||||
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.
|
||||
|
||||
**Authentication:** Required
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return {'items': [], 'total': 0, 'page': page, 'page_size': page_size, 'total_pages': 0}
|
||||
|
||||
# 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,
|
||||
}
|
||||
734
django-backend/api/v1/endpoints/parks.py
Normal file
734
django-backend/api/v1/endpoints/parks.py
Normal file
@@ -0,0 +1,734 @@
|
||||
"""
|
||||
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 apps.entities.services.park_submission import ParkSubmissionService
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
ParkCreate,
|
||||
ParkUpdate,
|
||||
ParkOut,
|
||||
ParkListOut,
|
||||
ErrorResponse,
|
||||
HistoryListResponse,
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
HistoryDiffCurrentSchema,
|
||||
FieldHistorySchema,
|
||||
HistoryActivitySummarySchema,
|
||||
RollbackRequestSchema,
|
||||
RollbackResponseSchema,
|
||||
ErrorSchema
|
||||
)
|
||||
from ..services.history_service import HistoryService
|
||||
from django.core.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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, 202: dict, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Create park",
|
||||
description="Create a new park through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def create_park(request, payload: ParkCreate):
|
||||
"""
|
||||
Create a new park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- payload: Park data (name, park_type, operator, coordinates, etc.)
|
||||
|
||||
**Returns:** Created park (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Park created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All parks flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Create park through Sacred Pipeline
|
||||
submission, park = ParkSubmissionService.create_entity_submission(
|
||||
user=user,
|
||||
data=payload.dict(),
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, Park was created immediately
|
||||
if park:
|
||||
logger.info(f"Park created (moderator): {park.id} by {user.email}")
|
||||
park.operator_name = park.operator.name if park.operator else None
|
||||
park.coordinates = park.coordinates
|
||||
return 201, park
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Park submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Park submission pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating park: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{park_id}",
|
||||
response={200: ParkOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Update park",
|
||||
description="Update an existing park through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def update_park(request, park_id: UUID, payload: ParkUpdate):
|
||||
"""
|
||||
Update a park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- park_id: UUID of the park
|
||||
- payload: Updated park data
|
||||
|
||||
**Returns:** Updated park (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
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 park through Sacred Pipeline
|
||||
submission, updated_park = ParkSubmissionService.update_entity_submission(
|
||||
entity=park,
|
||||
user=user,
|
||||
update_data=data,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, park was updated immediately
|
||||
if updated_park:
|
||||
logger.info(f"Park updated (moderator): {updated_park.id} by {user.email}")
|
||||
updated_park.operator_name = updated_park.operator.name if updated_park.operator else None
|
||||
updated_park.coordinates = updated_park.coordinates
|
||||
return 200, updated_park
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Park update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Park update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating park: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{park_id}",
|
||||
response={200: ParkOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Partial update park",
|
||||
description="Partially update an existing park through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def partial_update_park(request, park_id: UUID, payload: ParkUpdate):
|
||||
"""
|
||||
Partially update a park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- park_id: UUID of the park
|
||||
- payload: Fields to update (only provided fields are updated)
|
||||
|
||||
**Returns:** Updated park (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
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 park through Sacred Pipeline
|
||||
submission, updated_park = ParkSubmissionService.update_entity_submission(
|
||||
entity=park,
|
||||
user=user,
|
||||
update_data=data,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, park was updated immediately
|
||||
if updated_park:
|
||||
logger.info(f"Park partially updated (moderator): {updated_park.id} by {user.email}")
|
||||
updated_park.operator_name = updated_park.operator.name if updated_park.operator else None
|
||||
updated_park.coordinates = updated_park.coordinates
|
||||
return 200, updated_park
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Park partial update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Park update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error partially updating park: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{park_id}",
|
||||
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Delete park",
|
||||
description="Delete a park through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def delete_park(request, park_id: UUID):
|
||||
"""
|
||||
Delete a park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- park_id: UUID of the park
|
||||
|
||||
**Returns:** Deletion confirmation (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Park soft-deleted immediately (status set to 'closed')
|
||||
- Regular users: Deletion request created, enters moderation queue
|
||||
|
||||
**Deletion Strategy:**
|
||||
- Soft Delete (default): Sets park status to 'closed', preserves data
|
||||
- Hard Delete: Actually removes from database (moderators only)
|
||||
|
||||
**Note:** All deletions flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
|
||||
|
||||
# Delete park through Sacred Pipeline (soft delete by default)
|
||||
submission, deleted = ParkSubmissionService.delete_entity_submission(
|
||||
entity=park,
|
||||
user=user,
|
||||
deletion_type='soft', # Can be made configurable via query param
|
||||
deletion_reason='', # Can be provided in request body
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, deletion was applied immediately
|
||||
if deleted:
|
||||
logger.info(f"Park deleted (moderator): {park_id} by {user.email}")
|
||||
return 200, {
|
||||
'message': 'Park deleted successfully',
|
||||
'entity_id': str(park_id),
|
||||
'deletion_type': 'soft'
|
||||
}
|
||||
|
||||
# Regular user: deletion pending moderation
|
||||
logger.info(f"Park deletion submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Park deletion request pending moderation. You will be notified when it is approved.',
|
||||
'entity_id': str(park_id)
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting park: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{park_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get park history",
|
||||
description="Get historical changes for a park"
|
||||
)
|
||||
def get_park_history(
|
||||
request,
|
||||
park_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)")
|
||||
):
|
||||
"""Get history for a park."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify park exists
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
|
||||
# Parse dates if provided
|
||||
date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None
|
||||
date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None
|
||||
|
||||
# Get history
|
||||
offset = (page - 1) * page_size
|
||||
events, accessible_count = HistoryService.get_history(
|
||||
'park', str(park_id), request.user,
|
||||
date_from=date_from_obj, date_to=date_to_obj,
|
||||
limit=page_size, offset=offset
|
||||
)
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_events.append({
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'change_summary': event.get('change_summary', ''),
|
||||
'can_rollback': HistoryService.can_rollback(request.user)
|
||||
})
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (accessible_count + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
'entity_id': str(park_id),
|
||||
'entity_type': 'park',
|
||||
'entity_name': park.name,
|
||||
'total_events': accessible_count,
|
||||
'accessible_events': accessible_count,
|
||||
'access_limited': HistoryService.is_access_limited(request.user),
|
||||
'access_reason': HistoryService.get_access_reason(request.user),
|
||||
'events': formatted_events,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': total_pages,
|
||||
'total_items': accessible_count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{park_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific park history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_park_history_event(request, park_id: UUID, event_id: int):
|
||||
"""Get a specific history event for a park."""
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
event = HistoryService.get_event('park', event_id, request.user)
|
||||
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
return {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': str(park_id),
|
||||
'entity_type': 'park',
|
||||
'entity_name': park.name,
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{park_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two park history events",
|
||||
description="Compare two historical events for a park"
|
||||
)
|
||||
def compare_park_history(
|
||||
request,
|
||||
park_id: UUID,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a park."""
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'park', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(park_id),
|
||||
'entity_type': 'park',
|
||||
'entity_name': park.name,
|
||||
'event1': comparison['event1'],
|
||||
'event2': comparison['event2'],
|
||||
'differences': comparison['differences'],
|
||||
'changed_field_count': comparison['changed_field_count'],
|
||||
'unchanged_field_count': comparison['unchanged_field_count'],
|
||||
'time_between': comparison['time_between']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{park_id}/history/{event_id}/diff-current/',
|
||||
response={200: HistoryDiffCurrentSchema, 404: ErrorSchema},
|
||||
summary="Compare historical event with current state",
|
||||
description="Compare a historical event with the current park state"
|
||||
)
|
||||
def diff_park_history_with_current(request, park_id: UUID, event_id: int):
|
||||
"""Compare historical event with current park state."""
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'park', event_id, park, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(park_id),
|
||||
'entity_type': 'park',
|
||||
'entity_name': park.name,
|
||||
'event': diff['event'],
|
||||
'current_state': diff['current_state'],
|
||||
'differences': diff['differences'],
|
||||
'changed_field_count': diff['changed_field_count'],
|
||||
'time_since': diff['time_since']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{park_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback park to historical state",
|
||||
description="Rollback park to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_park(request, park_id: UUID, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback park to a historical state.
|
||||
|
||||
**Permission:** Moderators, Admins, Superusers only
|
||||
"""
|
||||
# Check authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {"error": "Authentication required"}
|
||||
|
||||
# Check rollback permission
|
||||
if not HistoryService.can_rollback(request.user):
|
||||
return 403, {"error": "Only moderators and administrators can perform rollbacks"}
|
||||
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
park, 'park', event_id, request.user,
|
||||
fields=payload.fields,
|
||||
comment=payload.comment,
|
||||
create_backup=payload.create_backup
|
||||
)
|
||||
return result
|
||||
except (ValueError, PermissionError) as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{park_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific park field"
|
||||
)
|
||||
def get_park_field_history(request, park_id: UUID, field_name: str):
|
||||
"""Get history of changes to a specific park field."""
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'park', str(park_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(park_id),
|
||||
'entity_type': 'park',
|
||||
'entity_name': park.name,
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{park_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get park activity summary",
|
||||
description="Get activity summary for a park"
|
||||
)
|
||||
def get_park_activity_summary(request, park_id: UUID):
|
||||
"""Get activity summary for a park."""
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'park', str(park_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(park_id),
|
||||
'entity_type': 'park',
|
||||
'entity_name': park.name,
|
||||
**summary
|
||||
}
|
||||
600
django-backend/api/v1/endpoints/photos.py
Normal file
600
django-backend/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)}
|
||||
263
django-backend/api/v1/endpoints/reports.py
Normal file
263
django-backend/api/v1/endpoints/reports.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Reports API endpoints.
|
||||
|
||||
Handles user-submitted reports for content moderation.
|
||||
"""
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from ninja import Router, Query
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Count, Avg, Q
|
||||
from django.db.models.functions import Extract
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.reports.models import Report
|
||||
from apps.users.permissions import require_role
|
||||
from api.v1.schemas import (
|
||||
ReportOut,
|
||||
ReportCreate,
|
||||
ReportUpdate,
|
||||
ReportListOut,
|
||||
ReportStatsOut,
|
||||
MessageSchema,
|
||||
ErrorResponse,
|
||||
)
|
||||
|
||||
|
||||
router = Router(tags=["Reports"])
|
||||
|
||||
|
||||
def serialize_report(report: Report) -> dict:
|
||||
"""Serialize a report to dict for output."""
|
||||
return {
|
||||
'id': report.id,
|
||||
'entity_type': report.entity_type,
|
||||
'entity_id': report.entity_id,
|
||||
'report_type': report.report_type,
|
||||
'description': report.description,
|
||||
'status': report.status,
|
||||
'reported_by_id': report.reported_by_id,
|
||||
'reported_by_email': report.reported_by.email if report.reported_by else None,
|
||||
'reviewed_by_id': report.reviewed_by_id,
|
||||
'reviewed_by_email': report.reviewed_by.email if report.reviewed_by else None,
|
||||
'reviewed_at': report.reviewed_at,
|
||||
'resolution_notes': report.resolution_notes,
|
||||
'created_at': report.created_at,
|
||||
'updated_at': report.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", response={201: ReportOut, 400: ErrorResponse, 401: ErrorResponse})
|
||||
def create_report(request, data: ReportCreate):
|
||||
"""
|
||||
Submit a report (authenticated users only).
|
||||
|
||||
Allows authenticated users to report inappropriate or inaccurate content.
|
||||
"""
|
||||
# Require authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
# Create report
|
||||
report = Report.objects.create(
|
||||
entity_type=data.entity_type,
|
||||
entity_id=data.entity_id,
|
||||
report_type=data.report_type,
|
||||
description=data.description,
|
||||
reported_by=request.user,
|
||||
status='pending'
|
||||
)
|
||||
|
||||
return 201, serialize_report(report)
|
||||
|
||||
|
||||
@router.get("/", response={200: ReportListOut, 401: ErrorResponse})
|
||||
def list_reports(
|
||||
request,
|
||||
status: str = Query(None, description="Filter by status"),
|
||||
report_type: str = Query(None, description="Filter by report type"),
|
||||
entity_type: str = Query(None, description="Filter by entity type"),
|
||||
entity_id: UUID = Query(None, description="Filter by entity ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
List reports.
|
||||
|
||||
Moderators see all reports. Regular users only see their own reports.
|
||||
"""
|
||||
# Require authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {'detail': 'Authentication required'}
|
||||
|
||||
# Build queryset
|
||||
queryset = Report.objects.all().select_related('reported_by', 'reviewed_by')
|
||||
|
||||
# Filter by user unless moderator
|
||||
is_moderator = hasattr(request.user, 'role') and request.user.role in ['moderator', 'admin']
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(reported_by=request.user)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
if report_type:
|
||||
queryset = queryset.filter(report_type=report_type)
|
||||
if entity_type:
|
||||
queryset = queryset.filter(entity_type=entity_type)
|
||||
if entity_id:
|
||||
queryset = queryset.filter(entity_id=entity_id)
|
||||
|
||||
# Order by date (newest first)
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
# Pagination
|
||||
total = queryset.count()
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
reports = queryset[start:end]
|
||||
|
||||
return 200, {
|
||||
'items': [serialize_report(report) for report in reports],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': total_pages,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{report_id}/", response={200: ReportOut, 404: ErrorResponse, 403: ErrorResponse})
|
||||
def get_report(request, report_id: UUID):
|
||||
"""
|
||||
Get a single report by ID.
|
||||
|
||||
Users can only view their own reports unless they are moderators.
|
||||
"""
|
||||
report = get_object_or_404(
|
||||
Report.objects.select_related('reported_by', 'reviewed_by'),
|
||||
id=report_id
|
||||
)
|
||||
|
||||
# Permission check: must be reporter or moderator
|
||||
is_moderator = hasattr(request.user, 'role') and request.user.role in ['moderator', 'admin']
|
||||
if not is_moderator and report.reported_by != request.user:
|
||||
return 403, {'detail': 'You do not have permission to view this report'}
|
||||
|
||||
return 200, serialize_report(report)
|
||||
|
||||
|
||||
@router.patch("/{report_id}/", response={200: ReportOut, 404: ErrorResponse, 403: ErrorResponse})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def update_report(request, report_id: UUID, data: ReportUpdate):
|
||||
"""
|
||||
Update a report (moderators only).
|
||||
|
||||
Allows moderators to update report status and add resolution notes.
|
||||
"""
|
||||
report = get_object_or_404(Report, id=report_id)
|
||||
|
||||
# Update fields if provided
|
||||
update_fields = []
|
||||
|
||||
if data.status is not None:
|
||||
report.status = data.status
|
||||
update_fields.append('status')
|
||||
|
||||
# If status is being changed to resolved/dismissed, set reviewed fields
|
||||
if data.status in ['resolved', 'dismissed'] and not report.reviewed_by:
|
||||
report.reviewed_by = request.user
|
||||
report.reviewed_at = timezone.now()
|
||||
update_fields.extend(['reviewed_by', 'reviewed_at'])
|
||||
|
||||
if data.resolution_notes is not None:
|
||||
report.resolution_notes = data.resolution_notes
|
||||
update_fields.append('resolution_notes')
|
||||
|
||||
if update_fields:
|
||||
update_fields.append('updated_at')
|
||||
report.save(update_fields=update_fields)
|
||||
|
||||
return 200, serialize_report(report)
|
||||
|
||||
|
||||
@router.get("/stats/", response={200: ReportStatsOut, 403: ErrorResponse})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def get_report_stats(request):
|
||||
"""
|
||||
Get report statistics (moderators only).
|
||||
|
||||
Returns various statistics about reports for moderation purposes.
|
||||
"""
|
||||
queryset = Report.objects.all()
|
||||
|
||||
# Count by status
|
||||
total_reports = queryset.count()
|
||||
pending_reports = queryset.filter(status='pending').count()
|
||||
reviewing_reports = queryset.filter(status='reviewing').count()
|
||||
resolved_reports = queryset.filter(status='resolved').count()
|
||||
dismissed_reports = queryset.filter(status='dismissed').count()
|
||||
|
||||
# Count by report type
|
||||
reports_by_type = dict(
|
||||
queryset.values('report_type')
|
||||
.annotate(count=Count('id'))
|
||||
.values_list('report_type', 'count')
|
||||
)
|
||||
|
||||
# Count by entity type
|
||||
reports_by_entity_type = dict(
|
||||
queryset.values('entity_type')
|
||||
.annotate(count=Count('id'))
|
||||
.values_list('entity_type', 'count')
|
||||
)
|
||||
|
||||
# Calculate average resolution time for resolved/dismissed reports
|
||||
resolved_queryset = queryset.filter(
|
||||
status__in=['resolved', 'dismissed'],
|
||||
reviewed_at__isnull=False
|
||||
)
|
||||
|
||||
avg_resolution_time = None
|
||||
if resolved_queryset.exists():
|
||||
# Calculate time difference in hours
|
||||
from django.db.models import F, ExpressionWrapper, DurationField
|
||||
from datetime import timedelta
|
||||
|
||||
time_diffs = []
|
||||
for report in resolved_queryset:
|
||||
if report.reviewed_at and report.created_at:
|
||||
diff = (report.reviewed_at - report.created_at).total_seconds() / 3600
|
||||
time_diffs.append(diff)
|
||||
|
||||
if time_diffs:
|
||||
avg_resolution_time = sum(time_diffs) / len(time_diffs)
|
||||
|
||||
return 200, {
|
||||
'total_reports': total_reports,
|
||||
'pending_reports': pending_reports,
|
||||
'reviewing_reports': reviewing_reports,
|
||||
'resolved_reports': resolved_reports,
|
||||
'dismissed_reports': dismissed_reports,
|
||||
'reports_by_type': reports_by_type,
|
||||
'reports_by_entity_type': reports_by_entity_type,
|
||||
'average_resolution_time_hours': avg_resolution_time,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{report_id}/", response={200: MessageSchema, 404: ErrorResponse, 403: ErrorResponse})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def delete_report(request, report_id: UUID):
|
||||
"""
|
||||
Delete a report (moderators only).
|
||||
|
||||
Permanently removes a report from the system.
|
||||
"""
|
||||
report = get_object_or_404(Report, id=report_id)
|
||||
report.delete()
|
||||
|
||||
return 200, {
|
||||
'message': 'Report deleted successfully',
|
||||
'success': True
|
||||
}
|
||||
844
django-backend/api/v1/endpoints/reviews.py
Normal file
844
django-backend/api/v1/endpoints/reviews.py
Normal file
@@ -0,0 +1,844 @@
|
||||
"""
|
||||
Review endpoints for API v1.
|
||||
|
||||
Provides CRUD operations for reviews with moderation workflow integration.
|
||||
Users can review parks and rides, vote on reviews, and moderators can approve/reject.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from ninja import Router, Query
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
import logging
|
||||
|
||||
from apps.reviews.models import Review, ReviewHelpfulVote
|
||||
from apps.reviews.services import ReviewSubmissionService
|
||||
from apps.entities.models import Park, Ride
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
ReviewCreateSchema,
|
||||
ReviewUpdateSchema,
|
||||
ReviewOut,
|
||||
ReviewListOut,
|
||||
ReviewStatsOut,
|
||||
VoteRequest,
|
||||
VoteResponse,
|
||||
ErrorResponse,
|
||||
UserSchema,
|
||||
HistoryListResponse,
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
HistoryDiffCurrentSchema,
|
||||
FieldHistorySchema,
|
||||
HistoryActivitySummarySchema,
|
||||
RollbackRequestSchema,
|
||||
RollbackResponseSchema,
|
||||
ErrorSchema,
|
||||
)
|
||||
from ..services.history_service import HistoryService
|
||||
|
||||
router = Router(tags=["Reviews"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReviewPagination(PageNumberPagination):
|
||||
"""Custom pagination for reviews."""
|
||||
page_size = 50
|
||||
|
||||
|
||||
def _get_entity(entity_type: str, entity_id: UUID):
|
||||
"""Helper to get and validate entity (Park or Ride)."""
|
||||
if entity_type == 'park':
|
||||
return get_object_or_404(Park, id=entity_id), ContentType.objects.get_for_model(Park)
|
||||
elif entity_type == 'ride':
|
||||
return get_object_or_404(Ride, id=entity_id), ContentType.objects.get_for_model(Ride)
|
||||
else:
|
||||
raise ValidationError(f"Invalid entity_type: {entity_type}")
|
||||
|
||||
|
||||
def _serialize_review(review: Review, user=None) -> dict:
|
||||
"""Serialize review with computed fields."""
|
||||
data = {
|
||||
'id': review.id,
|
||||
'user': UserSchema(
|
||||
id=review.user.id,
|
||||
username=review.user.username,
|
||||
display_name=review.user.display_name,
|
||||
avatar_url=review.user.avatar_url,
|
||||
reputation_score=review.user.reputation_score,
|
||||
),
|
||||
'entity_type': review.content_type.model,
|
||||
'entity_id': str(review.object_id),
|
||||
'entity_name': str(review.content_object) if review.content_object else 'Unknown',
|
||||
'title': review.title,
|
||||
'content': review.content,
|
||||
'rating': review.rating,
|
||||
'visit_date': review.visit_date,
|
||||
'wait_time_minutes': review.wait_time_minutes,
|
||||
'helpful_votes': review.helpful_votes,
|
||||
'total_votes': review.total_votes,
|
||||
'helpful_percentage': review.helpful_percentage,
|
||||
'moderation_status': review.moderation_status,
|
||||
'moderated_at': review.moderated_at,
|
||||
'moderated_by_email': review.moderated_by.email if review.moderated_by else None,
|
||||
'photo_count': review.photos.count(),
|
||||
'created': review.created,
|
||||
'modified': review.modified,
|
||||
'user_vote': None,
|
||||
}
|
||||
|
||||
# Add user's vote if authenticated
|
||||
if user and user.is_authenticated:
|
||||
try:
|
||||
vote = ReviewHelpfulVote.objects.get(review=review, user=user)
|
||||
data['user_vote'] = vote.is_helpful
|
||||
except ReviewHelpfulVote.DoesNotExist:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Review CRUD Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/", response={201: ReviewOut, 400: ErrorResponse, 409: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def create_review(request, data: ReviewCreateSchema):
|
||||
"""
|
||||
Create a new review for a park or ride through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- entity_type: "park" or "ride"
|
||||
- entity_id: UUID of park or ride
|
||||
- title: Review title
|
||||
- content: Review content (min 10 characters)
|
||||
- rating: 1-5 stars
|
||||
- visit_date: Optional visit date
|
||||
- wait_time_minutes: Optional wait time
|
||||
|
||||
**Returns:** Created review or submission confirmation
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Review created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All reviews flow through ContentSubmission pipeline.
|
||||
Users can only create one review per entity.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Get and validate entity
|
||||
entity, content_type = _get_entity(data.entity_type, data.entity_id)
|
||||
|
||||
# Create review through Sacred Pipeline
|
||||
submission, review = ReviewSubmissionService.create_review_submission(
|
||||
user=user,
|
||||
entity=entity,
|
||||
rating=data.rating,
|
||||
title=data.title,
|
||||
content=data.content,
|
||||
visit_date=data.visit_date,
|
||||
wait_time_minutes=data.wait_time_minutes,
|
||||
source='api'
|
||||
)
|
||||
|
||||
# If moderator bypass happened, Review was created immediately
|
||||
if review:
|
||||
logger.info(f"Review created (moderator): {review.id} by {user.email}")
|
||||
review_data = _serialize_review(review, user)
|
||||
return 201, review_data
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Review submission created: {submission.id} by {user.email}")
|
||||
return 201, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': 'pending_moderation',
|
||||
'message': 'Review submitted for moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating review: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.get("/", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def list_reviews(
|
||||
request,
|
||||
entity_type: Optional[str] = Query(None, description="Filter by entity type: park or ride"),
|
||||
entity_id: Optional[UUID] = Query(None, description="Filter by specific entity ID"),
|
||||
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
|
||||
rating: Optional[int] = Query(None, ge=1, le=5, description="Filter by rating"),
|
||||
moderation_status: Optional[str] = Query(None, description="Filter by moderation status"),
|
||||
ordering: Optional[str] = Query("-created", description="Sort by field")
|
||||
):
|
||||
"""
|
||||
List reviews with optional filtering.
|
||||
|
||||
**Authentication:** Optional (only approved reviews shown if not authenticated/not moderator)
|
||||
|
||||
**Filters:**
|
||||
- entity_type: park or ride
|
||||
- entity_id: Specific park/ride
|
||||
- user_id: Reviews by specific user
|
||||
- rating: Filter by star rating
|
||||
- moderation_status: pending/approved/rejected (moderators only)
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of reviews
|
||||
"""
|
||||
# Base query with optimizations
|
||||
queryset = Review.objects.select_related(
|
||||
'user',
|
||||
'moderated_by',
|
||||
'content_type'
|
||||
).prefetch_related('photos')
|
||||
|
||||
# Check if user is authenticated and is moderator
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
# Apply moderation filter
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
# Apply entity type filter
|
||||
if entity_type:
|
||||
if entity_type == 'park':
|
||||
ct = ContentType.objects.get_for_model(Park)
|
||||
elif entity_type == 'ride':
|
||||
ct = ContentType.objects.get_for_model(Ride)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
queryset = queryset.filter(content_type=ct)
|
||||
|
||||
# Apply entity ID filter
|
||||
if entity_id:
|
||||
queryset = queryset.filter(object_id=entity_id)
|
||||
|
||||
# Apply user filter
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
# Apply rating filter
|
||||
if rating:
|
||||
queryset = queryset.filter(rating=rating)
|
||||
|
||||
# Apply moderation status filter (moderators only)
|
||||
if moderation_status and is_moderator:
|
||||
queryset = queryset.filter(moderation_status=moderation_status)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
# Serialize reviews
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
@router.get("/{review_id}", response={200: ReviewOut, 404: ErrorResponse})
|
||||
def get_review(request, review_id: int):
|
||||
"""
|
||||
Get a specific review by ID.
|
||||
|
||||
**Authentication:** Optional
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
|
||||
**Returns:** Review details
|
||||
|
||||
**Note:** Only approved reviews are accessible to non-moderators.
|
||||
"""
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
is_owner = user and Review.objects.filter(id=review_id, user=user).exists() if user else False
|
||||
|
||||
review = get_object_or_404(
|
||||
Review.objects.select_related('user', 'moderated_by', 'content_type').prefetch_related('photos'),
|
||||
id=review_id
|
||||
)
|
||||
|
||||
# Check access
|
||||
if not review.is_approved and not is_moderator and not is_owner:
|
||||
return 404, {'detail': 'Review not found'}
|
||||
|
||||
review_data = _serialize_review(review, user)
|
||||
return 200, review_data
|
||||
|
||||
|
||||
@router.put("/{review_id}", response={200: ReviewOut, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def update_review(request, review_id: int, data: ReviewUpdateSchema):
|
||||
"""
|
||||
Update your own review.
|
||||
|
||||
**Authentication:** Required (must be review owner)
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
- data: Fields to update
|
||||
|
||||
**Returns:** Updated review
|
||||
|
||||
**Note:** Updating a review resets it to pending moderation.
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
review = get_object_or_404(
|
||||
Review.objects.select_related('user', 'content_type'),
|
||||
id=review_id
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if review.user != user:
|
||||
return 403, {'detail': 'You can only update your own reviews'}
|
||||
|
||||
# Update fields
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(review, key, value)
|
||||
|
||||
# Reset to pending moderation
|
||||
review.moderation_status = Review.MODERATION_PENDING
|
||||
review.moderated_at = None
|
||||
review.moderated_by = None
|
||||
review.moderation_notes = ''
|
||||
review.save()
|
||||
|
||||
logger.info(f"Review updated: {review.id} by {user.email}")
|
||||
|
||||
review_data = _serialize_review(review, user)
|
||||
return 200, review_data
|
||||
|
||||
|
||||
@router.delete("/{review_id}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def delete_review(request, review_id: int):
|
||||
"""
|
||||
Delete your own review.
|
||||
|
||||
**Authentication:** Required (must be review owner)
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
|
||||
**Returns:** No content (204)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
# Check ownership
|
||||
if review.user != user:
|
||||
return 403, {'detail': 'You can only delete your own reviews'}
|
||||
|
||||
logger.info(f"Review deleted: {review.id} by {user.email}")
|
||||
review.delete()
|
||||
|
||||
return 204, None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Voting Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/{review_id}/vote", response={200: VoteResponse, 400: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def vote_on_review(request, review_id: int, data: VoteRequest):
|
||||
"""
|
||||
Vote on a review (helpful or not helpful).
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
- is_helpful: True if helpful, False if not helpful
|
||||
|
||||
**Returns:** Updated vote counts
|
||||
|
||||
**Note:** Users can change their vote but cannot vote on their own reviews.
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
# Prevent self-voting
|
||||
if review.user == user:
|
||||
return 400, {'detail': 'You cannot vote on your own review'}
|
||||
|
||||
# Create or update vote
|
||||
vote, created = ReviewHelpfulVote.objects.update_or_create(
|
||||
review=review,
|
||||
user=user,
|
||||
defaults={'is_helpful': data.is_helpful}
|
||||
)
|
||||
|
||||
# Refresh review to get updated counts
|
||||
review.refresh_from_db()
|
||||
|
||||
return 200, {
|
||||
'success': True,
|
||||
'review_id': review.id,
|
||||
'helpful_votes': review.helpful_votes,
|
||||
'total_votes': review.total_votes,
|
||||
'helpful_percentage': review.helpful_percentage,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Entity-Specific Review Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/parks/{park_id}", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def get_park_reviews(
|
||||
request,
|
||||
park_id: UUID,
|
||||
rating: Optional[int] = Query(None, ge=1, le=5),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
Get all reviews for a specific park.
|
||||
|
||||
**Parameters:**
|
||||
- park_id: Park UUID
|
||||
- rating: Optional rating filter
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of park reviews
|
||||
"""
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
content_type = ContentType.objects.get_for_model(Park)
|
||||
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
queryset = Review.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=park.id
|
||||
).select_related('user', 'moderated_by').prefetch_related('photos')
|
||||
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
if rating:
|
||||
queryset = queryset.filter(rating=rating)
|
||||
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
@router.get("/rides/{ride_id}", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def get_ride_reviews(
|
||||
request,
|
||||
ride_id: UUID,
|
||||
rating: Optional[int] = Query(None, ge=1, le=5),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
Get all reviews for a specific ride.
|
||||
|
||||
**Parameters:**
|
||||
- ride_id: Ride UUID
|
||||
- rating: Optional rating filter
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of ride reviews
|
||||
"""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
content_type = ContentType.objects.get_for_model(Ride)
|
||||
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
queryset = Review.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=ride.id
|
||||
).select_related('user', 'moderated_by').prefetch_related('photos')
|
||||
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
if rating:
|
||||
queryset = queryset.filter(rating=rating)
|
||||
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def get_user_reviews(
|
||||
request,
|
||||
user_id: UUID,
|
||||
entity_type: Optional[str] = Query(None),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
Get all reviews by a specific user.
|
||||
|
||||
**Parameters:**
|
||||
- user_id: User UUID
|
||||
- entity_type: Optional filter (park or ride)
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of user's reviews
|
||||
|
||||
**Note:** Only approved reviews visible unless viewing own reviews or moderator.
|
||||
"""
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_owner = user and str(user.id) == str(user_id) if user else False
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
queryset = Review.objects.filter(
|
||||
user_id=user_id
|
||||
).select_related('user', 'moderated_by', 'content_type').prefetch_related('photos')
|
||||
|
||||
# Filter by moderation status
|
||||
if not is_owner and not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
# Apply entity type filter
|
||||
if entity_type:
|
||||
if entity_type == 'park':
|
||||
ct = ContentType.objects.get_for_model(Park)
|
||||
elif entity_type == 'ride':
|
||||
ct = ContentType.objects.get_for_model(Ride)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
queryset = queryset.filter(content_type=ct)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Statistics Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/stats/{entity_type}/{entity_id}", response={200: ReviewStatsOut, 404: ErrorResponse})
|
||||
def get_review_stats(request, entity_type: str, entity_id: UUID):
|
||||
"""
|
||||
Get review statistics for a park or ride.
|
||||
|
||||
**Parameters:**
|
||||
- entity_type: "park" or "ride"
|
||||
- entity_id: Entity UUID
|
||||
|
||||
**Returns:** Statistics including average rating and distribution
|
||||
"""
|
||||
try:
|
||||
entity, content_type = _get_entity(entity_type, entity_id)
|
||||
except ValidationError as e:
|
||||
return 404, {'detail': str(e)}
|
||||
|
||||
# Get approved reviews only
|
||||
reviews = Review.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=entity.id,
|
||||
moderation_status=Review.MODERATION_APPROVED
|
||||
)
|
||||
|
||||
# Calculate stats
|
||||
stats = reviews.aggregate(
|
||||
average_rating=Avg('rating'),
|
||||
total_reviews=Count('id')
|
||||
)
|
||||
|
||||
# Get rating distribution
|
||||
distribution = {}
|
||||
for rating in range(1, 6):
|
||||
distribution[rating] = reviews.filter(rating=rating).count()
|
||||
|
||||
return 200, {
|
||||
'average_rating': stats['average_rating'] or 0.0,
|
||||
'total_reviews': stats['total_reviews'] or 0,
|
||||
'rating_distribution': distribution,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get review history",
|
||||
description="Get historical changes for a review"
|
||||
)
|
||||
def get_review_history(
|
||||
request,
|
||||
review_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)")
|
||||
):
|
||||
"""Get history for a review."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify review exists
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
# Parse dates if provided
|
||||
date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None
|
||||
date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None
|
||||
|
||||
# Get history
|
||||
offset = (page - 1) * page_size
|
||||
events, accessible_count = HistoryService.get_history(
|
||||
'review', str(review_id), request.user,
|
||||
date_from=date_from_obj, date_to=date_to_obj,
|
||||
limit=page_size, offset=offset
|
||||
)
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_events.append({
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'change_summary': event.get('change_summary', ''),
|
||||
'can_rollback': HistoryService.can_rollback(request.user)
|
||||
})
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (accessible_count + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'total_events': accessible_count,
|
||||
'accessible_events': accessible_count,
|
||||
'access_limited': HistoryService.is_access_limited(request.user),
|
||||
'access_reason': HistoryService.get_access_reason(request.user),
|
||||
'events': formatted_events,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': total_pages,
|
||||
'total_items': accessible_count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific review history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_review_history_event(request, review_id: int, event_id: int):
|
||||
"""Get a specific history event for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
event = HistoryService.get_event('review', event_id, request.user)
|
||||
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
return {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two review history events",
|
||||
description="Compare two historical events for a review"
|
||||
)
|
||||
def compare_review_history(
|
||||
request,
|
||||
review_id: int,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'review', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'event1': comparison['event1'],
|
||||
'event2': comparison['event2'],
|
||||
'differences': comparison['differences'],
|
||||
'changed_field_count': comparison['changed_field_count'],
|
||||
'unchanged_field_count': comparison['unchanged_field_count'],
|
||||
'time_between': comparison['time_between']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/{event_id}/diff-current/',
|
||||
response={200: HistoryDiffCurrentSchema, 404: ErrorSchema},
|
||||
summary="Compare historical event with current state",
|
||||
description="Compare a historical event with the current review state"
|
||||
)
|
||||
def diff_review_history_with_current(request, review_id: int, event_id: int):
|
||||
"""Compare historical event with current review state."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'review', event_id, review, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'event': diff['event'],
|
||||
'current_state': diff['current_state'],
|
||||
'differences': diff['differences'],
|
||||
'changed_field_count': diff['changed_field_count'],
|
||||
'time_since': diff['time_since']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{review_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback review to historical state",
|
||||
description="Rollback review to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_review(request, review_id: int, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback review to a historical state.
|
||||
|
||||
**Permission:** Moderators, Admins, Superusers only
|
||||
"""
|
||||
# Check authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {"error": "Authentication required"}
|
||||
|
||||
# Check rollback permission
|
||||
if not HistoryService.can_rollback(request.user):
|
||||
return 403, {"error": "Only moderators and administrators can perform rollbacks"}
|
||||
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
review, 'review', event_id, request.user,
|
||||
fields=payload.fields,
|
||||
comment=payload.comment,
|
||||
create_backup=payload.create_backup
|
||||
)
|
||||
return result
|
||||
except (ValueError, PermissionError) as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific review field"
|
||||
)
|
||||
def get_review_field_history(request, review_id: int, field_name: str):
|
||||
"""Get history of changes to a specific review field."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'review', str(review_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get review activity summary",
|
||||
description="Get activity summary for a review"
|
||||
)
|
||||
def get_review_activity_summary(request, review_id: int):
|
||||
"""Get activity summary for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'review', str(review_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
**summary
|
||||
}
|
||||
410
django-backend/api/v1/endpoints/ride_credits.py
Normal file
410
django-backend/api/v1/endpoints/ride_credits.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
Ride Credit endpoints for API v1.
|
||||
|
||||
Provides CRUD operations for tracking which rides users have ridden (coaster counting).
|
||||
Users can log rides, track ride counts, and view statistics.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import date
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Count, Sum, Min, Max, Q
|
||||
from ninja import Router, Query
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
import logging
|
||||
|
||||
from apps.users.models import UserRideCredit, User
|
||||
from apps.entities.models import Ride
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
RideCreditCreateSchema,
|
||||
RideCreditUpdateSchema,
|
||||
RideCreditOut,
|
||||
RideCreditListOut,
|
||||
RideCreditStatsOut,
|
||||
ErrorResponse,
|
||||
UserSchema,
|
||||
)
|
||||
|
||||
router = Router(tags=["Ride Credits"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideCreditPagination(PageNumberPagination):
|
||||
"""Custom pagination for ride credits."""
|
||||
page_size = 50
|
||||
|
||||
|
||||
def _serialize_ride_credit(credit: UserRideCredit) -> dict:
|
||||
"""Serialize ride credit with computed fields."""
|
||||
ride = credit.ride
|
||||
park = ride.park
|
||||
|
||||
return {
|
||||
'id': credit.id,
|
||||
'user': UserSchema(
|
||||
id=credit.user.id,
|
||||
username=credit.user.username,
|
||||
display_name=credit.user.display_name,
|
||||
avatar_url=credit.user.avatar_url,
|
||||
reputation_score=credit.user.reputation_score,
|
||||
),
|
||||
'ride_id': str(ride.id),
|
||||
'ride_name': ride.name,
|
||||
'ride_slug': ride.slug,
|
||||
'park_id': str(park.id),
|
||||
'park_name': park.name,
|
||||
'park_slug': park.slug,
|
||||
'is_coaster': ride.is_coaster,
|
||||
'first_ride_date': credit.first_ride_date,
|
||||
'ride_count': credit.ride_count,
|
||||
'notes': credit.notes or '',
|
||||
'created': credit.created,
|
||||
'modified': credit.modified,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Ride Credit CRUD Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/", response={201: RideCreditOut, 400: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def create_ride_credit(request, data: RideCreditCreateSchema):
|
||||
"""
|
||||
Log a ride (create or update ride credit).
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- ride_id: UUID of ride
|
||||
- first_ride_date: Date of first ride (optional)
|
||||
- ride_count: Number of times ridden (default: 1)
|
||||
- notes: Notes about the ride experience (optional)
|
||||
|
||||
**Returns:** Created or updated ride credit
|
||||
|
||||
**Note:** If a credit already exists, it updates the ride_count.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Validate ride exists
|
||||
ride = get_object_or_404(Ride, id=data.ride_id)
|
||||
|
||||
# Check if credit already exists
|
||||
credit, created = UserRideCredit.objects.get_or_create(
|
||||
user=user,
|
||||
ride=ride,
|
||||
defaults={
|
||||
'first_ride_date': data.first_ride_date,
|
||||
'ride_count': data.ride_count,
|
||||
'notes': data.notes or '',
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update existing credit
|
||||
credit.ride_count += data.ride_count
|
||||
if data.first_ride_date and (not credit.first_ride_date or data.first_ride_date < credit.first_ride_date):
|
||||
credit.first_ride_date = data.first_ride_date
|
||||
if data.notes:
|
||||
credit.notes = data.notes
|
||||
credit.save()
|
||||
|
||||
logger.info(f"Ride credit {'created' if created else 'updated'}: {credit.id} by {user.email}")
|
||||
|
||||
credit_data = _serialize_ride_credit(credit)
|
||||
return 201, credit_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride credit: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.get("/", response={200: List[RideCreditOut]}, auth=jwt_auth)
|
||||
@require_auth
|
||||
@paginate(RideCreditPagination)
|
||||
def list_my_ride_credits(
|
||||
request,
|
||||
ride_id: Optional[UUID] = Query(None, description="Filter by ride"),
|
||||
park_id: Optional[UUID] = Query(None, description="Filter by park"),
|
||||
is_coaster: Optional[bool] = Query(None, description="Filter coasters only"),
|
||||
date_from: Optional[date] = Query(None, description="Credits from date"),
|
||||
date_to: Optional[date] = Query(None, description="Credits to date"),
|
||||
ordering: Optional[str] = Query("-first_ride_date", description="Sort by field")
|
||||
):
|
||||
"""
|
||||
List your own ride credits.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Filters:**
|
||||
- ride_id: Specific ride
|
||||
- park_id: Rides at specific park
|
||||
- is_coaster: Coasters only
|
||||
- date_from: Credits from date
|
||||
- date_to: Credits to date
|
||||
- ordering: Sort field (default: -first_ride_date)
|
||||
|
||||
**Returns:** Paginated list of your ride credits
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
# Base query with optimizations
|
||||
queryset = UserRideCredit.objects.filter(user=user).select_related('ride__park')
|
||||
|
||||
# Apply ride filter
|
||||
if ride_id:
|
||||
queryset = queryset.filter(ride_id=ride_id)
|
||||
|
||||
# Apply park filter
|
||||
if park_id:
|
||||
queryset = queryset.filter(ride__park_id=park_id)
|
||||
|
||||
# Apply coaster filter
|
||||
if is_coaster is not None:
|
||||
queryset = queryset.filter(ride__is_coaster=is_coaster)
|
||||
|
||||
# Apply date filters
|
||||
if date_from:
|
||||
queryset = queryset.filter(first_ride_date__gte=date_from)
|
||||
if date_to:
|
||||
queryset = queryset.filter(first_ride_date__lte=date_to)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['first_ride_date', 'ride_count', 'created', 'modified']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-first_ride_date')
|
||||
|
||||
# Serialize credits
|
||||
credits = [_serialize_ride_credit(credit) for credit in queryset]
|
||||
return credits
|
||||
|
||||
|
||||
@router.get("/{credit_id}", response={200: RideCreditOut, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def get_ride_credit(request, credit_id: UUID):
|
||||
"""
|
||||
Get a specific ride credit by ID.
|
||||
|
||||
**Authentication:** Required (must be credit owner)
|
||||
|
||||
**Parameters:**
|
||||
- credit_id: Credit UUID
|
||||
|
||||
**Returns:** Credit details
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
credit = get_object_or_404(
|
||||
UserRideCredit.objects.select_related('ride__park'),
|
||||
id=credit_id
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if credit.user != user:
|
||||
return 403, {'detail': 'You can only view your own ride credits'}
|
||||
|
||||
credit_data = _serialize_ride_credit(credit)
|
||||
return 200, credit_data
|
||||
|
||||
|
||||
@router.put("/{credit_id}", response={200: RideCreditOut, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def update_ride_credit(request, credit_id: UUID, data: RideCreditUpdateSchema):
|
||||
"""
|
||||
Update a ride credit.
|
||||
|
||||
**Authentication:** Required (must be credit owner)
|
||||
|
||||
**Parameters:**
|
||||
- credit_id: Credit UUID
|
||||
- data: Fields to update
|
||||
|
||||
**Returns:** Updated credit
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
credit = get_object_or_404(
|
||||
UserRideCredit.objects.select_related('ride__park'),
|
||||
id=credit_id
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if credit.user != user:
|
||||
return 403, {'detail': 'You can only update your own ride credits'}
|
||||
|
||||
# Update fields
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(credit, key, value)
|
||||
|
||||
credit.save()
|
||||
|
||||
logger.info(f"Ride credit updated: {credit.id} by {user.email}")
|
||||
|
||||
credit_data = _serialize_ride_credit(credit)
|
||||
return 200, credit_data
|
||||
|
||||
|
||||
@router.delete("/{credit_id}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def delete_ride_credit(request, credit_id: UUID):
|
||||
"""
|
||||
Delete a ride credit.
|
||||
|
||||
**Authentication:** Required (must be credit owner)
|
||||
|
||||
**Parameters:**
|
||||
- credit_id: Credit UUID
|
||||
|
||||
**Returns:** No content (204)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
credit = get_object_or_404(UserRideCredit, id=credit_id)
|
||||
|
||||
# Check ownership
|
||||
if credit.user != user:
|
||||
return 403, {'detail': 'You can only delete your own ride credits'}
|
||||
|
||||
logger.info(f"Ride credit deleted: {credit.id} by {user.email}")
|
||||
credit.delete()
|
||||
|
||||
return 204, None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User-Specific Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/users/{user_id}", response={200: List[RideCreditOut], 403: ErrorResponse})
|
||||
@paginate(RideCreditPagination)
|
||||
def get_user_ride_credits(
|
||||
request,
|
||||
user_id: UUID,
|
||||
park_id: Optional[UUID] = Query(None),
|
||||
is_coaster: Optional[bool] = Query(None),
|
||||
ordering: Optional[str] = Query("-first_ride_date")
|
||||
):
|
||||
"""
|
||||
Get a user's ride credits.
|
||||
|
||||
**Authentication:** Optional (respects privacy settings)
|
||||
|
||||
**Parameters:**
|
||||
- user_id: User UUID
|
||||
- park_id: Filter by park (optional)
|
||||
- is_coaster: Filter coasters only (optional)
|
||||
- ordering: Sort field (default: -first_ride_date)
|
||||
|
||||
**Returns:** Paginated list of user's ride credits
|
||||
|
||||
**Note:** Only visible if user's profile is public or viewer is the owner.
|
||||
"""
|
||||
target_user = get_object_or_404(User, id=user_id)
|
||||
|
||||
# Check if current user
|
||||
current_user = request.auth if hasattr(request, 'auth') else None
|
||||
is_owner = current_user and current_user.id == target_user.id
|
||||
|
||||
# Check privacy
|
||||
if not is_owner:
|
||||
# Check if profile is public
|
||||
try:
|
||||
profile = target_user.profile
|
||||
if not profile.profile_public:
|
||||
return 403, {'detail': 'This user\'s ride credits are private'}
|
||||
except:
|
||||
return 403, {'detail': 'This user\'s ride credits are private'}
|
||||
|
||||
# Build query
|
||||
queryset = UserRideCredit.objects.filter(user=target_user).select_related('ride__park')
|
||||
|
||||
# Apply filters
|
||||
if park_id:
|
||||
queryset = queryset.filter(ride__park_id=park_id)
|
||||
|
||||
if is_coaster is not None:
|
||||
queryset = queryset.filter(ride__is_coaster=is_coaster)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['first_ride_date', 'ride_count', 'created']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-first_ride_date')
|
||||
|
||||
# Serialize credits
|
||||
credits = [_serialize_ride_credit(credit) for credit in queryset]
|
||||
return credits
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/stats", response={200: RideCreditStatsOut, 403: ErrorResponse})
|
||||
def get_user_ride_stats(request, user_id: UUID):
|
||||
"""
|
||||
Get statistics about a user's ride credits.
|
||||
|
||||
**Authentication:** Optional (respects privacy settings)
|
||||
|
||||
**Parameters:**
|
||||
- user_id: User UUID
|
||||
|
||||
**Returns:** Statistics including total rides, credits, parks, etc.
|
||||
"""
|
||||
target_user = get_object_or_404(User, id=user_id)
|
||||
|
||||
# Check if current user
|
||||
current_user = request.auth if hasattr(request, 'auth') else None
|
||||
is_owner = current_user and current_user.id == target_user.id
|
||||
|
||||
# Check privacy
|
||||
if not is_owner:
|
||||
try:
|
||||
profile = target_user.profile
|
||||
if not profile.profile_public:
|
||||
return 403, {'detail': 'This user\'s statistics are private'}
|
||||
except:
|
||||
return 403, {'detail': 'This user\'s statistics are private'}
|
||||
|
||||
# Get all credits
|
||||
credits = UserRideCredit.objects.filter(user=target_user).select_related('ride__park')
|
||||
|
||||
# Calculate basic stats
|
||||
stats = credits.aggregate(
|
||||
total_rides=Sum('ride_count'),
|
||||
total_credits=Count('id'),
|
||||
unique_parks=Count('ride__park', distinct=True),
|
||||
coaster_count=Count('id', filter=Q(ride__is_coaster=True)),
|
||||
first_credit_date=Min('first_ride_date'),
|
||||
last_credit_date=Max('first_ride_date'),
|
||||
)
|
||||
|
||||
# Get top park
|
||||
park_counts = credits.values('ride__park__name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count').first()
|
||||
|
||||
top_park = park_counts['ride__park__name'] if park_counts else None
|
||||
top_park_count = park_counts['count'] if park_counts else 0
|
||||
|
||||
# Get recent credits (last 5)
|
||||
recent_credits = credits.order_by('-first_ride_date')[:5]
|
||||
recent_credits_data = [_serialize_ride_credit(c) for c in recent_credits]
|
||||
|
||||
return 200, {
|
||||
'total_rides': stats['total_rides'] or 0,
|
||||
'total_credits': stats['total_credits'] or 0,
|
||||
'unique_parks': stats['unique_parks'] or 0,
|
||||
'coaster_count': stats['coaster_count'] or 0,
|
||||
'first_credit_date': stats['first_credit_date'],
|
||||
'last_credit_date': stats['last_credit_date'],
|
||||
'top_park': top_park,
|
||||
'top_park_count': top_park_count,
|
||||
'recent_credits': recent_credits_data,
|
||||
}
|
||||
640
django-backend/api/v1/endpoints/ride_models.py
Normal file
640
django-backend/api/v1/endpoints/ride_models.py
Normal file
@@ -0,0 +1,640 @@
|
||||
"""
|
||||
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 apps.entities.services.ride_model_submission import RideModelSubmissionService
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
RideModelCreate,
|
||||
RideModelUpdate,
|
||||
RideModelOut,
|
||||
RideModelListOut,
|
||||
ErrorResponse,
|
||||
HistoryListResponse,
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
HistoryDiffCurrentSchema,
|
||||
FieldHistorySchema,
|
||||
HistoryActivitySummarySchema,
|
||||
RollbackRequestSchema,
|
||||
RollbackResponseSchema,
|
||||
ErrorSchema
|
||||
)
|
||||
from ..services.history_service import HistoryService
|
||||
from django.core.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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, 202: dict, 400: ErrorResponse, 401: ErrorResponse, 404: ErrorResponse},
|
||||
summary="Create ride model",
|
||||
description="Create a new ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def create_ride_model(request, payload: RideModelCreate):
|
||||
"""
|
||||
Create a new ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- payload: Ride model data (name, manufacturer, model_type, specifications, etc.)
|
||||
|
||||
**Returns:** Created ride model (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Ride model created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All ride models flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Create ride model through Sacred Pipeline
|
||||
submission, ride_model = RideModelSubmissionService.create_entity_submission(
|
||||
user=user,
|
||||
data=payload.dict(),
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, RideModel was created immediately
|
||||
if ride_model:
|
||||
logger.info(f"RideModel created (moderator): {ride_model.id} by {user.email}")
|
||||
ride_model.manufacturer_name = ride_model.manufacturer.name if ride_model.manufacturer else None
|
||||
return 201, ride_model
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"RideModel submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model submission pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{model_id}",
|
||||
response={200: RideModelOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Update ride model",
|
||||
description="Update an existing ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def update_ride_model(request, model_id: UUID, payload: RideModelUpdate):
|
||||
"""
|
||||
Update a ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- model_id: UUID of the ride model
|
||||
- payload: Updated ride model data
|
||||
|
||||
**Returns:** Updated ride model (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update ride model through Sacred Pipeline
|
||||
submission, updated_model = RideModelSubmissionService.update_entity_submission(
|
||||
entity=model,
|
||||
user=user,
|
||||
update_data=data,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, ride model was updated immediately
|
||||
if updated_model:
|
||||
logger.info(f"RideModel updated (moderator): {updated_model.id} by {user.email}")
|
||||
updated_model.manufacturer_name = updated_model.manufacturer.name if updated_model.manufacturer else None
|
||||
return 200, updated_model
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"RideModel update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{model_id}",
|
||||
response={200: RideModelOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Partial update ride model",
|
||||
description="Partially update an existing ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def partial_update_ride_model(request, model_id: UUID, payload: RideModelUpdate):
|
||||
"""
|
||||
Partially update a ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- model_id: UUID of the ride model
|
||||
- payload: Fields to update (only provided fields are updated)
|
||||
|
||||
**Returns:** Updated ride model (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update ride model through Sacred Pipeline
|
||||
submission, updated_model = RideModelSubmissionService.update_entity_submission(
|
||||
entity=model,
|
||||
user=user,
|
||||
update_data=data,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, ride model was updated immediately
|
||||
if updated_model:
|
||||
logger.info(f"RideModel partially updated (moderator): {updated_model.id} by {user.email}")
|
||||
updated_model.manufacturer_name = updated_model.manufacturer.name if updated_model.manufacturer else None
|
||||
return 200, updated_model
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"RideModel partial update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error partially updating ride model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{model_id}",
|
||||
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Delete ride model",
|
||||
description="Delete a ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def delete_ride_model(request, model_id: UUID):
|
||||
"""
|
||||
Delete a ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- model_id: UUID of the ride model
|
||||
|
||||
**Returns:** Deletion confirmation (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: RideModel hard-deleted immediately (removed from database)
|
||||
- Regular users: Deletion request created, enters moderation queue
|
||||
|
||||
**Deletion Strategy:**
|
||||
- Hard Delete: Removes ride model from database (RideModel has no status field for soft delete)
|
||||
|
||||
**Note:** All deletions flow through ContentSubmission pipeline for moderation.
|
||||
**Warning:** Deleting a ride model may affect related rides.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
# Delete ride model through Sacred Pipeline (hard delete - no status field)
|
||||
submission, deleted = RideModelSubmissionService.delete_entity_submission(
|
||||
entity=model,
|
||||
user=user,
|
||||
deletion_type='hard', # RideModel has no status field
|
||||
deletion_reason='',
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, deletion was applied immediately
|
||||
if deleted:
|
||||
logger.info(f"RideModel deleted (moderator): {model_id} by {user.email}")
|
||||
return 200, {
|
||||
'message': 'Ride model deleted successfully',
|
||||
'entity_id': str(model_id),
|
||||
'deletion_type': 'hard'
|
||||
}
|
||||
|
||||
# Regular user: deletion pending moderation
|
||||
logger.info(f"RideModel deletion submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model deletion request pending moderation. You will be notified when it is approved.',
|
||||
'entity_id': str(model_id)
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get ride model history",
|
||||
description="Get historical changes for a ride model"
|
||||
)
|
||||
def get_ride_model_history(
|
||||
request,
|
||||
model_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)")
|
||||
):
|
||||
"""Get history for a ride model."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify ride model exists
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
# Parse dates if provided
|
||||
date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None
|
||||
date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None
|
||||
|
||||
# Get history
|
||||
offset = (page - 1) * page_size
|
||||
events, accessible_count = HistoryService.get_history(
|
||||
'ridemodel', str(model_id), request.user,
|
||||
date_from=date_from_obj, date_to=date_to_obj,
|
||||
limit=page_size, offset=offset
|
||||
)
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_events.append({
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'change_summary': event.get('change_summary', ''),
|
||||
'can_rollback': HistoryService.can_rollback(request.user)
|
||||
})
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (accessible_count + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
'total_events': accessible_count,
|
||||
'accessible_events': accessible_count,
|
||||
'access_limited': HistoryService.is_access_limited(request.user),
|
||||
'access_reason': HistoryService.get_access_reason(request.user),
|
||||
'events': formatted_events,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': total_pages,
|
||||
'total_items': accessible_count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific ride model history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_ride_model_history_event(request, model_id: UUID, event_id: int):
|
||||
"""Get a specific history event for a ride model."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
event = HistoryService.get_event('ridemodel', event_id, request.user)
|
||||
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
return {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two ride model history events",
|
||||
description="Compare two historical events for a ride model"
|
||||
)
|
||||
def compare_ride_model_history(
|
||||
request,
|
||||
model_id: UUID,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a ride model."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'ridemodel', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
'event1': comparison['event1'],
|
||||
'event2': comparison['event2'],
|
||||
'differences': comparison['differences'],
|
||||
'changed_field_count': comparison['changed_field_count'],
|
||||
'unchanged_field_count': comparison['unchanged_field_count'],
|
||||
'time_between': comparison['time_between']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/{event_id}/diff-current/',
|
||||
response={200: HistoryDiffCurrentSchema, 404: ErrorSchema},
|
||||
summary="Compare historical event with current state",
|
||||
description="Compare a historical event with the current ride model state"
|
||||
)
|
||||
def diff_ride_model_history_with_current(request, model_id: UUID, event_id: int):
|
||||
"""Compare historical event with current ride model state."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'ridemodel', event_id, ride_model, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
'event': diff['event'],
|
||||
'current_state': diff['current_state'],
|
||||
'differences': diff['differences'],
|
||||
'changed_field_count': diff['changed_field_count'],
|
||||
'time_since': diff['time_since']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{model_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback ride model to historical state",
|
||||
description="Rollback ride model to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_ride_model(request, model_id: UUID, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback ride model to a historical state.
|
||||
|
||||
**Permission:** Moderators, Admins, Superusers only
|
||||
"""
|
||||
# Check authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {"error": "Authentication required"}
|
||||
|
||||
# Check rollback permission
|
||||
if not HistoryService.can_rollback(request.user):
|
||||
return 403, {"error": "Only moderators and administrators can perform rollbacks"}
|
||||
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
ride_model, 'ridemodel', event_id, request.user,
|
||||
fields=payload.fields,
|
||||
comment=payload.comment,
|
||||
create_backup=payload.create_backup
|
||||
)
|
||||
return result
|
||||
except (ValueError, PermissionError) as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific ride model field"
|
||||
)
|
||||
def get_ride_model_field_history(request, model_id: UUID, field_name: str):
|
||||
"""Get history of changes to a specific ride model field."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'ridemodel', str(model_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get ride model activity summary",
|
||||
description="Get activity summary for a ride model"
|
||||
)
|
||||
def get_ride_model_activity_summary(request, model_id: UUID):
|
||||
"""Get activity summary for a ride model."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'ridemodel', str(model_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
**summary
|
||||
}
|
||||
772
django-backend/api/v1/endpoints/rides.py
Normal file
772
django-backend/api/v1/endpoints/rides.py
Normal file
@@ -0,0 +1,772 @@
|
||||
"""
|
||||
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 apps.entities.services.ride_submission import RideSubmissionService
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
RideCreate,
|
||||
RideUpdate,
|
||||
RideOut,
|
||||
RideListOut,
|
||||
RideNameHistoryOut,
|
||||
ErrorResponse,
|
||||
HistoryListResponse,
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
HistoryDiffCurrentSchema,
|
||||
FieldHistorySchema,
|
||||
HistoryActivitySummarySchema,
|
||||
RollbackRequestSchema,
|
||||
RollbackResponseSchema,
|
||||
ErrorSchema
|
||||
)
|
||||
from ..services.history_service import HistoryService
|
||||
from django.core.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{ride_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get ride history",
|
||||
description="Get historical changes for a ride"
|
||||
)
|
||||
def get_ride_history(
|
||||
request,
|
||||
ride_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)")
|
||||
):
|
||||
"""Get history for a ride."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify ride exists
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
|
||||
# Parse dates if provided
|
||||
date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None
|
||||
date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None
|
||||
|
||||
# Get history
|
||||
offset = (page - 1) * page_size
|
||||
events, accessible_count = HistoryService.get_history(
|
||||
'ride', str(ride_id), request.user,
|
||||
date_from=date_from_obj, date_to=date_to_obj,
|
||||
limit=page_size, offset=offset
|
||||
)
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_events.append({
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'change_summary': event.get('change_summary', ''),
|
||||
'can_rollback': HistoryService.can_rollback(request.user)
|
||||
})
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (accessible_count + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
'entity_id': str(ride_id),
|
||||
'entity_type': 'ride',
|
||||
'entity_name': ride.name,
|
||||
'total_events': accessible_count,
|
||||
'accessible_events': accessible_count,
|
||||
'access_limited': HistoryService.is_access_limited(request.user),
|
||||
'access_reason': HistoryService.get_access_reason(request.user),
|
||||
'events': formatted_events,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': total_pages,
|
||||
'total_items': accessible_count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{ride_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific ride history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_ride_history_event(request, ride_id: UUID, event_id: int):
|
||||
"""Get a specific history event for a ride."""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
event = HistoryService.get_event('ride', event_id, request.user)
|
||||
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
return {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': str(ride_id),
|
||||
'entity_type': 'ride',
|
||||
'entity_name': ride.name,
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{ride_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two ride history events",
|
||||
description="Compare two historical events for a ride"
|
||||
)
|
||||
def compare_ride_history(
|
||||
request,
|
||||
ride_id: UUID,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a ride."""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'ride', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(ride_id),
|
||||
'entity_type': 'ride',
|
||||
'entity_name': ride.name,
|
||||
'event1': comparison['event1'],
|
||||
'event2': comparison['event2'],
|
||||
'differences': comparison['differences'],
|
||||
'changed_field_count': comparison['changed_field_count'],
|
||||
'unchanged_field_count': comparison['unchanged_field_count'],
|
||||
'time_between': comparison['time_between']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{ride_id}/history/{event_id}/diff-current/',
|
||||
response={200: HistoryDiffCurrentSchema, 404: ErrorSchema},
|
||||
summary="Compare historical event with current state",
|
||||
description="Compare a historical event with the current ride state"
|
||||
)
|
||||
def diff_ride_history_with_current(request, ride_id: UUID, event_id: int):
|
||||
"""Compare historical event with current ride state."""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'ride', event_id, ride, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(ride_id),
|
||||
'entity_type': 'ride',
|
||||
'entity_name': ride.name,
|
||||
'event': diff['event'],
|
||||
'current_state': diff['current_state'],
|
||||
'differences': diff['differences'],
|
||||
'changed_field_count': diff['changed_field_count'],
|
||||
'time_since': diff['time_since']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{ride_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback ride to historical state",
|
||||
description="Rollback ride to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_ride(request, ride_id: UUID, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback ride to a historical state.
|
||||
|
||||
**Permission:** Moderators, Admins, Superusers only
|
||||
"""
|
||||
# Check authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {"error": "Authentication required"}
|
||||
|
||||
# Check rollback permission
|
||||
if not HistoryService.can_rollback(request.user):
|
||||
return 403, {"error": "Only moderators and administrators can perform rollbacks"}
|
||||
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
ride, 'ride', event_id, request.user,
|
||||
fields=payload.fields,
|
||||
comment=payload.comment,
|
||||
create_backup=payload.create_backup
|
||||
)
|
||||
return result
|
||||
except (ValueError, PermissionError) as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{ride_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific ride field"
|
||||
)
|
||||
def get_ride_field_history(request, ride_id: UUID, field_name: str):
|
||||
"""Get history of changes to a specific ride field."""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'ride', str(ride_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(ride_id),
|
||||
'entity_type': 'ride',
|
||||
'entity_name': ride.name,
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{ride_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get ride activity summary",
|
||||
description="Get activity summary for a ride"
|
||||
)
|
||||
def get_ride_activity_summary(request, ride_id: UUID):
|
||||
"""Get activity summary for a ride."""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'ride', str(ride_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(ride_id),
|
||||
'entity_type': 'ride',
|
||||
'entity_name': ride.name,
|
||||
**summary
|
||||
}
|
||||
|
||||
|
||||
@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.get(
|
||||
"/{ride_id}/name-history/",
|
||||
response={200: List[RideNameHistoryOut], 404: ErrorResponse},
|
||||
summary="Get ride name history",
|
||||
description="Get historical names for a ride"
|
||||
)
|
||||
def get_ride_name_history(request, ride_id: UUID):
|
||||
"""
|
||||
Get historical names for a ride.
|
||||
|
||||
**Parameters:**
|
||||
- ride_id: UUID of the ride
|
||||
|
||||
**Returns:** List of former ride names with date ranges
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "...",
|
||||
"former_name": "Original Name",
|
||||
"from_year": 2000,
|
||||
"to_year": 2010,
|
||||
"date_changed": "2010-05-15",
|
||||
"date_changed_precision": "day",
|
||||
"reason": "Rebranding",
|
||||
"order_index": 1,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
]
|
||||
```
|
||||
"""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
name_history = ride.name_history.all()
|
||||
return list(name_history)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response={201: RideOut, 202: dict, 400: ErrorResponse, 401: ErrorResponse, 404: ErrorResponse},
|
||||
summary="Create ride",
|
||||
description="Create a new ride through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def create_ride(request, payload: RideCreate):
|
||||
"""
|
||||
Create a new ride through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- payload: Ride data (name, park, ride_category, manufacturer, model, etc.)
|
||||
|
||||
**Returns:** Created ride (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Ride created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All rides flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Create ride through Sacred Pipeline
|
||||
submission, ride = RideSubmissionService.create_entity_submission(
|
||||
user=user,
|
||||
data=payload.dict(),
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, Ride was created immediately
|
||||
if ride:
|
||||
logger.info(f"Ride created (moderator): {ride.id} by {user.email}")
|
||||
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
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Ride submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride submission pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{ride_id}",
|
||||
response={200: RideOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Update ride",
|
||||
description="Update an existing ride through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def update_ride(request, ride_id: UUID, payload: RideUpdate):
|
||||
"""
|
||||
Update a ride through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- ride_id: UUID of the ride
|
||||
- payload: Updated ride data
|
||||
|
||||
**Returns:** Updated ride (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
ride = get_object_or_404(
|
||||
Ride.objects.select_related('park', 'manufacturer', 'model'),
|
||||
id=ride_id
|
||||
)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update ride through Sacred Pipeline
|
||||
submission, updated_ride = RideSubmissionService.update_entity_submission(
|
||||
entity=ride,
|
||||
user=user,
|
||||
update_data=data,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, ride was updated immediately
|
||||
if updated_ride:
|
||||
logger.info(f"Ride updated (moderator): {updated_ride.id} by {user.email}")
|
||||
updated_ride.park_name = updated_ride.park.name if updated_ride.park else None
|
||||
updated_ride.manufacturer_name = updated_ride.manufacturer.name if updated_ride.manufacturer else None
|
||||
updated_ride.model_name = updated_ride.model.name if updated_ride.model else None
|
||||
return 200, updated_ride
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Ride update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{ride_id}",
|
||||
response={200: RideOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Partial update ride",
|
||||
description="Partially update an existing ride through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def partial_update_ride(request, ride_id: UUID, payload: RideUpdate):
|
||||
"""
|
||||
Partially update a ride through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- ride_id: UUID of the ride
|
||||
- payload: Fields to update (only provided fields are updated)
|
||||
|
||||
**Returns:** Updated ride (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Updates applied immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All updates flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
ride = get_object_or_404(
|
||||
Ride.objects.select_related('park', 'manufacturer', 'model'),
|
||||
id=ride_id
|
||||
)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update ride through Sacred Pipeline
|
||||
submission, updated_ride = RideSubmissionService.update_entity_submission(
|
||||
entity=ride,
|
||||
user=user,
|
||||
update_data=data,
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, ride was updated immediately
|
||||
if updated_ride:
|
||||
logger.info(f"Ride partially updated (moderator): {updated_ride.id} by {user.email}")
|
||||
updated_ride.park_name = updated_ride.park.name if updated_ride.park else None
|
||||
updated_ride.manufacturer_name = updated_ride.manufacturer.name if updated_ride.manufacturer else None
|
||||
updated_ride.model_name = updated_ride.model.name if updated_ride.model else None
|
||||
return 200, updated_ride
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Ride partial update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride update pending moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error partially updating ride: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{ride_id}",
|
||||
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Delete ride",
|
||||
description="Delete a ride through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def delete_ride(request, ride_id: UUID):
|
||||
"""
|
||||
Delete a ride through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- ride_id: UUID of the ride
|
||||
|
||||
**Returns:** Deletion confirmation (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Ride soft-deleted immediately (status set to 'closed')
|
||||
- Regular users: Deletion request created, enters moderation queue
|
||||
|
||||
**Deletion Strategy:**
|
||||
- Soft Delete (default): Sets ride status to 'closed', preserves data
|
||||
- Hard Delete: Actually removes from database (moderators only)
|
||||
|
||||
**Note:** All deletions flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
ride = get_object_or_404(Ride.objects.select_related('park', 'manufacturer'), id=ride_id)
|
||||
|
||||
# Delete ride through Sacred Pipeline (soft delete by default)
|
||||
submission, deleted = RideSubmissionService.delete_entity_submission(
|
||||
entity=ride,
|
||||
user=user,
|
||||
deletion_type='soft',
|
||||
deletion_reason='',
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR'),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# If moderator bypass happened, deletion was applied immediately
|
||||
if deleted:
|
||||
logger.info(f"Ride deleted (moderator): {ride_id} by {user.email}")
|
||||
return 200, {
|
||||
'message': 'Ride deleted successfully',
|
||||
'entity_id': str(ride_id),
|
||||
'deletion_type': 'soft'
|
||||
}
|
||||
|
||||
# Regular user: deletion pending moderation
|
||||
logger.info(f"Ride deletion submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride deletion request pending moderation. You will be notified when it is approved.',
|
||||
'entity_id': str(ride_id)
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@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-backend/api/v1/endpoints/search.py
Normal file
438
django-backend/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))
|
||||
155
django-backend/api/v1/endpoints/seo.py
Normal file
155
django-backend/api/v1/endpoints/seo.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
SEO Meta Tag API Endpoints
|
||||
|
||||
Provides meta tag data for frontend pages to enable dynamic SEO,
|
||||
OpenGraph social sharing, and structured data.
|
||||
"""
|
||||
|
||||
from ninja import Router
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
|
||||
from apps.entities.models import Park, Ride, Company, RideModel
|
||||
from apps.core.utils.seo import SEOTags
|
||||
|
||||
router = Router(tags=['SEO'])
|
||||
|
||||
|
||||
@router.get('/meta/home')
|
||||
def get_home_meta(request):
|
||||
"""
|
||||
Get SEO meta tags for the home page.
|
||||
|
||||
Returns:
|
||||
Dictionary of meta tags including OpenGraph, Twitter Cards, and structured data
|
||||
"""
|
||||
return SEOTags.for_home()
|
||||
|
||||
|
||||
@router.get('/meta/park/{park_slug}')
|
||||
def get_park_meta(request, park_slug: str):
|
||||
"""
|
||||
Get SEO meta tags for a park page.
|
||||
|
||||
Args:
|
||||
park_slug: URL slug of the park
|
||||
|
||||
Returns:
|
||||
Dictionary of meta tags including OpenGraph, Twitter Cards, and canonical URL
|
||||
"""
|
||||
park = get_object_or_404(
|
||||
Park.objects.select_related('locality', 'country'),
|
||||
slug=park_slug,
|
||||
is_active=True
|
||||
)
|
||||
return SEOTags.for_park(park)
|
||||
|
||||
|
||||
@router.get('/meta/ride/{park_slug}/{ride_slug}')
|
||||
def get_ride_meta(request, park_slug: str, ride_slug: str):
|
||||
"""
|
||||
Get SEO meta tags for a ride page.
|
||||
|
||||
Args:
|
||||
park_slug: URL slug of the park
|
||||
ride_slug: URL slug of the ride
|
||||
|
||||
Returns:
|
||||
Dictionary of meta tags including OpenGraph, Twitter Cards, and canonical URL
|
||||
"""
|
||||
ride = get_object_or_404(
|
||||
Ride.objects.select_related(
|
||||
'park',
|
||||
'ride_type',
|
||||
'manufacturer'
|
||||
),
|
||||
slug=ride_slug,
|
||||
park__slug=park_slug,
|
||||
is_active=True
|
||||
)
|
||||
return SEOTags.for_ride(ride)
|
||||
|
||||
|
||||
@router.get('/meta/company/{company_slug}')
|
||||
def get_company_meta(request, company_slug: str):
|
||||
"""
|
||||
Get SEO meta tags for a company/manufacturer page.
|
||||
|
||||
Args:
|
||||
company_slug: URL slug of the company
|
||||
|
||||
Returns:
|
||||
Dictionary of meta tags including OpenGraph, Twitter Cards, and canonical URL
|
||||
"""
|
||||
company = get_object_or_404(
|
||||
Company.objects.prefetch_related('company_types'),
|
||||
slug=company_slug,
|
||||
is_active=True
|
||||
)
|
||||
return SEOTags.for_company(company)
|
||||
|
||||
|
||||
@router.get('/meta/ride-model/{model_slug}')
|
||||
def get_ride_model_meta(request, model_slug: str):
|
||||
"""
|
||||
Get SEO meta tags for a ride model page.
|
||||
|
||||
Args:
|
||||
model_slug: URL slug of the ride model
|
||||
|
||||
Returns:
|
||||
Dictionary of meta tags including OpenGraph, Twitter Cards, and canonical URL
|
||||
"""
|
||||
model = get_object_or_404(
|
||||
RideModel.objects.select_related(
|
||||
'manufacturer',
|
||||
'ride_type'
|
||||
),
|
||||
slug=model_slug,
|
||||
is_active=True
|
||||
)
|
||||
return SEOTags.for_ride_model(model)
|
||||
|
||||
|
||||
@router.get('/structured-data/park/{park_slug}')
|
||||
def get_park_structured_data(request, park_slug: str):
|
||||
"""
|
||||
Get JSON-LD structured data for a park page.
|
||||
|
||||
Args:
|
||||
park_slug: URL slug of the park
|
||||
|
||||
Returns:
|
||||
JSON-LD structured data for search engines
|
||||
"""
|
||||
park = get_object_or_404(
|
||||
Park.objects.select_related('locality', 'country'),
|
||||
slug=park_slug,
|
||||
is_active=True
|
||||
)
|
||||
return SEOTags.structured_data_for_park(park)
|
||||
|
||||
|
||||
@router.get('/structured-data/ride/{park_slug}/{ride_slug}')
|
||||
def get_ride_structured_data(request, park_slug: str, ride_slug: str):
|
||||
"""
|
||||
Get JSON-LD structured data for a ride page.
|
||||
|
||||
Args:
|
||||
park_slug: URL slug of the park
|
||||
ride_slug: URL slug of the ride
|
||||
|
||||
Returns:
|
||||
JSON-LD structured data for search engines
|
||||
"""
|
||||
ride = get_object_or_404(
|
||||
Ride.objects.select_related(
|
||||
'park',
|
||||
'ride_type',
|
||||
'manufacturer'
|
||||
),
|
||||
slug=ride_slug,
|
||||
park__slug=park_slug,
|
||||
is_active=True
|
||||
)
|
||||
return SEOTags.structured_data_for_ride(ride)
|
||||
339
django-backend/api/v1/endpoints/timeline.py
Normal file
339
django-backend/api/v1/endpoints/timeline.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Timeline API endpoints.
|
||||
|
||||
Handles entity timeline events for tracking significant lifecycle events
|
||||
like openings, closings, relocations, etc.
|
||||
"""
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
from ninja import Router, Query
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Count, Min, Max
|
||||
|
||||
from apps.timeline.models import EntityTimelineEvent
|
||||
from apps.entities.models import Park, Ride, Company, RideModel
|
||||
from apps.users.permissions import require_role
|
||||
from api.v1.schemas import (
|
||||
EntityTimelineEventOut,
|
||||
EntityTimelineEventCreate,
|
||||
EntityTimelineEventUpdate,
|
||||
EntityTimelineEventListOut,
|
||||
TimelineStatsOut,
|
||||
MessageSchema,
|
||||
ErrorResponse,
|
||||
)
|
||||
|
||||
|
||||
router = Router(tags=["Timeline"])
|
||||
|
||||
|
||||
def get_entity_model(entity_type: str):
|
||||
"""Get the Django model class for an entity type."""
|
||||
models = {
|
||||
'park': Park,
|
||||
'ride': Ride,
|
||||
'company': Company,
|
||||
'ridemodel': RideModel,
|
||||
}
|
||||
return models.get(entity_type.lower())
|
||||
|
||||
|
||||
def serialize_timeline_event(event: EntityTimelineEvent) -> dict:
|
||||
"""Serialize a timeline event to dict for output."""
|
||||
return {
|
||||
'id': event.id,
|
||||
'entity_id': event.entity_id,
|
||||
'entity_type': event.entity_type,
|
||||
'event_type': event.event_type,
|
||||
'event_date': event.event_date,
|
||||
'event_date_precision': event.event_date_precision,
|
||||
'title': event.title,
|
||||
'description': event.description,
|
||||
'from_entity_id': event.from_entity_id,
|
||||
'to_entity_id': event.to_entity_id,
|
||||
'from_location_id': event.from_location_id,
|
||||
'from_location_name': event.from_location.name if event.from_location else None,
|
||||
'to_location_id': event.to_location_id,
|
||||
'to_location_name': event.to_location.name if event.to_location else None,
|
||||
'from_value': event.from_value,
|
||||
'to_value': event.to_value,
|
||||
'is_public': event.is_public,
|
||||
'display_order': event.display_order,
|
||||
'created_by_id': event.created_by_id,
|
||||
'created_by_email': event.created_by.email if event.created_by else None,
|
||||
'approved_by_id': event.approved_by_id,
|
||||
'approved_by_email': event.approved_by.email if event.approved_by else None,
|
||||
'submission_id': event.submission_id,
|
||||
'created_at': event.created_at,
|
||||
'updated_at': event.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{entity_type}/{entity_id}/", response={200: List[EntityTimelineEventOut], 404: ErrorResponse})
|
||||
def get_entity_timeline(
|
||||
request,
|
||||
entity_type: str,
|
||||
entity_id: UUID,
|
||||
event_type: str = Query(None, description="Filter by event type"),
|
||||
is_public: bool = Query(None, description="Filter by public/private"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
Get timeline events for a specific entity.
|
||||
|
||||
Returns a paginated list of timeline events for the specified entity.
|
||||
Regular users only see public events; moderators see all events.
|
||||
"""
|
||||
# Validate entity type
|
||||
model = get_entity_model(entity_type)
|
||||
if not model:
|
||||
return 404, {'detail': f'Invalid entity type: {entity_type}'}
|
||||
|
||||
# Verify entity exists
|
||||
entity = get_object_or_404(model, id=entity_id)
|
||||
|
||||
# Build query
|
||||
queryset = EntityTimelineEvent.objects.filter(
|
||||
entity_type=entity_type.lower(),
|
||||
entity_id=entity_id
|
||||
).select_related('from_location', 'to_location', 'created_by', 'approved_by')
|
||||
|
||||
# Filter by public status (non-moderators only see public events)
|
||||
is_moderator = hasattr(request.user, 'role') and request.user.role in ['moderator', 'admin']
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
# Apply filters
|
||||
if event_type:
|
||||
queryset = queryset.filter(event_type=event_type)
|
||||
if is_public is not None:
|
||||
queryset = queryset.filter(is_public=is_public)
|
||||
|
||||
# Order by date (newest first) and display order
|
||||
queryset = queryset.order_by('-event_date', 'display_order', '-created_at')
|
||||
|
||||
# Pagination
|
||||
total = queryset.count()
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
events = queryset[start:end]
|
||||
|
||||
return 200, [serialize_timeline_event(event) for event in events]
|
||||
|
||||
|
||||
@router.get("/recent/", response={200: List[EntityTimelineEventOut]})
|
||||
def get_recent_timeline_events(
|
||||
request,
|
||||
entity_type: str = Query(None, description="Filter by entity type"),
|
||||
event_type: str = Query(None, description="Filter by event type"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
Get recent timeline events across all entities.
|
||||
|
||||
Returns the most recent timeline events. Only public events are returned
|
||||
for regular users; moderators see all events.
|
||||
"""
|
||||
# Build query
|
||||
queryset = EntityTimelineEvent.objects.all().select_related(
|
||||
'from_location', 'to_location', 'created_by', 'approved_by'
|
||||
)
|
||||
|
||||
# Filter by public status
|
||||
is_moderator = hasattr(request.user, 'role') and request.user.role in ['moderator', 'admin']
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
# Apply filters
|
||||
if entity_type:
|
||||
queryset = queryset.filter(entity_type=entity_type.lower())
|
||||
if event_type:
|
||||
queryset = queryset.filter(event_type=event_type)
|
||||
|
||||
# Order by date and limit
|
||||
queryset = queryset.order_by('-event_date', '-created_at')[:limit]
|
||||
|
||||
return 200, [serialize_timeline_event(event) for event in queryset]
|
||||
|
||||
|
||||
@router.get("/stats/{entity_type}/{entity_id}/", response={200: TimelineStatsOut, 404: ErrorResponse})
|
||||
def get_timeline_stats(request, entity_type: str, entity_id: UUID):
|
||||
"""
|
||||
Get statistics about timeline events for an entity.
|
||||
"""
|
||||
# Validate entity type
|
||||
model = get_entity_model(entity_type)
|
||||
if not model:
|
||||
return 404, {'detail': f'Invalid entity type: {entity_type}'}
|
||||
|
||||
# Verify entity exists
|
||||
entity = get_object_or_404(model, id=entity_id)
|
||||
|
||||
# Build query
|
||||
queryset = EntityTimelineEvent.objects.filter(
|
||||
entity_type=entity_type.lower(),
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
# Filter by public status if not moderator
|
||||
is_moderator = hasattr(request.user, 'role') and request.user.role in ['moderator', 'admin']
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
# Get stats
|
||||
total_events = queryset.count()
|
||||
public_events = queryset.filter(is_public=True).count()
|
||||
|
||||
# Event type distribution
|
||||
event_types = dict(queryset.values('event_type').annotate(count=Count('id')).values_list('event_type', 'count'))
|
||||
|
||||
# Date range
|
||||
date_stats = queryset.aggregate(
|
||||
earliest=Min('event_date'),
|
||||
latest=Max('event_date')
|
||||
)
|
||||
|
||||
return 200, {
|
||||
'total_events': total_events,
|
||||
'public_events': public_events,
|
||||
'event_types': event_types,
|
||||
'earliest_event': date_stats['earliest'],
|
||||
'latest_event': date_stats['latest'],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", response={201: EntityTimelineEventOut, 400: ErrorResponse, 403: ErrorResponse})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def create_timeline_event(request, data: EntityTimelineEventCreate):
|
||||
"""
|
||||
Create a new timeline event (moderators only).
|
||||
|
||||
Allows moderators to manually create timeline events for entities.
|
||||
"""
|
||||
# Validate entity exists
|
||||
model = get_entity_model(data.entity_type)
|
||||
if not model:
|
||||
return 400, {'detail': f'Invalid entity type: {data.entity_type}'}
|
||||
|
||||
entity = get_object_or_404(model, id=data.entity_id)
|
||||
|
||||
# Validate locations if provided
|
||||
if data.from_location_id:
|
||||
get_object_or_404(Park, id=data.from_location_id)
|
||||
if data.to_location_id:
|
||||
get_object_or_404(Park, id=data.to_location_id)
|
||||
|
||||
# Create event
|
||||
event = EntityTimelineEvent.objects.create(
|
||||
entity_id=data.entity_id,
|
||||
entity_type=data.entity_type.lower(),
|
||||
event_type=data.event_type,
|
||||
event_date=data.event_date,
|
||||
event_date_precision=data.event_date_precision or 'day',
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
from_entity_id=data.from_entity_id,
|
||||
to_entity_id=data.to_entity_id,
|
||||
from_location_id=data.from_location_id,
|
||||
to_location_id=data.to_location_id,
|
||||
from_value=data.from_value,
|
||||
to_value=data.to_value,
|
||||
is_public=data.is_public,
|
||||
display_order=data.display_order,
|
||||
created_by=request.user,
|
||||
approved_by=request.user, # Moderator-created events are auto-approved
|
||||
)
|
||||
|
||||
return 201, serialize_timeline_event(event)
|
||||
|
||||
|
||||
@router.patch("/{event_id}/", response={200: EntityTimelineEventOut, 404: ErrorResponse, 403: ErrorResponse})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def update_timeline_event(request, event_id: UUID, data: EntityTimelineEventUpdate):
|
||||
"""
|
||||
Update a timeline event (moderators only).
|
||||
"""
|
||||
event = get_object_or_404(EntityTimelineEvent, id=event_id)
|
||||
|
||||
# Update fields if provided
|
||||
update_fields = []
|
||||
|
||||
if data.event_type is not None:
|
||||
event.event_type = data.event_type
|
||||
update_fields.append('event_type')
|
||||
|
||||
if data.event_date is not None:
|
||||
event.event_date = data.event_date
|
||||
update_fields.append('event_date')
|
||||
|
||||
if data.event_date_precision is not None:
|
||||
event.event_date_precision = data.event_date_precision
|
||||
update_fields.append('event_date_precision')
|
||||
|
||||
if data.title is not None:
|
||||
event.title = data.title
|
||||
update_fields.append('title')
|
||||
|
||||
if data.description is not None:
|
||||
event.description = data.description
|
||||
update_fields.append('description')
|
||||
|
||||
if data.from_entity_id is not None:
|
||||
event.from_entity_id = data.from_entity_id
|
||||
update_fields.append('from_entity_id')
|
||||
|
||||
if data.to_entity_id is not None:
|
||||
event.to_entity_id = data.to_entity_id
|
||||
update_fields.append('to_entity_id')
|
||||
|
||||
if data.from_location_id is not None:
|
||||
# Validate park exists
|
||||
if data.from_location_id:
|
||||
get_object_or_404(Park, id=data.from_location_id)
|
||||
event.from_location_id = data.from_location_id
|
||||
update_fields.append('from_location_id')
|
||||
|
||||
if data.to_location_id is not None:
|
||||
# Validate park exists
|
||||
if data.to_location_id:
|
||||
get_object_or_404(Park, id=data.to_location_id)
|
||||
event.to_location_id = data.to_location_id
|
||||
update_fields.append('to_location_id')
|
||||
|
||||
if data.from_value is not None:
|
||||
event.from_value = data.from_value
|
||||
update_fields.append('from_value')
|
||||
|
||||
if data.to_value is not None:
|
||||
event.to_value = data.to_value
|
||||
update_fields.append('to_value')
|
||||
|
||||
if data.is_public is not None:
|
||||
event.is_public = data.is_public
|
||||
update_fields.append('is_public')
|
||||
|
||||
if data.display_order is not None:
|
||||
event.display_order = data.display_order
|
||||
update_fields.append('display_order')
|
||||
|
||||
if update_fields:
|
||||
update_fields.append('updated_at')
|
||||
event.save(update_fields=update_fields)
|
||||
|
||||
return 200, serialize_timeline_event(event)
|
||||
|
||||
|
||||
@router.delete("/{event_id}/", response={200: MessageSchema, 404: ErrorResponse, 403: ErrorResponse})
|
||||
@require_role(['moderator', 'admin'])
|
||||
def delete_timeline_event(request, event_id: UUID):
|
||||
"""
|
||||
Delete a timeline event (moderators only).
|
||||
"""
|
||||
event = get_object_or_404(EntityTimelineEvent, id=event_id)
|
||||
event.delete()
|
||||
|
||||
return 200, {
|
||||
'message': 'Timeline event deleted successfully',
|
||||
'success': True
|
||||
}
|
||||
574
django-backend/api/v1/endpoints/top_lists.py
Normal file
574
django-backend/api/v1/endpoints/top_lists.py
Normal file
@@ -0,0 +1,574 @@
|
||||
"""
|
||||
Top List endpoints for API v1.
|
||||
|
||||
Provides CRUD operations for user-created ranked lists.
|
||||
Users can create lists of parks, rides, or coasters with custom rankings and notes.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Max
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from ninja import Router, Query
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
import logging
|
||||
|
||||
from apps.users.models import UserTopList, UserTopListItem, User
|
||||
from apps.entities.models import Park, Ride
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
TopListCreateSchema,
|
||||
TopListUpdateSchema,
|
||||
TopListItemCreateSchema,
|
||||
TopListItemUpdateSchema,
|
||||
TopListOut,
|
||||
TopListDetailOut,
|
||||
TopListListOut,
|
||||
TopListItemOut,
|
||||
ErrorResponse,
|
||||
UserSchema,
|
||||
)
|
||||
|
||||
router = Router(tags=["Top Lists"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TopListPagination(PageNumberPagination):
|
||||
"""Custom pagination for top lists."""
|
||||
page_size = 50
|
||||
|
||||
|
||||
def _get_entity(entity_type: str, entity_id: UUID):
|
||||
"""Helper to get and validate entity (Park or Ride)."""
|
||||
if entity_type == 'park':
|
||||
return get_object_or_404(Park, id=entity_id), ContentType.objects.get_for_model(Park)
|
||||
elif entity_type == 'ride':
|
||||
return get_object_or_404(Ride, id=entity_id), ContentType.objects.get_for_model(Ride)
|
||||
else:
|
||||
raise ValidationError(f"Invalid entity_type: {entity_type}")
|
||||
|
||||
|
||||
def _serialize_list_item(item: UserTopListItem) -> dict:
|
||||
"""Serialize top list item with computed fields."""
|
||||
entity = item.content_object
|
||||
|
||||
data = {
|
||||
'id': item.id,
|
||||
'position': item.position,
|
||||
'entity_type': item.content_type.model,
|
||||
'entity_id': str(item.object_id),
|
||||
'entity_name': entity.name if entity else 'Unknown',
|
||||
'entity_slug': entity.slug if entity and hasattr(entity, 'slug') else '',
|
||||
'entity_image_url': None, # TODO: Get from entity
|
||||
'park_name': None,
|
||||
'notes': item.notes or '',
|
||||
'created': item.created,
|
||||
'modified': item.modified,
|
||||
}
|
||||
|
||||
# If entity is a ride, add park name
|
||||
if item.content_type.model == 'ride' and entity and hasattr(entity, 'park'):
|
||||
data['park_name'] = entity.park.name if entity.park else None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _serialize_top_list(top_list: UserTopList, include_items: bool = False) -> dict:
|
||||
"""Serialize top list with optional items."""
|
||||
data = {
|
||||
'id': top_list.id,
|
||||
'user': UserSchema(
|
||||
id=top_list.user.id,
|
||||
username=top_list.user.username,
|
||||
display_name=top_list.user.display_name,
|
||||
avatar_url=top_list.user.avatar_url,
|
||||
reputation_score=top_list.user.reputation_score,
|
||||
),
|
||||
'list_type': top_list.list_type,
|
||||
'title': top_list.title,
|
||||
'description': top_list.description or '',
|
||||
'is_public': top_list.is_public,
|
||||
'item_count': top_list.item_count,
|
||||
'created': top_list.created,
|
||||
'modified': top_list.modified,
|
||||
}
|
||||
|
||||
if include_items:
|
||||
items = top_list.items.select_related('content_type').order_by('position')
|
||||
data['items'] = [_serialize_list_item(item) for item in items]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Top List CRUD Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/", response={201: TopListOut, 400: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def create_top_list(request, data: TopListCreateSchema):
|
||||
"""
|
||||
Create a new top list.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- list_type: "parks", "rides", or "coasters"
|
||||
- title: List title
|
||||
- description: List description (optional)
|
||||
- is_public: Whether list is publicly visible (default: true)
|
||||
|
||||
**Returns:** Created top list
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Create list
|
||||
top_list = UserTopList.objects.create(
|
||||
user=user,
|
||||
list_type=data.list_type,
|
||||
title=data.title,
|
||||
description=data.description or '',
|
||||
is_public=data.is_public,
|
||||
)
|
||||
|
||||
logger.info(f"Top list created: {top_list.id} by {user.email}")
|
||||
|
||||
list_data = _serialize_top_list(top_list)
|
||||
return 201, list_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating top list: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.get("/", response={200: List[TopListOut]})
|
||||
@paginate(TopListPagination)
|
||||
def list_top_lists(
|
||||
request,
|
||||
list_type: Optional[str] = Query(None, description="Filter by list type"),
|
||||
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
|
||||
ordering: Optional[str] = Query("-created", description="Sort by field")
|
||||
):
|
||||
"""
|
||||
List accessible top lists.
|
||||
|
||||
**Authentication:** Optional
|
||||
|
||||
**Filters:**
|
||||
- list_type: parks, rides, or coasters
|
||||
- user_id: Lists by specific user
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of top lists
|
||||
|
||||
**Note:** Shows public lists + user's own private lists if authenticated.
|
||||
"""
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
|
||||
# Base query
|
||||
queryset = UserTopList.objects.select_related('user')
|
||||
|
||||
# Apply visibility filter
|
||||
if user:
|
||||
# Show public lists + user's own lists
|
||||
queryset = queryset.filter(Q(is_public=True) | Q(user=user))
|
||||
else:
|
||||
# Only public lists
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
# Apply list type filter
|
||||
if list_type:
|
||||
queryset = queryset.filter(list_type=list_type)
|
||||
|
||||
# Apply user filter
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['created', 'modified', 'title']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
# Serialize lists
|
||||
lists = [_serialize_top_list(tl) for tl in queryset]
|
||||
return lists
|
||||
|
||||
|
||||
@router.get("/public", response={200: List[TopListOut]})
|
||||
@paginate(TopListPagination)
|
||||
def list_public_top_lists(
|
||||
request,
|
||||
list_type: Optional[str] = Query(None),
|
||||
user_id: Optional[UUID] = Query(None),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
List public top lists.
|
||||
|
||||
**Authentication:** Optional
|
||||
|
||||
**Parameters:**
|
||||
- list_type: Filter by type (optional)
|
||||
- user_id: Filter by user (optional)
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of public top lists
|
||||
"""
|
||||
queryset = UserTopList.objects.filter(is_public=True).select_related('user')
|
||||
|
||||
if list_type:
|
||||
queryset = queryset.filter(list_type=list_type)
|
||||
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
valid_order_fields = ['created', 'modified', 'title']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
lists = [_serialize_top_list(tl) for tl in queryset]
|
||||
return lists
|
||||
|
||||
|
||||
@router.get("/{list_id}", response={200: TopListDetailOut, 403: ErrorResponse, 404: ErrorResponse})
|
||||
def get_top_list(request, list_id: UUID):
|
||||
"""
|
||||
Get a specific top list with all items.
|
||||
|
||||
**Authentication:** Optional
|
||||
|
||||
**Parameters:**
|
||||
- list_id: List UUID
|
||||
|
||||
**Returns:** Top list with all items
|
||||
|
||||
**Note:** Private lists only accessible to owner.
|
||||
"""
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
|
||||
top_list = get_object_or_404(
|
||||
UserTopList.objects.select_related('user'),
|
||||
id=list_id
|
||||
)
|
||||
|
||||
# Check access
|
||||
if not top_list.is_public:
|
||||
if not user or top_list.user != user:
|
||||
return 403, {'detail': 'This list is private'}
|
||||
|
||||
list_data = _serialize_top_list(top_list, include_items=True)
|
||||
return 200, list_data
|
||||
|
||||
|
||||
@router.put("/{list_id}", response={200: TopListOut, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def update_top_list(request, list_id: UUID, data: TopListUpdateSchema):
|
||||
"""
|
||||
Update a top list.
|
||||
|
||||
**Authentication:** Required (must be list owner)
|
||||
|
||||
**Parameters:**
|
||||
- list_id: List UUID
|
||||
- data: Fields to update
|
||||
|
||||
**Returns:** Updated list
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
top_list = get_object_or_404(UserTopList, id=list_id)
|
||||
|
||||
# Check ownership
|
||||
if top_list.user != user:
|
||||
return 403, {'detail': 'You can only update your own lists'}
|
||||
|
||||
# Update fields
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(top_list, key, value)
|
||||
|
||||
top_list.save()
|
||||
|
||||
logger.info(f"Top list updated: {top_list.id} by {user.email}")
|
||||
|
||||
list_data = _serialize_top_list(top_list)
|
||||
return 200, list_data
|
||||
|
||||
|
||||
@router.delete("/{list_id}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def delete_top_list(request, list_id: UUID):
|
||||
"""
|
||||
Delete a top list.
|
||||
|
||||
**Authentication:** Required (must be list owner)
|
||||
|
||||
**Parameters:**
|
||||
- list_id: List UUID
|
||||
|
||||
**Returns:** No content (204)
|
||||
|
||||
**Note:** This also deletes all items in the list.
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
top_list = get_object_or_404(UserTopList, id=list_id)
|
||||
|
||||
# Check ownership
|
||||
if top_list.user != user:
|
||||
return 403, {'detail': 'You can only delete your own lists'}
|
||||
|
||||
logger.info(f"Top list deleted: {top_list.id} by {user.email}")
|
||||
top_list.delete()
|
||||
|
||||
return 204, None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# List Item Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/{list_id}/items", response={201: TopListItemOut, 400: ErrorResponse, 403: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def add_list_item(request, list_id: UUID, data: TopListItemCreateSchema):
|
||||
"""
|
||||
Add an item to a top list.
|
||||
|
||||
**Authentication:** Required (must be list owner)
|
||||
|
||||
**Parameters:**
|
||||
- list_id: List UUID
|
||||
- entity_type: "park" or "ride"
|
||||
- entity_id: Entity UUID
|
||||
- position: Position in list (optional, auto-assigned if not provided)
|
||||
- notes: Notes about this item (optional)
|
||||
|
||||
**Returns:** Created list item
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
top_list = get_object_or_404(UserTopList, id=list_id)
|
||||
|
||||
# Check ownership
|
||||
if top_list.user != user:
|
||||
return 403, {'detail': 'You can only modify your own lists'}
|
||||
|
||||
# Validate entity
|
||||
entity, content_type = _get_entity(data.entity_type, data.entity_id)
|
||||
|
||||
# Validate entity type matches list type
|
||||
if top_list.list_type == 'parks' and data.entity_type != 'park':
|
||||
return 400, {'detail': 'Can only add parks to a parks list'}
|
||||
elif top_list.list_type in ['rides', 'coasters']:
|
||||
if data.entity_type != 'ride':
|
||||
return 400, {'detail': f'Can only add rides to a {top_list.list_type} list'}
|
||||
if top_list.list_type == 'coasters' and not entity.is_coaster:
|
||||
return 400, {'detail': 'Can only add coasters to a coasters list'}
|
||||
|
||||
# Determine position
|
||||
if data.position is None:
|
||||
# Auto-assign position (append to end)
|
||||
max_pos = top_list.items.aggregate(max_pos=Max('position'))['max_pos']
|
||||
position = (max_pos or 0) + 1
|
||||
else:
|
||||
position = data.position
|
||||
# Check if position is taken
|
||||
if top_list.items.filter(position=position).exists():
|
||||
return 400, {'detail': f'Position {position} is already taken'}
|
||||
|
||||
# Create item
|
||||
with transaction.atomic():
|
||||
item = UserTopListItem.objects.create(
|
||||
top_list=top_list,
|
||||
content_type=content_type,
|
||||
object_id=entity.id,
|
||||
position=position,
|
||||
notes=data.notes or '',
|
||||
)
|
||||
|
||||
logger.info(f"List item added: {item.id} to list {list_id}")
|
||||
|
||||
item_data = _serialize_list_item(item)
|
||||
return 201, item_data
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding list item: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.put("/{list_id}/items/{position}", response={200: TopListItemOut, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def update_list_item(request, list_id: UUID, position: int, data: TopListItemUpdateSchema):
|
||||
"""
|
||||
Update a list item.
|
||||
|
||||
**Authentication:** Required (must be list owner)
|
||||
|
||||
**Parameters:**
|
||||
- list_id: List UUID
|
||||
- position: Current position
|
||||
- data: Fields to update (position, notes)
|
||||
|
||||
**Returns:** Updated item
|
||||
|
||||
**Note:** If changing position, items are reordered automatically.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
top_list = get_object_or_404(UserTopList, id=list_id)
|
||||
|
||||
# Check ownership
|
||||
if top_list.user != user:
|
||||
return 403, {'detail': 'You can only modify your own lists'}
|
||||
|
||||
# Get item
|
||||
item = get_object_or_404(
|
||||
UserTopListItem.objects.select_related('content_type'),
|
||||
top_list=top_list,
|
||||
position=position
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
# Handle position change
|
||||
if data.position is not None and data.position != position:
|
||||
new_position = data.position
|
||||
|
||||
# Check if new position exists
|
||||
target_item = top_list.items.filter(position=new_position).first()
|
||||
|
||||
if target_item:
|
||||
# Swap positions
|
||||
target_item.position = position
|
||||
target_item.save()
|
||||
|
||||
item.position = new_position
|
||||
|
||||
# Update notes if provided
|
||||
if data.notes is not None:
|
||||
item.notes = data.notes
|
||||
|
||||
item.save()
|
||||
|
||||
logger.info(f"List item updated: {item.id}")
|
||||
|
||||
item_data = _serialize_list_item(item)
|
||||
return 200, item_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating list item: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.delete("/{list_id}/items/{position}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def delete_list_item(request, list_id: UUID, position: int):
|
||||
"""
|
||||
Remove an item from a list.
|
||||
|
||||
**Authentication:** Required (must be list owner)
|
||||
|
||||
**Parameters:**
|
||||
- list_id: List UUID
|
||||
- position: Position of item to remove
|
||||
|
||||
**Returns:** No content (204)
|
||||
|
||||
**Note:** Remaining items are automatically reordered.
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
top_list = get_object_or_404(UserTopList, id=list_id)
|
||||
|
||||
# Check ownership
|
||||
if top_list.user != user:
|
||||
return 403, {'detail': 'You can only modify your own lists'}
|
||||
|
||||
# Get item
|
||||
item = get_object_or_404(
|
||||
UserTopListItem,
|
||||
top_list=top_list,
|
||||
position=position
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
# Delete item
|
||||
item.delete()
|
||||
|
||||
# Reorder remaining items
|
||||
items_to_reorder = top_list.items.filter(position__gt=position).order_by('position')
|
||||
for i, remaining_item in enumerate(items_to_reorder, start=position):
|
||||
remaining_item.position = i
|
||||
remaining_item.save()
|
||||
|
||||
logger.info(f"List item deleted from list {list_id} at position {position}")
|
||||
|
||||
return 204, None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User-Specific Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/users/{user_id}", response={200: List[TopListOut], 403: ErrorResponse})
|
||||
@paginate(TopListPagination)
|
||||
def get_user_top_lists(
|
||||
request,
|
||||
user_id: UUID,
|
||||
list_type: Optional[str] = Query(None),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
Get a user's top lists.
|
||||
|
||||
**Authentication:** Optional
|
||||
|
||||
**Parameters:**
|
||||
- user_id: User UUID
|
||||
- list_type: Filter by type (optional)
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of user's top lists
|
||||
|
||||
**Note:** Only public lists visible unless viewing own lists.
|
||||
"""
|
||||
target_user = get_object_or_404(User, id=user_id)
|
||||
|
||||
# Check if current user
|
||||
current_user = request.auth if hasattr(request, 'auth') else None
|
||||
is_owner = current_user and current_user.id == target_user.id
|
||||
|
||||
# Build query
|
||||
queryset = UserTopList.objects.filter(user=target_user).select_related('user')
|
||||
|
||||
# Apply visibility filter
|
||||
if not is_owner:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
# Apply list type filter
|
||||
if list_type:
|
||||
queryset = queryset.filter(list_type=list_type)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['created', 'modified', 'title']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
# Serialize lists
|
||||
lists = [_serialize_top_list(tl) for tl in queryset]
|
||||
return lists
|
||||
369
django-backend/api/v1/endpoints/versioning.py
Normal file
369
django-backend/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
|
||||
# }
|
||||
1565
django-backend/api/v1/schemas.py
Normal file
1565
django-backend/api/v1/schemas.py
Normal file
File diff suppressed because it is too large
Load Diff
5
django-backend/api/v1/services/__init__.py
Normal file
5
django-backend/api/v1/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Service layer for API v1.
|
||||
|
||||
Provides business logic separated from endpoint handlers.
|
||||
"""
|
||||
629
django-backend/api/v1/services/history_service.py
Normal file
629
django-backend/api/v1/services/history_service.py
Normal file
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
History service for pghistory Event models.
|
||||
|
||||
Provides business logic for history queries, comparisons, and rollbacks
|
||||
using pghistory Event models (CompanyEvent, ParkEvent, RideEvent, etc.).
|
||||
"""
|
||||
|
||||
from datetime import timedelta, date, datetime
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from django.utils import timezone
|
||||
from django.db.models import QuerySet, Q
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
class HistoryService:
|
||||
"""
|
||||
Service for managing entity history via pghistory Event models.
|
||||
|
||||
Provides:
|
||||
- History queries with role-based access control
|
||||
- Event comparisons and diffs
|
||||
- Rollback functionality
|
||||
- Field-specific history tracking
|
||||
"""
|
||||
|
||||
# Mapping of entity types to their pghistory Event model paths
|
||||
EVENT_MODELS = {
|
||||
'park': ('apps.entities.models', 'ParkEvent'),
|
||||
'ride': ('apps.entities.models', 'RideEvent'),
|
||||
'company': ('apps.entities.models', 'CompanyEvent'),
|
||||
'ridemodel': ('apps.entities.models', 'RideModelEvent'),
|
||||
'review': ('apps.reviews.models', 'ReviewEvent'),
|
||||
}
|
||||
|
||||
# Mapping of entity types to their main model paths
|
||||
ENTITY_MODELS = {
|
||||
'park': ('apps.entities.models', 'Park'),
|
||||
'ride': ('apps.entities.models', 'Ride'),
|
||||
'company': ('apps.entities.models', 'Company'),
|
||||
'ridemodel': ('apps.entities.models', 'RideModel'),
|
||||
'review': ('apps.reviews.models', 'Review'),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_event_model(cls, entity_type: str):
|
||||
"""
|
||||
Get the pghistory Event model class for an entity type.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity ('park', 'ride', 'company', 'ridemodel', 'review')
|
||||
|
||||
Returns:
|
||||
Event model class (e.g., ParkEvent)
|
||||
|
||||
Raises:
|
||||
ValueError: If entity type is unknown
|
||||
"""
|
||||
entity_type_lower = entity_type.lower()
|
||||
if entity_type_lower not in cls.EVENT_MODELS:
|
||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||
|
||||
module_path, class_name = cls.EVENT_MODELS[entity_type_lower]
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
return getattr(module, class_name)
|
||||
|
||||
@classmethod
|
||||
def get_entity_model(cls, entity_type: str):
|
||||
"""Get the main entity model class for an entity type."""
|
||||
entity_type_lower = entity_type.lower()
|
||||
if entity_type_lower not in cls.ENTITY_MODELS:
|
||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||
|
||||
module_path, class_name = cls.ENTITY_MODELS[entity_type_lower]
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
return getattr(module, class_name)
|
||||
|
||||
@classmethod
|
||||
def get_history(
|
||||
cls,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
user=None,
|
||||
operation: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
field_changed: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> Tuple[QuerySet, int]:
|
||||
"""
|
||||
Get history for an entity with filtering and access control.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
entity_id: UUID of the entity
|
||||
user: User making the request (for access control)
|
||||
operation: Filter by operation type ('INSERT' or 'UPDATE')
|
||||
date_from: Filter events after this date
|
||||
date_to: Filter events before this date
|
||||
field_changed: Filter events that changed this field (requires comparison)
|
||||
limit: Maximum number of events to return
|
||||
offset: Number of events to skip (for pagination)
|
||||
|
||||
Returns:
|
||||
Tuple of (queryset, total_count)
|
||||
"""
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
|
||||
# Base queryset for this entity
|
||||
queryset = EventModel.objects.filter(
|
||||
pgh_obj_id=entity_id
|
||||
).order_by('-pgh_created_at')
|
||||
|
||||
# Get total count before access control for informational purposes
|
||||
total_count = queryset.count()
|
||||
|
||||
# Apply access control (time-based filtering)
|
||||
queryset = cls._apply_access_control(queryset, user)
|
||||
accessible_count = queryset.count()
|
||||
|
||||
# Apply additional filters
|
||||
if date_from:
|
||||
queryset = queryset.filter(pgh_created_at__gte=date_from)
|
||||
|
||||
if date_to:
|
||||
queryset = queryset.filter(pgh_created_at__lte=date_to)
|
||||
|
||||
# Note: field_changed filtering requires comparing consecutive events
|
||||
# This is expensive and should be done in the API layer if needed
|
||||
|
||||
return queryset[offset:offset + limit], accessible_count
|
||||
|
||||
@classmethod
|
||||
def _apply_access_control(cls, queryset: QuerySet, user) -> QuerySet:
|
||||
"""
|
||||
Apply time-based access control based on user role.
|
||||
|
||||
Access Rules:
|
||||
- Unauthenticated: Last 30 days
|
||||
- Authenticated: Last 1 year
|
||||
- Moderators/Admins/Superusers: Unlimited
|
||||
|
||||
Args:
|
||||
queryset: Base queryset to filter
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Filtered queryset
|
||||
"""
|
||||
# Check for privileged users first
|
||||
if user and user.is_authenticated:
|
||||
# Superusers and staff get unlimited access
|
||||
if user.is_superuser or user.is_staff:
|
||||
return queryset
|
||||
|
||||
# Check for moderator/admin role if role system exists
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return queryset
|
||||
|
||||
# Regular authenticated users: 1 year
|
||||
cutoff = timezone.now() - timedelta(days=365)
|
||||
return queryset.filter(pgh_created_at__gte=cutoff)
|
||||
|
||||
# Unauthenticated users: 30 days
|
||||
cutoff = timezone.now() - timedelta(days=30)
|
||||
return queryset.filter(pgh_created_at__gte=cutoff)
|
||||
|
||||
@classmethod
|
||||
def get_access_reason(cls, user) -> str:
|
||||
"""Get human-readable description of access level."""
|
||||
if user and user.is_authenticated:
|
||||
if user.is_superuser or user.is_staff:
|
||||
return "Full access (administrator)"
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return "Full access (moderator)"
|
||||
return "Limited to last 1 year (authenticated user)"
|
||||
return "Limited to last 30 days (public access)"
|
||||
|
||||
@classmethod
|
||||
def is_access_limited(cls, user) -> bool:
|
||||
"""Check if user has limited access."""
|
||||
if not user or not user.is_authenticated:
|
||||
return True
|
||||
if user.is_superuser or user.is_staff:
|
||||
return False
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_event(
|
||||
cls,
|
||||
entity_type: str,
|
||||
event_id: int,
|
||||
user=None
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Get a specific event by ID with access control.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
event_id: ID of the event (pgh_id)
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Event object or None if not found/not accessible
|
||||
"""
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
|
||||
try:
|
||||
event = EventModel.objects.get(pgh_id=event_id)
|
||||
|
||||
# Check if user has access to this event based on timestamp
|
||||
queryset = EventModel.objects.filter(pgh_id=event_id)
|
||||
if not cls._apply_access_control(queryset, user).exists():
|
||||
return None # User doesn't have access to this event
|
||||
|
||||
return event
|
||||
except EventModel.DoesNotExist:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def compare_events(
|
||||
cls,
|
||||
entity_type: str,
|
||||
event_id1: int,
|
||||
event_id2: int,
|
||||
user=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare two historical events.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
event_id1: ID of first event
|
||||
event_id2: ID of second event
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Dictionary containing comparison results
|
||||
|
||||
Raises:
|
||||
ValueError: If events not found or not accessible
|
||||
"""
|
||||
event1 = cls.get_event(entity_type, event_id1, user)
|
||||
event2 = cls.get_event(entity_type, event_id2, user)
|
||||
|
||||
if not event1 or not event2:
|
||||
raise ValueError("One or both events not found or not accessible")
|
||||
|
||||
# Ensure events are for the same entity
|
||||
if event1.pgh_obj_id != event2.pgh_obj_id:
|
||||
raise ValueError("Events must be for the same entity")
|
||||
|
||||
# Compute differences
|
||||
differences = cls._compute_differences(event1, event2)
|
||||
|
||||
# Calculate time between events
|
||||
time_delta = abs(event2.pgh_created_at - event1.pgh_created_at)
|
||||
|
||||
return {
|
||||
'event1': event1,
|
||||
'event2': event2,
|
||||
'differences': differences,
|
||||
'changed_field_count': len(differences),
|
||||
'unchanged_field_count': cls._get_field_count(event1) - len(differences),
|
||||
'time_between': cls._format_timedelta(time_delta)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def compare_with_current(
|
||||
cls,
|
||||
entity_type: str,
|
||||
event_id: int,
|
||||
entity,
|
||||
user=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare historical event with current entity state.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
event_id: ID of historical event
|
||||
entity: Current entity instance
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Dictionary containing comparison results
|
||||
|
||||
Raises:
|
||||
ValueError: If event not found or not accessible
|
||||
"""
|
||||
event = cls.get_event(entity_type, event_id, user)
|
||||
if not event:
|
||||
raise ValueError("Event not found or not accessible")
|
||||
|
||||
# Ensure event is for this entity
|
||||
if str(event.pgh_obj_id) != str(entity.id):
|
||||
raise ValueError("Event is not for the specified entity")
|
||||
|
||||
# Compute differences between historical and current
|
||||
differences = {}
|
||||
fields = cls._get_entity_fields(event)
|
||||
|
||||
for field in fields:
|
||||
historical_val = getattr(event, field, None)
|
||||
current_val = getattr(entity, field, None)
|
||||
|
||||
if historical_val != current_val:
|
||||
differences[field] = {
|
||||
'historical_value': cls._serialize_value(historical_val),
|
||||
'current_value': cls._serialize_value(current_val),
|
||||
'changed': True
|
||||
}
|
||||
|
||||
# Calculate time since event
|
||||
time_delta = timezone.now() - event.pgh_created_at
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'current_state': entity,
|
||||
'differences': differences,
|
||||
'changed_field_count': len(differences),
|
||||
'time_since': cls._format_timedelta(time_delta)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_rollback(cls, user) -> bool:
|
||||
"""Check if user has permission to perform rollbacks."""
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser or user.is_staff:
|
||||
return True
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def rollback_to_event(
|
||||
cls,
|
||||
entity,
|
||||
entity_type: str,
|
||||
event_id: int,
|
||||
user,
|
||||
fields: Optional[List[str]] = None,
|
||||
comment: str = "",
|
||||
create_backup: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Rollback entity to a historical state.
|
||||
|
||||
IMPORTANT: This modifies the entity and saves it!
|
||||
|
||||
Args:
|
||||
entity: Current entity instance
|
||||
entity_type: Type of entity
|
||||
event_id: ID of event to rollback to
|
||||
user: User performing the rollback
|
||||
fields: Optional list of specific fields to rollback (None = all fields)
|
||||
comment: Optional comment explaining the rollback
|
||||
create_backup: Whether to note the backup event ID
|
||||
|
||||
Returns:
|
||||
Dictionary containing rollback results
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If user doesn't have rollback permission
|
||||
ValueError: If event not found or invalid
|
||||
"""
|
||||
# Permission check
|
||||
if not cls.can_rollback(user):
|
||||
raise PermissionDenied("Only moderators and administrators can perform rollbacks")
|
||||
|
||||
event = cls.get_event(entity_type, event_id, user)
|
||||
if not event:
|
||||
raise ValueError("Event not found or not accessible")
|
||||
|
||||
# Ensure event is for this entity
|
||||
if str(event.pgh_obj_id) != str(entity.id):
|
||||
raise ValueError("Event is not for the specified entity")
|
||||
|
||||
# Track pre-rollback state for backup reference
|
||||
backup_event_id = None
|
||||
if create_backup:
|
||||
# The current state will be captured automatically by pghistory
|
||||
# when we save. We just need to note what the last event was.
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
last_event = EventModel.objects.filter(
|
||||
pgh_obj_id=entity.id
|
||||
).order_by('-pgh_created_at').first()
|
||||
if last_event:
|
||||
backup_event_id = last_event.pgh_id
|
||||
|
||||
# Determine which fields to rollback
|
||||
if fields is None:
|
||||
fields = cls._get_entity_fields(event)
|
||||
|
||||
# Track changes
|
||||
changes = {}
|
||||
for field in fields:
|
||||
if hasattr(entity, field) and hasattr(event, field):
|
||||
old_val = getattr(entity, field)
|
||||
new_val = getattr(event, field)
|
||||
|
||||
if old_val != new_val:
|
||||
setattr(entity, field, new_val)
|
||||
changes[field] = {
|
||||
'from': cls._serialize_value(old_val),
|
||||
'to': cls._serialize_value(new_val)
|
||||
}
|
||||
|
||||
# Save entity (pghistory will automatically create new event)
|
||||
entity.save()
|
||||
|
||||
# Get the new event that was just created
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
new_event = EventModel.objects.filter(
|
||||
pgh_obj_id=entity.id
|
||||
).order_by('-pgh_created_at').first()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Successfully rolled back {len(changes)} field(s) to state from {event.pgh_created_at.strftime("%Y-%m-%d")}',
|
||||
'entity_id': str(entity.id),
|
||||
'rollback_event_id': event_id,
|
||||
'new_event_id': new_event.pgh_id if new_event else None,
|
||||
'fields_changed': changes,
|
||||
'backup_event_id': backup_event_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_field_history(
|
||||
cls,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
field_name: str,
|
||||
user=None,
|
||||
limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get history of changes to a specific field.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
entity_id: UUID of the entity
|
||||
field_name: Name of the field to track
|
||||
user: User making the request
|
||||
limit: Maximum number of changes to return
|
||||
|
||||
Returns:
|
||||
List of field changes
|
||||
"""
|
||||
events, _ = cls.get_history(entity_type, entity_id, user, limit=limit)
|
||||
|
||||
field_history = []
|
||||
previous_value = None
|
||||
first_value = None
|
||||
|
||||
# Iterate through events in reverse chronological order
|
||||
for event in events:
|
||||
if not hasattr(event, field_name):
|
||||
continue
|
||||
|
||||
current_value = getattr(event, field_name, None)
|
||||
|
||||
# Track first (oldest) value
|
||||
if first_value is None:
|
||||
first_value = current_value
|
||||
|
||||
# Detect changes
|
||||
if previous_value is not None and current_value != previous_value:
|
||||
field_history.append({
|
||||
'timestamp': event.pgh_created_at,
|
||||
'event_id': event.pgh_id,
|
||||
'old_value': cls._serialize_value(previous_value),
|
||||
'new_value': cls._serialize_value(current_value),
|
||||
'change_type': 'UPDATE'
|
||||
})
|
||||
elif previous_value is None:
|
||||
# First event we're seeing (most recent)
|
||||
field_history.append({
|
||||
'timestamp': event.pgh_created_at,
|
||||
'event_id': event.pgh_id,
|
||||
'old_value': None,
|
||||
'new_value': cls._serialize_value(current_value),
|
||||
'change_type': 'INSERT' if len(list(events)) == 1 else 'UPDATE'
|
||||
})
|
||||
|
||||
previous_value = current_value
|
||||
|
||||
return {
|
||||
'history': field_history,
|
||||
'total_changes': len(field_history),
|
||||
'first_value': cls._serialize_value(first_value),
|
||||
'current_value': cls._serialize_value(previous_value) if previous_value is not None else None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_activity_summary(
|
||||
cls,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
user=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get activity summary for an entity.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
entity_id: UUID of the entity
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Dictionary with activity statistics
|
||||
"""
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
now = timezone.now()
|
||||
|
||||
# Get all events for this entity (respecting access control)
|
||||
all_events = EventModel.objects.filter(pgh_obj_id=entity_id)
|
||||
total_events = all_events.count()
|
||||
|
||||
accessible_events = cls._apply_access_control(all_events, user)
|
||||
accessible_count = accessible_events.count()
|
||||
|
||||
# Time-based summaries
|
||||
last_24h = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=1)
|
||||
).count()
|
||||
|
||||
last_7d = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=7)
|
||||
).count()
|
||||
|
||||
last_30d = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=30)
|
||||
).count()
|
||||
|
||||
last_year = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=365)
|
||||
).count()
|
||||
|
||||
# Get recent activity (last 10 events)
|
||||
recent_activity = accessible_events.order_by('-pgh_created_at')[:10]
|
||||
|
||||
return {
|
||||
'total_events': total_events,
|
||||
'accessible_events': accessible_count,
|
||||
'summary': {
|
||||
'last_24_hours': last_24h,
|
||||
'last_7_days': last_7d,
|
||||
'last_30_days': last_30d,
|
||||
'last_year': last_year
|
||||
},
|
||||
'recent_activity': [
|
||||
{
|
||||
'timestamp': event.pgh_created_at,
|
||||
'event_id': event.pgh_id,
|
||||
'operation': 'INSERT' if event == accessible_events.last() else 'UPDATE'
|
||||
}
|
||||
for event in recent_activity
|
||||
]
|
||||
}
|
||||
|
||||
# Helper methods
|
||||
|
||||
@classmethod
|
||||
def _compute_differences(cls, event1, event2) -> Dict[str, Any]:
|
||||
"""Compute differences between two events."""
|
||||
differences = {}
|
||||
fields = cls._get_entity_fields(event1)
|
||||
|
||||
for field in fields:
|
||||
val1 = getattr(event1, field, None)
|
||||
val2 = getattr(event2, field, None)
|
||||
|
||||
if val1 != val2:
|
||||
differences[field] = {
|
||||
'event1_value': cls._serialize_value(val1),
|
||||
'event2_value': cls._serialize_value(val2)
|
||||
}
|
||||
|
||||
return differences
|
||||
|
||||
@classmethod
|
||||
def _get_entity_fields(cls, event) -> List[str]:
|
||||
"""Get list of entity field names (excluding pghistory fields)."""
|
||||
return [
|
||||
f.name for f in event._meta.fields
|
||||
if not f.name.startswith('pgh_') and f.name not in ['id']
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _get_field_count(cls, event) -> int:
|
||||
"""Get count of entity fields."""
|
||||
return len(cls._get_entity_fields(event))
|
||||
|
||||
@classmethod
|
||||
def _serialize_value(cls, value) -> Any:
|
||||
"""Serialize a value for JSON response."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if hasattr(value, 'id'): # Foreign key
|
||||
return str(value.id)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _format_timedelta(cls, delta: timedelta) -> str:
|
||||
"""Format a timedelta as human-readable string."""
|
||||
days = delta.days
|
||||
if days == 0:
|
||||
hours = delta.seconds // 3600
|
||||
if hours == 0:
|
||||
minutes = delta.seconds // 60
|
||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
elif days < 30:
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
elif days < 365:
|
||||
months = days // 30
|
||||
return f"{months} month{'s' if months != 1 else ''}"
|
||||
else:
|
||||
years = days // 365
|
||||
months = (days % 365) // 30
|
||||
if months > 0:
|
||||
return f"{years} year{'s' if years != 1 else ''}, {months} month{'s' if months != 1 else ''}"
|
||||
return f"{years} year{'s' if years != 1 else ''}"
|
||||
Reference in New Issue
Block a user