mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:51:09 -05:00
- Implemented PrimeProgress component with support for labels, helper text, and various styles (size, variant, color). - Created PrimeSelect component with dropdown functionality, custom templates, and validation states. - Developed PrimeSkeleton component for loading placeholders with different shapes and animations. - Updated index.ts to export new components for easy import. - Enhanced PrimeVueTest.vue to include tests for new components and their functionalities. - Introduced a custom ThrillWiki theme for PrimeVue with tailored color schemes and component styles. - Added ambient type declarations for various components to improve TypeScript support.
378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""
|
|
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)
|