mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:51:10 -05:00
remove backend
This commit is contained in:
379
apps/api/v1/viewsets_rankings.py
Normal file
379
apps/api/v1/viewsets_rankings.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user