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