Removed VueJS frontend and dramatically enhanced API

This commit is contained in:
pacnpal
2025-08-28 14:01:28 -04:00
parent 08a4a2d034
commit 715e284b3e
123 changed files with 4056 additions and 26546 deletions

View File

@@ -301,56 +301,77 @@ class SocialProvidersAPIView(APIView):
def get(self, request: Request) -> Response:
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
site = get_current_site(request._request) # type: ignore[attr-defined]
try:
site = get_current_site(request._request) # type: ignore[attr-defined]
# Cache key based on site and request host
# Use pk for Site objects, domain for RequestSite objects
site_identifier = getattr(site, "pk", site.domain)
cache_key = f"social_providers:{site_identifier}:{request.get_host()}"
# Cache key based on site and request host
# Use pk for Site objects, domain for RequestSite objects
site_identifier = getattr(site, "pk", site.domain)
cache_key = f"social_providers:{site_identifier}:{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)
# 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 = []
providers_list = []
# Optimized query: filter by site and order by provider name
from allauth.socialaccount.models import SocialApp
# Optimized query: filter by site and order by provider name
from allauth.socialaccount.models import SocialApp
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
for social_app in social_apps:
try:
# Simplified provider name resolution - avoid expensive provider class loading
provider_name = social_app.name or social_app.provider.title()
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
except ObjectDoesNotExist:
# If no social apps exist, return empty list
social_apps = []
# Build auth URL efficiently
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
for social_app in social_apps:
try:
# Simplified provider name resolution - avoid expensive provider class loading
provider_name = social_app.name or social_app.provider.title()
providers_list.append(
{
"id": social_app.provider,
"name": provider_name,
"authUrl": auth_url,
}
)
# Build auth URL efficiently
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
except Exception:
# Skip if provider can't be loaded
continue
providers_list.append(
{
"id": social_app.provider,
"name": provider_name,
"authUrl": auth_url,
}
)
# Serialize and cache the result
serializer = SocialProviderOutputSerializer(providers_list, many=True)
response_data = serializer.data
except Exception:
# Skip if provider can't be loaded
continue
# Cache for 15 minutes (900 seconds)
cache.set(cache_key, response_data, 900)
# Serialize and cache the result
serializer = SocialProviderOutputSerializer(providers_list, many=True)
response_data = serializer.data
return Response(response_data)
# Cache for 15 minutes (900 seconds)
cache.set(cache_key, response_data, 900)
return Response(response_data)
except Exception as e:
# Return a proper JSON error response instead of letting it bubble up
return Response(
{
"status": "error",
"error": {
"code": "SOCIAL_PROVIDERS_ERROR",
"message": "Unable to retrieve social providers",
"details": str(e) if str(e) else None,
},
"data": None,
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@extend_schema_view(

View File

@@ -55,7 +55,9 @@ except ImportError:
@extend_schema_view(
get=extend_schema(
summary="Health check",
description="Get comprehensive health check information including system metrics.",
description=(
"Get comprehensive health check information including system metrics."
),
responses={
200: HealthCheckOutputSerializer,
503: HealthCheckOutputSerializer,
@@ -104,18 +106,30 @@ class HealthCheckAPIView(APIView):
# Process individual health checks
for plugin in plugins:
plugin_name = plugin.identifier()
# Handle both plugin objects and strings
if hasattr(plugin, 'identifier'):
plugin_name = plugin.identifier()
plugin_class_name = plugin.__class__.__name__
critical_service = getattr(plugin, "critical_service", False)
response_time = getattr(plugin, "_response_time", None)
else:
# If plugin is a string, use it directly
plugin_name = str(plugin)
plugin_class_name = plugin_name
critical_service = False
response_time = None
plugin_errors = (
errors.get(plugin.__class__.__name__, [])
errors.get(plugin_class_name, [])
if isinstance(errors, dict)
else []
)
health_data["checks"][plugin_name] = {
"status": "healthy" if not plugin_errors else "unhealthy",
"critical": getattr(plugin, "critical_service", False),
"critical": critical_service,
"errors": [str(error) for error in plugin_errors],
"response_time_ms": getattr(plugin, "_response_time", None),
"response_time_ms": response_time,
}
# Calculate total response time
@@ -320,6 +334,16 @@ class PerformanceMetricsAPIView(APIView):
},
tags=["Health"],
),
options=extend_schema(
summary="CORS preflight for simple health check",
description=(
"Handle CORS preflight requests for the simple health check endpoint."
),
responses={
200: SimpleHealthOutputSerializer,
},
tags=["Health"],
),
)
class SimpleHealthAPIView(APIView):
"""Simple health check endpoint for load balancers."""
@@ -342,7 +366,7 @@ class SimpleHealthAPIView(APIView):
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data)
return Response(serializer.data, status=200)
except Exception as e:
response_data = {
"status": "error",
@@ -351,3 +375,12 @@ class SimpleHealthAPIView(APIView):
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data, status=503)
def options(self, request: Request) -> Response:
"""Handle OPTIONS requests for CORS preflight."""
response_data = {
"status": "ok",
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data)

View File

@@ -0,0 +1,358 @@
"""
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)