""" API viewsets for the ride ranking system. """ from typing import TYPE_CHECKING, Any, Type, cast from django.db.models import Q, QuerySet 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.request import Request from rest_framework.response import Response from rest_framework.serializers import BaseSerializer from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.views import APIView if TYPE_CHECKING: pass # Import models inside methods to avoid Django initialization issues 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) -> QuerySet[Any]: # type: ignore """Get rankings with optimized queries.""" from apps.rides.models import RideRanking queryset = RideRanking.objects.select_related( "ride", "ride__park", "ride__park__location", "ride__manufacturer" ) # Cast self.request to DRF Request so type checker recognizes query_params request = cast(Request, self.request) # Filter by category category = request.query_params.get("category") if category: queryset = queryset.filter(ride__category=category) # Filter by minimum mutual riders min_riders = 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 = request.query_params.get("park") if park_slug: queryset = queryset.filter(ride__park__slug=park_slug) return queryset def get_serializer_class(self) -> Any: # type: ignore[override] """Use different serializers for list vs detail.""" if self.action == "retrieve": return cast(Type[BaseSerializer], RideRankingDetailSerializer) elif self.action == "history": return cast(Type[BaseSerializer], RankingSnapshotSerializer) elif self.action == "statistics": return cast(Type[BaseSerializer], RankingStatsSerializer) return cast(Type[BaseSerializer], RideRankingSerializer) @action(detail=True, methods=["get"]) def history(self, request, ride_slug=None): """Get ranking history for a specific ride.""" from apps.rides.models import RankingSnapshot 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.""" from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot 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) @extend_schema( summary="Get ride comparisons", description="Get head-to-head comparisons for a specific ride", responses={200: OpenApiTypes.OBJECT}, tags=["Rankings"], ) @action(detail=True, methods=["get"]) def comparisons(self, request, ride_slug=None): """Get head-to-head comparisons for a specific ride.""" from apps.rides.models import RidePairComparison 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 ) # Replace direct import with a guarded runtime import to avoid static-analysis/initialization errors try: from apps.rides.services import RideRankingService # type: ignore except Exception: RideRankingService = None # type: ignore # Attempt a dynamic import as a fallback if the direct import failed if RideRankingService is None: try: import importlib _services_mod = importlib.import_module("apps.rides.services") RideRankingService = getattr(_services_mod, "RideRankingService", None) except Exception: RideRankingService = None if not RideRankingService: return Response( {"error": "Ranking service unavailable"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) category = request.data.get("category") service = RideRankingService() result = service.update_all_rankings(category=category) return Response(result)