mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 15:11:09 -05:00
- Introduced Next.js integration guide for ThrillWiki API, detailing authentication, core domain APIs, data structures, and implementation patterns. - Documented the migration to Rich Choice Objects, highlighting changes for frontend developers and enhanced metadata availability. - Fixed the missing `get_by_slug` method in the Ride model, ensuring proper functionality of ride detail endpoints. - Created a test script to verify manufacturer syncing with ride models, ensuring data integrity across related models.
369 lines
12 KiB
Python
369 lines
12 KiB
Python
"""
|
|
Statistics API views for ThrillWiki.
|
|
|
|
Provides aggregate statistics about the platform's content including
|
|
counts of parks, rides, manufacturers, and other entities.
|
|
"""
|
|
|
|
from rest_framework.views import APIView
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
|
from django.db.models import Count
|
|
from django.core.cache import cache
|
|
from django.utils import timezone
|
|
from drf_spectacular.utils import extend_schema, OpenApiExample
|
|
from datetime import datetime
|
|
|
|
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
|
|
from apps.rides.models import (
|
|
Ride,
|
|
RollerCoasterStats,
|
|
RideReview,
|
|
RidePhoto,
|
|
Company as RideCompany,
|
|
)
|
|
from ..serializers.stats import StatsSerializer
|
|
|
|
|
|
class StatsAPIView(APIView):
|
|
"""
|
|
API endpoint that returns aggregate statistics about the platform.
|
|
|
|
Returns counts of various entities like parks, rides, manufacturers, etc.
|
|
Results are cached for performance.
|
|
"""
|
|
|
|
permission_classes = [AllowAny]
|
|
|
|
def _get_relative_time(self, timestamp_str):
|
|
"""
|
|
Convert an ISO timestamp to a human-readable relative time.
|
|
|
|
Args:
|
|
timestamp_str: ISO format timestamp string
|
|
|
|
Returns:
|
|
str: Human-readable relative time (e.g., "2 days, 3 hours, 15 minutes ago", "just now")
|
|
"""
|
|
if not timestamp_str or timestamp_str == "just_now":
|
|
return "just now"
|
|
|
|
try:
|
|
# Parse the ISO timestamp
|
|
if isinstance(timestamp_str, str):
|
|
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
|
else:
|
|
timestamp = timestamp_str
|
|
|
|
# Make timezone-aware if needed
|
|
if timestamp.tzinfo is None:
|
|
timestamp = timezone.make_aware(timestamp)
|
|
|
|
now = timezone.now()
|
|
diff = now - timestamp
|
|
total_seconds = int(diff.total_seconds())
|
|
|
|
# If less than a minute, return "just now"
|
|
if total_seconds < 60:
|
|
return "just now"
|
|
|
|
# Calculate time components
|
|
days = diff.days
|
|
hours = (total_seconds % 86400) // 3600
|
|
minutes = (total_seconds % 3600) // 60
|
|
|
|
# Build the relative time string
|
|
parts = []
|
|
|
|
if days > 0:
|
|
parts.append(f'{days} day{"s" if days != 1 else ""}')
|
|
|
|
if hours > 0:
|
|
parts.append(f'{hours} hour{"s" if hours != 1 else ""}')
|
|
|
|
if minutes > 0:
|
|
parts.append(f'{minutes} minute{"s" if minutes != 1 else ""}')
|
|
|
|
# Join parts with commas and add "ago"
|
|
if len(parts) == 0:
|
|
return "just now"
|
|
elif len(parts) == 1:
|
|
return f"{parts[0]} ago"
|
|
elif len(parts) == 2:
|
|
return f"{parts[0]} and {parts[1]} ago"
|
|
else:
|
|
return f'{", ".join(parts[:-1])}, and {parts[-1]} ago'
|
|
|
|
except (ValueError, TypeError):
|
|
return "unknown"
|
|
|
|
@extend_schema(
|
|
operation_id="get_platform_stats",
|
|
summary="Get platform statistics",
|
|
description="""
|
|
Returns comprehensive aggregate statistics about the ThrillWiki platform.
|
|
|
|
This endpoint provides detailed counts and breakdowns of all major entities including:
|
|
- Parks, rides, and roller coasters
|
|
- Companies (manufacturers, operators, designers, property owners)
|
|
- Photos and reviews
|
|
- Ride categories (roller coasters, dark rides, flat rides, etc.)
|
|
- Status breakdowns (operating, closed, under construction, etc.)
|
|
|
|
Results are cached for 5 minutes for optimal performance and automatically
|
|
invalidated when relevant data changes.
|
|
|
|
**No authentication required** - this is a public endpoint.
|
|
""".strip(),
|
|
responses={
|
|
200: StatsSerializer,
|
|
500: {
|
|
"type": "object",
|
|
"properties": {
|
|
"error": {
|
|
"type": "string",
|
|
"description": "Error message if statistics calculation fails",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
tags=["Statistics"],
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Sample Response",
|
|
description="Example of platform statistics response",
|
|
value={
|
|
"total_parks": 7,
|
|
"total_rides": 10,
|
|
"total_manufacturers": 6,
|
|
"total_operators": 7,
|
|
"total_designers": 4,
|
|
"total_property_owners": 0,
|
|
"total_roller_coasters": 8,
|
|
"total_photos": 0,
|
|
"total_park_photos": 0,
|
|
"total_ride_photos": 0,
|
|
"total_reviews": 8,
|
|
"total_park_reviews": 4,
|
|
"total_ride_reviews": 4,
|
|
"roller_coasters": 10,
|
|
"operating_parks": 7,
|
|
"operating_rides": 10,
|
|
"last_updated": "2025-08-28T17:34:59.677143+00:00",
|
|
"relative_last_updated": "just now",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
def get(self, request):
|
|
"""Get platform statistics."""
|
|
# Try to get cached stats first
|
|
cache_key = "platform_stats"
|
|
cached_stats = cache.get(cache_key)
|
|
|
|
if cached_stats:
|
|
return Response(cached_stats, status=status.HTTP_200_OK)
|
|
|
|
# Calculate fresh stats
|
|
stats = self._calculate_stats()
|
|
|
|
# Cache for 5 minutes
|
|
cache.set(cache_key, stats, 300)
|
|
|
|
return Response(stats, status=status.HTTP_200_OK)
|
|
|
|
def _calculate_stats(self):
|
|
"""Calculate all platform statistics."""
|
|
|
|
# Basic entity counts
|
|
total_parks = Park.objects.count()
|
|
total_rides = Ride.objects.count()
|
|
|
|
# Company counts by role
|
|
total_manufacturers = RideCompany.objects.filter(
|
|
roles__contains=["MANUFACTURER"]
|
|
).count()
|
|
|
|
total_operators = ParkCompany.objects.filter(
|
|
roles__contains=["OPERATOR"]
|
|
).count()
|
|
|
|
total_designers = RideCompany.objects.filter(
|
|
roles__contains=["DESIGNER"]
|
|
).count()
|
|
|
|
total_property_owners = ParkCompany.objects.filter(
|
|
roles__contains=["PROPERTY_OWNER"]
|
|
).count()
|
|
|
|
# Photo counts (combined)
|
|
total_park_photos = ParkPhoto.objects.count()
|
|
total_ride_photos = RidePhoto.objects.count()
|
|
total_photos = total_park_photos + total_ride_photos
|
|
|
|
# Ride type counts
|
|
total_roller_coasters = RollerCoasterStats.objects.count()
|
|
|
|
# Ride category counts
|
|
ride_categories = (
|
|
Ride.objects.values("category")
|
|
.annotate(count=Count("id"))
|
|
.exclude(category="")
|
|
)
|
|
|
|
category_stats = {}
|
|
for category in ride_categories:
|
|
category_code = category["category"]
|
|
category_count = category["count"]
|
|
|
|
# Convert category codes to readable names
|
|
category_names = {
|
|
"RC": "roller_coasters",
|
|
"DR": "dark_rides",
|
|
"FR": "flat_rides",
|
|
"WR": "water_rides",
|
|
"TR": "transport_rides",
|
|
"OT": "other_rides",
|
|
}
|
|
|
|
category_name = category_names.get(
|
|
category_code, f"category_{category_code.lower()}"
|
|
)
|
|
category_stats[category_name] = category_count
|
|
|
|
# Park status counts
|
|
park_statuses = Park.objects.values("status").annotate(count=Count("id"))
|
|
|
|
park_status_stats = {}
|
|
for status_item in park_statuses:
|
|
status_code = status_item["status"]
|
|
status_count = status_item["count"]
|
|
|
|
# Convert status codes to readable names
|
|
status_names = {
|
|
"OPERATING": "operating_parks",
|
|
"CLOSED_TEMP": "temporarily_closed_parks",
|
|
"CLOSED_PERM": "permanently_closed_parks",
|
|
"UNDER_CONSTRUCTION": "under_construction_parks",
|
|
"DEMOLISHED": "demolished_parks",
|
|
"RELOCATED": "relocated_parks",
|
|
}
|
|
|
|
if status_code in status_names:
|
|
status_name = status_names[status_code]
|
|
else:
|
|
raise ValueError(f"Unknown park status: {status_code}")
|
|
park_status_stats[status_name] = status_count
|
|
|
|
# Ride status counts
|
|
ride_statuses = Ride.objects.values("status").annotate(count=Count("id"))
|
|
|
|
ride_status_stats = {}
|
|
for status_item in ride_statuses:
|
|
status_code = status_item["status"]
|
|
status_count = status_item["count"]
|
|
|
|
# Convert status codes to readable names
|
|
status_names = {
|
|
"OPERATING": "operating_rides",
|
|
"CLOSED_TEMP": "temporarily_closed_rides",
|
|
"SBNO": "sbno_rides",
|
|
"CLOSING": "closing_rides",
|
|
"CLOSED_PERM": "permanently_closed_rides",
|
|
"UNDER_CONSTRUCTION": "under_construction_rides",
|
|
"DEMOLISHED": "demolished_rides",
|
|
"RELOCATED": "relocated_rides",
|
|
}
|
|
|
|
status_name = status_names.get(
|
|
status_code, f"ride_status_{status_code.lower()}"
|
|
)
|
|
ride_status_stats[status_name] = status_count
|
|
|
|
# Review counts
|
|
total_park_reviews = ParkReview.objects.count()
|
|
total_ride_reviews = RideReview.objects.count()
|
|
total_reviews = total_park_reviews + total_ride_reviews
|
|
|
|
# Timestamp handling
|
|
now = timezone.now()
|
|
last_updated_iso = now.isoformat()
|
|
|
|
# Get cached timestamp or use current time
|
|
cached_timestamp = cache.get("platform_stats_timestamp")
|
|
if cached_timestamp and cached_timestamp != "just_now":
|
|
# Use cached timestamp for consistency
|
|
last_updated_iso = cached_timestamp
|
|
else:
|
|
# Set new timestamp in cache
|
|
cache.set("platform_stats_timestamp", last_updated_iso, 300)
|
|
|
|
# Calculate relative time
|
|
relative_last_updated = self._get_relative_time(last_updated_iso)
|
|
|
|
# Combine all stats
|
|
stats = {
|
|
# Core entity counts
|
|
"total_parks": total_parks,
|
|
"total_rides": total_rides,
|
|
"total_manufacturers": total_manufacturers,
|
|
"total_operators": total_operators,
|
|
"total_designers": total_designers,
|
|
"total_property_owners": total_property_owners,
|
|
"total_roller_coasters": total_roller_coasters,
|
|
# Photo counts
|
|
"total_photos": total_photos,
|
|
"total_park_photos": total_park_photos,
|
|
"total_ride_photos": total_ride_photos,
|
|
# Review counts
|
|
"total_reviews": total_reviews,
|
|
"total_park_reviews": total_park_reviews,
|
|
"total_ride_reviews": total_ride_reviews,
|
|
# Category breakdowns
|
|
**category_stats,
|
|
# Status breakdowns
|
|
**park_status_stats,
|
|
**ride_status_stats,
|
|
# Metadata
|
|
"last_updated": last_updated_iso,
|
|
"relative_last_updated": relative_last_updated,
|
|
}
|
|
|
|
return stats
|
|
|
|
|
|
class StatsRecalculateAPIView(APIView):
|
|
"""
|
|
Admin-only API endpoint to force recalculation of platform statistics.
|
|
|
|
This endpoint clears the cache and forces a fresh calculation of all statistics.
|
|
Only accessible to admin users.
|
|
"""
|
|
|
|
permission_classes = [IsAdminUser]
|
|
|
|
@extend_schema(exclude=True)
|
|
def post(self, request):
|
|
"""Force recalculation of platform statistics."""
|
|
# Clear the cache
|
|
cache.delete("platform_stats")
|
|
cache.delete("platform_stats_timestamp")
|
|
|
|
# Create a new StatsAPIView instance to reuse the calculation logic
|
|
stats_view = StatsAPIView()
|
|
fresh_stats = stats_view._calculate_stats()
|
|
|
|
# Cache the fresh stats
|
|
cache.set("platform_stats", fresh_stats, 300)
|
|
|
|
# Return success response with the fresh stats
|
|
return Response(
|
|
{
|
|
"message": "Platform statistics have been successfully recalculated",
|
|
"stats": fresh_stats,
|
|
"recalculated_at": timezone.now().isoformat(),
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|