""" 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", } status_name = status_names.get(status_code, f"status_{status_code.lower()}") 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, )