Files
thrilltrack-explorer/django-backend/api/v1/endpoints/ride_models.py

641 lines
22 KiB
Python

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