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