mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:11:09 -05:00
feat: Implement Entity Suggestion Manager and Modal components
- Added EntitySuggestionManager.vue to manage entity suggestions and authentication. - Created EntitySuggestionModal.vue for displaying suggestions and adding new entities. - Integrated AuthManager for user authentication within the suggestion modal. - Enhanced signal handling in start-servers.sh for graceful shutdown of servers. - Improved server startup script to ensure proper cleanup and responsiveness to termination signals. - Added documentation for signal handling fixes and usage instructions.
This commit is contained in:
252
backend/apps/api/v1/serializers_rankings.py
Normal file
252
backend/apps/api/v1/serializers_rankings.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
API serializers for the ride ranking system.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_serializer, OpenApiExample
|
||||
|
||||
from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Ranking Example",
|
||||
summary="Example ranking response",
|
||||
description="A ride ranking with all metrics",
|
||||
value={
|
||||
"id": 1,
|
||||
"rank": 1,
|
||||
"ride": {
|
||||
"id": 123,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"park": {"id": 45, "name": "Cedar Point", "slug": "cedar-point"},
|
||||
"category": "RC",
|
||||
},
|
||||
"wins": 523,
|
||||
"losses": 87,
|
||||
"ties": 45,
|
||||
"winning_percentage": 0.8234,
|
||||
"mutual_riders_count": 1250,
|
||||
"comparison_count": 655,
|
||||
"average_rating": 9.2,
|
||||
"last_calculated": "2024-01-15T02:00:00Z",
|
||||
"rank_change": 2,
|
||||
"previous_rank": 3,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideRankingSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ride rankings."""
|
||||
|
||||
ride = serializers.SerializerMethodField()
|
||||
rank_change = serializers.SerializerMethodField()
|
||||
previous_rank = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RideRanking
|
||||
fields = [
|
||||
"id",
|
||||
"rank",
|
||||
"ride",
|
||||
"wins",
|
||||
"losses",
|
||||
"ties",
|
||||
"winning_percentage",
|
||||
"mutual_riders_count",
|
||||
"comparison_count",
|
||||
"average_rating",
|
||||
"last_calculated",
|
||||
"rank_change",
|
||||
"previous_rank",
|
||||
]
|
||||
|
||||
def get_ride(self, obj):
|
||||
"""Get ride details."""
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
"park": {
|
||||
"id": obj.ride.park.id,
|
||||
"name": obj.ride.park.name,
|
||||
"slug": obj.ride.park.slug,
|
||||
},
|
||||
"category": obj.ride.category,
|
||||
}
|
||||
|
||||
def get_rank_change(self, obj):
|
||||
"""Calculate rank change from previous snapshot."""
|
||||
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:2]
|
||||
|
||||
if len(latest_snapshots) >= 2:
|
||||
return latest_snapshots[0].rank - latest_snapshots[1].rank
|
||||
return None
|
||||
|
||||
def get_previous_rank(self, obj):
|
||||
"""Get previous rank."""
|
||||
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:2]
|
||||
|
||||
if len(latest_snapshots) >= 2:
|
||||
return latest_snapshots[1].rank
|
||||
return None
|
||||
|
||||
|
||||
class RideRankingDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed serializer for a specific ride's ranking."""
|
||||
|
||||
ride = serializers.SerializerMethodField()
|
||||
head_to_head_comparisons = serializers.SerializerMethodField()
|
||||
ranking_history = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RideRanking
|
||||
fields = [
|
||||
"id",
|
||||
"rank",
|
||||
"ride",
|
||||
"wins",
|
||||
"losses",
|
||||
"ties",
|
||||
"winning_percentage",
|
||||
"mutual_riders_count",
|
||||
"comparison_count",
|
||||
"average_rating",
|
||||
"last_calculated",
|
||||
"calculation_version",
|
||||
"head_to_head_comparisons",
|
||||
"ranking_history",
|
||||
]
|
||||
|
||||
def get_ride(self, obj):
|
||||
"""Get detailed ride information."""
|
||||
ride = obj.ride
|
||||
return {
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"description": ride.description,
|
||||
"park": {
|
||||
"id": ride.park.id,
|
||||
"name": ride.park.name,
|
||||
"slug": ride.park.slug,
|
||||
"location": {
|
||||
"city": (
|
||||
ride.park.location.city
|
||||
if hasattr(ride.park, "location")
|
||||
else None
|
||||
),
|
||||
"state": (
|
||||
ride.park.location.state
|
||||
if hasattr(ride.park, "location")
|
||||
else None
|
||||
),
|
||||
"country": (
|
||||
ride.park.location.country
|
||||
if hasattr(ride.park, "location")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
"category": ride.category,
|
||||
"manufacturer": (
|
||||
{"id": ride.manufacturer.id, "name": ride.manufacturer.name}
|
||||
if ride.manufacturer
|
||||
else None
|
||||
),
|
||||
"opening_date": ride.opening_date,
|
||||
"status": ride.status,
|
||||
}
|
||||
|
||||
def get_head_to_head_comparisons(self, obj):
|
||||
"""Get top head-to-head comparisons."""
|
||||
from django.db.models import Q
|
||||
|
||||
comparisons = (
|
||||
RidePairComparison.objects.filter(Q(ride_a=obj.ride) | Q(ride_b=obj.ride))
|
||||
.select_related("ride_a", "ride_b")
|
||||
.order_by("-mutual_riders_count")[:10]
|
||||
)
|
||||
|
||||
results = []
|
||||
for comp in comparisons:
|
||||
if comp.ride_a == obj.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": opponent.park.name,
|
||||
},
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"ties": comp.ties,
|
||||
"result": result,
|
||||
"mutual_riders": comp.mutual_riders_count,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def get_ranking_history(self, obj):
|
||||
"""Get recent ranking history."""
|
||||
history = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:30]
|
||||
|
||||
return [
|
||||
{
|
||||
"date": snapshot.snapshot_date,
|
||||
"rank": snapshot.rank,
|
||||
"winning_percentage": float(snapshot.winning_percentage),
|
||||
}
|
||||
for snapshot in history
|
||||
]
|
||||
|
||||
|
||||
class RankingSnapshotSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ranking history snapshots."""
|
||||
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RankingSnapshot
|
||||
fields = [
|
||||
"id",
|
||||
"ride",
|
||||
"ride_name",
|
||||
"park_name",
|
||||
"rank",
|
||||
"winning_percentage",
|
||||
"snapshot_date",
|
||||
]
|
||||
|
||||
|
||||
class RankingStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for ranking system statistics."""
|
||||
|
||||
total_ranked_rides = serializers.IntegerField()
|
||||
total_comparisons = serializers.IntegerField()
|
||||
last_calculation_time = serializers.DateTimeField()
|
||||
calculation_duration = serializers.FloatField()
|
||||
top_rated_ride = serializers.DictField()
|
||||
most_compared_ride = serializers.DictField()
|
||||
biggest_rank_change = serializers.DictField()
|
||||
@@ -44,8 +44,14 @@ from .viewsets import (
|
||||
UserProfileViewSet,
|
||||
TopListViewSet,
|
||||
TopListItemViewSet,
|
||||
# Trending system views
|
||||
TrendingAPIView,
|
||||
NewContentAPIView,
|
||||
)
|
||||
|
||||
# Import ranking viewsets
|
||||
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
|
||||
|
||||
# Create the main API router
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -53,7 +59,7 @@ router = DefaultRouter()
|
||||
|
||||
# Core models
|
||||
router.register(r"parks", ParkViewSet, basename="park")
|
||||
router.register(r"rides", RideViewSet, basename="ride")
|
||||
# Note: rides registered below with list-only actions to enforce nested-only detail access
|
||||
|
||||
# Park-related models
|
||||
router.register(r"park-areas", ParkAreaViewSet, basename="park-area")
|
||||
@@ -79,6 +85,9 @@ router.register(r"top-list-items", TopListItemViewSet, basename="top-list-item")
|
||||
router.register(r"ref/parks", ParkReadOnlyViewSet, basename="park-ref")
|
||||
router.register(r"ref/rides", RideReadOnlyViewSet, basename="ride-ref")
|
||||
|
||||
# Register ranking endpoints
|
||||
router.register(r"rankings", RideRankingViewSet, basename="ranking")
|
||||
|
||||
app_name = "api_v1"
|
||||
|
||||
urlpatterns = [
|
||||
@@ -137,6 +146,39 @@ urlpatterns = [
|
||||
RideHistoryViewSet.as_view({"get": "retrieve"}),
|
||||
name="ride-history-detail",
|
||||
),
|
||||
# Nested park-scoped ride endpoints
|
||||
path(
|
||||
"parks/<str:park_slug>/rides/",
|
||||
RideViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="park-rides-list",
|
||||
),
|
||||
path(
|
||||
"parks/<str:park_slug>/rides/<str:ride_slug>/",
|
||||
RideViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="park-rides-detail",
|
||||
),
|
||||
# Trending system endpoints
|
||||
path("trending/content/", TrendingAPIView.as_view(), name="trending"),
|
||||
path("trending/new/", NewContentAPIView.as_view(), name="new-content"),
|
||||
# Ranking system endpoints
|
||||
path(
|
||||
"rankings/calculate/",
|
||||
TriggerRankingCalculationView.as_view(),
|
||||
name="trigger-ranking-calculation",
|
||||
),
|
||||
# Global rides list endpoint (detail access only via nested park routes)
|
||||
path(
|
||||
"rides/",
|
||||
RideViewSet.as_view({"get": "list"}),
|
||||
name="ride-list",
|
||||
),
|
||||
# Include all router-generated URLs
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -28,6 +28,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import Http404
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from allauth.socialaccount import providers
|
||||
from health_check.views import MainView
|
||||
@@ -669,12 +670,20 @@ class RideViewSet(ModelViewSet):
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get optimized queryset based on action."""
|
||||
if self.action == "list":
|
||||
# Parse filter parameters for list view
|
||||
# CRITICAL FIX: Check if this is a nested endpoint first
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
if park_slug:
|
||||
# For nested endpoints, use the dedicated park selector
|
||||
from apps.rides.selectors import rides_in_park
|
||||
return rides_in_park(park_slug=park_slug)
|
||||
|
||||
# For global endpoints, parse filter parameters and use general selector
|
||||
filter_serializer = RideFilterInputSerializer(
|
||||
data=self.request.query_params # type: ignore[attr-defined]
|
||||
)
|
||||
filter_serializer.is_valid(raise_exception=True)
|
||||
filters = filter_serializer.validated_data
|
||||
|
||||
return ride_list_for_display(filters=filters) # type: ignore[arg-type]
|
||||
|
||||
# For other actions, return base queryset
|
||||
@@ -690,7 +699,10 @@ class RideViewSet(ModelViewSet):
|
||||
ride_slug = self.kwargs.get("slug") or self.kwargs.get("ride_slug")
|
||||
|
||||
if park_slug and ride_slug:
|
||||
return ride_detail_optimized(slug=ride_slug, park_slug=park_slug)
|
||||
try:
|
||||
return ride_detail_optimized(slug=ride_slug, park_slug=park_slug)
|
||||
except Ride.DoesNotExist:
|
||||
raise Http404("Ride not found")
|
||||
elif ride_slug:
|
||||
# For rides accessed directly by slug, we'll use the first approach
|
||||
# and let the 404 handling work naturally
|
||||
@@ -1748,21 +1760,43 @@ class LoginAPIView(TurnstileMixin, APIView):
|
||||
email_or_username = serializer.validated_data["username"]
|
||||
password = serializer.validated_data["password"] # type: ignore[index]
|
||||
|
||||
# Try to authenticate with email first, then username
|
||||
# Optimized user lookup: single query using Q objects
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = None
|
||||
if "@" in email_or_username:
|
||||
try:
|
||||
user_obj = UserModel.objects.get(email=email_or_username)
|
||||
|
||||
# Single query to find user by email OR username
|
||||
try:
|
||||
if "@" in email_or_username:
|
||||
# Email-like input: try email first, then username as fallback
|
||||
user_obj = (
|
||||
User.objects.select_related()
|
||||
.filter(
|
||||
Q(email=email_or_username) | Q(username=email_or_username)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
# Username-like input: try username first, then email as fallback
|
||||
user_obj = (
|
||||
User.objects.select_related()
|
||||
.filter(
|
||||
Q(username=email_or_username) | Q(email=email_or_username)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if user_obj:
|
||||
user = authenticate(
|
||||
# type: ignore[attr-defined]
|
||||
request._request,
|
||||
username=user_obj.username,
|
||||
password=password,
|
||||
)
|
||||
except UserModel.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user:
|
||||
except Exception:
|
||||
# Fallback to original behavior
|
||||
user = authenticate(
|
||||
# type: ignore[attr-defined]
|
||||
request._request,
|
||||
@@ -1773,6 +1807,7 @@ class LoginAPIView(TurnstileMixin, APIView):
|
||||
if user:
|
||||
if user.is_active:
|
||||
login(request._request, user) # type: ignore[attr-defined]
|
||||
# Optimized token creation - get_or_create is atomic
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
@@ -1981,48 +2016,56 @@ class SocialProvidersAPIView(APIView):
|
||||
serializer_class = SocialProviderOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
from django.core.cache import cache
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
site = get_current_site(request._request) # type: ignore[attr-defined]
|
||||
|
||||
# Cache key based on site and request host
|
||||
cache_key = (
|
||||
f"social_providers:{getattr(site, 'id', site.pk)}:{request.get_host()}"
|
||||
)
|
||||
|
||||
# Try to get from cache first (cache for 15 minutes)
|
||||
cached_providers = cache.get(cache_key)
|
||||
if cached_providers is not None:
|
||||
return Response(cached_providers)
|
||||
|
||||
providers_list = []
|
||||
|
||||
# Get all configured social apps for the current site
|
||||
social_apps = SocialApp.objects.filter(sites=site)
|
||||
# Optimized query: filter by site and order by provider name
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
# Get provider class from providers module
|
||||
provider_module = getattr(providers, social_app.provider, None)
|
||||
if provider_module and hasattr(provider_module, "provider"):
|
||||
provider_class = provider_module.provider
|
||||
provider_instance = provider_class(request)
|
||||
# Simplified provider name resolution - avoid expensive provider class loading
|
||||
provider_name = social_app.name or social_app.provider.title()
|
||||
|
||||
# Build auth URL efficiently
|
||||
auth_url = request.build_absolute_uri(
|
||||
f"/accounts/{social_app.provider}/login/"
|
||||
)
|
||||
|
||||
providers_list.append(
|
||||
{
|
||||
"id": social_app.provider,
|
||||
"name": provider_name,
|
||||
"authUrl": auth_url,
|
||||
}
|
||||
)
|
||||
|
||||
auth_url = request.build_absolute_uri(
|
||||
f"/accounts/{social_app.provider}/login/"
|
||||
)
|
||||
providers_list.append(
|
||||
{
|
||||
"id": social_app.provider,
|
||||
"name": provider_instance.name,
|
||||
"authUrl": auth_url,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Fallback: use provider id as name
|
||||
auth_url = request.build_absolute_uri(
|
||||
f"/accounts/{social_app.provider}/login/"
|
||||
)
|
||||
providers_list.append(
|
||||
{
|
||||
"id": social_app.provider,
|
||||
"name": social_app.provider.title(),
|
||||
"authUrl": auth_url,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# Skip if provider can't be loaded
|
||||
continue
|
||||
|
||||
# Serialize and cache the result
|
||||
serializer = SocialProviderOutputSerializer(providers_list, many=True)
|
||||
return Response(serializer.data)
|
||||
response_data = serializer.data
|
||||
|
||||
# Cache for 15 minutes (900 seconds)
|
||||
cache.set(cache_key, response_data, 900)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -2908,3 +2951,192 @@ class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
|
||||
|
||||
serializer = UnifiedHistoryTimelineSerializer(timeline_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# === TRENDING VIEWSETS ===
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Get trending content",
|
||||
description="Retrieve trending parks and rides based on view counts, ratings, and recency.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of trending items to return (default: 20, max: 100)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="timeframe",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Timeframe for trending calculation (day, week, month) - default: week",
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Trending"],
|
||||
),
|
||||
)
|
||||
class TrendingAPIView(APIView):
|
||||
"""API endpoint for trending content."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get trending parks and rides."""
|
||||
from apps.core.services.trending_service import TrendingService
|
||||
|
||||
# Parse parameters
|
||||
limit = min(int(request.query_params.get("limit", 20)), 100)
|
||||
|
||||
# Get trending content
|
||||
trending_service = TrendingService()
|
||||
all_trending = trending_service.get_trending_content(limit=limit * 2)
|
||||
|
||||
# Separate by content type
|
||||
trending_rides = []
|
||||
trending_parks = []
|
||||
|
||||
for item in all_trending:
|
||||
if item.get("category") == "ride":
|
||||
trending_rides.append(item)
|
||||
elif item.get("category") == "park":
|
||||
trending_parks.append(item)
|
||||
|
||||
# Limit each category
|
||||
trending_rides = trending_rides[: limit // 3] if trending_rides else []
|
||||
trending_parks = trending_parks[: limit // 3] if trending_parks else []
|
||||
|
||||
# Create mock latest reviews (since not implemented yet)
|
||||
latest_reviews = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Steel Vengeance Review",
|
||||
"location": "Cedar Point",
|
||||
"category": "Roller Coaster",
|
||||
"rating": 5.0,
|
||||
"rank": 1,
|
||||
"views": 1234,
|
||||
"views_change": "+45%",
|
||||
"slug": "steel-vengeance-review",
|
||||
}
|
||||
][: limit // 3]
|
||||
|
||||
# Return in expected frontend format
|
||||
response_data = {
|
||||
"trending_rides": trending_rides,
|
||||
"trending_parks": trending_parks,
|
||||
"latest_reviews": latest_reviews,
|
||||
}
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Get new content",
|
||||
description="Retrieve recently added parks and rides.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of new items to return (default: 20, max: 100)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="days",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of days to look back for new content (default: 30, max: 365)",
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Trending"],
|
||||
),
|
||||
)
|
||||
class NewContentAPIView(APIView):
|
||||
"""API endpoint for new content."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get new parks and rides."""
|
||||
from apps.core.services.trending_service import TrendingService
|
||||
from datetime import datetime, date
|
||||
|
||||
# Parse parameters
|
||||
limit = min(int(request.query_params.get("limit", 20)), 100)
|
||||
|
||||
# Get new content with longer timeframe to get more data
|
||||
trending_service = TrendingService()
|
||||
all_new_content = trending_service.get_new_content(
|
||||
limit=limit * 2, days_back=60
|
||||
)
|
||||
|
||||
recently_added = []
|
||||
newly_opened = []
|
||||
upcoming = []
|
||||
|
||||
# Categorize items based on date
|
||||
today = date.today()
|
||||
|
||||
for item in all_new_content:
|
||||
date_added = item.get("date_added", "")
|
||||
if date_added:
|
||||
try:
|
||||
# Parse the date string
|
||||
if isinstance(date_added, str):
|
||||
item_date = datetime.fromisoformat(date_added).date()
|
||||
else:
|
||||
item_date = date_added
|
||||
|
||||
# Calculate days difference
|
||||
days_diff = (today - item_date).days
|
||||
|
||||
if days_diff <= 30: # Recently added (last 30 days)
|
||||
recently_added.append(item)
|
||||
elif days_diff <= 365: # Newly opened (last year)
|
||||
newly_opened.append(item)
|
||||
else: # Older items
|
||||
newly_opened.append(item)
|
||||
|
||||
except (ValueError, TypeError):
|
||||
# If date parsing fails, add to recently added
|
||||
recently_added.append(item)
|
||||
else:
|
||||
recently_added.append(item)
|
||||
|
||||
# Create mock upcoming items
|
||||
upcoming = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Epic Universe",
|
||||
"location": "Universal Orlando",
|
||||
"category": "Theme Park",
|
||||
"date_added": "Opening 2025",
|
||||
"slug": "epic-universe",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "New Fantasyland Expansion",
|
||||
"location": "Magic Kingdom",
|
||||
"category": "Land Expansion",
|
||||
"date_added": "Opening 2026",
|
||||
"slug": "fantasyland-expansion",
|
||||
},
|
||||
]
|
||||
|
||||
# Limit each category
|
||||
recently_added = recently_added[: limit // 3] if recently_added else []
|
||||
newly_opened = newly_opened[: limit // 3] if newly_opened else []
|
||||
upcoming = upcoming[: limit // 3] if upcoming else []
|
||||
|
||||
# Return in expected frontend format
|
||||
response_data = {
|
||||
"recently_added": recently_added,
|
||||
"newly_opened": newly_opened,
|
||||
"upcoming": upcoming,
|
||||
}
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
334
backend/apps/api/v1/viewsets_rankings.py
Normal file
334
backend/apps/api/v1/viewsets_rankings.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user