""" API viewsets for the ride ranking system. """ from django.db.models import Q, Count, Max from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from drf_spectacular.types import OpenApiTypes from rest_framework import status from rest_framework.decorators import action from rest_framework.filters import OrderingFilter from rest_framework.permissions import IsAuthenticatedOrReadOnly, AllowAny from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.views import APIView from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot from apps.rides.services import RideRankingService from .serializers_rankings import ( RideRankingSerializer, RideRankingDetailSerializer, RankingSnapshotSerializer, RankingStatsSerializer, ) @extend_schema_view( list=extend_schema( summary="List ride rankings", description="Get the current ride rankings calculated using the Internet Roller Coaster Poll algorithm.", parameters=[ OpenApiParameter( name="category", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by ride category (RC, DR, FR, WR, TR, OT)", enum=["RC", "DR", "FR", "WR", "TR", "OT"], ), OpenApiParameter( name="min_riders", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, description="Minimum number of mutual riders required", ), OpenApiParameter( name="park", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by park slug", ), OpenApiParameter( name="ordering", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Order results (rank, -rank, winning_percentage, -winning_percentage)", ), ], responses={200: RideRankingSerializer(many=True)}, tags=["Rankings"], ), retrieve=extend_schema( summary="Get ranking details", description="Get detailed ranking information for a specific ride.", responses={ 200: RideRankingDetailSerializer, 404: OpenApiTypes.OBJECT, }, tags=["Rankings"], ), history=extend_schema( summary="Get ranking history", description="Get historical ranking data for a specific ride.", responses={200: RankingSnapshotSerializer(many=True)}, tags=["Rankings"], ), statistics=extend_schema( summary="Get ranking statistics", description="Get overall statistics about the ranking system.", responses={200: RankingStatsSerializer}, tags=["Rankings", "Statistics"], ), ) class RideRankingViewSet(ReadOnlyModelViewSet): """ ViewSet for ride rankings. Provides access to ride rankings calculated using the Internet Roller Coaster Poll algorithm. Rankings are updated daily and based on pairwise comparisons of user ratings. """ permission_classes = [AllowAny] lookup_field = "ride__slug" lookup_url_kwarg = "ride_slug" filter_backends = [DjangoFilterBackend, OrderingFilter] filterset_fields = ["ride__category"] ordering_fields = [ "rank", "winning_percentage", "mutual_riders_count", "average_rating", ] ordering = ["rank"] def get_queryset(self): """Get rankings with optimized queries.""" queryset = RideRanking.objects.select_related( "ride", "ride__park", "ride__park__location", "ride__manufacturer" ) # Filter by category category = self.request.query_params.get("category") if category: queryset = queryset.filter(ride__category=category) # Filter by minimum mutual riders min_riders = self.request.query_params.get("min_riders") if min_riders: try: queryset = queryset.filter(mutual_riders_count__gte=int(min_riders)) except ValueError: pass # Filter by park park_slug = self.request.query_params.get("park") if park_slug: queryset = queryset.filter(ride__park__slug=park_slug) return queryset def get_serializer_class(self): """Use different serializers for list vs detail.""" if self.action == "retrieve": return RideRankingDetailSerializer elif self.action == "history": return RankingSnapshotSerializer elif self.action == "statistics": return RankingStatsSerializer return RideRankingSerializer @action(detail=True, methods=["get"]) def history(self, request, ride_slug=None): """Get ranking history for a specific ride.""" ranking = self.get_object() history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by( "-snapshot_date" )[ :90 ] # Last 3 months serializer = self.get_serializer(history, many=True) return Response(serializer.data) @action(detail=False, methods=["get"]) def statistics(self, request): """Get overall ranking system statistics.""" total_rankings = RideRanking.objects.count() total_comparisons = RidePairComparison.objects.count() # Get last calculation time latest_ranking = RideRanking.objects.order_by("-last_calculated").first() last_calc_time = latest_ranking.last_calculated if latest_ranking else None # Get top rated ride top_rated = RideRanking.objects.select_related("ride", "ride__park").first() # Get most compared ride most_compared = ( RideRanking.objects.select_related("ride", "ride__park") .order_by("-comparison_count") .first() ) # Get biggest rank change (last 7 days) from datetime import timedelta week_ago = timezone.now().date() - timedelta(days=7) biggest_change = None max_change = 0 current_rankings = RideRanking.objects.select_related("ride") for ranking in current_rankings[:100]: # Check top 100 for performance old_snapshot = ( RankingSnapshot.objects.filter( ride=ranking.ride, snapshot_date__lte=week_ago ) .order_by("-snapshot_date") .first() ) if old_snapshot: change = abs(old_snapshot.rank - ranking.rank) if change > max_change: max_change = change biggest_change = { "ride": { "id": ranking.ride.id, "name": ranking.ride.name, "slug": ranking.ride.slug, }, "current_rank": ranking.rank, "previous_rank": old_snapshot.rank, "change": old_snapshot.rank - ranking.rank, } stats = { "total_ranked_rides": total_rankings, "total_comparisons": total_comparisons, "last_calculation_time": last_calc_time, "calculation_duration": None, # Would need to track this separately "top_rated_ride": ( { "id": top_rated.ride.id, "name": top_rated.ride.name, "slug": top_rated.ride.slug, "park": top_rated.ride.park.name, "rank": top_rated.rank, "winning_percentage": float(top_rated.winning_percentage), "average_rating": ( float(top_rated.average_rating) if top_rated.average_rating else None ), } if top_rated else None ), "most_compared_ride": ( { "id": most_compared.ride.id, "name": most_compared.ride.name, "slug": most_compared.ride.slug, "park": most_compared.ride.park.name, "comparison_count": most_compared.comparison_count, } if most_compared else None ), "biggest_rank_change": biggest_change, } serializer = RankingStatsSerializer(stats) return Response(serializer.data) @action(detail=True, methods=["get"]) def comparisons(self, request, ride_slug=None): """Get head-to-head comparisons for a specific ride.""" ranking = self.get_object() comparisons = ( RidePairComparison.objects.filter( Q(ride_a=ranking.ride) | Q(ride_b=ranking.ride) ) .select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park") .order_by("-mutual_riders_count")[:50] ) results = [] for comp in comparisons: if comp.ride_a == ranking.ride: opponent = comp.ride_b wins = comp.ride_a_wins losses = comp.ride_b_wins else: opponent = comp.ride_a wins = comp.ride_b_wins losses = comp.ride_a_wins result = "win" if wins > losses else "loss" if losses > wins else "tie" results.append( { "opponent": { "id": opponent.id, "name": opponent.name, "slug": opponent.slug, "park": { "id": opponent.park.id, "name": opponent.park.name, "slug": opponent.park.slug, }, }, "wins": wins, "losses": losses, "ties": comp.ties, "result": result, "mutual_riders": comp.mutual_riders_count, "ride_a_avg_rating": ( float(comp.ride_a_avg_rating) if comp.ride_a_avg_rating else None ), "ride_b_avg_rating": ( float(comp.ride_b_avg_rating) if comp.ride_b_avg_rating else None ), } ) return Response(results) @extend_schema( summary="Trigger ranking calculation", description="Manually trigger a ranking calculation (admin only).", request=None, responses={ 200: OpenApiTypes.OBJECT, 403: OpenApiTypes.OBJECT, }, tags=["Rankings", "Admin"], ) class TriggerRankingCalculationView(APIView): """ Admin endpoint to manually trigger ranking calculation. """ permission_classes = [IsAuthenticatedOrReadOnly] def post(self, request): """Trigger ranking calculation.""" if not request.user.is_staff: return Response( {"error": "Admin access required"}, status=status.HTTP_403_FORBIDDEN ) category = request.data.get("category") service = RideRankingService() result = service.update_all_rankings(category=category) return Response(result)