Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

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

View 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",
}

View File

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

View File

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

View File

@@ -0,0 +1,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
}

View 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"}

View 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)}

View 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,
}

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

View File

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

View File

@@ -0,0 +1,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
}

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

View 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,
}

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

View 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

View File

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

View File

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

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

View 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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
"""
Service layer for API v1.
Provides business logic separated from endpoint handlers.
"""

View 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 ''}"