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

411 lines
13 KiB
Python

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