""" 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, Q from django.core.cache import cache from django.utils import timezone from drf_spectacular.utils import extend_schema, OpenApiExample from datetime import datetime, timedelta 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)