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

340 lines
11 KiB
Python

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