mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 07:11:11 -05:00
411 lines
13 KiB
Python
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,
|
|
}
|