Files
thrillwiki_django_no_react/backend/apps/api/v1/viewsets_rankings.py

344 lines
12 KiB
Python

"""
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
# 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):
"""Get rankings with optimized queries."""
from apps.rides.models import RideRanking
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."""
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)
@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
)
from apps.rides.services import RideRankingService
category = request.data.get("category")
service = RideRankingService()
result = service.update_all_rankings(category=category)
return Response(result)