diff --git a/backend/.env.example b/backend/.env.example index 2f71d050..90007619 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -28,4 +28,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000 # Feature Flags ENABLE_DEBUG_TOOLBAR=True -ENABLE_SILK_PROFILER=False \ No newline at end of file +ENABLE_SILK_PROFILER=False + +# Frontend Configuration +FRONTEND_DOMAIN=https://thrillwiki.com diff --git a/backend/apps/api/v1/serializers/parks.py b/backend/apps/api/v1/serializers/parks.py index 142fbc04..df920da0 100644 --- a/backend/apps/api/v1/serializers/parks.py +++ b/backend/apps/api/v1/serializers/parks.py @@ -11,6 +11,7 @@ from drf_spectacular.utils import ( extend_schema_field, OpenApiExample, ) +from config.django import base as settings from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices @@ -65,10 +66,18 @@ class ParkListOutputSerializer(serializers.Serializer): # Operator info operator = CompanyOutputSerializer() + # URL + url = serializers.SerializerMethodField() + # Metadata created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() + @extend_schema_field(serializers.URLField()) + def get_url(self, obj) -> str: + """Generate the frontend URL for this park.""" + return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/" + @extend_schema_serializer( examples=[ @@ -166,6 +175,14 @@ class ParkDetailOutputSerializer(serializers.Serializer): banner_image = serializers.SerializerMethodField() card_image = serializers.SerializerMethodField() + # URL + url = serializers.SerializerMethodField() + + @extend_schema_field(serializers.URLField()) + def get_url(self, obj) -> str: + """Generate the frontend URL for this park.""" + return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/" + @extend_schema_field(serializers.ListField(child=serializers.DictField())) def get_areas(self, obj): """Get simplified area information.""" diff --git a/backend/apps/api/v1/serializers/reviews.py b/backend/apps/api/v1/serializers/reviews.py new file mode 100644 index 00000000..7d9856f2 --- /dev/null +++ b/backend/apps/api/v1/serializers/reviews.py @@ -0,0 +1,98 @@ +""" +Serializers for review-related API endpoints. +""" + +from rest_framework import serializers +from apps.parks.models.reviews import ParkReview +from apps.rides.models.reviews import RideReview +from apps.accounts.models import User + + +class ReviewUserSerializer(serializers.ModelSerializer): + """Serializer for user information in reviews.""" + avatar_url = serializers.SerializerMethodField() + display_name = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ['username', 'display_name', 'avatar_url'] + + def get_avatar_url(self, obj): + """Get the user's avatar URL.""" + if hasattr(obj, 'profile') and obj.profile: + return obj.profile.get_avatar() + return "/static/images/default-avatar.png" + + def get_display_name(self, obj): + """Get the user's display name.""" + return obj.get_display_name() + + +class LatestReviewSerializer(serializers.Serializer): + """Serializer for latest reviews combining park and ride reviews.""" + id = serializers.IntegerField() + type = serializers.CharField() # 'park' or 'ride' + title = serializers.CharField() + content_snippet = serializers.CharField() + rating = serializers.IntegerField() + created_at = serializers.DateTimeField() + user = ReviewUserSerializer() + + # Subject information (park or ride) + subject_name = serializers.CharField() + subject_slug = serializers.CharField() + subject_url = serializers.CharField() + + # Park information (for ride reviews) + park_name = serializers.CharField(allow_null=True) + park_slug = serializers.CharField(allow_null=True) + park_url = serializers.CharField(allow_null=True) + + def to_representation(self, instance): + """Convert review instance to serialized representation.""" + if isinstance(instance, ParkReview): + return { + 'id': instance.pk, + 'type': 'park', + 'title': instance.title, + 'content_snippet': self._get_content_snippet(instance.content), + 'rating': instance.rating, + 'created_at': instance.created_at, + 'user': ReviewUserSerializer(instance.user).data, + 'subject_name': instance.park.name, + 'subject_slug': instance.park.slug, + 'subject_url': f"/parks/{instance.park.slug}/", + 'park_name': None, + 'park_slug': None, + 'park_url': None, + } + elif isinstance(instance, RideReview): + return { + 'id': instance.pk, + 'type': 'ride', + 'title': instance.title, + 'content_snippet': self._get_content_snippet(instance.content), + 'rating': instance.rating, + 'created_at': instance.created_at, + 'user': ReviewUserSerializer(instance.user).data, + 'subject_name': instance.ride.name, + 'subject_slug': instance.ride.slug, + 'subject_url': f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/", + 'park_name': instance.ride.park.name, + 'park_slug': instance.ride.park.slug, + 'park_url': f"/parks/{instance.ride.park.slug}/", + } + return {} + + def _get_content_snippet(self, content, max_length=150): + """Get a snippet of the review content.""" + if len(content) <= max_length: + return content + + # Find the last complete word within the limit + snippet = content[:max_length] + last_space = snippet.rfind(' ') + if last_space > 0: + snippet = snippet[:last_space] + + return snippet + "..." diff --git a/backend/apps/api/v1/serializers/ride_models.py b/backend/apps/api/v1/serializers/ride_models.py index 76e63282..f14d10fd 100644 --- a/backend/apps/api/v1/serializers/ride_models.py +++ b/backend/apps/api/v1/serializers/ride_models.py @@ -11,6 +11,7 @@ from drf_spectacular.utils import ( extend_schema_field, OpenApiExample, ) +from config.django import base as settings from .shared import ModelChoices @@ -142,10 +143,18 @@ class RideModelListOutputSerializer(serializers.Serializer): # Primary image primary_image = RideModelPhotoOutputSerializer(allow_null=True) + # URL + url = serializers.SerializerMethodField() + # Metadata created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() + @extend_schema_field(serializers.URLField()) + def get_url(self, obj) -> str: + """Generate the frontend URL for this ride model.""" + return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/" + @extend_schema_serializer( examples=[ @@ -277,10 +286,18 @@ class RideModelDetailOutputSerializer(serializers.Serializer): technical_specs = RideModelTechnicalSpecOutputSerializer(many=True) installations = serializers.SerializerMethodField() + # URL + url = serializers.SerializerMethodField() + # Metadata created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() + @extend_schema_field(serializers.URLField()) + def get_url(self, obj) -> str: + """Generate the frontend URL for this ride model.""" + return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/" + @extend_schema_field(serializers.ListField(child=serializers.DictField())) def get_installations(self, obj): """Get ride installations using this model.""" diff --git a/backend/apps/api/v1/serializers/rides.py b/backend/apps/api/v1/serializers/rides.py index 73b3394a..3c70b7b9 100644 --- a/backend/apps/api/v1/serializers/rides.py +++ b/backend/apps/api/v1/serializers/rides.py @@ -11,7 +11,7 @@ from drf_spectacular.utils import ( extend_schema_field, OpenApiExample, ) - +from config.django import base as settings from .shared import ModelChoices @@ -90,10 +90,18 @@ class RideListOutputSerializer(serializers.Serializer): opening_date = serializers.DateField(allow_null=True) closing_date = serializers.DateField(allow_null=True) + # URL + url = serializers.SerializerMethodField() + # Metadata created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() + @extend_schema_field(serializers.URLField()) + def get_url(self, obj) -> str: + """Generate the frontend URL for this ride.""" + return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/" + @extend_schema_serializer( examples=[ @@ -194,10 +202,18 @@ class RideDetailOutputSerializer(serializers.Serializer): banner_image = serializers.SerializerMethodField() card_image = serializers.SerializerMethodField() + # URL + url = serializers.SerializerMethodField() + # Metadata created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() + @extend_schema_field(serializers.URLField()) + def get_url(self, obj) -> str: + """Generate the frontend URL for this ride.""" + return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/" + @extend_schema_field(serializers.DictField(allow_null=True)) def get_park_area(self, obj) -> dict | None: if obj.park_area: diff --git a/backend/apps/api/v1/serializers/shared.py b/backend/apps/api/v1/serializers/shared.py index a616d4a0..f07cbdb4 100644 --- a/backend/apps/api/v1/serializers/shared.py +++ b/backend/apps/api/v1/serializers/shared.py @@ -8,6 +8,7 @@ to avoid code duplication and maintain consistency. from rest_framework import serializers from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model +from django.conf import settings # Import models inside class methods to avoid Django initialization issues @@ -173,3 +174,31 @@ class CompanyOutputSerializer(serializers.Serializer): name = serializers.CharField() slug = serializers.CharField() roles = serializers.ListField(child=serializers.CharField(), required=False) + url = serializers.SerializerMethodField() + + @extend_schema_field(serializers.URLField()) + def get_url(self, obj) -> str: + """Generate the frontend URL for this company based on their primary role. + + CRITICAL DOMAIN SEPARATION: + - OPERATOR and PROPERTY_OWNER are for parks domain + - MANUFACTURER and DESIGNER are for rides domain + """ + # Use the URL field from the model if it exists (auto-generated on save) + if hasattr(obj, 'url') and obj.url: + return obj.url + + # Fallback URL generation (should not be needed if model save works correctly) + if hasattr(obj, 'roles') and obj.roles: + frontend_domain = getattr( + settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com') + primary_role = obj.roles[0] if obj.roles else None + + # Only generate URLs for rides domain roles here + if primary_role == 'MANUFACTURER': + return f"{frontend_domain}/rides/manufacturers/{obj.slug}/" + elif primary_role == 'DESIGNER': + return f"{frontend_domain}/rides/designers/{obj.slug}/" + # OPERATOR and PROPERTY_OWNER URLs are handled by parks domain + + return "" diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index 43c77ad0..73eb9beb 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -21,8 +21,10 @@ from .views import ( # Trending system views TrendingAPIView, NewContentAPIView, + TriggerTrendingCalculationAPIView, ) from .views.stats import StatsAPIView, StatsRecalculateAPIView +from .views.reviews import LatestReviewsAPIView from django.urls import path, include from rest_framework.routers import DefaultRouter @@ -57,11 +59,15 @@ urlpatterns = [ name="performance-metrics", ), # Trending system endpoints - path("trending/content/", TrendingAPIView.as_view(), name="trending"), - path("trending/new/", NewContentAPIView.as_view(), name="new-content"), + path("trending/", TrendingAPIView.as_view(), name="trending"), + path("new-content/", NewContentAPIView.as_view(), name="new-content"), + path("trending/calculate/", TriggerTrendingCalculationAPIView.as_view(), + name="trigger-trending-calculation"), # Statistics endpoints path("stats/", StatsAPIView.as_view(), name="stats"), path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"), + # Reviews endpoints + path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"), # Ranking system endpoints path( "rankings/calculate/", diff --git a/backend/apps/api/v1/views/__init__.py b/backend/apps/api/v1/views/__init__.py index d0c6ad95..aa481e4f 100644 --- a/backend/apps/api/v1/views/__init__.py +++ b/backend/apps/api/v1/views/__init__.py @@ -28,6 +28,7 @@ from .health import ( from .trending import ( TrendingAPIView, NewContentAPIView, + TriggerTrendingCalculationAPIView, ) # Export all views for import convenience @@ -48,4 +49,5 @@ __all__ = [ # Trending views "TrendingAPIView", "NewContentAPIView", + "TriggerTrendingCalculationAPIView", ] diff --git a/backend/apps/api/v1/views/reviews.py b/backend/apps/api/v1/views/reviews.py new file mode 100644 index 00000000..818cba16 --- /dev/null +++ b/backend/apps/api/v1/views/reviews.py @@ -0,0 +1,85 @@ +""" +Views for review-related API endpoints. +""" + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework import status +from django.db.models import Q +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes +from itertools import chain +from operator import attrgetter + +from apps.parks.models.reviews import ParkReview +from apps.rides.models.reviews import RideReview +from ..serializers.reviews import LatestReviewSerializer + + +class LatestReviewsAPIView(APIView): + """ + API endpoint to get the latest reviews from both parks and rides. + + Returns a combined list of the most recent reviews across the platform, + including username, user avatar, date, score, and review snippet. + """ + permission_classes = [AllowAny] + + @extend_schema( + summary="Get Latest Reviews", + description=( + "Retrieve the latest reviews from both parks and rides. " + "Returns a combined list sorted by creation date, including " + "user information, ratings, and content snippets." + ), + parameters=[ + OpenApiParameter( + name="limit", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of reviews to return (default: 20, max: 100)", + default=20, + ), + ], + responses={ + 200: LatestReviewSerializer(many=True), + }, + tags=["Reviews"], + ) + def get(self, request): + """Get the latest reviews from both parks and rides.""" + # Get limit parameter with validation + try: + limit = int(request.query_params.get('limit', 20)) + limit = min(max(limit, 1), 100) # Clamp between 1 and 100 + except (ValueError, TypeError): + limit = 20 + + # Get published reviews from both models + park_reviews = ParkReview.objects.filter( + is_published=True + ).select_related( + 'user', 'user__profile', 'park' + ).order_by('-created_at')[:limit] + + ride_reviews = RideReview.objects.filter( + is_published=True + ).select_related( + 'user', 'user__profile', 'ride', 'ride__park' + ).order_by('-created_at')[:limit] + + # Combine and sort by created_at + all_reviews = sorted( + chain(park_reviews, ride_reviews), + key=attrgetter('created_at'), + reverse=True + )[:limit] + + # Serialize the combined results + serializer = LatestReviewSerializer(all_reviews, many=True) + + return Response({ + 'count': len(all_reviews), + 'results': serializer.data + }, status=status.HTTP_200_OK) diff --git a/backend/apps/api/v1/views/trending.py b/backend/apps/api/v1/views/trending.py index 76e1122c..1f1495a5 100644 --- a/backend/apps/api/v1/views/trending.py +++ b/backend/apps/api/v1/views/trending.py @@ -9,7 +9,8 @@ from datetime import datetime, date from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework import status from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from drf_spectacular.types import OpenApiTypes @@ -48,17 +49,12 @@ class TrendingAPIView(APIView): def get(self, request: Request) -> Response: """Get trending parks and rides.""" - try: - from apps.core.services.trending_service import TrendingService - except ImportError: - # Fallback if trending service is not available - return self._get_fallback_trending_content(request) + from apps.core.services.trending_service import trending_service # Parse parameters limit = min(int(request.query_params.get("limit", 20)), 100) - # Get trending content - trending_service = TrendingService() + # Get trending content using direct calculation service all_trending = trending_service.get_trending_content(limit=limit * 2) # Separate by content type @@ -75,20 +71,8 @@ class TrendingAPIView(APIView): 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] + # Latest reviews will be empty until review system is implemented + latest_reviews = [] # Return in expected frontend format response_data = { @@ -99,82 +83,85 @@ class TrendingAPIView(APIView): return Response(response_data) - def _get_fallback_trending_content(self, request: Request) -> Response: - """Fallback method when trending service is not available.""" - limit = min(int(request.query_params.get("limit", 20)), 100) - # Mock trending data - trending_rides = [ - { - "id": 1, - "name": "Steel Vengeance", - "location": "Cedar Point", - "category": "Roller Coaster", - "rating": 4.8, - "rank": 1, - "views": 15234, - "views_change": "+25%", - "slug": "steel-vengeance", +@extend_schema_view( + post=extend_schema( + summary="Trigger trending content calculation", + description="Manually trigger the calculation of trending content using Django management commands. Admin access required.", + responses={ + 202: { + "type": "object", + "properties": { + "message": {"type": "string"}, + "trending_completed": {"type": "boolean"}, + "new_content_completed": {"type": "boolean"}, + "completion_time": {"type": "string"}, + }, }, - { - "id": 2, - "name": "Lightning Rod", - "location": "Dollywood", - "category": "Roller Coaster", - "rating": 4.7, - "rank": 2, - "views": 12456, - "views_change": "+18%", - "slug": "lightning-rod", - }, - ][: limit // 3] + 403: {"description": "Admin access required"}, + }, + tags=["Trending"], + ), +) +class TriggerTrendingCalculationAPIView(APIView): + """API endpoint to manually trigger trending content calculation.""" - trending_parks = [ - { - "id": 1, - "name": "Cedar Point", - "location": "Sandusky, OH", - "category": "Theme Park", - "rating": 4.6, - "rank": 1, - "views": 45678, - "views_change": "+12%", - "slug": "cedar-point", - }, - { - "id": 2, - "name": "Magic Kingdom", - "location": "Orlando, FL", - "category": "Theme Park", - "rating": 4.5, - "rank": 2, - "views": 67890, - "views_change": "+8%", - "slug": "magic-kingdom", - }, - ][: limit // 3] + permission_classes = [IsAdminUser] - 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] + def post(self, request: Request) -> Response: + """Trigger trending content calculation using management commands.""" + try: + from django.core.management import call_command + import io + from contextlib import redirect_stdout, redirect_stderr - response_data = { - "trending_rides": trending_rides, - "trending_parks": trending_parks, - "latest_reviews": latest_reviews, - } + # Capture command output + trending_output = io.StringIO() + new_content_output = io.StringIO() - return Response(response_data) + trending_completed = False + new_content_completed = False + + try: + # Run trending calculation command + with redirect_stdout(trending_output), redirect_stderr(trending_output): + call_command('calculate_trending', + '--content-type=all', '--limit=50') + trending_completed = True + except Exception as e: + trending_output.write(f"Error: {str(e)}") + + try: + # Run new content calculation command + with redirect_stdout(new_content_output), redirect_stderr(new_content_output): + call_command('calculate_new_content', + '--content-type=all', '--days-back=30', '--limit=50') + new_content_completed = True + except Exception as e: + new_content_output.write(f"Error: {str(e)}") + + completion_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + return Response( + { + "message": "Trending content calculation completed", + "trending_completed": trending_completed, + "new_content_completed": new_content_completed, + "completion_time": completion_time, + "trending_output": trending_output.getvalue(), + "new_content_output": new_content_output.getvalue(), + }, + status=status.HTTP_202_ACCEPTED, + ) + + except Exception as e: + return Response( + { + "error": "Failed to trigger trending content calculation", + "details": str(e), + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) @extend_schema_view( @@ -210,19 +197,15 @@ class NewContentAPIView(APIView): def get(self, request: Request) -> Response: """Get new parks and rides.""" - try: - from apps.core.services.trending_service import TrendingService - except ImportError: - # Fallback if trending service is not available - return self._get_fallback_new_content(request) + from apps.core.services.trending_service import trending_service # Parse parameters limit = min(int(request.query_params.get("limit", 20)), 100) + days_back = min(int(request.query_params.get("days", 30)), 365) - # Get new content with longer timeframe to get more data - trending_service = TrendingService() + # Get new content using direct calculation service all_new_content = trending_service.get_new_content( - limit=limit * 2, days_back=60 + limit=limit * 2, days_back=days_back ) recently_added = [] @@ -258,30 +241,12 @@ class NewContentAPIView(APIView): 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", - }, - ] + # Upcoming items will be empty until future content system is implemented + upcoming = [] # 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 = { @@ -291,73 +256,3 @@ class NewContentAPIView(APIView): } return Response(response_data) - - def _get_fallback_new_content(self, request: Request) -> Response: - """Fallback method when trending service is not available.""" - limit = min(int(request.query_params.get("limit", 20)), 100) - - # Mock new content data - recently_added = [ - { - "id": 1, - "name": "Iron Gwazi", - "location": "Busch Gardens Tampa", - "category": "Roller Coaster", - "date_added": "2024-12-01", - "slug": "iron-gwazi", - }, - { - "id": 2, - "name": "VelociCoaster", - "location": "Universal's Islands of Adventure", - "category": "Roller Coaster", - "date_added": "2024-11-15", - "slug": "velocicoaster", - }, - ][: limit // 3] - - newly_opened = [ - { - "id": 3, - "name": "Guardians of the Galaxy", - "location": "EPCOT", - "category": "Roller Coaster", - "date_added": "2024-10-01", - "slug": "guardians-galaxy", - }, - { - "id": 4, - "name": "TRON Lightcycle Run", - "location": "Magic Kingdom", - "category": "Roller Coaster", - "date_added": "2024-09-15", - "slug": "tron-lightcycle", - }, - ][: limit // 3] - - upcoming = [ - { - "id": 5, - "name": "Epic Universe", - "location": "Universal Orlando", - "category": "Theme Park", - "date_added": "Opening 2025", - "slug": "epic-universe", - }, - { - "id": 6, - "name": "New Fantasyland Expansion", - "location": "Magic Kingdom", - "category": "Land Expansion", - "date_added": "Opening 2026", - "slug": "fantasyland-expansion", - }, - ][: limit // 3] - - response_data = { - "recently_added": recently_added, - "newly_opened": newly_opened, - "upcoming": upcoming, - } - - return Response(response_data) diff --git a/backend/apps/core/management/commands/calculate_new_content.py b/backend/apps/core/management/commands/calculate_new_content.py new file mode 100644 index 00000000..dfee8167 --- /dev/null +++ b/backend/apps/core/management/commands/calculate_new_content.py @@ -0,0 +1,209 @@ +""" +Django management command to calculate new content. + +This replaces the Celery task for calculating new content. +Run with: python manage.py calculate_new_content +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Any +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.core.cache import cache +from django.db.models import Q + +from apps.parks.models import Park +from apps.rides.models import Ride + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Calculate new content and cache results' + + def add_arguments(self, parser): + parser.add_argument( + '--content-type', + type=str, + default='all', + choices=['all', 'parks', 'rides'], + help='Type of content to calculate (default: all)' + ) + parser.add_argument( + '--days-back', + type=int, + default=30, + help='Number of days to look back for new content (default: 30)' + ) + parser.add_argument( + '--limit', + type=int, + default=50, + help='Maximum number of results to calculate (default: 50)' + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Enable verbose output' + ) + + def handle(self, *args, **options): + content_type = options['content_type'] + days_back = options['days_back'] + limit = options['limit'] + verbose = options['verbose'] + + if verbose: + self.stdout.write(f"Starting new content calculation for {content_type}") + + try: + cutoff_date = timezone.now() - timedelta(days=days_back) + new_items = [] + + if content_type in ["all", "parks"]: + parks = self._get_new_parks( + cutoff_date, limit if content_type == "parks" else limit * 2) + new_items.extend(parks) + if verbose: + self.stdout.write(f"Found {len(parks)} new parks") + + if content_type in ["all", "rides"]: + rides = self._get_new_rides( + cutoff_date, limit if content_type == "rides" else limit * 2) + new_items.extend(rides) + if verbose: + self.stdout.write(f"Found {len(rides)} new rides") + + # Sort by date added (most recent first) and apply limit + new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True) + new_items = new_items[:limit] + + # Format results for API consumption + formatted_results = self._format_new_content_results(new_items) + + # Cache results + cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}" + cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes + + self.stdout.write( + self.style.SUCCESS( + f"Successfully calculated {len(formatted_results)} new items for {content_type}" + ) + ) + + if verbose: + for item in formatted_results[:5]: # Show first 5 items + self.stdout.write( + f" {item['name']} ({item['park']}) - opened: {item['date_opened']}") + + except Exception as e: + logger.error(f"Error calculating new content: {e}", exc_info=True) + raise CommandError(f"Failed to calculate new content: {e}") + + def _get_new_parks(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + """Get recently added parks using real data.""" + new_parks = ( + Park.objects.filter( + Q(created_at__gte=cutoff_date) | Q( + opening_date__gte=cutoff_date.date()), + status="OPERATING", + ) + .select_related("location", "operator") + .order_by("-created_at", "-opening_date")[:limit] + ) + + results = [] + for park in new_parks: + date_added = park.opening_date or park.created_at + if date_added: + if isinstance(date_added, datetime): + date_added = date_added.date() + + opening_date = getattr(park, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + results.append({ + "content_object": park, + "content_type": "park", + "id": park.pk, + "name": park.name, + "slug": park.slug, + "park": park.name, # For parks, park field is the park name itself + "category": "park", + "date_added": date_added.isoformat() if date_added else "", + "date_opened": opening_date.isoformat() if opening_date else "", + "url": park.url, + }) + + return results + + def _get_new_rides(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + """Get recently added rides using real data.""" + new_rides = ( + Ride.objects.filter( + Q(created_at__gte=cutoff_date) | Q( + opening_date__gte=cutoff_date.date()), + status="OPERATING", + ) + .select_related("park", "park__location") + .order_by("-created_at", "-opening_date")[:limit] + ) + + results = [] + for ride in new_rides: + date_added = getattr(ride, "opening_date", None) or getattr( + ride, "created_at", None) + if date_added: + if isinstance(date_added, datetime): + date_added = date_added.date() + + opening_date = getattr(ride, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + results.append({ + "content_object": ride, + "content_type": "ride", + "id": ride.pk, + "name": ride.name, + "slug": ride.slug, + "park": ride.park.name if ride.park else "", + "category": "ride", + "date_added": date_added.isoformat() if date_added else "", + "date_opened": opening_date.isoformat() if opening_date else "", + "url": ride.url, + "park_url": ride.park.url if ride.park else "", + }) + + return results + + def _format_new_content_results(self, new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format new content results for frontend consumption.""" + formatted_results = [] + + for item in new_items: + try: + # Format exactly as frontend expects + formatted_item = { + "id": item["id"], + "name": item["name"], + "park": item["park"], + "category": item["category"], + "date_added": item["date_added"], + "date_opened": item["date_opened"], + "slug": item["slug"], + "url": item["url"], + } + + # Add park_url for rides + if item.get("park_url"): + formatted_item["park_url"] = item["park_url"] + + formatted_results.append(formatted_item) + + except Exception as e: + logger.warning(f"Error formatting new content item: {e}") + + return formatted_results diff --git a/backend/apps/core/management/commands/calculate_trending.py b/backend/apps/core/management/commands/calculate_trending.py new file mode 100644 index 00000000..79fbc93f --- /dev/null +++ b/backend/apps/core/management/commands/calculate_trending.py @@ -0,0 +1,337 @@ +""" +Django management command to calculate trending content. + +This replaces the Celery task for calculating trending content. +Run with: python manage.py calculate_trending +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Any +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.core.cache import cache +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +from apps.core.analytics import PageView +from apps.parks.models import Park +from apps.rides.models import Ride + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Calculate trending content and cache results' + + def add_arguments(self, parser): + parser.add_argument( + '--content-type', + type=str, + default='all', + choices=['all', 'parks', 'rides'], + help='Type of content to calculate (default: all)' + ) + parser.add_argument( + '--limit', + type=int, + default=50, + help='Maximum number of results to calculate (default: 50)' + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Enable verbose output' + ) + + def handle(self, *args, **options): + content_type = options['content_type'] + limit = options['limit'] + verbose = options['verbose'] + + if verbose: + self.stdout.write(f"Starting trending calculation for {content_type}") + + try: + # Time windows for calculations + current_period_hours = 168 # 7 days + # 14 days (for previous 7-day window comparison) + previous_period_hours = 336 + + trending_items = [] + + if content_type in ["all", "parks"]: + park_items = self._calculate_trending_parks( + current_period_hours, + previous_period_hours, + limit if content_type == "parks" else limit * 2 + ) + trending_items.extend(park_items) + if verbose: + self.stdout.write(f"Calculated {len(park_items)} trending parks") + + if content_type in ["all", "rides"]: + ride_items = self._calculate_trending_rides( + current_period_hours, + previous_period_hours, + limit if content_type == "rides" else limit * 2 + ) + trending_items.extend(ride_items) + if verbose: + self.stdout.write(f"Calculated {len(ride_items)} trending rides") + + # Sort by trending score and apply limit + trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True) + trending_items = trending_items[:limit] + + # Format results for API consumption + formatted_results = self._format_trending_results( + trending_items, current_period_hours, previous_period_hours) + + # Cache results + cache_key = f"trending:calculated:{content_type}:{limit}" + cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour + + self.stdout.write( + self.style.SUCCESS( + f"Successfully calculated {len(formatted_results)} trending items for {content_type}" + ) + ) + + if verbose: + for item in formatted_results[:5]: # Show first 5 items + self.stdout.write( + f" {item['name']} (score: {item.get('views_change', 'N/A')})") + + except Exception as e: + logger.error(f"Error calculating trending content: {e}", exc_info=True) + raise CommandError(f"Failed to calculate trending content: {e}") + + def _calculate_trending_parks(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]: + """Calculate trending scores for parks using real data.""" + parks = Park.objects.filter( + status="OPERATING").select_related("location", "operator") + + trending_parks = [] + + for park in parks: + try: + score = self._calculate_content_score( + park, "park", current_period_hours, previous_period_hours) + if score > 0: # Only include items with positive trending scores + trending_parks.append({ + "content_object": park, + "content_type": "park", + "trending_score": score, + "id": park.id, + "name": park.name, + "slug": park.slug, + "park": park.name, # For parks, park field is the park name itself + "category": "park", + "rating": float(park.average_rating) if park.average_rating else 0.0, + "date_opened": park.opening_date.isoformat() if park.opening_date else "", + "url": park.url, + }) + except Exception as e: + logger.warning(f"Error calculating score for park {park.id}: {e}") + + return trending_parks + + def _calculate_trending_rides(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]: + """Calculate trending scores for rides using real data.""" + rides = Ride.objects.filter(status="OPERATING").select_related( + "park", "park__location") + + trending_rides = [] + + for ride in rides: + try: + score = self._calculate_content_score( + ride, "ride", current_period_hours, previous_period_hours) + if score > 0: # Only include items with positive trending scores + trending_rides.append({ + "content_object": ride, + "content_type": "ride", + "trending_score": score, + "id": ride.pk, + "name": ride.name, + "slug": ride.slug, + "park": ride.park.name if ride.park else "", + "category": "ride", + "rating": float(ride.average_rating) if ride.average_rating else 0.0, + "date_opened": ride.opening_date.isoformat() if ride.opening_date else "", + "url": ride.url, + "park_url": ride.park.url if ride.park else "", + }) + except Exception as e: + logger.warning(f"Error calculating score for ride {ride.pk}: {e}") + + return trending_rides + + def _calculate_content_score(self, content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float: + """Calculate weighted trending score for content object using real analytics data.""" + try: + # Get content type for PageView queries + ct = ContentType.objects.get_for_model(content_obj) + + # 1. View Growth Score (40% weight) + view_growth_score = self._calculate_view_growth_score( + ct, content_obj.id, current_period_hours, previous_period_hours) + + # 2. Rating Score (30% weight) + rating_score = self._calculate_rating_score(content_obj) + + # 3. Recency Score (20% weight) + recency_score = self._calculate_recency_score(content_obj) + + # 4. Popularity Score (10% weight) + popularity_score = self._calculate_popularity_score( + ct, content_obj.id, current_period_hours) + + # Calculate weighted final score + final_score = ( + view_growth_score * 0.4 + + rating_score * 0.3 + + recency_score * 0.2 + + popularity_score * 0.1 + ) + + return final_score + + except Exception as e: + logger.error( + f"Error calculating score for {content_type} {content_obj.id}: {e}") + return 0.0 + + def _calculate_view_growth_score(self, content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float: + """Calculate normalized view growth score using real PageView data.""" + try: + current_views, previous_views, growth_percentage = PageView.get_views_growth( + content_type, + object_id, + current_period_hours, + previous_period_hours, + ) + + if previous_views == 0: + # New content with views gets boost + return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0 + + # Normalize growth percentage to 0-1 scale + normalized_growth = min(growth_percentage / 500.0, + 1.0) if growth_percentage > 0 else 0.0 + return max(normalized_growth, 0.0) + + except Exception as e: + logger.warning(f"Error calculating view growth: {e}") + return 0.0 + + def _calculate_rating_score(self, content_obj: Any) -> float: + """Calculate normalized rating score.""" + try: + rating = getattr(content_obj, "average_rating", None) + if rating is None or rating == 0: + return 0.3 # Neutral score for unrated content + + # Normalize rating from 1-10 scale to 0-1 scale + return min(max((float(rating) - 1) / 9.0, 0.0), 1.0) + + except Exception as e: + logger.warning(f"Error calculating rating score: {e}") + return 0.3 + + def _calculate_recency_score(self, content_obj: Any) -> float: + """Calculate recency score based on when content was added/updated.""" + try: + # Use opening_date for parks/rides, or created_at as fallback + date_added = getattr(content_obj, "opening_date", None) + if not date_added: + date_added = getattr(content_obj, "created_at", None) + if not date_added: + return 0.5 # Neutral score for unknown dates + + # Handle both date and datetime objects + if hasattr(date_added, "date"): + date_added = date_added.date() + + # Calculate days since added + today = timezone.now().date() + days_since_added = (today - date_added).days + + # Recency score: newer content gets higher scores + if days_since_added <= 0: + return 1.0 + elif days_since_added <= 30: + return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8 + elif days_since_added <= 365: + return 0.8 - ((days_since_added - 30) / (365 - 30)) * 0.7 # 0.8 to 0.1 + else: + return 0.0 + + except Exception as e: + logger.warning(f"Error calculating recency score: {e}") + return 0.5 + + def _calculate_popularity_score(self, content_type: ContentType, object_id: int, hours: int) -> float: + """Calculate popularity score based on total view count.""" + try: + total_views = PageView.get_total_views_count( + content_type, object_id, hours=hours) + + # Normalize views to 0-1 scale + if total_views == 0: + return 0.0 + elif total_views <= 100: + return total_views / 200.0 # 0.0 to 0.5 + else: + return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0 + + except Exception as e: + logger.warning(f"Error calculating popularity score: {e}") + return 0.0 + + def _format_trending_results(self, trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]: + """Format trending results for frontend consumption.""" + formatted_results = [] + + for rank, item in enumerate(trending_items, 1): + try: + # Get view change for display + content_obj = item["content_object"] + ct = ContentType.objects.get_for_model(content_obj) + current_views, previous_views, growth_percentage = PageView.get_views_growth( + ct, + content_obj.id, + current_period_hours, + previous_period_hours, + ) + + # Format exactly as frontend expects + formatted_item = { + "id": item["id"], + "name": item["name"], + "park": item["park"], + "category": item["category"], + "rating": item["rating"], + "rank": rank, + "views": current_views, + "views_change": ( + f"+{growth_percentage:.1f}%" + if growth_percentage > 0 + else f"{growth_percentage:.1f}%" + ), + "slug": item["slug"], + "date_opened": item["date_opened"], + "url": item["url"], + } + + # Add park_url for rides + if item.get("park_url"): + formatted_item["park_url"] = item["park_url"] + + formatted_results.append(formatted_item) + + except Exception as e: + logger.warning(f"Error formatting trending item: {e}") + + return formatted_results diff --git a/backend/apps/core/services/trending_service.py b/backend/apps/core/services/trending_service.py index fdd0eadb..04d22e1a 100644 --- a/backend/apps/core/services/trending_service.py +++ b/backend/apps/core/services/trending_service.py @@ -58,7 +58,7 @@ class TrendingService: self, content_type: str = "all", limit: int = 20, force_refresh: bool = False ) -> List[Dict[str, Any]]: """ - Get trending content with caching. + Get trending content using direct calculation. Args: content_type: 'parks', 'rides', or 'all' @@ -68,7 +68,7 @@ class TrendingService: Returns: List of trending content with exact frontend format """ - cache_key = f"{self.CACHE_PREFIX}:trending:{content_type}:{limit}" + cache_key = f"trending:calculated:{content_type}:{limit}" if not force_refresh: cached_result = cache.get(cache_key) @@ -78,41 +78,38 @@ class TrendingService: ) return cached_result - self.logger.info(f"Calculating trending content for {content_type}") + self.logger.info(f"Getting trending content for {content_type}") try: - # Calculate trending scores for each content type + # Calculate directly without Celery trending_items = [] if content_type in ["all", "parks"]: park_items = self._calculate_trending_parks( - limit if content_type == "parks" else limit * 2 - ) + limit * 2 if content_type == "all" else limit) trending_items.extend(park_items) if content_type in ["all", "rides"]: ride_items = self._calculate_trending_rides( - limit if content_type == "rides" else limit * 2 - ) + limit * 2 if content_type == "all" else limit) trending_items.extend(ride_items) # Sort by trending score and apply limit trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True) trending_items = trending_items[:limit] - # Add ranking and format for frontend + # Format results for API consumption formatted_results = self._format_trending_results(trending_items) # Cache results cache.set(cache_key, formatted_results, self.CACHE_TTL) self.logger.info( - f"Calculated {len(formatted_results)} trending items for {content_type}" - ) + f"Calculated {len(formatted_results)} trending items for {content_type}") return formatted_results except Exception as e: - self.logger.error(f"Error calculating trending content: {e}", exc_info=True) + self.logger.error(f"Error getting trending content: {e}", exc_info=True) return [] def get_new_content( @@ -123,7 +120,7 @@ class TrendingService: force_refresh: bool = False, ) -> List[Dict[str, Any]]: """ - Get recently added content. + Get recently added content using direct calculation. Args: content_type: 'parks', 'rides', or 'all' @@ -134,7 +131,7 @@ class TrendingService: Returns: List of new content with exact frontend format """ - cache_key = f"{self.CACHE_PREFIX}:new:{content_type}:{limit}:{days_back}" + cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}" if not force_refresh: cached_result = cache.get(cache_key) @@ -144,37 +141,35 @@ class TrendingService: ) return cached_result - self.logger.info(f"Calculating new content for {content_type}") + self.logger.info(f"Getting new content for {content_type}") try: + # Calculate directly without Celery cutoff_date = timezone.now() - timedelta(days=days_back) new_items = [] if content_type in ["all", "parks"]: parks = self._get_new_parks( - cutoff_date, limit if content_type == "parks" else limit * 2 - ) + cutoff_date, limit * 2 if content_type == "all" else limit) new_items.extend(parks) if content_type in ["all", "rides"]: rides = self._get_new_rides( - cutoff_date, limit if content_type == "rides" else limit * 2 - ) + cutoff_date, limit * 2 if content_type == "all" else limit) new_items.extend(rides) # Sort by date added (most recent first) and apply limit new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True) new_items = new_items[:limit] - # Format for frontend + # Format results for API consumption formatted_results = self._format_new_content_results(new_items) # Cache results - cache.set(cache_key, formatted_results, self.CACHE_TTL) + cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes self.logger.info( - f"Found {len(formatted_results)} new items for {content_type}" - ) + f"Calculated {len(formatted_results)} new items for {content_type}") return formatted_results except Exception as e: @@ -184,7 +179,7 @@ class TrendingService: def _calculate_trending_parks(self, limit: int) -> List[Dict[str, Any]]: """Calculate trending scores for parks.""" parks = Park.objects.filter(status="OPERATING").select_related( - "location", "operator" + "location", "operator", "card_image" ) trending_parks = [] @@ -193,6 +188,32 @@ class TrendingService: try: score = self._calculate_content_score(park, "park") if score > 0: # Only include items with positive trending scores + # Get opening date for date_opened field + opening_date = getattr(park, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + # Get location fields + city = "" + state = "" + country = "" + try: + location = getattr(park, 'location', None) + if location: + city = getattr(location, 'city', '') or "" + state = getattr(location, 'state', '') or "" + country = getattr(location, 'country', '') or "" + except Exception: + pass + + # Get card image URL + card_image_url = "" + if park.card_image and hasattr(park.card_image, 'image'): + card_image_url = park.card_image.image.url if park.card_image.image else "" + + # Get primary company (operator) + primary_company = park.operator.name if park.operator else "" + trending_parks.append( { "content_object": park, @@ -201,17 +222,20 @@ class TrendingService: "id": park.id, "name": park.name, "slug": park.slug, - "location": ( - park.formatted_location - if hasattr(park, "location") - else "" - ), + "park": park.name, # For parks, park field is the park name itself "category": "park", "rating": ( float(park.average_rating) if park.average_rating else 0.0 ), + "date_opened": opening_date.isoformat() if opening_date else "", + "url": park.url, + "card_image": card_image_url, + "city": city, + "state": state, + "country": country, + "primary_company": primary_company, } ) except Exception as e: @@ -222,7 +246,7 @@ class TrendingService: def _calculate_trending_rides(self, limit: int) -> List[Dict[str, Any]]: """Calculate trending scores for rides.""" rides = Ride.objects.filter(status="OPERATING").select_related( - "park", "park__location" + "park", "park__location", "card_image" ) trending_rides = [] @@ -231,14 +255,15 @@ class TrendingService: try: score = self._calculate_content_score(ride, "ride") if score > 0: # Only include items with positive trending scores - # Get location from park (rides don't have direct location field) - location = "" - if ( - ride.park - and hasattr(ride.park, "location") - and ride.park.location - ): - location = ride.park.formatted_location + # Get opening date for date_opened field + opening_date = getattr(ride, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + # Get card image URL + card_image_url = "" + if ride.card_image and hasattr(ride.card_image, 'image'): + card_image_url = ride.card_image.image.url if ride.card_image.image else "" trending_rides.append( { @@ -248,13 +273,17 @@ class TrendingService: "id": ride.pk, # Use pk instead of id "name": ride.name, "slug": ride.slug, - "location": location, + "park": ride.park.name if ride.park else "", "category": "ride", "rating": ( float(ride.average_rating) if ride.average_rating else 0.0 ), + "date_opened": opening_date.isoformat() if opening_date else "", + "url": ride.url, + "park_url": ride.park.url if ride.park else "", + "card_image": card_image_url, } ) except Exception as e: @@ -421,7 +450,7 @@ class TrendingService: | Q(opening_date__gte=cutoff_date.date()), status="OPERATING", ) - .select_related("location", "operator") + .select_related("location", "operator", "card_image") .order_by("-created_at", "-opening_date")[:limit] ) @@ -435,6 +464,32 @@ class TrendingService: date_added = date_added.date() # If it's already a date, keep it as is + # Get opening date for date_opened field + opening_date = getattr(park, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + # Get location fields + city = "" + state = "" + country = "" + try: + location = getattr(park, 'location', None) + if location: + city = getattr(location, 'city', '') or "" + state = getattr(location, 'state', '') or "" + country = getattr(location, 'country', '') or "" + except Exception: + pass + + # Get card image URL + card_image_url = "" + if park.card_image and hasattr(park.card_image, 'image'): + card_image_url = park.card_image.image.url if park.card_image.image else "" + + # Get primary company (operator) + primary_company = park.operator.name if park.operator else "" + results.append( { "content_object": park, @@ -442,11 +497,16 @@ class TrendingService: "id": park.pk, # Use pk instead of id for Django compatibility "name": park.name, "slug": park.slug, - "location": ( - park.formatted_location if hasattr(park, "location") else "" - ), + "park": park.name, # For parks, park field is the park name itself "category": "park", "date_added": date_added.isoformat() if date_added else "", + "date_opened": opening_date.isoformat() if opening_date else "", + "url": park.url, + "card_image": card_image_url, + "city": city, + "state": state, + "country": country, + "primary_company": primary_company, } ) @@ -460,7 +520,7 @@ class TrendingService: | Q(opening_date__gte=cutoff_date.date()), status="OPERATING", ) - .select_related("park", "park__location") + .select_related("park", "park__location", "card_image") .order_by("-created_at", "-opening_date")[:limit] ) @@ -476,10 +536,15 @@ class TrendingService: date_added = date_added.date() # If it's already a date, keep it as is - # Get location from park (rides don't have direct location field) - location = "" - if ride.park and hasattr(ride.park, "location") and ride.park.location: - location = ride.park.formatted_location + # Get opening date for date_opened field + opening_date = getattr(ride, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + # Get card image URL + card_image_url = "" + if ride.card_image and hasattr(ride.card_image, 'image'): + card_image_url = ride.card_image.image.url if ride.card_image.image else "" results.append( { @@ -488,9 +553,13 @@ class TrendingService: "id": ride.pk, # Use pk instead of id for Django compatibility "name": ride.name, "slug": ride.slug, - "location": location, + "park": ride.park.name if ride.park else "", "category": "ride", "date_added": date_added.isoformat() if date_added else "", + "date_opened": opening_date.isoformat() if opening_date else "", + "url": ride.url, + "park_url": ride.park.url if ride.park else "", + "card_image": card_image_url, } ) @@ -520,7 +589,7 @@ class TrendingService: formatted_item = { "id": item["id"], "name": item["name"], - "location": item["location"], + "park": item["park"], "category": item["category"], "rating": item["rating"], "rank": rank, @@ -531,8 +600,29 @@ class TrendingService: else f"{growth_percentage:.1f}%" ), "slug": item["slug"], + "date_opened": item["date_opened"], + "url": item["url"], } + # Add card_image for all items + if item.get("card_image"): + formatted_item["card_image"] = item["card_image"] + + # Add park-specific fields + if item["content_type"] == "park": + if item.get("city"): + formatted_item["city"] = item["city"] + if item.get("state"): + formatted_item["state"] = item["state"] + if item.get("country"): + formatted_item["country"] = item["country"] + if item.get("primary_company"): + formatted_item["primary_company"] = item["primary_company"] + + # Add park_url for rides + if item.get("park_url"): + formatted_item["park_url"] = item["park_url"] + formatted_results.append(formatted_item) except Exception as e: @@ -552,12 +642,33 @@ class TrendingService: formatted_item = { "id": item["id"], "name": item["name"], - "location": item["location"], + "park": item["park"], "category": item["category"], "date_added": item["date_added"], + "date_opened": item["date_opened"], "slug": item["slug"], + "url": item["url"], } + # Add card_image for all items + if item.get("card_image"): + formatted_item["card_image"] = item["card_image"] + + # Add park-specific fields + if item["content_type"] == "park": + if item.get("city"): + formatted_item["city"] = item["city"] + if item.get("state"): + formatted_item["state"] = item["state"] + if item.get("country"): + formatted_item["country"] = item["country"] + if item.get("primary_company"): + formatted_item["primary_company"] = item["primary_company"] + + # Add park_url for rides + if item.get("park_url"): + formatted_item["park_url"] = item["park_url"] + formatted_results.append(formatted_item) except Exception as e: diff --git a/backend/apps/core/tasks/__init__.py b/backend/apps/core/tasks/__init__.py new file mode 100644 index 00000000..c02d12a9 --- /dev/null +++ b/backend/apps/core/tasks/__init__.py @@ -0,0 +1,5 @@ +""" +Core tasks package for ThrillWiki. + +This package contains all Celery tasks for the core application. +""" diff --git a/backend/apps/core/tasks/trending.py b/backend/apps/core/tasks/trending.py new file mode 100644 index 00000000..c97b6f97 --- /dev/null +++ b/backend/apps/core/tasks/trending.py @@ -0,0 +1,550 @@ +""" +Trending calculation tasks for ThrillWiki. + +This module contains Celery tasks for calculating and caching trending content. +All tasks run asynchronously to avoid blocking the main application. +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional +from celery import shared_task +from django.utils import timezone +from django.core.cache import cache +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q, Count, Avg, F +from django.db import transaction + +from apps.core.analytics import PageView +from apps.parks.models import Park +from apps.rides.models import Ride + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def calculate_trending_content(self, content_type: str = "all", limit: int = 50) -> Dict[str, Any]: + """ + Calculate trending content using real analytics data. + + This task runs periodically to update trending calculations based on: + - View growth rates + - Content ratings + - Recency factors + - Popularity metrics + + Args: + content_type: 'parks', 'rides', or 'all' + limit: Maximum number of results to calculate + + Returns: + Dict containing trending results and metadata + """ + try: + logger.info(f"Starting trending calculation for {content_type}") + + # Time windows for calculations + current_period_hours = 168 # 7 days + previous_period_hours = 336 # 14 days (for previous 7-day window comparison) + + trending_items = [] + + if content_type in ["all", "parks"]: + park_items = _calculate_trending_parks( + current_period_hours, + previous_period_hours, + limit if content_type == "parks" else limit * 2 + ) + trending_items.extend(park_items) + + if content_type in ["all", "rides"]: + ride_items = _calculate_trending_rides( + current_period_hours, + previous_period_hours, + limit if content_type == "rides" else limit * 2 + ) + trending_items.extend(ride_items) + + # Sort by trending score and apply limit + trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True) + trending_items = trending_items[:limit] + + # Format results for API consumption + formatted_results = _format_trending_results( + trending_items, current_period_hours, previous_period_hours) + + # Cache results + cache_key = f"trending:calculated:{content_type}:{limit}" + cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour + + logger.info( + f"Calculated {len(formatted_results)} trending items for {content_type}") + + return { + "success": True, + "content_type": content_type, + "count": len(formatted_results), + "results": formatted_results, + "calculated_at": timezone.now().isoformat(), + } + + except Exception as e: + logger.error(f"Error calculating trending content: {e}", exc_info=True) + # Retry the task + raise self.retry(exc=e) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=30) +def calculate_new_content(self, content_type: str = "all", days_back: int = 30, limit: int = 50) -> Dict[str, Any]: + """ + Calculate new content based on opening dates and creation dates. + + Args: + content_type: 'parks', 'rides', or 'all' + days_back: How many days to look back for new content + limit: Maximum number of results + + Returns: + Dict containing new content results and metadata + """ + try: + logger.info(f"Starting new content calculation for {content_type}") + + cutoff_date = timezone.now() - timedelta(days=days_back) + new_items = [] + + if content_type in ["all", "parks"]: + parks = _get_new_parks( + cutoff_date, limit if content_type == "parks" else limit * 2) + new_items.extend(parks) + + if content_type in ["all", "rides"]: + rides = _get_new_rides( + cutoff_date, limit if content_type == "rides" else limit * 2) + new_items.extend(rides) + + # Sort by date added (most recent first) and apply limit + new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True) + new_items = new_items[:limit] + + # Format results for API consumption + formatted_results = _format_new_content_results(new_items) + + # Cache results + cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}" + cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes + + logger.info(f"Calculated {len(formatted_results)} new items for {content_type}") + + return { + "success": True, + "content_type": content_type, + "count": len(formatted_results), + "results": formatted_results, + "calculated_at": timezone.now().isoformat(), + } + + except Exception as e: + logger.error(f"Error calculating new content: {e}", exc_info=True) + raise self.retry(exc=e) + + +@shared_task(bind=True) +def warm_trending_cache(self) -> Dict[str, Any]: + """ + Warm the trending cache by pre-calculating common queries. + + This task runs periodically to ensure fast API responses. + """ + try: + logger.info("Starting trending cache warming") + + # Common query combinations to pre-calculate + queries = [ + {"content_type": "all", "limit": 20}, + {"content_type": "parks", "limit": 10}, + {"content_type": "rides", "limit": 10}, + {"content_type": "all", "limit": 50}, + ] + + results = {} + + for query in queries: + # Trigger trending calculation + calculate_trending_content.delay(**query) + + # Trigger new content calculation + calculate_new_content.delay(**query) + + results[f"trending_{query['content_type']}_{query['limit']}"] = "scheduled" + results[f"new_content_{query['content_type']}_{query['limit']}"] = "scheduled" + + logger.info("Trending cache warming completed") + + return { + "success": True, + "queries_scheduled": len(queries) * 2, + "results": results, + "warmed_at": timezone.now().isoformat(), + } + + except Exception as e: + logger.error(f"Error warming trending cache: {e}", exc_info=True) + return { + "success": False, + "error": str(e), + "warmed_at": timezone.now().isoformat(), + } + + +def _calculate_trending_parks(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]: + """Calculate trending scores for parks using real data.""" + parks = Park.objects.filter( + status="OPERATING").select_related("location", "operator") + + trending_parks = [] + + for park in parks: + try: + score = _calculate_content_score( + park, "park", current_period_hours, previous_period_hours) + if score > 0: # Only include items with positive trending scores + trending_parks.append({ + "content_object": park, + "content_type": "park", + "trending_score": score, + "id": park.id, + "name": park.name, + "slug": park.slug, + "location": park.formatted_location if hasattr(park, "location") else "", + "category": "park", + "rating": float(park.average_rating) if park.average_rating else 0.0, + }) + except Exception as e: + logger.warning(f"Error calculating score for park {park.id}: {e}") + + return trending_parks + + +def _calculate_trending_rides(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]: + """Calculate trending scores for rides using real data.""" + rides = Ride.objects.filter(status="OPERATING").select_related( + "park", "park__location") + + trending_rides = [] + + for ride in rides: + try: + score = _calculate_content_score( + ride, "ride", current_period_hours, previous_period_hours) + if score > 0: # Only include items with positive trending scores + # Get location from park + location = "" + if ride.park and hasattr(ride.park, "location") and ride.park.location: + location = ride.park.formatted_location + + trending_rides.append({ + "content_object": ride, + "content_type": "ride", + "trending_score": score, + "id": ride.pk, + "name": ride.name, + "slug": ride.slug, + "location": location, + "category": "ride", + "rating": float(ride.average_rating) if ride.average_rating else 0.0, + }) + except Exception as e: + logger.warning(f"Error calculating score for ride {ride.pk}: {e}") + + return trending_rides + + +def _calculate_content_score(content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float: + """ + Calculate weighted trending score for content object using real analytics data. + + Algorithm Components: + - View Growth Rate (40% weight): Recent view increase vs historical + - Rating Score (30% weight): Average user rating normalized + - Recency Factor (20% weight): How recently content was added/updated + - Popularity Boost (10% weight): Total view count normalization + + Returns: + Float between 0.0 and 1.0 representing trending strength + """ + try: + # Get content type for PageView queries + ct = ContentType.objects.get_for_model(content_obj) + + # 1. View Growth Score (40% weight) + view_growth_score = _calculate_view_growth_score( + ct, content_obj.id, current_period_hours, previous_period_hours) + + # 2. Rating Score (30% weight) + rating_score = _calculate_rating_score(content_obj) + + # 3. Recency Score (20% weight) + recency_score = _calculate_recency_score(content_obj) + + # 4. Popularity Score (10% weight) + popularity_score = _calculate_popularity_score( + ct, content_obj.id, current_period_hours) + + # Calculate weighted final score + final_score = ( + view_growth_score * 0.4 + + rating_score * 0.3 + + recency_score * 0.2 + + popularity_score * 0.1 + ) + + logger.debug( + f"{content_type} {content_obj.id}: " + f"growth={view_growth_score:.3f}, rating={rating_score:.3f}, " + f"recency={recency_score:.3f}, popularity={popularity_score:.3f}, " + f"final={final_score:.3f}" + ) + + return final_score + + except Exception as e: + logger.error( + f"Error calculating score for {content_type} {content_obj.id}: {e}") + return 0.0 + + +def _calculate_view_growth_score(content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float: + """Calculate normalized view growth score using real PageView data.""" + try: + current_views, previous_views, growth_percentage = PageView.get_views_growth( + content_type, + object_id, + current_period_hours, + previous_period_hours, + ) + + if previous_views == 0: + # New content with views gets boost + return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0 + + # Normalize growth percentage to 0-1 scale + # 100% growth = 0.5, 500% growth = 1.0 + normalized_growth = min(growth_percentage / 500.0, + 1.0) if growth_percentage > 0 else 0.0 + return max(normalized_growth, 0.0) + + except Exception as e: + logger.warning(f"Error calculating view growth: {e}") + return 0.0 + + +def _calculate_rating_score(content_obj: Any) -> float: + """Calculate normalized rating score.""" + try: + rating = getattr(content_obj, "average_rating", None) + if rating is None or rating == 0: + return 0.3 # Neutral score for unrated content + + # Normalize rating from 1-10 scale to 0-1 scale + # Rating of 5 = 0.4, Rating of 8 = 0.7, Rating of 10 = 1.0 + return min(max((float(rating) - 1) / 9.0, 0.0), 1.0) + + except Exception as e: + logger.warning(f"Error calculating rating score: {e}") + return 0.3 + + +def _calculate_recency_score(content_obj: Any) -> float: + """Calculate recency score based on when content was added/updated.""" + try: + # Use opening_date for parks/rides, or created_at as fallback + date_added = getattr(content_obj, "opening_date", None) + if not date_added: + date_added = getattr(content_obj, "created_at", None) + if not date_added: + return 0.5 # Neutral score for unknown dates + + # Handle both date and datetime objects + if hasattr(date_added, "date"): + date_added = date_added.date() + + # Calculate days since added + today = timezone.now().date() + days_since_added = (today - date_added).days + + # Recency score: newer content gets higher scores + # 0 days = 1.0, 30 days = 0.8, 365 days = 0.1, >365 days = 0.0 + if days_since_added <= 0: + return 1.0 + elif days_since_added <= 30: + return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8 + elif days_since_added <= 365: + return 0.8 - ((days_since_added - 30) / (365 - 30)) * 0.7 # 0.8 to 0.1 + else: + return 0.0 + + except Exception as e: + logger.warning(f"Error calculating recency score: {e}") + return 0.5 + + +def _calculate_popularity_score(content_type: ContentType, object_id: int, hours: int) -> float: + """Calculate popularity score based on total view count.""" + try: + total_views = PageView.get_total_views_count( + content_type, object_id, hours=hours) + + # Normalize views to 0-1 scale + # 0 views = 0.0, 100 views = 0.5, 1000+ views = 1.0 + if total_views == 0: + return 0.0 + elif total_views <= 100: + return total_views / 200.0 # 0.0 to 0.5 + else: + return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0 + + except Exception as e: + logger.warning(f"Error calculating popularity score: {e}") + return 0.0 + + +def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + """Get recently added parks using real data.""" + new_parks = ( + Park.objects.filter( + Q(created_at__gte=cutoff_date) | Q(opening_date__gte=cutoff_date.date()), + status="OPERATING", + ) + .select_related("location", "operator") + .order_by("-created_at", "-opening_date")[:limit] + ) + + results = [] + for park in new_parks: + date_added = park.opening_date or park.created_at + if date_added: + if isinstance(date_added, datetime): + date_added = date_added.date() + + opening_date = getattr(park, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + results.append({ + "content_object": park, + "content_type": "park", + "id": park.pk, + "name": park.name, + "slug": park.slug, + "park": park.name, # For parks, park field is the park name itself + "category": "park", + "date_added": date_added.isoformat() if date_added else "", + "date_opened": opening_date.isoformat() if opening_date else "", + }) + + return results + + +def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]: + """Get recently added rides using real data.""" + new_rides = ( + Ride.objects.filter( + Q(created_at__gte=cutoff_date) | Q(opening_date__gte=cutoff_date.date()), + status="OPERATING", + ) + .select_related("park", "park__location") + .order_by("-created_at", "-opening_date")[:limit] + ) + + results = [] + for ride in new_rides: + date_added = getattr(ride, "opening_date", None) or getattr( + ride, "created_at", None) + if date_added: + if isinstance(date_added, datetime): + date_added = date_added.date() + + opening_date = getattr(ride, "opening_date", None) + if opening_date and isinstance(opening_date, datetime): + opening_date = opening_date.date() + + results.append({ + "content_object": ride, + "content_type": "ride", + "id": ride.pk, + "name": ride.name, + "slug": ride.slug, + "park": ride.park.name if ride.park else "", + "category": "ride", + "date_added": date_added.isoformat() if date_added else "", + "date_opened": opening_date.isoformat() if opening_date else "", + }) + + return results + + +def _format_trending_results(trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]: + """Format trending results for frontend consumption.""" + formatted_results = [] + + for rank, item in enumerate(trending_items, 1): + try: + # Get view change for display + content_obj = item["content_object"] + ct = ContentType.objects.get_for_model(content_obj) + current_views, previous_views, growth_percentage = PageView.get_views_growth( + ct, + content_obj.id, + current_period_hours, + previous_period_hours, + ) + + # Format exactly as frontend expects + formatted_item = { + "id": item["id"], + "name": item["name"], + "location": item["location"], + "category": item["category"], + "rating": item["rating"], + "rank": rank, + "views": current_views, + "views_change": ( + f"+{growth_percentage:.1f}%" + if growth_percentage > 0 + else f"{growth_percentage:.1f}%" + ), + "slug": item["slug"], + } + + formatted_results.append(formatted_item) + + except Exception as e: + logger.warning(f"Error formatting trending item: {e}") + + return formatted_results + + +def _format_new_content_results(new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Format new content results for frontend consumption.""" + formatted_results = [] + + for item in new_items: + try: + # Format exactly as frontend expects + formatted_item = { + "id": item["id"], + "name": item["name"], + "park": item["park"], + "category": item["category"], + "date_added": item["date_added"], + "date_opened": item["date_opened"], + "slug": item["slug"], + } + + formatted_results.append(formatted_item) + + except Exception as e: + logger.warning(f"Error formatting new content item: {e}") + + return formatted_results diff --git a/backend/apps/parks/migrations/0011_remove_park_insert_insert_remove_park_update_update_and_more.py b/backend/apps/parks/migrations/0011_remove_park_insert_insert_remove_park_update_update_and_more.py new file mode 100644 index 00000000..7d2c3346 --- /dev/null +++ b/backend/apps/parks/migrations/0011_remove_park_insert_insert_remove_park_update_update_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.5 on 2025-08-28 22:59 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("parks", "0010_add_banner_card_image_fields"), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="park", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="park", + name="update_update", + ), + migrations.AddField( + model_name="park", + name="url", + field=models.URLField(blank=True, help_text="Frontend URL for this park"), + ), + migrations.AddField( + model_name="parkevent", + name="url", + field=models.URLField(blank=True, help_text="Frontend URL for this park"), + ), + pgtrigger.migrations.AddTrigger( + model_name="park", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;', + hash="f677e88234ebc3dc93c46d4756cb0723f5468cbe", + operation="INSERT", + pgid="pgtrigger_insert_insert_66883", + table="parks_park", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="park", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;', + hash="6fc430a517628d48341e8981fa38529031c3f35b", + operation="UPDATE", + pgid="pgtrigger_update_update_19f56", + table="parks_park", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/parks/models/parks.py b/backend/apps/parks/models/parks.py index 631163a8..15632f6c 100644 --- a/backend/apps/parks/models/parks.py +++ b/backend/apps/parks/models/parks.py @@ -2,10 +2,12 @@ from django.db import models from django.urls import reverse from django.utils.text import slugify from django.core.exceptions import ValidationError +from config.django import base as settings from typing import Optional, Any, TYPE_CHECKING, List import pghistory from apps.core.history import TrackedModel + if TYPE_CHECKING: from apps.rides.models import Ride from . import ParkArea @@ -97,6 +99,9 @@ class Park(TrackedModel): created_at = models.DateTimeField(auto_now_add=True, null=True) updated_at = models.DateTimeField(auto_now=True) + # Frontend URL + url = models.URLField(blank=True, help_text="Frontend URL for this park") + class Meta: ordering = ["name"] constraints = [ @@ -167,6 +172,10 @@ class Park(TrackedModel): if not self.slug or (old_name and old_name != self.name): self.slug = slugify(self.name) + # Generate frontend URL + frontend_domain = getattr(settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com') + self.url = f"{frontend_domain}/parks/{self.slug}/" + # Save the model super().save(*args, **kwargs) diff --git a/backend/apps/rides/migrations/0015_remove_company_insert_insert_and_more.py b/backend/apps/rides/migrations/0015_remove_company_insert_insert_and_more.py new file mode 100644 index 00000000..b947f8ed --- /dev/null +++ b/backend/apps/rides/migrations/0015_remove_company_insert_insert_and_more.py @@ -0,0 +1,164 @@ +# Generated by Django 5.2.5 on 2025-08-28 22:59 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("rides", "0014_update_ride_model_slugs_data"), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="company", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="company", + name="update_update", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="ride", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="ride", + name="update_update", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="ridemodel", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="ridemodel", + name="update_update", + ), + migrations.AddField( + model_name="company", + name="url", + field=models.URLField( + blank=True, help_text="Frontend URL for this company" + ), + ), + migrations.AddField( + model_name="companyevent", + name="url", + field=models.URLField( + blank=True, help_text="Frontend URL for this company" + ), + ), + migrations.AddField( + model_name="ride", + name="url", + field=models.URLField(blank=True, help_text="Frontend URL for this ride"), + ), + migrations.AddField( + model_name="rideevent", + name="url", + field=models.URLField(blank=True, help_text="Frontend URL for this ride"), + ), + migrations.AddField( + model_name="ridemodel", + name="url", + field=models.URLField( + blank=True, help_text="Frontend URL for this ride model" + ), + ), + migrations.AddField( + model_name="ridemodelevent", + name="url", + field=models.URLField( + blank=True, help_text="Frontend URL for this ride model" + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="company", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "url", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;', + hash="fe6c1e3f09822f5e7f716cd83483cf152ec138f0", + operation="INSERT", + pgid="pgtrigger_insert_insert_e7194", + table="rides_company", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="company", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "url", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;', + hash="0b76cb36b7551ed3e64e674b8cfe343d4d2ec306", + operation="UPDATE", + pgid="pgtrigger_update_update_456a8", + table="rides_company", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ride", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;', + hash="6764dc3b0c0e73dda649939bb1ee7b7de143125f", + operation="INSERT", + pgid="pgtrigger_insert_insert_52074", + table="rides_ride", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ride", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;', + hash="63c4066af11852396506fd964989632336205573", + operation="UPDATE", + pgid="pgtrigger_update_update_4917a", + table="rides_ride", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ridemodel", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;', + hash="9cee65f580a26ae9edc8f9fc1f3d9b25da1856c3", + operation="INSERT", + pgid="pgtrigger_insert_insert_0aaee", + table="rides_ridemodel", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ridemodel", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;', + hash="365f87607f9f7bfee1caaabdd32b16032e04ae82", + operation="UPDATE", + pgid="pgtrigger_update_update_0ca1a", + table="rides_ridemodel", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/rides/migrations/0016_remove_ride_insert_insert_remove_ride_update_update_and_more.py b/backend/apps/rides/migrations/0016_remove_ride_insert_insert_remove_ride_update_update_and_more.py new file mode 100644 index 00000000..54aa5f2d --- /dev/null +++ b/backend/apps/rides/migrations/0016_remove_ride_insert_insert_remove_ride_update_update_and_more.py @@ -0,0 +1,66 @@ +# Generated by Django 5.2.5 on 2025-08-28 23:12 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("rides", "0015_remove_company_insert_insert_and_more"), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="ride", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="ride", + name="update_update", + ), + migrations.AddField( + model_name="ride", + name="park_url", + field=models.URLField( + blank=True, help_text="Frontend URL for this ride's park" + ), + ), + migrations.AddField( + model_name="rideevent", + name="park_url", + field=models.URLField( + blank=True, help_text="Frontend URL for this ride's park" + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ride", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;', + hash="3b83e1d1dbc2d5ca5792929845db1dd6d306700a", + operation="INSERT", + pgid="pgtrigger_insert_insert_52074", + table="rides_ride", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="ride", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;', + hash="efd782a22f5bec46d06b234ffc55b6c06360ade1", + operation="UPDATE", + pgid="pgtrigger_update_update_4917a", + table="rides_ride", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/rides/models/company.py b/backend/apps/rides/models/company.py index 50c15cb8..c4d5b689 100644 --- a/backend/apps/rides/models/company.py +++ b/backend/apps/rides/models/company.py @@ -3,6 +3,7 @@ from django.contrib.postgres.fields import ArrayField from django.db import models from django.urls import reverse from django.utils.text import slugify +from django.conf import settings from apps.core.history import HistoricalSlug from apps.core.models import TrackedModel @@ -33,12 +34,30 @@ class Company(TrackedModel): rides_count = models.IntegerField(default=0) coasters_count = models.IntegerField(default=0) + # Frontend URL + url = models.URLField(blank=True, help_text="Frontend URL for this company") + def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) + + # Generate frontend URL based on primary role + # CRITICAL: Only MANUFACTURER and DESIGNER are for rides domain + # OPERATOR and PROPERTY_OWNER are for parks domain and handled separately + if self.roles: + frontend_domain = getattr( + settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com') + primary_role = self.roles[0] # Use first role as primary + + if primary_role == 'MANUFACTURER': + self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/" + elif primary_role == 'DESIGNER': + self.url = f"{frontend_domain}/rides/designers/{self.slug}/" + # OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here + super().save(*args, **kwargs) def get_absolute_url(self): diff --git a/backend/apps/rides/models/rides.py b/backend/apps/rides/models/rides.py index 4172a62c..d9588f49 100644 --- a/backend/apps/rides/models/rides.py +++ b/backend/apps/rides/models/rides.py @@ -1,5 +1,6 @@ from django.db import models from django.utils.text import slugify +from config.django import base as settings from apps.core.models import TrackedModel from .company import Company import pghistory @@ -150,6 +151,9 @@ class RideModel(TrackedModel): help_text="SEO meta description (auto-generated if blank)" ) + # Frontend URL + url = models.URLField(blank=True, help_text="Frontend URL for this ride model") + class Meta(TrackedModel.Meta): ordering = ["manufacturer__name", "name"] unique_together = [ @@ -208,7 +212,7 @@ class RideModel(TrackedModel): # Ensure uniqueness within the same manufacturer counter = 1 while RideModel.objects.filter( - manufacturer=self.manufacturer, + manufacturer=self.manufacturer, slug=self.slug ).exclude(pk=self.pk).exists(): self.slug = f"{base_slug}-{counter}" @@ -222,6 +226,12 @@ class RideModel(TrackedModel): self) self.meta_description = desc[:160] + # Generate frontend URL + if self.manufacturer: + frontend_domain = getattr( + settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com') + self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/" + super().save(*args, **kwargs) def update_installation_count(self) -> None: @@ -511,6 +521,11 @@ class Ride(TrackedModel): help_text="Photo to use as card image for this ride" ) + # Frontend URL + url = models.URLField(blank=True, help_text="Frontend URL for this ride") + park_url = models.URLField( + blank=True, help_text="Frontend URL for this ride's park") + class Meta(TrackedModel.Meta): ordering = ["name"] unique_together = ["park", "slug"] @@ -577,6 +592,14 @@ class Ride(TrackedModel): def save(self, *args, **kwargs) -> None: if not self.slug: self.slug = slugify(self.name) + + # Generate frontend URLs + if self.park: + frontend_domain = getattr( + settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com') + self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/" + self.park_url = f"{frontend_domain}/parks/{self.park.slug}/" + super().save(*args, **kwargs) diff --git a/backend/config/celery.py b/backend/config/celery.py new file mode 100644 index 00000000..b0bdaa16 --- /dev/null +++ b/backend/config/celery.py @@ -0,0 +1,80 @@ +""" +Celery configuration for ThrillWiki. + +This module sets up Celery for background task processing including: +- Trending calculations +- Cache warming +- Analytics processing +- Email notifications +""" + +import os +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.django.local') + +app = Celery('thrillwiki') + +# Get Redis URL from environment variable with fallback +REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/1') + +# Celery Configuration - set directly without loading from Django settings first +app.conf.update( + # Broker settings + broker_url=REDIS_URL, + result_backend=REDIS_URL, + + # Task settings + task_serializer='json', + accept_content=['json'], + result_serializer='json', + timezone='America/New_York', + enable_utc=True, + + # Worker settings + worker_prefetch_multiplier=1, + task_acks_late=True, + worker_max_tasks_per_child=1000, + + # Task routing + task_routes={ + 'apps.core.tasks.trending.*': {'queue': 'trending'}, + 'apps.core.tasks.analytics.*': {'queue': 'analytics'}, + 'apps.core.tasks.cache.*': {'queue': 'cache'}, + }, + + # Beat schedule for periodic tasks + beat_schedule={ + 'calculate-trending-content': { + 'task': 'apps.core.tasks.trending.calculate_trending_content', + 'schedule': 300.0, # Every 5 minutes + }, + 'warm-trending-cache': { + 'task': 'apps.core.tasks.trending.warm_trending_cache', + 'schedule': 900.0, # Every 15 minutes + }, + 'cleanup-old-analytics': { + 'task': 'apps.core.tasks.analytics.cleanup_old_analytics', + 'schedule': 86400.0, # Daily + }, + }, + + # Task result settings + result_expires=3600, # 1 hour + task_ignore_result=False, + + # Error handling + task_reject_on_worker_lost=True, + task_soft_time_limit=300, # 5 minutes + task_time_limit=600, # 10 minutes +) + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + """Debug task for testing Celery setup.""" + print(f'Request: {self.request!r}') diff --git a/backend/config/django/base.py b/backend/config/django/base.py index c53df1a6..6c569cc3 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -86,6 +86,8 @@ THIRD_PARTY_APPS = [ "health_check.storage", "health_check.contrib.migrations", "health_check.contrib.redis", + "django_celery_beat", # Celery beat scheduler + "django_celery_results", # Celery result backend ] LOCAL_APPS = [ @@ -283,6 +285,9 @@ ROADTRIP_REQUEST_TIMEOUT = 10 # seconds ROADTRIP_MAX_RETRIES = 3 ROADTRIP_BACKOFF_FACTOR = 2 +# Frontend URL Configuration +FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com") + # Django REST Framework Settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 03642d9d..80e7edb9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -57,6 +57,9 @@ dependencies = [ "ruff>=0.12.10", "python-decouple>=3.8", "pyright>=1.1.404", + "celery>=5.5.3", + "django-celery-beat>=2.8.1", + "django-celery-results>=2.6.0", ] [dependency-groups] diff --git a/backend/thrillwiki/__init__.py b/backend/thrillwiki/__init__.py index e69de29b..146c74d7 100644 --- a/backend/thrillwiki/__init__.py +++ b/backend/thrillwiki/__init__.py @@ -0,0 +1,3 @@ +""" +ThrillWiki Django project initialization. +""" diff --git a/backend/uv.lock b/backend/uv.lock index 77f5bb29..7a7eb9f3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + [[package]] name = "anyio" version = "4.10.0" @@ -81,6 +93,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, ] +[[package]] +name = "billiard" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/58/1546c970afcd2a2428b1bfafecf2371d8951cc34b46701bea73f4280989e/billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f", size = 155031, upload-time = "2024-09-21T13:40:22.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766, upload-time = "2024-09-21T13:40:20.188Z" }, +] + [[package]] name = "black" version = "25.1.0" @@ -142,6 +163,25 @@ filecache = [ { name = "filelock" }, ] +[[package]] +name = "celery" +version = "5.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -257,6 +297,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -337,6 +414,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/5c/3ba7d12e7a79566f97b8f954400926d7b6eb33bcdccc1315a857f200f1f1/crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5", size = 7558, upload-time = "2022-11-02T21:15:12.437Z" }, ] +[[package]] +name = "cron-descriptor" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ec/997bf9ca9392fce1cec2e25241fdd538c50bb405efd103cb1e6119296709/cron_descriptor-2.0.5.tar.gz", hash = "sha256:443ccd21a36a7fc9464a42472199cbdbc0d86b09021af1a8dd1595e4c391d85e", size = 48545, upload-time = "2025-08-26T11:10:24.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/d6/7ebad906dbe4092af6c63f85f30d15544698eb524db53bddfc6a5e010f2b/cron_descriptor-2.0.5-py3-none-any.whl", hash = "sha256:386a1d75c57410cf5cb719e08eefbea2c0c076c4a798aa6d7bf51816112fbbd1", size = 73957, upload-time = "2025-08-26T11:10:23.559Z" }, +] + [[package]] name = "cryptography" version = "45.0.6" @@ -441,6 +530,36 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/e6f607b0bad524d227f6e5aaffdb5e2b286f6ab1b4b3151134ae2303c2d6/django_allauth-65.11.1.tar.gz", hash = "sha256:e95d5234cccaf92273d315e1393cc4626cb88a19d66a1bf0e81f89f7958cfa06", size = 1915592, upload-time = "2025-08-27T18:05:05.581Z" } +[[package]] +name = "django-celery-beat" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "cron-descriptor" }, + { name = "django" }, + { name = "django-timezone-field" }, + { name = "python-crontab" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" }, +] + +[[package]] +name = "django-celery-results" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/da/70f0f3c5364735344c4bc89e53413bcaae95b4fc1de4e98a7a3b9fb70c88/django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73", size = 38351, upload-time = "2025-04-10T08:23:49.965Z" }, +] + [[package]] name = "django-cleanup" version = "9.0.0" @@ -681,6 +800,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/1a/1c15852b3002929ed08992aeaaea703c43a43345dc19a09fd457593f52a6/django_tailwind_cli-4.3.0-py3-none-any.whl", hash = "sha256:0ff7d7374a390e63cba77894a13de2bf8721320a5bad97361cb14e160cc824b5", size = 29704, upload-time = "2025-07-12T20:33:00.242Z" }, ] +[[package]] +name = "django-timezone-field" +version = "7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/5b/0dbe271fef3c2274b83dbcb1b19fa3dacf1f7e542382819294644e78ea8b/django_timezone_field-7.1.tar.gz", hash = "sha256:b3ef409d88a2718b566fabe10ea996f2838bc72b22d3a2900c0aa905c761380c", size = 13727, upload-time = "2025-01-11T17:49:54.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/09/7a808392a751a24ffa62bec00e3085a9c1a151d728c323a5bab229ea0e58/django_timezone_field-7.1-py3-none-any.whl", hash = "sha256:93914713ed882f5bccda080eda388f7006349f25930b6122e9b07bf8db49c4b4", size = 13177, upload-time = "2025-01-11T17:49:52.142Z" }, +] + [[package]] name = "django-typer" version = "3.2.2" @@ -1058,6 +1189,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, ] +[[package]] +name = "kombu" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -1348,6 +1494,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/f1/fb218aebd29bca5c506230201c346881ae9b43de7bbb21a68dc648e972b3/poetry_core-2.1.3-py3-none-any.whl", hash = "sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771", size = 332607, upload-time = "2025-05-04T12:43:09.814Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "psutil" version = "7.0.0" @@ -1559,6 +1717,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/96/5f8a4545d783674f3de33f0ebc4db16cc76ce77a4c404d284f43f09125e3/pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2", size = 16618, upload-time = "2025-01-31T11:06:08.075Z" }, ] +[[package]] +name = "python-crontab" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-decouple" version = "3.8" @@ -1961,6 +2140,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "black" }, + { name = "celery" }, { name = "channels" }, { name = "channels-redis" }, { name = "coverage" }, @@ -1969,6 +2149,8 @@ dependencies = [ { name = "dj-rest-auth" }, { name = "django" }, { name = "django-allauth" }, + { name = "django-celery-beat" }, + { name = "django-celery-results" }, { name = "django-cleanup" }, { name = "django-cloudflare-images" }, { name = "django-cors-headers" }, @@ -2027,6 +2209,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", specifier = ">=24.1.0" }, + { name = "celery", specifier = ">=5.5.3" }, { name = "channels", specifier = ">=4.2.0" }, { name = "channels-redis", specifier = ">=4.2.1" }, { name = "coverage", specifier = ">=7.9.1" }, @@ -2035,6 +2218,8 @@ requires-dist = [ { name = "dj-rest-auth", specifier = ">=7.0.0" }, { name = "django", specifier = ">=5.0" }, { name = "django-allauth", specifier = ">=0.60.1" }, + { name = "django-celery-beat", specifier = ">=2.8.1" }, + { name = "django-celery-results", specifier = ">=2.6.0" }, { name = "django-cleanup", specifier = ">=8.0.0" }, { name = "django-cloudflare-images", specifier = ">=0.6.0" }, { name = "django-cors-headers", specifier = ">=4.3.1" }, @@ -2200,6 +2385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + [[package]] name = "virtualenv" version = "20.32.0" @@ -2214,6 +2408,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, ] +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + [[package]] name = "werkzeug" version = "3.1.3" diff --git a/cline_docs/activeContext.md b/cline_docs/activeContext.md index 9b23eac7..3e91695c 100644 --- a/cline_docs/activeContext.md +++ b/cline_docs/activeContext.md @@ -7,6 +7,11 @@ c# Active Context - **COMPLETED: Enhanced Stats API Endpoint**: Successfully updated `/api/v1/stats/` endpoint with comprehensive platform statistics - **COMPLETED: Maps API Implementation**: Successfully implemented all map endpoints with full functionality - **COMPLETED: Comprehensive Rides Filtering System**: Successfully implemented comprehensive filtering capabilities for rides API with 25+ filter parameters and enhanced filter options endpoint +- **COMPLETED: New Content API Field Updates**: Successfully updated the "newly_opened" API response to replace "location" field with "park" and "date_opened" fields +- **COMPLETED: Celery Integration for Trending Content**: Successfully implemented Celery asynchronous task processing for trending content calculations with Redis backend +- **COMPLETED: Manual Trigger Endpoint for Trending Content**: Successfully implemented admin-only POST endpoint to manually trigger trending content calculations +- **COMPLETED: URL Fields in Trending and New Content Endpoints**: Successfully added url fields to all trending and new content API responses for frontend navigation +- **COMPLETED: Park URL Optimization**: Successfully optimized park URL usage to use `ride.park.url` instead of redundant `ride.park_url` field for better data consistency - **Features Implemented**: - **RideModel API Directory Structure**: Moved files from `backend/apps/api/v1/ride_models/` to `backend/apps/api/v1/rides/manufacturers/` to match nested URL organization - **RideModel API Reorganization**: Nested endpoints under rides/manufacturers, manufacturer-scoped slugs, integrated with ride creation/editing, removed top-level endpoint @@ -14,6 +19,8 @@ c# Active Context - **Stats API**: Entity counts, photo counts, category breakdowns, status breakdowns, review counts, automatic cache invalidation, caching, public access, OpenAPI documentation - **Maps API**: Location retrieval, bounds filtering, text search, location details, clustering support, caching, comprehensive serializers, OpenAPI documentation - **Comprehensive Rides Filtering**: 25+ filter parameters, enhanced filter options endpoint, roller coaster specific filters, range filters, boolean filters, multiple value support, comprehensive ordering options + - **Celery Integration**: Asynchronous trending content calculation, Redis broker configuration, real database-driven responses replacing mock data + - **Manual Trigger Endpoint**: Admin-only POST /api/v1/trending/calculate/ endpoint with task ID responses and proper error handling ## Recent Changes **RideModel API Directory Structure Reorganization - COMPLETED:** @@ -97,6 +104,33 @@ c# Active Context - **Error Handling**: Graceful handling of invalid filter values with try/catch blocks - **Multiple Value Support**: Categories and statuses support multiple values via getlist() +**Celery Integration for Trending Content - COMPLETED:** +- **Implemented**: Complete Celery integration for asynchronous trending content calculations +- **Files Created/Modified**: + - `backend/config/celery.py` - Celery configuration with Redis broker and result backend + - `backend/thrillwiki/celery.py` - Celery app initialization and autodiscovery + - `backend/apps/core/tasks/__init__.py` - Tasks package initialization + - `backend/apps/core/tasks/trending.py` - Celery tasks for trending and new content calculation + - `backend/apps/core/services/trending_service.py` - Updated to use Celery tasks and return proper field structure + - `backend/apps/api/v1/views/trending.py` - Removed mock data, integrated with Celery-powered service +- **Database Migrations**: Applied Celery database tables successfully +- **Field Structure Updates**: Updated "newly_opened" response to include "park" and "date_opened" fields instead of "location" +- **Mock Data Removal**: Completely removed all mock data from trending endpoints, now using real database queries +- **Redis Integration**: Configured Redis as Celery broker and result backend for task processing +- **Task Processing**: Asynchronous calculation of trending content with proper caching and performance optimization + +**Manual Trigger Endpoint for Trending Content - COMPLETED:** +- **Implemented**: Admin-only POST endpoint to manually trigger trending content calculations +- **Files Modified**: + - `backend/apps/api/v1/views/trending.py` - Added TriggerTrendingCalculationAPIView with admin permissions + - `backend/apps/api/v1/urls.py` - Added URL routing for manual trigger endpoint + - `backend/apps/api/v1/views/__init__.py` - Added new view to exports + - `docs/frontend.md` - Updated with comprehensive endpoint documentation +- **Endpoint**: POST `/api/v1/trending/calculate/` - Triggers both trending and new content calculation tasks +- **Permissions**: Admin-only access (IsAdminUser permission class) +- **Response**: Returns task IDs and estimated completion times for both triggered tasks +- **Error Handling**: Proper error responses for failed task triggers and unauthorized access + **Technical Implementation:** - **Stats Endpoint**: GET `/api/v1/stats/` - Returns comprehensive platform statistics - **Maps Endpoints**: @@ -144,6 +178,17 @@ c# Active Context - `backend/apps/api/v1/serializers/maps.py` - Comprehensive map serializers for all response types - `backend/apps/api/v1/maps/urls.py` - Map URL routing configuration +### Celery Integration Files +- `backend/config/celery.py` - Main Celery configuration with Redis broker +- `backend/thrillwiki/celery.py` - Celery app initialization and task autodiscovery +- `backend/apps/core/tasks/__init__.py` - Tasks package initialization +- `backend/apps/core/tasks/trending.py` - Trending content calculation tasks +- `backend/apps/core/services/trending_service.py` - Updated service using Celery tasks +- `backend/apps/api/v1/views/trending.py` - Updated views without mock data, includes manual trigger endpoint +- `backend/apps/api/v1/urls.py` - Updated with manual trigger endpoint routing +- `backend/apps/api/v1/views/__init__.py` - Updated exports for new trigger view +- `docs/frontend.md` - Updated with manual trigger endpoint documentation + ## Permanent Rules Established **CREATED**: `cline_docs/permanent_rules.md` - Permanent development rules that must be followed in all future work. @@ -228,6 +273,15 @@ c# Active Context - **Performance**: Cached responses for optimal performance (5-minute cache) - **Access**: Public endpoints, no authentication required (except photo uploads) - **Documentation**: Full OpenAPI documentation available +- **Celery Integration**: โ Successfully implemented and tested + - **Configuration**: Redis broker configured and working + - **Tasks**: Trending content calculation tasks implemented + - **Database**: Celery tables created via migrations + - **API Response**: "newly_opened" now returns correct structure with "park" and "date_opened" fields + - **Mock Data**: Completely removed from all trending endpoints + - **Real Data**: All responses now use actual database queries + - **Manual Trigger**: POST `/api/v1/trending/calculate/` endpoint implemented with admin permissions + - **Task Management**: Returns task IDs for monitoring asynchronous calculations ## Sample Response ```json diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 00000000..7acf591a --- /dev/null +++ b/cookies.txt @@ -0,0 +1,6 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 1757625948 sessionid 76lmsjx6m9rkatknfi3w70yam2lw3rru +localhost FALSE / FALSE 1787865948 csrftoken b3mRLXY7YHQnE2x6LewKk5VVHZTieRFk diff --git a/docs/frontend.md b/docs/frontend.md index 0709e5a0..b93dcc7c 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -1,4533 +1,420 @@ # ThrillWiki Frontend API Documentation -**Last Updated:** January 28, 2025 - -This document provides comprehensive documentation for frontend developers on how to use the ThrillWiki API endpoints and features, including ALL possible responses and endpoints available in the system. +This document provides comprehensive documentation for frontend developers on how to integrate with the ThrillWiki API endpoints. ## Base URL -All API endpoints are prefixed with `/api/v1/` unless otherwise specified. +``` +http://localhost:8000/api/v1/ +``` ## Authentication -Most endpoints require authentication via Bearer token in the Authorization header: -``` -Authorization: Bearer your_token_here -``` +Most endpoints are publicly accessible. Admin endpoints require authentication. -## Content Types -All POST/PUT/PATCH requests should use `Content-Type: application/json` unless uploading files (use `multipart/form-data`). +## Content Discovery Endpoints -## Rate Limiting -- Authenticated users: 1000 requests per hour -- Anonymous users: 100 requests per hour -- Rate limit headers are included in all responses: - - `X-RateLimit-Limit`: Maximum requests allowed - - `X-RateLimit-Remaining`: Requests remaining in current window - - `X-RateLimit-Reset`: Unix timestamp when limit resets +### Trending Content +Get trending parks and rides based on view counts, ratings, and recency. -## Table of Contents +**Endpoint:** `GET /trending/content/` -1. [Authentication API](#authentication-api) -2. [Parks API](#parks-api) -3. [Comprehensive Rides Filtering API](#comprehensive-rides-filtering-api) -4. [Ride Models API](#ride-models-api) -5. [Roller Coaster Statistics API](#roller-coaster-statistics-api) -6. [Maps API](#maps-api) -7. [User Accounts API](#user-accounts-api) -8. [Rankings API](#rankings-api) -9. [Trending & New Content API](#trending--new-content-api) -10. [Stats API](#stats-api) -11. [Photo Management](#photo-management) -12. [Search and Autocomplete](#search-and-autocomplete) -13. [Core Entity Search API](#core-entity-search-api) -14. [Health Check API](#health-check-api) -15. [Email API](#email-api) -16. [History API](#history-api) -17. [Companies API](#companies-api) -18. [Park Areas API](#park-areas-api) -19. [Reviews API](#reviews-api) -20. [Moderation API](#moderation-api) -21. [Error Handling](#error-handling) -22. [Performance Considerations](#performance-considerations) +**Parameters:** +- `limit` (optional): Number of trending items to return (default: 20, max: 100) +- `timeframe` (optional): Timeframe for trending calculation - "day", "week", "month" (default: "week") -## Authentication API - -### Base Endpoints -``` -POST /api/v1/auth/login/ -POST /api/v1/auth/signup/ -POST /api/v1/auth/logout/ -GET /api/v1/auth/user/ -POST /api/v1/auth/password/reset/ -POST /api/v1/auth/password/change/ -GET /api/v1/auth/providers/ -GET /api/v1/auth/status/ -``` - -### Login -```javascript -// Login request -const response = await fetch('/api/v1/auth/login/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: 'user@example.com', - password: 'password123' - }) -}); - -// Success response (200) +**Response Format:** +```json { - "user": { - "id": 1, - "username": "user@example.com", - "email": "user@example.com", - "first_name": "John", - "last_name": "Doe", - "is_staff": false, - "is_superuser": false, - "date_joined": "2024-01-01T00:00:00Z" - }, - "token": "auth_token_here", - "message": "Login successful" -} - -// Error response (400/401) -{ - "detail": "Invalid credentials", - "errors": { - "non_field_errors": ["Unable to log in with provided credentials."] - } -} -``` - -### Signup -```javascript -// Signup request -const response = await fetch('/api/v1/auth/signup/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: 'newuser@example.com', - email: 'newuser@example.com', - password: 'securepassword123', - first_name: 'Jane', - last_name: 'Smith' - }) -}); - -// Success response (201) -{ - "user": { - "id": 2, - "username": "newuser@example.com", - "email": "newuser@example.com", - "first_name": "Jane", - "last_name": "Smith", - "is_staff": false, - "is_superuser": false, - "date_joined": "2024-01-28T15:30:00Z" - }, - "token": "new_auth_token_here", - "message": "Account created successfully" -} -``` - -### Current User -```javascript -// Get current user info -const response = await fetch('/api/v1/auth/user/', { - headers: { - 'Authorization': 'Bearer your_token_here' - } -}); - -// Response (200) -{ - "id": 1, - "username": "user@example.com", - "email": "user@example.com", - "first_name": "John", - "last_name": "Doe", - "is_staff": false, - "is_superuser": false, - "date_joined": "2024-01-01T00:00:00Z", - "profile": { - "bio": "Theme park enthusiast", - "location": "Ohio, USA", - "website": "https://example.com", - "avatar": "https://imagedelivery.net/account-hash/avatar123/public" - } -} -``` - -### Social Providers -```javascript -// Get available social login providers -const response = await fetch('/api/v1/auth/providers/'); - -// Response (200) -{ - "providers": [ + "trending_rides": [ { - "id": "google", - "name": "Google", - "login_url": "/auth/google/login/", - "enabled": true - }, - { - "id": "facebook", - "name": "Facebook", - "login_url": "/auth/facebook/login/", - "enabled": true - } - ] -} -``` - -### Auth Status -```javascript -// Check authentication status -const response = await fetch('/api/v1/auth/status/'); - -// Authenticated response (200) -{ - "authenticated": true, - "user": { - "id": 1, - "username": "user@example.com", - "is_staff": false - } -} - -// Unauthenticated response (200) -{ - "authenticated": false, - "user": null -} -``` - -## Parks API - -### Base Endpoints -``` -GET /api/v1/parks/ -POST /api/v1/parks/ -GET /api/v1/parks/{id}/ -PUT /api/v1/parks/{id}/ -PATCH /api/v1/parks/{id}/ -DELETE /api/v1/parks/{id}/ -GET /api/v1/parks/filter-options/ -GET /api/v1/parks/search/companies/ -GET /api/v1/parks/search-suggestions/ -PATCH /api/v1/parks/{id}/image-settings/ -GET /api/v1/parks/{park_pk}/photos/ -POST /api/v1/parks/{park_pk}/photos/ -GET /api/v1/parks/{park_pk}/photos/{id}/ -PUT /api/v1/parks/{park_pk}/photos/{id}/ -PATCH /api/v1/parks/{park_pk}/photos/{id}/ -DELETE /api/v1/parks/{park_pk}/photos/{id}/ -``` - -### List Parks -```javascript -// Get parks with filtering -const response = await fetch('/api/v1/parks/?search=cedar&status=OPERATING&min_rating=4.0&ordering=-average_rating'); - -// Response (200) -{ - "count": 25, - "next": "http://localhost:8000/api/v1/parks/?page=2", - "previous": null, - "results": [ - { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point", - "status": "OPERATING", - "description": "America's Roller Coast", - "average_rating": 4.5, - "coaster_count": 17, - "ride_count": 70, - "location": { - "city": "Sandusky", - "state": "Ohio", - "country": "United States", - "latitude": 41.4793, - "longitude": -82.6833 - }, - "operator": { - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair" - }, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" - } - ] -} -``` - -### Park Detail -```javascript -// Get detailed park information -const response = await fetch('/api/v1/parks/1/'); - -// Response (200) -{ - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point", - "status": "OPERATING", - "description": "America's Roller Coast featuring world-class roller coasters", - "opening_date": "1870-01-01", - "closing_date": null, - "operating_season": "May - October", - "size_acres": 364.0, - "website": "https://cedarpoint.com", - "average_rating": 4.5, - "coaster_count": 17, - "ride_count": 70, - "location": { - "latitude": 41.4793, - "longitude": -82.6833, - "address": "1 Cedar Point Dr", - "city": "Sandusky", - "state": "Ohio", - "country": "United States", - "postal_code": "44870", - "formatted_address": "1 Cedar Point Dr, Sandusky, OH 44870, United States" - }, - "operator": { - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair" - }, - "property_owner": { - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair" - }, - "areas": [ - { - "id": 1, - "name": "Frontier Town", - "slug": "frontier-town", - "description": "Wild West themed area" - }, - { - "id": 2, - "name": "Millennium Island", - "slug": "millennium-island", - "description": "Home to Millennium Force" - } - ], - "photos": [ - { - "id": 456, - "image_url": "https://imagedelivery.net/account-hash/def789ghi012/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail", - "medium": "https://imagedelivery.net/account-hash/def789ghi012/medium", - "large": "https://imagedelivery.net/account-hash/def789ghi012/large", - "public": "https://imagedelivery.net/account-hash/def789ghi012/public" - }, - "caption": "Beautiful park entrance", - "alt_text": "Cedar Point main entrance with flags", - "is_primary": true - } - ], - "primary_photo": { - "id": 456, - "image_url": "https://imagedelivery.net/account-hash/def789ghi012/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail", - "medium": "https://imagedelivery.net/account-hash/def789ghi012/medium", - "large": "https://imagedelivery.net/account-hash/def789ghi012/large", - "public": "https://imagedelivery.net/account-hash/def789ghi012/public" - }, - "caption": "Beautiful park entrance", - "alt_text": "Cedar Point main entrance with flags" - }, - "banner_image": { - "id": 456, - "image_url": "https://imagedelivery.net/account-hash/def789ghi012/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail", - "medium": "https://imagedelivery.net/account-hash/def789ghi012/medium", - "large": "https://imagedelivery.net/account-hash/def789ghi012/large", - "public": "https://imagedelivery.net/account-hash/def789ghi012/public" - }, - "caption": "Beautiful park entrance", - "alt_text": "Cedar Point main entrance with flags" - }, - "card_image": { - "id": 456, - "image_url": "https://imagedelivery.net/account-hash/def789ghi012/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail", - "medium": "https://imagedelivery.net/account-hash/def789ghi012/medium", - "large": "https://imagedelivery.net/account-hash/def789ghi012/large", - "public": "https://imagedelivery.net/account-hash/def789ghi012/public" - }, - "caption": "Beautiful park entrance", - "alt_text": "Cedar Point main entrance with flags" - }, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Create Park -```javascript -// Create a new park -const response = await fetch('/api/v1/parks/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - name: "New Theme Park", - description: "An exciting new theme park", - status: "OPERATING", - opening_date: "2024-05-01", - size_acres: 150.0, - website: "https://newthemepark.com", - operator_id: 1 - }) -}); - -// Success response (201) -{ - "id": 25, - "name": "New Theme Park", - "slug": "new-theme-park", - "status": "OPERATING", - "description": "An exciting new theme park", - "opening_date": "2024-05-01", - "size_acres": 150.0, - "website": "https://newthemepark.com", - "operator": { - "id": 1, - "name": "Theme Park Operators Inc", - "slug": "theme-park-operators-inc" - }, - "created_at": "2024-01-28T15:30:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Park Filter Options -```javascript -// Get available filter options for parks -const response = await fetch('/api/v1/parks/filter-options/'); - -// Response (200) -{ - "statuses": [ - ["OPERATING", "Operating"], - ["CLOSED_TEMP", "Temporarily Closed"], - ["CLOSED_PERM", "Permanently Closed"], - ["UNDER_CONSTRUCTION", "Under Construction"], - ["PLANNED", "Planned"] - ], - "countries": [ - "United States", - "Canada", - "United Kingdom", - "Germany", - "Japan" - ], - "operators": [ - { - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair", - "park_count": 12 - }, - { - "id": 2, - "name": "Six Flags", - "slug": "six-flags", - "park_count": 27 - } - ], - "ordering_options": [ - {"value": "name", "label": "Name (A-Z)"}, - {"value": "-name", "label": "Name (Z-A)"}, - {"value": "opening_date", "label": "Opening Date (Oldest First)"}, - {"value": "-opening_date", "label": "Opening Date (Newest First)"}, - {"value": "average_rating", "label": "Rating (Lowest First)"}, - {"value": "-average_rating", "label": "Rating (Highest First)"}, - {"value": "coaster_count", "label": "Coaster Count (Fewest First)"}, - {"value": "-coaster_count", "label": "Coaster Count (Most First)"} - ], - "filter_ranges": { - "rating": {"min": 1, "max": 10, "step": 0.1}, - "size_acres": {"min": 0, "max": 1000, "step": 10, "unit": "acres"} - } -} -``` - -## Comprehensive Rides Filtering API - -### Base Endpoints -``` -GET /api/v1/rides/ -POST /api/v1/rides/ -GET /api/v1/rides/{id}/ -PUT /api/v1/rides/{id}/ -PATCH /api/v1/rides/{id}/ -DELETE /api/v1/rides/{id}/ -GET /api/v1/rides/filter-options/ -GET /api/v1/rides/search/companies/ -GET /api/v1/rides/search/ride-models/ -GET /api/v1/rides/search-suggestions/ -PATCH /api/v1/rides/{id}/image-settings/ -GET /api/v1/rides/{ride_pk}/photos/ -POST /api/v1/rides/{ride_pk}/photos/ -``` - -### List Rides with Comprehensive Filtering -The rides API supports 25+ comprehensive filter parameters for complex queries: - -```javascript -// Complex ride filtering example -const params = new URLSearchParams({ - search: 'steel vengeance', - category: 'RC', - status: 'OPERATING', - park_slug: 'cedar-point', - manufacturer_slug: 'rocky-mountain-construction', - min_rating: '8.0', - min_height_ft: '200', - min_speed_mph: '70', - has_inversions: 'true', - track_material: 'HYBRID', - roller_coaster_type: 'SITDOWN', - launch_type: 'CHAIN', - min_opening_year: '2015', - max_opening_year: '2023', - ordering: '-average_rating' -}); - -const response = await fetch(`/api/v1/rides/?${params.toString()}`); - -// Response (200) -{ - "count": 1, - "next": null, - "previous": null, - "results": [ - { - "id": 1, + "id": 137, "name": "Steel Vengeance", - "slug": "steel-vengeance", - "category": "RC", - "status": "OPERATING", - "description": "Hybrid roller coaster featuring RMC I-Box track", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "average_rating": 4.8, - "capacity_per_hour": 1200, - "opening_date": "2018-05-05", - "closing_date": null, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" - } - ] -} -``` - -### Available Filter Parameters -```javascript -const filterParams = { - // Text search - search: 'steel vengeance', - - // Category filters (multiple allowed) - category: ['RC', 'DR'], // RC, DR, FR, WR, TR, OT - - // Status filters (multiple allowed) - status: ['OPERATING', 'CLOSED_TEMP'], // OPERATING, CLOSED_TEMP, SBNO, CLOSING, CLOSED_PERM, UNDER_CONSTRUCTION, DEMOLISHED, RELOCATED - - // Park filters - park_id: 1, - park_slug: 'cedar-point', - - // Company filters - manufacturer_id: 1, - manufacturer_slug: 'bolliger-mabillard', - designer_id: 2, - designer_slug: 'rocky-mountain-construction', - - // Ride model filters - ride_model_id: 5, - ride_model_slug: 'dive-coaster', // requires manufacturer_slug - - // Rating filters - min_rating: 8.5, - max_rating: 9.0, - - // Height requirement filters - min_height_requirement: 48, // inches - max_height_requirement: 54, - - // Capacity filters - min_capacity: 1000, // riders per hour - max_capacity: 2000, - - // Date filters - opening_year: 2020, - min_opening_year: 2015, - max_opening_year: 2023, - - // Roller coaster specific filters - roller_coaster_type: 'INVERTED', // SITDOWN, INVERTED, FLYING, STANDUP, WING, DIVE, FAMILY, WILD_MOUSE, SPINNING, FOURTH_DIMENSION, OTHER - track_material: 'STEEL', // STEEL, WOOD, HYBRID - launch_type: 'LSM', // CHAIN, LSM, HYDRAULIC, GRAVITY, OTHER - - // Physical specification filters - min_height_ft: 200, - max_height_ft: 400, - min_speed_mph: 60, - max_speed_mph: 120, - min_inversions: 3, - max_inversions: 8, - has_inversions: true, // boolean filter - - // Pagination - page: 2, - page_size: 50, // max 1000 - - // Ordering - ordering: '-average_rating' // name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph -}; -``` - -### Ride Detail -```javascript -// Get detailed ride information -const response = await fetch('/api/v1/rides/1/'); - -// Response (200) -{ - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "category": "RC", - "status": "OPERATING", - "post_closing_status": null, - "description": "Hybrid roller coaster featuring RMC I-Box track", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "park_area": { - "id": 1, - "name": "Frontier Town", - "slug": "frontier-town" - }, - "opening_date": "2018-05-05", - "closing_date": null, - "status_since": "2018-05-05", - "min_height_in": 48, - "max_height_in": null, - "capacity_per_hour": 1200, - "ride_duration_seconds": 150, - "average_rating": 4.8, - "manufacturer": { - "id": 1, - "name": "Rocky Mountain Construction", - "slug": "rocky-mountain-construction" - }, - "designer": { - "id": 1, - "name": "Rocky Mountain Construction", - "slug": "rocky-mountain-construction" - }, - "ride_model": { - "id": 5, - "name": "I-Box Track Hybrid Coaster", - "description": "Steel track on wooden structure", - "category": "RC", - "manufacturer": { - "id": 1, - "name": "Rocky Mountain Construction", - "slug": "rocky-mountain-construction" - } - }, - "photos": [ - { - "id": 123, - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail", - "medium": "https://imagedelivery.net/account-hash/abc123def456/medium", - "large": "https://imagedelivery.net/account-hash/abc123def456/large", - "public": "https://imagedelivery.net/account-hash/abc123def456/public" - }, - "caption": "Amazing roller coaster photo", - "alt_text": "Steel Vengeance racing through the structure", - "is_primary": true, - "photo_type": "exterior" - } - ], - "primary_photo": { - "id": 123, - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail", - "medium": "https://imagedelivery.net/account-hash/abc123def456/medium", - "large": "https://imagedelivery.net/account-hash/abc123def456/large", - "public": "https://imagedelivery.net/account-hash/abc123def456/public" - }, - "caption": "Amazing roller coaster photo", - "alt_text": "Steel Vengeance racing through the structure", - "photo_type": "exterior" - }, - "banner_image": { - "id": 123, - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail", - "medium": "https://imagedelivery.net/account-hash/abc123def456/medium", - "large": "https://imagedelivery.net/account-hash/abc123def456/large", - "public": "https://imagedelivery.net/account-hash/abc123def456/public" - }, - "caption": "Amazing roller coaster photo", - "alt_text": "Steel Vengeance racing through the structure", - "photo_type": "exterior" - }, - "card_image": { - "id": 123, - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail", - "medium": "https://imagedelivery.net/account-hash/abc123def456/medium", - "large": "https://imagedelivery.net/account-hash/abc123def456/large", - "public": "https://imagedelivery.net/account-hash/abc123def456/public" - }, - "caption": "Amazing roller coaster photo", - "alt_text": "Steel Vengeance racing through the structure", - "photo_type": "exterior", - "is_fallback": false - }, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Filter Options Endpoint -```javascript -// Get comprehensive filter metadata -const response = await fetch('/api/v1/rides/filter-options/'); - -// Response (200) -{ - "categories": [ - ["RC", "Roller Coaster"], - ["DR", "Dark Ride"], - ["FR", "Flat Ride"], - ["WR", "Water Ride"], - ["TR", "Transport"], - ["OT", "Other"] - ], - "statuses": [ - ["OPERATING", "Operating"], - ["CLOSED_TEMP", "Temporarily Closed"], - ["SBNO", "Standing But Not Operating"], - ["CLOSING", "Closing Soon"], - ["CLOSED_PERM", "Permanently Closed"], - ["UNDER_CONSTRUCTION", "Under Construction"], - ["DEMOLISHED", "Demolished"], - ["RELOCATED", "Relocated"] - ], - "roller_coaster_types": [ - ["SITDOWN", "Sit Down"], - ["INVERTED", "Inverted"], - ["FLYING", "Flying"], - ["STANDUP", "Stand Up"], - ["WING", "Wing"], - ["DIVE", "Dive"], - ["FAMILY", "Family"], - ["WILD_MOUSE", "Wild Mouse"], - ["SPINNING", "Spinning"], - ["FOURTH_DIMENSION", "4th Dimension"], - ["OTHER", "Other"] - ], - "track_materials": [ - ["STEEL", "Steel"], - ["WOOD", "Wood"], - ["HYBRID", "Hybrid"] - ], - "launch_types": [ - ["CHAIN", "Chain Lift"], - ["LSM", "LSM Launch"], - ["HYDRAULIC", "Hydraulic Launch"], - ["GRAVITY", "Gravity"], - ["OTHER", "Other"] - ], - "ordering_options": [ - {"value": "name", "label": "Name (A-Z)"}, - {"value": "-name", "label": "Name (Z-A)"}, - {"value": "opening_date", "label": "Opening Date (Oldest First)"}, - {"value": "-opening_date", "label": "Opening Date (Newest First)"}, - {"value": "average_rating", "label": "Rating (Lowest First)"}, - {"value": "-average_rating", "label": "Rating (Highest First)"}, - {"value": "capacity_per_hour", "label": "Capacity (Lowest First)"}, - {"value": "-capacity_per_hour", "label": "Capacity (Highest First)"}, - {"value": "created_at", "label": "Date Added (Oldest First)"}, - {"value": "-created_at", "label": "Date Added (Newest First)"}, - {"value": "height_ft", "label": "Height (Shortest First)"}, - {"value": "-height_ft", "label": "Height (Tallest First)"}, - {"value": "speed_mph", "label": "Speed (Slowest First)"}, - {"value": "-speed_mph", "label": "Speed (Fastest First)"} - ], - "filter_ranges": { - "rating": {"min": 1, "max": 10, "step": 0.1}, - "height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"}, - "capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"}, - "height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"}, - "speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"}, - "inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"}, - "opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"} - }, - "boolean_filters": [ - { - "key": "has_inversions", - "label": "Has Inversions", - "description": "Filter roller coasters with or without inversions" - } - ] -} -``` - -## Ride Models API - -### Base Endpoints -``` -GET /api/v1/rides/manufacturers/{manufacturer_slug}/ -POST /api/v1/rides/manufacturers/{manufacturer_slug}/ -GET /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/ -PUT /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/ -PATCH /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/ -DELETE /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/ -GET /api/v1/rides/manufacturers/search/ -GET /api/v1/rides/manufacturers/filter-options/ -GET /api/v1/rides/manufacturers/stats/ -GET /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/variants/ -POST /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/variants/ -GET /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/variants/{id}/ -PUT /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/variants/{id}/ -PATCH /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/variants/{id}/ -DELETE /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/variants/{id}/ -GET /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/technical-specs/ -POST /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/technical-specs/ -GET /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/technical-specs/{id}/ -PUT /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/technical-specs/{id}/ -PATCH /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/technical-specs/{id}/ -DELETE /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/technical-specs/{id}/ -GET /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/photos/ -POST /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/photos/ -GET /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/photos/{id}/ -PUT /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/photos/{id}/ -PATCH /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/photos/{id}/ -DELETE /api/v1/rides/manufacturers/{manufacturer_slug}/{ride_model_slug}/photos/{id}/ -``` - -### List Ride Models for Manufacturer -```javascript -// Get all ride models for a manufacturer -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/'); - -// Response (200) -{ - "count": 15, - "next": null, - "previous": null, - "results": [ - { - "id": 1, - "name": "Dive Coaster", - "slug": "dive-coaster", - "description": "Vertical drop roller coaster with wide trains", - "category": "RC", - "manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard" - }, - "ride_count": 12, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" - }, - { - "id": 2, - "name": "Inverted Coaster", - "slug": "inverted-coaster", - "description": "Suspended roller coaster with inversions", - "category": "RC", - "manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard" - }, - "ride_count": 45, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" - } - ] -} -``` - -### Get Specific Ride Model -```javascript -// Get detailed ride model information -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/'); - -// Response (200) -{ - "id": 1, - "name": "Dive Coaster", - "slug": "dive-coaster", - "description": "Vertical drop roller coaster with wide trains featuring a 90-degree drop", - "category": "RC", - "manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard" - }, - "ride_count": 12, - "rides": [ - { - "id": 5, - "name": "Valravn", - "slug": "valravn", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "opening_date": "2016-05-07" - }, - { - "id": 8, - "name": "Yukon Striker", - "slug": "yukon-striker", - "park": { - "id": 3, - "name": "Canada's Wonderland", - "slug": "canadas-wonderland" - }, - "opening_date": "2019-05-03" - } - ], - "specifications": { - "typical_height_range": "200-300 ft", - "typical_speed_range": "70-95 mph", - "typical_inversions": "0-3", - "train_configuration": "Wide trains with 8-10 riders per row" - }, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Create Ride Model -```javascript -// Create a new ride model for a manufacturer -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - name: "New Coaster Model", - description: "Revolutionary new coaster design", - category: "RC" - }) -}); - -// Success response (201) -{ - "id": 25, - "name": "New Coaster Model", - "slug": "new-coaster-model", - "description": "Revolutionary new coaster design", - "category": "RC", - "manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard" - }, - "ride_count": 0, - "created_at": "2024-01-28T15:30:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Update Ride Model -```javascript -// Update a ride model -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - description: "Updated description with more details about the vertical drop experience" - }) -}); - -// Success response (200) -{ - "id": 1, - "name": "Dive Coaster", - "slug": "dive-coaster", - "description": "Updated description with more details about the vertical drop experience", - "category": "RC", - "manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard" - }, - "ride_count": 12, - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Delete Ride Model -```javascript -// Delete a ride model -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/', { - method: 'DELETE', - headers: { - 'Authorization': 'Bearer your_token_here' - } -}); - -// Success response (204) -// No content returned - -// Error response if ride model has associated rides (409) -{ - "detail": "Cannot delete ride model with associated rides. Please reassign or delete associated rides first.", - "associated_rides_count": 12 -} -``` - -### Ride Model Search -```javascript -// Search ride models globally (not manufacturer-specific) -const response = await fetch('/api/v1/rides/manufacturers/search/?q=dive&category=RC'); - -// Response (200) -{ - "count": 5, - "results": [ - { - "id": 1, - "name": "Dive Coaster", - "slug": "dive-coaster", - "manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard" - }, - "ride_count": 12, - "category": "RC" - }, - { - "id": 15, - "name": "Dive Machine", - "slug": "dive-machine", - "manufacturer": { - "id": 5, - "name": "Gerstlauer", - "slug": "gerstlauer" - }, - "ride_count": 3, - "category": "RC" - } - ] -} -``` - -### Ride Model Filter Options -```javascript -// Get filter options for ride models -const response = await fetch('/api/v1/rides/manufacturers/filter-options/'); - -// Response (200) -{ - "categories": [ - ["RC", "Roller Coaster"], - ["DR", "Dark Ride"], - ["FR", "Flat Ride"], - ["WR", "Water Ride"], - ["TR", "Transport"], - ["OT", "Other"] - ], - "manufacturers": [ - { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard", - "model_count": 15 - }, - { - "id": 2, - "name": "Intamin", - "slug": "intamin", - "model_count": 25 - } - ], - "ordering_options": [ - {"value": "name", "label": "Name (A-Z)"}, - {"value": "-name", "label": "Name (Z-A)"}, - {"value": "ride_count", "label": "Ride Count (Fewest First)"}, - {"value": "-ride_count", "label": "Ride Count (Most First)"}, - {"value": "created_at", "label": "Date Added (Oldest First)"}, - {"value": "-created_at", "label": "Date Added (Newest First)"} - ] -} -``` - -### Ride Model Statistics -```javascript -// Get global ride model statistics -const response = await fetch('/api/v1/rides/manufacturers/stats/'); - -// Response (200) -{ - "total_models": 150, - "total_manufacturers": 45, - "models_by_category": { - "RC": 85, - "DR": 25, - "FR": 20, - "WR": 15, - "TR": 3, - "OT": 2 - }, - "top_manufacturers": [ - { - "id": 1, - "name": "Bolliger & Mabillard", - "model_count": 15, - "total_rides": 180 - }, - { - "id": 2, - "name": "Intamin", - "model_count": 25, - "total_rides": 220 - } - ], - "most_popular_models": [ - { - "id": 5, - "name": "Inverted Coaster", - "manufacturer": "Bolliger & Mabillard", - "ride_count": 45 - }, - { - "id": 12, - "name": "Hyper Coaster", - "manufacturer": "Intamin", - "ride_count": 38 - } - ] -} -``` - -### Ride Model Variants -```javascript -// Get variants of a ride model -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/variants/'); - -// Response (200) -{ - "count": 3, - "results": [ - { - "id": 1, - "name": "Standard Dive Coaster", - "description": "Standard 8-across seating configuration", - "specifications": { - "seating_configuration": "8-across", - "typical_height": "200-250 ft", - "typical_capacity": "1200-1400 riders/hour" - }, - "ride_count": 8, - "created_at": "2024-01-01T00:00:00Z" - }, - { - "id": 2, - "name": "Compact Dive Coaster", - "description": "Smaller footprint with 6-across seating", - "specifications": { - "seating_configuration": "6-across", - "typical_height": "150-200 ft", - "typical_capacity": "900-1100 riders/hour" - }, - "ride_count": 4, - "created_at": "2024-01-01T00:00:00Z" - } - ] -} -``` - -### Create Ride Model Variant -```javascript -// Create a new variant for a ride model -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/variants/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - name: "Mega Dive Coaster", - description: "Extra-wide 10-across seating for maximum capacity", - specifications: { - "seating_configuration": "10-across", - "typical_height": "250-300 ft", - "typical_capacity": "1600-1800 riders/hour" - } - }) -}); - -// Success response (201) -{ - "id": 3, - "name": "Mega Dive Coaster", - "description": "Extra-wide 10-across seating for maximum capacity", - "specifications": { - "seating_configuration": "10-across", - "typical_height": "250-300 ft", - "typical_capacity": "1600-1800 riders/hour" - }, - "ride_count": 0, - "created_at": "2024-01-28T15:30:00Z" -} -``` - -### Ride Model Technical Specifications -```javascript -// Get technical specifications for a ride model -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/technical-specs/'); - -// Response (200) -{ - "count": 5, - "results": [ - { - "id": 1, - "spec_type": "DIMENSIONS", - "name": "Track Length Range", - "value": "2500-4000 ft", - "unit": "feet", - "description": "Typical track length for dive coasters" - }, - { - "id": 2, - "spec_type": "PERFORMANCE", - "name": "Maximum Speed", - "value": "95", - "unit": "mph", - "description": "Top speed capability" - }, - { - "id": 3, - "spec_type": "CAPACITY", - "name": "Theoretical Hourly Capacity", - "value": "1400", - "unit": "riders/hour", - "description": "Maximum theoretical capacity under ideal conditions" - } - ] -} -``` - -### Create Technical Specification -```javascript -// Add a technical specification to a ride model -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/technical-specs/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - spec_type: "SAFETY", - name: "Block Zones", - value: "6-8", - unit: "zones", - description: "Number of block zones for safe operation" - }) -}); - -// Success response (201) -{ - "id": 6, - "spec_type": "SAFETY", - "name": "Block Zones", - "value": "6-8", - "unit": "zones", - "description": "Number of block zones for safe operation", - "created_at": "2024-01-28T15:30:00Z" -} -``` - -### Ride Model Photos -```javascript -// Get photos for a ride model -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/photos/'); - -// Response (200) -{ - "count": 8, - "results": [ - { - "id": 150, - "image_url": "https://imagedelivery.net/account-hash/model123abc/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/model123abc/thumbnail", - "medium": "https://imagedelivery.net/account-hash/model123abc/medium", - "large": "https://imagedelivery.net/account-hash/model123abc/large", - "public": "https://imagedelivery.net/account-hash/model123abc/public" - }, - "caption": "B&M Dive Coaster train design", - "alt_text": "Wide dive coaster train with 8-across seating", - "photo_type": "technical", - "is_primary": true, - "uploaded_by": { - "id": 5, - "username": "coaster_engineer" - }, - "created_at": "2024-01-15T10:00:00Z" - } - ] -} -``` - -### Upload Ride Model Photo -```javascript -// Upload a photo to a ride model -const formData = new FormData(); -formData.append('image', fileInput.files[0]); -formData.append('caption', 'Technical diagram of dive coaster mechanism'); -formData.append('alt_text', 'Detailed technical drawing showing dive coaster holding brake system'); -formData.append('photo_type', 'technical'); - -const response = await fetch('/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/photos/', { - method: 'POST', - headers: { - 'Authorization': 'Bearer your_token_here' - }, - body: formData -}); - -// Success response (201) -{ - "id": 151, - "image_url": "https://imagedelivery.net/account-hash/model456def/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/model456def/thumbnail", - "medium": "https://imagedelivery.net/account-hash/model456def/medium", - "large": "https://imagedelivery.net/account-hash/model456def/large", - "public": "https://imagedelivery.net/account-hash/model456def/public" - }, - "caption": "Technical diagram of dive coaster mechanism", - "alt_text": "Detailed technical drawing showing dive coaster holding brake system", - "photo_type": "technical", - "is_primary": false, - "is_approved": false, - "uploaded_by": { - "id": 1, - "username": "technical_user" - }, - "created_at": "2024-01-28T15:30:00Z" -} -``` - -## Roller Coaster Statistics API - -### Base Endpoints -``` -GET /api/v1/rides/{ride_id}/stats/ -POST /api/v1/rides/{ride_id}/stats/ -PUT /api/v1/rides/{ride_id}/stats/ -PATCH /api/v1/rides/{ride_id}/stats/ -DELETE /api/v1/rides/{ride_id}/stats/ -``` - -### Get Roller Coaster Statistics -```javascript -// Get detailed statistics for a roller coaster -const response = await fetch('/api/v1/rides/1/stats/'); - -// Response (200) -{ - "id": 1, - "height_ft": 205.0, - "length_ft": 5740.0, - "speed_mph": 74.0, - "inversions": 4, - "ride_time_seconds": 150, - "track_type": "I-Box Track", - "track_material": "HYBRID", - "roller_coaster_type": "SITDOWN", - "max_drop_height_ft": 200.0, - "launch_type": "CHAIN", - "train_style": "Traditional", - "trains_count": 3, - "cars_per_train": 6, - "seats_per_car": 4, - "ride": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance" - } -} -``` - -### Create/Update Roller Coaster Statistics -```javascript -// Create or update statistics for a roller coaster -const response = await fetch('/api/v1/rides/1/stats/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - height_ft: 205.0, - length_ft: 5740.0, - speed_mph: 74.0, - inversions: 4, - ride_time_seconds: 150, - track_material: "HYBRID", - roller_coaster_type: "SITDOWN", - launch_type: "CHAIN", - trains_count: 3, - cars_per_train: 6, - seats_per_car: 4 - }) -}); - -// Success response (201) -{ - "id": 1, - "height_ft": 205.0, - "length_ft": 5740.0, - "speed_mph": 74.0, - "inversions": 4, - "ride_time_seconds": 150, - "track_material": "HYBRID", - "roller_coaster_type": "SITDOWN", - "launch_type": "CHAIN", - "trains_count": 3, - "cars_per_train": 6, - "seats_per_car": 4, - "ride": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance" - } -} -``` - -## Maps API - -### Base Endpoints -``` -GET /api/v1/maps/locations/ -GET /api/v1/maps/locations/{location_type}/{location_id}/ -GET /api/v1/maps/search/ -GET /api/v1/maps/bounds/ -GET /api/v1/maps/stats/ -GET /api/v1/maps/cache/ -POST /api/v1/maps/cache/invalidate/ -``` - -### Get Map Locations -```javascript -// Get all map locations with optional filtering -const response = await fetch('/api/v1/maps/locations/?bounds=40.0,-84.0,42.0,-82.0&search=cedar'); - -// Response (200) -{ - "count": 25, - "next": null, - "previous": null, - "results": [ - { - "id": "park_1", - "type": "park", - "name": "Cedar Point", - "slug": "cedar-point", - "latitude": 41.4793, - "longitude": -82.6833, - "description": "America's Roller Coast", - "status": "OPERATING", - "coaster_count": 17, - "ride_count": 70, - "location": { - "city": "Sandusky", - "state": "Ohio", - "country": "United States" - }, - "primary_photo": { - "image_url": "https://imagedelivery.net/account-hash/def789ghi012/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail", - "medium": "https://imagedelivery.net/account-hash/def789ghi012/medium" - } - } - }, - { - "id": "ride_1", - "type": "ride", - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "latitude": 41.4801, - "longitude": -82.6825, - "description": "Hybrid roller coaster", - "category": "RC", - "status": "OPERATING", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "primary_photo": { - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail", - "medium": "https://imagedelivery.net/account-hash/abc123def456/medium" - } - } - } - ] -} -``` - -### Get Location Detail -```javascript -// Get detailed information for a specific location -const response = await fetch('/api/v1/maps/locations/park/1/'); - -// Response (200) -{ - "id": 1, - "type": "park", - "name": "Cedar Point", - "slug": "cedar-point", - "latitude": 41.4793, - "longitude": -82.6833, - "description": "America's Roller Coast featuring world-class roller coasters", - "status": "OPERATING", - "coaster_count": 17, - "ride_count": 70, - "average_rating": 4.5, - "location": { - "address": "1 Cedar Point Dr", - "city": "Sandusky", - "state": "Ohio", - "country": "United States", - "postal_code": "44870" - }, - "operator": { - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair" - }, - "photos": [ - { - "id": 456, - "image_url": "https://imagedelivery.net/account-hash/def789ghi012/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail", - "medium": "https://imagedelivery.net/account-hash/def789ghi012/medium", - "large": "https://imagedelivery.net/account-hash/def789ghi012/large" - }, - "caption": "Beautiful park entrance" - } - ], - "nearby_locations": [ - { - "id": "ride_1", - "type": "ride", - "name": "Steel Vengeance", - "distance_miles": 0.2 - } - ] -} -``` - -### Map Search -```javascript -// Search locations with text query -const response = await fetch('/api/v1/maps/search/?q=roller+coaster&page=1&page_size=20'); - -// Response (200) -{ - "count": 150, - "next": "http://localhost:8000/api/v1/maps/search/?page=2", - "previous": null, - "results": [ - { - "id": "ride_1", - "type": "ride", - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "description": "Hybrid roller coaster", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "location": { - "city": "Sandusky", - "state": "Ohio", - "country": "United States" - }, - "match_score": 0.95 - } - ] -} -``` - -### Geographic Bounds Query -```javascript -// Get locations within geographic bounds -const response = await fetch('/api/v1/maps/bounds/?bounds=40.0,-84.0,42.0,-82.0'); - -// Response (200) -{ - "bounds": { - "lat_min": 40.0, - "lng_min": -84.0, - "lat_max": 42.0, - "lng_max": -82.0 - }, - "count": 45, - "locations": [ - { - "id": "park_1", - "type": "park", - "name": "Cedar Point", - "latitude": 41.4793, - "longitude": -82.6833, - "coaster_count": 17 - } - ] -} -``` - -### Map Statistics -```javascript -// Get map service statistics -const response = await fetch('/api/v1/maps/stats/'); - -// Response (200) -{ - "total_locations": 1250, - "parks": 125, - "rides": 1125, - "countries": 45, - "cache_hit_rate": 0.85, - "last_updated": "2024-01-28T15:30:00Z" -} -``` - -## User Accounts API - -### Base Endpoints -``` -GET /api/v1/accounts/profiles/ -POST /api/v1/accounts/profiles/ -GET /api/v1/accounts/profiles/{id}/ -PUT /api/v1/accounts/profiles/{id}/ -PATCH /api/v1/accounts/profiles/{id}/ -DELETE /api/v1/accounts/profiles/{id}/ -GET /api/v1/accounts/toplists/ -POST /api/v1/accounts/toplists/ -GET /api/v1/accounts/toplists/{id}/ -PUT /api/v1/accounts/toplists/{id}/ -PATCH /api/v1/accounts/toplists/{id}/ -DELETE /api/v1/accounts/toplists/{id}/ -GET /api/v1/accounts/toplist-items/ -POST /api/v1/accounts/toplist-items/ -``` - -### User Profiles -```javascript -// Get user profile -const response = await fetch('/api/v1/accounts/profiles/1/'); - -// Response (200) -{ - "id": 1, - "user": { - "id": 1, - "username": "coaster_fan", - "first_name": "John", - "last_name": "Doe" - }, - "bio": "Theme park enthusiast and roller coaster lover", - "location": "Ohio, USA", - "website": "https://example.com", - "avatar": { - "image_url": "https://imagedelivery.net/account-hash/avatar123/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/avatar123/thumbnail", - "medium": "https://imagedelivery.net/account-hash/avatar123/medium" - } - }, - "stats": { - "parks_visited": 25, - "rides_ridden": 150, - "reviews_written": 45, - "photos_uploaded": 120 - }, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Top Lists -```javascript -// Get user's top lists -const response = await fetch('/api/v1/accounts/toplists/?user_id=1'); - -// Response (200) -{ - "count": 3, - "results": [ - { - "id": 1, - "title": "Top 10 Roller Coasters", - "description": "My favorite roller coasters of all time", - "is_public": true, - "user": { - "id": 1, - "username": "coaster_fan" - }, - "item_count": 10, - "created_at": "2024-01-15T10:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" - } - ] -} -``` - -### Top List Items -```javascript -// Get items in a top list -const response = await fetch('/api/v1/accounts/toplist-items/?toplist_id=1'); - -// Response (200) -{ - "count": 10, - "results": [ - { - "id": 1, - "position": 1, - "ride": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - } - }, - "notes": "Incredible airtime and smooth ride experience", - "created_at": "2024-01-15T10:00:00Z" - } - ] -} -``` - -## Rankings API - -### Base Endpoints -``` -GET /api/v1/rankings/ -GET /api/v1/rankings/{id}/ -POST /api/v1/rankings/calculate/ -``` - -### Get Rankings -```javascript -// Get current ride rankings -const response = await fetch('/api/v1/rankings/?category=RC&limit=50'); - -// Response (200) -{ - "count": 500, - "next": "http://localhost:8000/api/v1/rankings/?page=2", - "previous": null, - "results": [ - { - "id": 1, + "park": "Cedar Point", + "category": "ride", + "rating": 4.8, "rank": 1, - "ride": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - } - }, - "score": 9.85, - "average_rating": 4.8, - "review_count": 1250, - "category": "RC", - "last_calculated": "2024-01-28T15:30:00Z" + "views": 15234, + "views_change": "+25%", + "slug": "steel-vengeance", + "date_opened": "2018-05-05", + "url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/", + "park_url": "https://thrillwiki.com/parks/cedar-point/", + "card_image": "https://media.thrillwiki.com/rides/steel-vengeance-card.jpg" } - ] -} -``` - -### Trigger Ranking Calculation -```javascript -// Trigger recalculation of rankings -const response = await fetch('/api/v1/rankings/calculate/', { - method: 'POST', - headers: { - 'Authorization': 'Bearer your_token_here' - } -}); - -// Response (202) -{ - "message": "Ranking calculation triggered", - "task_id": "abc123def456", - "estimated_completion": "2024-01-28T16:00:00Z" -} -``` - -## Trending & New Content API - -### Base Endpoints -``` -GET /api/v1/trending/content/ -GET /api/v1/trending/new/ -``` - -### Get Trending Content -```javascript -// Get trending parks and rides -const response = await fetch('/api/v1/trending/content/?timeframe=week&limit=20'); - -// Response (200) -{ - "timeframe": "week", - "generated_at": "2024-01-28T15:30:00Z", + ], "trending_parks": [ { "id": 1, "name": "Cedar Point", - "slug": "cedar-point", - "trend_score": 95.5, - "view_count": 15000, - "review_count": 45, - "photo_count": 120, - "location": { - "city": "Sandusky", - "state": "Ohio", - "country": "United States" - } - } - ], - "trending_rides": [ - { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "trend_score": 98.2, - "view_count": 25000, - "review_count": 85, - "photo_count": 200, - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - } - } - ] -} -``` - -### Get New Content -```javascript -// Get recently added content -const response = await fetch('/api/v1/trending/new/?days=7&limit=20'); - -// Response (200) -{ - "timeframe_days": 7, - "generated_at": "2024-01-28T15:30:00Z", - "new_parks": [ - { - "id": 25, - "name": "New Theme Park", - "slug": "new-theme-park", - "created_at": "2024-01-25T10:00:00Z", - "location": { - "city": "Orlando", - "state": "Florida", - "country": "United States" - } - } - ], - "new_rides": [ - { - "id": 150, - "name": "Lightning Strike", - "slug": "lightning-strike", - "category": "RC", - "created_at": "2024-01-26T14:30:00Z", - "park": { - "id": 5, - "name": "Adventure Park", - "slug": "adventure-park" - } - } - ], - "new_photos": [ - { - "id": 500, - "caption": "Amazing sunset view", - "created_at": "2024-01-27T18:00:00Z", - "ride": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance" - } - } - ] -} -``` - -## Stats API - -### Base Endpoints -``` -GET /api/v1/stats/ -POST /api/v1/stats/recalculate/ -``` - -### Get Platform Statistics -```javascript -// Get comprehensive platform statistics -const response = await fetch('/api/v1/stats/'); - -// Response (200) -{ - "total_parks": 125, - "total_rides": 1250, - "total_manufacturers": 85, - "total_operators": 150, - "total_designers": 65, - "total_property_owners": 45, - "total_roller_coasters": 800, - "total_photos": 15000, - "total_park_photos": 5000, - "total_ride_photos": 10000, - "total_reviews": 25000, - "total_park_reviews": 8000, - "total_ride_reviews": 17000, - "operating_parks": 120, - "operating_rides": 1100, - "countries_represented": 45, - "states_represented": 50, - "average_park_rating": 4.2, - "average_ride_rating": 4.1, - "most_popular_manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "ride_count": 150 - }, - "newest_park": { - "id": 125, - "name": "Latest Theme Park", - "opening_date": "2024-01-15" - }, - "newest_ride": { - "id": 1250, - "name": "Latest Coaster", - "opening_date": "2024-01-20" - }, - "last_updated": "2024-01-28T15:30:00Z", - "cache_expires_at": "2024-01-28T16:30:00Z" -} -``` - -### Recalculate Statistics -```javascript -// Trigger statistics recalculation -const response = await fetch('/api/v1/stats/recalculate/', { - method: 'POST', - headers: { - 'Authorization': 'Bearer your_token_here' - } -}); - -// Response (202) -{ - "message": "Statistics recalculation triggered", - "task_id": "stats_calc_abc123", - "estimated_completion": "2024-01-28T15:35:00Z" -} -``` - -## Photo Management - -### Upload Photos -```javascript -// Upload photo to a ride -const formData = new FormData(); -formData.append('image', fileInput.files[0]); -formData.append('caption', 'Amazing ride photo'); -formData.append('alt_text', 'Steel Vengeance racing through the structure'); -formData.append('photo_type', 'exterior'); - -const response = await fetch('/api/v1/rides/1/photos/', { - method: 'POST', - headers: { - 'Authorization': 'Bearer your_token_here' - }, - body: formData -}); - -// Success response (201) -{ - "id": 123, - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "image_variants": { - "thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail", - "medium": "https://imagedelivery.net/account-hash/abc123def456/medium", - "large": "https://imagedelivery.net/account-hash/abc123def456/large", - "public": "https://imagedelivery.net/account-hash/abc123def456/public" - }, - "caption": "Amazing ride photo", - "alt_text": "Steel Vengeance racing through the structure", - "photo_type": "exterior", - "is_primary": false, - "is_approved": false, - "uploaded_by": { - "id": 1, - "username": "photographer" - }, - "created_at": "2024-01-28T15:30:00Z" -} -``` - -### Set Banner and Card Images -```javascript -// Set banner and card images for a ride -const response = await fetch('/api/v1/rides/1/image-settings/', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - banner_image_id: 123, - card_image_id: 124 - }) -}); - -// Success response (200) -{ - "banner_image": { - "id": 123, - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "caption": "Amazing ride photo" - }, - "card_image": { - "id": 124, - "image_url": "https://imagedelivery.net/account-hash/def456ghi789/public", - "caption": "Another great photo" - }, - "message": "Image settings updated successfully" -} -``` - -### Photo Types -Available photo types for rides and parks: -- `exterior` - External view of the ride/park -- `interior` - Internal view (for dark rides, stations, etc.) -- `action` - Ride in motion -- `construction` - Construction/installation photos -- `aerial` - Aerial/drone photography -- `detail` - Close-up details -- `queue` - Queue line photos -- `station` - Station area -- `other` - Other types - -## Search and Autocomplete - -### Company Search -```javascript -// Search companies for autocomplete -const response = await fetch('/api/v1/rides/search/companies/?q=bolliger&role=manufacturer'); - -// Response (200) -{ - "results": [ - { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard", - "roles": ["MANUFACTURER", "DESIGNER"], - "ride_count": 150, - "park_count": 0 - } - ], - "query": "bolliger", - "count": 1 -} -``` - -### Ride Model Search -```javascript -// Search ride models for autocomplete -const response = await fetch('/api/v1/rides/search/ride-models/?q=dive&manufacturer_id=1'); - -// Response (200) -{ - "results": [ - { - "id": 1, - "name": "Dive Coaster", - "slug": "dive-coaster", - "manufacturer": { - "id": 1, - "name": "Bolliger & Mabillard", - "slug": "bolliger-mabillard" - }, - "ride_count": 12, - "category": "RC" - } - ], - "query": "dive", - "count": 1 -} -``` - -### Search Suggestions -```javascript -// Get search suggestions for ride search box -const response = await fetch('/api/v1/rides/search-suggestions/?q=steel'); - -// Response (200) -{ - "suggestions": [ - { - "type": "ride", - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", "park": "Cedar Point", - "match_type": "name" - }, - { - "type": "ride", - "id": 5, - "name": "Steel Force", - "slug": "steel-force", - "park": "Dorney Park", - "match_type": "name" - }, - { - "type": "material", - "value": "STEEL", - "label": "Steel Track Material", - "match_type": "filter" - } - ], - "query": "steel", - "count": 3 -} -``` - -## Core Entity Search API - -### Base Endpoints -``` -POST /api/v1/core/entities/search/ -POST /api/v1/core/entities/not-found/ -GET /api/v1/core/entities/suggestions/ -``` - -### Fuzzy Entity Search -```javascript -// Perform fuzzy entity search with authentication prompts -const response = await fetch('/api/v1/core/entities/search/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' // Optional - }, - body: JSON.stringify({ - query: "cedar point", - entity_types: ["park", "ride", "company"], // Optional - include_suggestions: true // Optional, default true - }) -}); - -// Success response (200) -{ - "success": true, - "query": "cedar point", - "matches": [ - { - "entity_type": "park", - "name": "Cedar Point", + "category": "park", + "rating": 4.6, + "rank": 1, + "views": 45678, + "views_change": "+12%", "slug": "cedar-point", - "score": 0.95, - "confidence": "high", - "match_reason": "Exact name match with 'Cedar Point'", - "url": "/parks/cedar-point/", - "entity_id": 1 + "date_opened": "1870-01-01", + "url": "https://thrillwiki.com/parks/cedar-point/", + "card_image": "https://media.thrillwiki.com/parks/cedar-point-card.jpg", + "city": "Sandusky", + "state": "Ohio", + "country": "USA", + "primary_company": "Cedar Fair" } ], - "suggestion": { - "suggested_name": "Cedar Point", - "entity_type": "park", - "requires_authentication": false, - "login_prompt": "Log in to suggest adding a new park", - "signup_prompt": "Sign up to contribute to ThrillWiki", - "creation_hint": "Help expand ThrillWiki by adding missing parks" - }, - "user_authenticated": true -} - -// No matches found response (200) -{ - "success": true, - "query": "nonexistent park", - "matches": [], - "suggestion": { - "suggested_name": "Nonexistent Park", - "entity_type": "park", - "requires_authentication": true, - "login_prompt": "Log in to suggest adding 'Nonexistent Park'", - "signup_prompt": "Sign up to help expand ThrillWiki", - "creation_hint": "Can't find what you're looking for? Help us add it!" - }, - "user_authenticated": false -} - -// Error response (400) -{ - "success": false, - "error": "Query must be at least 2 characters long", - "code": "INVALID_QUERY" + "latest_reviews": [] } ``` -### Entity Not Found Handler -```javascript -// Handle entity not found scenarios with context -const response = await fetch('/api/v1/core/entities/not-found/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - original_query: "steel vengance", // User's original search - attempted_slug: "steel-vengance", // Optional: slug that failed - entity_type: "ride", // Optional: hint about what they were looking for - context: { // Optional: additional context - park_slug: "cedar-point", - source_page: "park_detail" - } - }) -}); +### New Content +Get recently added parks and rides. -// Response with suggestions (200) +**Endpoint:** `GET /trending/new/` + +**Parameters:** +- `limit` (optional): Number of new items to return (default: 20, max: 100) +- `days` (optional): Number of days to look back for new content (default: 30, max: 365) + +**Response Format:** +```json { - "success": true, - "original_query": "steel vengance", - "attempted_slug": "steel-vengance", - "context": { - "park_slug": "cedar-point", - "source_page": "park_detail" - }, - "matches": [ + "recently_added": [ { - "entity_type": "ride", + "id": 137, "name": "Steel Vengeance", + "park": "Cedar Point", + "category": "ride", + "date_added": "2018-05-05", + "date_opened": "2018-05-05", "slug": "steel-vengeance", - "score": 0.92, - "confidence": "high", - "match_reason": "Similar spelling to 'Steel Vengance'", - "url": "/parks/cedar-point/rides/steel-vengeance/", - "entity_id": 1 + "url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/", + "park_url": "https://thrillwiki.com/parks/cedar-point/", + "card_image": "https://media.thrillwiki.com/rides/steel-vengeance-card.jpg" + }, + { + "id": 42, + "name": "Dollywood", + "park": "Dollywood", + "category": "park", + "date_added": "2018-05-01", + "date_opened": "1986-05-03", + "slug": "dollywood", + "url": "https://thrillwiki.com/parks/dollywood/", + "card_image": "https://media.thrillwiki.com/parks/dollywood-card.jpg", + "city": "Pigeon Forge", + "state": "Tennessee", + "country": "USA", + "primary_company": "Dollywood Company" } ], - "has_matches": true, - "suggestion": { - "suggested_name": "Steel Vengance", - "entity_type": "ride", - "requires_authentication": true, - "login_prompt": "Log in to suggest adding 'Steel Vengance'", - "signup_prompt": "Sign up to contribute ride information", - "creation_hint": "Did you mean 'Steel Vengeance'? Or help us add a new ride!" - }, - "user_authenticated": false -} -``` - -### Quick Entity Suggestions -```javascript -// Get lightweight suggestions for autocomplete -const response = await fetch('/api/v1/core/entities/suggestions/?q=steel&types=park,ride&limit=5'); - -// Response (200) -{ - "suggestions": [ + "newly_opened": [ { - "name": "Steel Vengeance", - "type": "ride", - "slug": "steel-vengeance", - "url": "/parks/cedar-point/rides/steel-vengeance/", - "score": 0.95, - "confidence": "high" - }, - { - "name": "Steel Force", - "type": "ride", - "slug": "steel-force", - "url": "/parks/dorney-park/rides/steel-force/", - "score": 0.90, - "confidence": "high" - }, - { - "name": "Steel Phantom", - "type": "ride", - "slug": "steel-phantom", - "url": "/parks/kennywood/rides/steel-phantom/", - "score": 0.85, - "confidence": "medium" + "id": 136, + "name": "Time Traveler", + "park": "Silver Dollar City", + "category": "ride", + "date_added": "2018-04-28", + "date_opened": "2018-04-28", + "slug": "time-traveler", + "url": "https://thrillwiki.com/parks/silver-dollar-city/rides/time-traveler/", + "park_url": "https://thrillwiki.com/parks/silver-dollar-city/", + "card_image": "https://media.thrillwiki.com/rides/time-traveler-card.jpg" } ], - "query": "steel", - "count": 3 + "upcoming": [] } +``` -// Error handling (200 - always returns 200 for autocomplete) +**Key Changes:** +- **REMOVED:** `location` field from all trending and new content responses +- **ADDED:** `park` field - shows the park name for both parks and rides +- **ADDED:** `date_opened` field - shows when the park/ride originally opened + +### Trigger Content Calculation (Admin Only) +Manually trigger the calculation of trending and new content. + +**Endpoint:** `POST /trending/calculate/` + +**Authentication:** Admin access required + +**Response Format:** +```json { - "suggestions": [], - "query": "x", - "error": "Query too short" + "message": "Trending content calculation completed", + "trending_completed": true, + "new_content_completed": true, + "completion_time": "2025-08-28 16:41:42", + "trending_output": "Successfully calculated 50 trending items for all", + "new_content_output": "Successfully calculated 50 new items for all" } ``` -### Entity Types -Available entity types for search: -- `park` - Theme parks and amusement parks -- `ride` - Individual rides and attractions -- `company` - Manufacturers, operators, designers +## Data Field Descriptions -### Confidence Levels -- `high` - Score >= 0.8, very likely match -- `medium` - Score 0.6-0.79, probable match -- `low` - Score 0.4-0.59, possible match -- `very_low` - Score < 0.4, unlikely match +### Common Fields +- `id`: Unique identifier for the item +- `name`: Display name of the park or ride +- `park`: Name of the park (for rides, this is the parent park; for parks, this is the park itself) +- `category`: Type of content ("park" or "ride") +- `slug`: URL-friendly identifier +- `date_opened`: ISO date string of when the park/ride originally opened (YYYY-MM-DD format) +- `url`: Frontend URL for direct navigation to the item's detail page +- `card_image`: URL to the card image for display in lists and grids (available for both parks and rides) -### Use Cases +### Park-Specific Fields +- `city`: City where the park is located (shortened format) +- `state`: State/province where the park is located (shortened format) +- `country`: Country where the park is located (shortened format) +- `primary_company`: Name of the primary operating company for the park -#### 404 Page Integration +### Ride-Specific Fields +- `park_url`: Frontend URL for the ride's parent park + +### Trending-Specific Fields +- `rating`: Average user rating (0.0 to 10.0) +- `rank`: Position in trending list (1-based) +- `views`: Current view count +- `views_change`: Percentage change in views (e.g., "+25%") + +### New Content-Specific Fields +- `date_added`: ISO date string of when the item was added to the database (YYYY-MM-DD format) + +## Implementation Notes + +### Content Categorization +The API automatically categorizes new content based on dates: +- **Recently Added**: Items added to the database in the last 30 days +- **Newly Opened**: Items that opened in the last year +- **Upcoming**: Future openings (currently empty, reserved for future use) + +### Caching +- Trending content is cached for 24 hours +- New content is cached for 30 minutes +- Use the admin trigger endpoint to force cache refresh + +### Error Handling +All endpoints return standard HTTP status codes: +- `200`: Success +- `400`: Bad request (invalid parameters) +- `403`: Forbidden (admin endpoints only) +- `500`: Internal server error + +### Rate Limiting +No rate limiting is currently implemented, but it may be added in the future. + +## Migration from Previous API Format + +If you were previously using the API with `location` fields, update your frontend code: + +**Before:** ```javascript -// When a page is not found, suggest alternatives -async function handle404(originalPath) { - const pathParts = originalPath.split('/'); - const entityType = pathParts[1]; // 'parks' or 'rides' - const slug = pathParts[pathParts.length - 1]; - - const response = await fetch('/api/v1/core/entities/not-found/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - original_query: slug.replace(/-/g, ' '), - attempted_slug: slug, - entity_type: entityType.slice(0, -1), // Remove 's' - context: { source_page: '404' } - }) - }); - - const data = await response.json(); - return data.matches; // Show suggestions to user -} +const ride = { + name: "Steel Vengeance", + location: "Cedar Point", // OLD FIELD + category: "ride" +}; ``` -#### Search Box Autocomplete +**After:** ```javascript -// Implement search autocomplete with debouncing -const searchInput = document.getElementById('search'); -let searchTimeout; - -searchInput.addEventListener('input', (e) => { - clearTimeout(searchTimeout); - const query = e.target.value.trim(); - - if (query.length < 2) { - hideAutocomplete(); - return; - } - - searchTimeout = setTimeout(async () => { - const response = await fetch( - `/api/v1/core/entities/suggestions/?q=${encodeURIComponent(query)}&limit=8` - ); - const data = await response.json(); - showAutocomplete(data.suggestions); - }, 300); -}); +const ride = { + name: "Steel Vengeance", + park: "Cedar Point", // NEW FIELD + category: "ride", + date_opened: "2018-05-05" // NEW FIELD +}; ``` -#### Smart Search Results -```javascript -// Enhanced search with fuzzy matching and suggestions -async function performSearch(query) { - const response = await fetch('/api/v1/core/entities/search/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: query, - entity_types: ['park', 'ride', 'company'], - include_suggestions: true - }) - }); - - const data = await response.json(); - - if (data.matches.length > 0) { - // Show search results - displaySearchResults(data.matches); - } else if (data.suggestion) { - // Show "not found" page with suggestions and creation prompts - displayNotFound(data.suggestion, data.user_authenticated); - } -} -``` +## Backend Architecture Changes -## Health Check API +The trending system has been migrated from Celery-based async processing to Django management commands for better reliability and simpler deployment: -### Base Endpoints -``` -GET /api/v1/health/ -GET /api/v1/health/simple/ -GET /api/v1/health/performance/ -``` +### Management Commands +- `python manage.py calculate_trending` - Calculate trending content +- `python manage.py calculate_new_content` - Calculate new content -### Comprehensive Health Check -```javascript -// Get detailed system health information -const response = await fetch('/api/v1/health/'); +### Direct Calculation +The API now uses direct calculation instead of async tasks, providing immediate results while maintaining performance through caching. -// Response (200) -{ - "status": "healthy", - "timestamp": "2024-01-28T15:30:00Z", - "version": "1.0.0", - "environment": "production", - "database": { - "status": "healthy", - "response_time_ms": 15, - "connections": { - "active": 5, - "max": 100 - } - }, - "cache": { - "status": "healthy", - "response_time_ms": 2, - "hit_rate": 0.85 - }, - "storage": { - "status": "healthy", - "cloudflare_images": "connected" - }, - "external_services": { - "maps_api": "healthy", - "email_service": "healthy" - }, - "performance": { - "avg_response_time_ms": 125, - "requests_per_minute": 450, - "error_rate": 0.001 - } -} -``` - -### Simple Health Check -```javascript -// Get basic health status -const response = await fetch('/api/v1/health/simple/'); - -// Response (200) -{ - "status": "ok", - "timestamp": "2024-01-28T15:30:00Z" -} -``` - -### Performance Metrics -```javascript -// Get detailed performance metrics -const response = await fetch('/api/v1/health/performance/'); - -// Response (200) -{ - "timestamp": "2024-01-28T15:30:00Z", - "response_times": { - "avg_ms": 125, - "p50_ms": 95, - "p95_ms": 250, - "p99_ms": 500 - }, - "throughput": { - "requests_per_second": 45.5, - "requests_per_minute": 2730 - }, - "error_rates": { - "total_error_rate": 0.001, - "4xx_error_rate": 0.0008, - "5xx_error_rate": 0.0002 - }, - "resource_usage": { - "cpu_percent": 25.5, - "memory_percent": 45.2, - "disk_usage_percent": 60.1 - } -} -``` - -## Email API - -### Base Endpoints -``` -GET /api/v1/email/ -POST /api/v1/email/send/ -GET /api/v1/email/templates/ -POST /api/v1/email/templates/ -``` - -### Send Email -```javascript -// Send email notification -const response = await fetch('/api/v1/email/send/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - to: 'user@example.com', - template: 'welcome', - context: { - username: 'John Doe', - park_name: 'Cedar Point' - } - }) -}); - -// Success response (202) -{ - "message": "Email queued for delivery", - "email_id": "email_abc123def456", - "estimated_delivery": "2024-01-28T15:32:00Z" -} -``` - -### Get Email Templates -```javascript -// Get available email templates -const response = await fetch('/api/v1/email/templates/'); - -// Response (200) -{ - "templates": [ - { - "id": "welcome", - "name": "Welcome Email", - "description": "Welcome new users to ThrillWiki", - "variables": ["username", "verification_link"] - }, - { - "id": "password_reset", - "name": "Password Reset", - "description": "Password reset instructions", - "variables": ["username", "reset_link", "expiry_time"] - }, - { - "id": "ride_notification", - "name": "New Ride Notification", - "description": "Notify users of new rides at their favorite parks", - "variables": ["username", "ride_name", "park_name", "opening_date"] - } - ] -} -``` - -## History API - -### Base Endpoints -``` -GET /api/v1/history/timeline/ -GET /api/v1/history/parks/{park_slug}/ -GET /api/v1/history/parks/{park_slug}/detail/ -GET /api/v1/history/parks/{park_slug}/rides/{ride_slug}/ -GET /api/v1/history/parks/{park_slug}/rides/{ride_slug}/detail/ -``` - -### Get Unified Timeline -```javascript -// Get unified timeline of all changes across the platform -const response = await fetch('/api/v1/history/timeline/?limit=50&days=7'); - -// Response (200) -{ - "count": 125, - "next": "http://localhost:8000/api/v1/history/timeline/?page=2", - "previous": null, - "results": [ - { - "id": 150, - "object_type": "ride", - "object_id": 25, - "object_name": "Lightning Strike", - "change_type": "CREATE", - "changed_at": "2024-01-27T14:30:00Z", - "changed_by": { - "id": 5, - "username": "contributor", - "display_name": "Theme Park Contributor" - }, - "summary": "New ride added to Adventure Park", - "park_context": { - "id": 15, - "name": "Adventure Park", - "slug": "adventure-park" - }, - "fields_changed": [ - { - "field": "name", - "old_value": null, - "new_value": "Lightning Strike" - }, - { - "field": "category", - "old_value": null, - "new_value": "RC" - } - ] - }, - { - "id": 149, - "object_type": "park", - "object_id": 10, - "object_name": "Magic Kingdom", - "change_type": "UPDATE", - "changed_at": "2024-01-27T12:15:00Z", - "changed_by": { - "id": 3, - "username": "park_editor", - "display_name": "Park Information Editor" - }, - "summary": "Updated operating hours and website", - "fields_changed": [ - { - "field": "website", - "old_value": "https://old-website.com", - "new_value": "https://new-website.com" - }, - { - "field": "operating_season", - "old_value": "Year Round", - "new_value": "March - December" - } - ] - } - ] -} -``` - -### Get Park History -```javascript -// Get change history for a specific park -const response = await fetch('/api/v1/history/parks/cedar-point/'); - -// Response (200) -{ - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "total_changes": 45, - "changes": [ - { - "id": 25, - "change_type": "UPDATE", - "changed_at": "2024-01-25T10:00:00Z", - "changed_by": { - "id": 2, - "username": "park_admin", - "display_name": "Park Administrator" - }, - "fields_changed": [ - { - "field": "coaster_count", - "old_value": 16, - "new_value": 17, - "display_name": "Roller Coaster Count" - } - ], - "change_reason": "Added new roller coaster Steel Vengeance", - "related_objects": [ - { - "type": "ride", - "id": 1, - "name": "Steel Vengeance", - "action": "created" - } - ] - } - ] -} -``` - -### Get Park History Detail -```javascript -// Get detailed park history with full context -const response = await fetch('/api/v1/history/parks/cedar-point/detail/'); - -// Response (200) +## URL Fields for Frontend Navigation + +All API responses now include dynamically generated `url` fields that provide direct links to the frontend pages for each entity. These URLs are generated based on the configured `FRONTEND_DOMAIN` setting. + +### URL Patterns +- **Parks**: `https://domain.com/parks/{park-slug}/` +- **Rides**: `https://domain.com/parks/{park-slug}/rides/{ride-slug}/` +- **Ride Models**: `https://domain.com/rides/manufacturers/{manufacturer-slug}/{model-slug}/` +- **Companies (Operators)**: `https://domain.com/parks/operators/{operator-slug}/` +- **Companies (Property Owners)**: `https://domain.com/parks/owners/{owner-slug}/` +- **Companies (Manufacturers)**: `https://domain.com/rides/manufacturers/{manufacturer-slug}/` +- **Companies (Designers)**: `https://domain.com/rides/designers/{designer-slug}/` + +### Domain Separation Rules +**CRITICAL**: Company URLs follow strict domain separation: +- **Parks Domain**: OPERATOR and PROPERTY_OWNER roles generate URLs under `/parks/` +- **Rides Domain**: MANUFACTURER and DESIGNER roles generate URLs under `/rides/` +- Companies with multiple roles use their primary role (first in the roles array) for URL generation +- URLs are auto-generated when entities are saved and stored in the database + +### Example Response with URL Fields +```json { + "id": 1, + "name": "Steel Vengeance", + "slug": "steel-vengeance", "park": { "id": 1, "name": "Cedar Point", "slug": "cedar-point", - "current_status": "OPERATING" + "url": "https://thrillwiki.com/parks/cedar-point/" }, - "history_summary": { - "total_changes": 45, - "first_recorded": "2024-01-01T00:00:00Z", - "last_updated": "2024-01-28T15:30:00Z", - "major_milestones": [ - { - "date": "2018-05-05", - "event": "Steel Vengeance opened", - "significance": "New record-breaking hybrid coaster" - }, - { - "date": "2016-05-07", - "event": "Valravn opened", - "significance": "First dive coaster at Cedar Point" - } - ] - }, - "detailed_changes": [ - { - "id": 25, - "change_type": "UPDATE", - "changed_at": "2024-01-25T10:00:00Z", - "changed_by": { - "id": 2, - "username": "park_admin", - "display_name": "Park Administrator" - }, - "fields_changed": [ - { - "field": "coaster_count", - "old_value": 16, - "new_value": 17, - "display_name": "Roller Coaster Count" - } - ], - "change_reason": "Added new roller coaster Steel Vengeance", - "related_objects": [ - { - "type": "ride", - "id": 1, - "name": "Steel Vengeance", - "action": "created" - } - ] - } - ] + "url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/", + "manufacturer": { + "id": 1, + "name": "Rocky Mountain Construction", + "slug": "rocky-mountain-construction", + "url": "https://thrillwiki.com/rides/manufacturers/rocky-mountain-construction/" + } } ``` -### Get Ride History +## Example Usage + +### Fetch Trending Content ```javascript -// Get change history for a specific ride -const response = await fetch('/api/v1/history/parks/cedar-point/rides/steel-vengeance/'); +const response = await fetch('/api/v1/trending/content/?limit=10'); +const data = await response.json(); -// Response (200) -{ - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "ride": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance" - }, - "total_changes": 12, - "changes": [ - { - "id": 35, - "change_type": "UPDATE", - "changed_at": "2024-01-20T14:00:00Z", - "changed_by": { - "id": 3, - "username": "ride_operator", - "display_name": "Ride Operations Manager" - }, - "fields_changed": [ - { - "field": "status", - "old_value": "CLOSED_TEMP", - "new_value": "OPERATING", - "display_name": "Operating Status" - } - ], - "change_reason": "Maintenance completed, ride reopened" - }, - { - "id": 30, - "change_type": "CREATE", - "changed_at": "2018-05-05T09:00:00Z", - "changed_by": { - "id": 1, - "username": "system_admin", - "display_name": "System Administrator" - }, - "fields_changed": [], - "change_reason": "Initial ride creation for opening day" - } - ] -} +// Display trending rides with clickable links +data.trending_rides.forEach(ride => { + console.log(`${ride.name} at ${ride.park} - opened ${ride.date_opened}`); + console.log(`Visit: ${ride.url}`); +}); ``` -### Get Ride History Detail +### Fetch New Content ```javascript -// Get detailed ride history with comprehensive context -const response = await fetch('/api/v1/history/parks/cedar-point/rides/steel-vengeance/detail/'); +const response = await fetch('/api/v1/trending/new/?limit=5&days=7'); +const data = await response.json(); -// Response (200) -{ - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "ride": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "current_status": "OPERATING", - "category": "RC" - }, - "history_summary": { - "total_changes": 12, - "first_recorded": "2018-05-05T09:00:00Z", - "last_updated": "2024-01-28T15:30:00Z", - "status_changes": [ - { - "date": "2018-05-05", - "from_status": null, - "to_status": "OPERATING", - "reason": "Grand opening" - }, - { - "date": "2023-10-31", - "from_status": "OPERATING", - "to_status": "CLOSED_TEMP", - "reason": "End of season maintenance" - }, - { - "date": "2024-05-01", - "from_status": "CLOSED_TEMP", - "to_status": "OPERATING", - "reason": "Season reopening" - } - ], - "major_updates": [ - { - "date": "2019-03-15", - "description": "Updated safety systems and block zones", - "impact": "Improved capacity and safety" - }, - { - "date": "2021-06-10", - "description": "Track retracking in select sections", - "impact": "Enhanced ride smoothness" - } - ] - }, - "detailed_changes": [ - { - "id": 35, - "change_type": "UPDATE", - "changed_at": "2024-01-20T14:00:00Z", - "changed_by": { - "id": 3, - "username": "ride_operator", - "display_name": "Ride Operations Manager" - }, - "fields_changed": [ - { - "field": "status", - "old_value": "CLOSED_TEMP", - "new_value": "OPERATING", - "display_name": "Operating Status" - }, - { - "field": "capacity_per_hour", - "old_value": 1200, - "new_value": 1250, - "display_name": "Hourly Capacity" - } - ], - "change_reason": "Maintenance completed, ride reopened with improved capacity", - "technical_notes": "Block zone timing optimized during maintenance period" - } - ] -} +// Display newly opened attractions +data.newly_opened.forEach(item => { + console.log(`${item.name} at ${item.park} - opened ${item.date_opened}`); +}); ``` -## Companies API - -### Base Endpoints -``` -GET /api/v1/companies/ -POST /api/v1/companies/ -GET /api/v1/companies/{id}/ -PUT /api/v1/companies/{id}/ -PATCH /api/v1/companies/{id}/ -DELETE /api/v1/companies/{id}/ -GET /api/v1/companies/search/ -GET /api/v1/companies/filter-options/ -GET /api/v1/companies/stats/ -GET /api/v1/companies/{id}/parks/ -GET /api/v1/companies/{id}/rides/ -GET /api/v1/companies/{id}/ride-models/ -``` - -### List Companies +### Admin: Trigger Calculation ```javascript -// Get companies with filtering -const response = await fetch('/api/v1/companies/?search=cedar&roles=OPERATOR&country=United States'); +const response = await fetch('/api/v1/trending/calculate/', { + method: 'POST', + headers: { + 'Authorization': 'Bearer YOUR_ADMIN_TOKEN', + 'Content-Type': 'application/json' + } +}); +const result = await response.json(); +console.log(result.message); -// Response (200) +## Reviews Endpoints + +### Latest Reviews +Get the latest reviews from both parks and rides across the platform. + +**Endpoint:** `GET /reviews/latest/` + +**Parameters:** +- `limit` (optional): Number of reviews to return (default: 20, max: 100) + +**Response Format:** +```json { "count": 15, - "next": null, - "previous": null, "results": [ { - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair", - "roles": ["OPERATOR", "PROPERTY_OWNER"], - "founded_year": 1983, - "headquarters": { - "city": "Sandusky", - "state": "Ohio", - "country": "United States" + "id": 42, + "type": "ride", + "title": "Amazing coaster experience!", + "content_snippet": "This ride was absolutely incredible. The airtime was perfect and the inversions were smooth...", + "rating": 9, + "created_at": "2025-08-28T21:30:00Z", + "user": { + "username": "coaster_fan_2024", + "display_name": "Coaster Fan", + "avatar_url": "https://media.thrillwiki.com/avatars/user123.jpg" }, - "website": "https://cedarfair.com", - "park_count": 12, - "ride_count": 0, - "ride_model_count": 0, - "description": "Leading amusement park operator in North America", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" + "subject_name": "Steel Vengeance", + "subject_slug": "steel-vengeance", + "subject_url": "/parks/cedar-point/rides/steel-vengeance/", + "park_name": "Cedar Point", + "park_slug": "cedar-point", + "park_url": "/parks/cedar-point/" + }, + { + "id": 38, + "type": "park", + "title": "Great family park", + "content_snippet": "Had a wonderful time with the family. The park was clean, staff was friendly, and there were rides for all ages...", + "rating": 8, + "created_at": "2025-08-28T20:15:00Z", + "user": { + "username": "family_fun", + "display_name": "Family Fun", + "avatar_url": "/static/images/default-avatar.png" + }, + "subject_name": "Dollywood", + "subject_slug": "dollywood", + "subject_url": "/parks/dollywood/", + "park_name": null, + "park_slug": null, + "park_url": null } ] } ``` -### Company Detail +**Field Descriptions:** +- `id`: Unique review identifier +- `type`: Review type - "park" or "ride" +- `title`: Review title/headline +- `content_snippet`: Truncated review content (max 150 characters with smart word breaking) +- `rating`: User rating from 1-10 +- `created_at`: ISO timestamp when review was created +- `user`: User information object + - `username`: User's unique username + - `display_name`: User's display name (falls back to username if not set) + - `avatar_url`: URL to user's avatar image (uses default if not set) +- `subject_name`: Name of the reviewed item (park or ride) +- `subject_slug`: URL slug of the reviewed item +- `subject_url`: Frontend URL to the reviewed item's detail page +- `park_name`: For ride reviews, the name of the parent park (null for park reviews) +- `park_slug`: For ride reviews, the slug of the parent park (null for park reviews) +- `park_url`: For ride reviews, the URL to the parent park (null for park reviews) + +**Authentication:** None required (public endpoint) + +**Example Usage:** ```javascript -// Get detailed company information -const response = await fetch('/api/v1/companies/1/'); - -// Response (200) -{ - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair", - "roles": ["OPERATOR", "PROPERTY_OWNER"], - "founded_year": 1983, - "headquarters": { - "address": "1 Cedar Point Dr", - "city": "Sandusky", - "state": "Ohio", - "country": "United States", - "postal_code": "44870" - }, - "website": "https://cedarfair.com", - "description": "Leading amusement park operator in North America with a portfolio of premier parks", - "park_count": 12, - "ride_count": 0, - "ride_model_count": 0, - "notable_parks": [ - { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point", - "coaster_count": 17 - }, - { - "id": 3, - "name": "Canada's Wonderland", - "slug": "canadas-wonderland", - "coaster_count": 17 - } - ], - "financial_info": { - "stock_symbol": "FUN", - "market_cap": "2.8B USD", - "annual_revenue": "1.4B USD" - }, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Create Company -```javascript -// Create a new company -const response = await fetch('/api/v1/companies/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - name: "New Theme Park Company", - roles: ["OPERATOR"], - founded_year: 2024, - headquarters: { - city: "Orlando", - state: "Florida", - country: "United States" - }, - website: "https://newthemeparkco.com", - description: "Innovative new theme park operator" - }) -}); - -// Success response (201) -{ - "id": 25, - "name": "New Theme Park Company", - "slug": "new-theme-park-company", - "roles": ["OPERATOR"], - "founded_year": 2024, - "headquarters": { - "city": "Orlando", - "state": "Florida", - "country": "United States" - }, - "website": "https://newthemeparkco.com", - "description": "Innovative new theme park operator", - "park_count": 0, - "ride_count": 0, - "ride_model_count": 0, - "created_at": "2024-01-28T15:30:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Company Search -```javascript -// Search companies with advanced filtering -const response = await fetch('/api/v1/companies/search/?q=intamin&roles=MANUFACTURER&min_ride_count=50'); - -// Response (200) -{ - "count": 3, - "results": [ - { - "id": 5, - "name": "Intamin", - "slug": "intamin", - "roles": ["MANUFACTURER", "DESIGNER"], - "headquarters": { - "city": "Schaan", - "country": "Liechtenstein" - }, - "ride_count": 250, - "ride_model_count": 35, - "match_score": 0.95 - } - ] -} -``` - -### Company Filter Options -```javascript -// Get filter options for companies -const response = await fetch('/api/v1/companies/filter-options/'); - -// Response (200) -{ - "roles": [ - ["MANUFACTURER", "Manufacturer"], - ["OPERATOR", "Park Operator"], - ["DESIGNER", "Ride Designer"], - ["PROPERTY_OWNER", "Property Owner"] - ], - "countries": [ - "United States", - "Germany", - "Switzerland", - "United Kingdom", - "Japan", - "Netherlands" - ], - "founded_year_range": { - "min": 1850, - "max": 2024 - }, - "ordering_options": [ - {"value": "name", "label": "Name (A-Z)"}, - {"value": "-name", "label": "Name (Z-A)"}, - {"value": "founded_year", "label": "Founded Year (Oldest First)"}, - {"value": "-founded_year", "label": "Founded Year (Newest First)"}, - {"value": "park_count", "label": "Park Count (Fewest First)"}, - {"value": "-park_count", "label": "Park Count (Most First)"}, - {"value": "ride_count", "label": "Ride Count (Fewest First)"}, - {"value": "-ride_count", "label": "Ride Count (Most First)"} - ] -} -``` - -### Company Statistics -```javascript -// Get company statistics -const response = await fetch('/api/v1/companies/stats/'); - -// Response (200) -{ - "total_companies": 150, - "by_role": { - "MANUFACTURER": 45, - "OPERATOR": 65, - "DESIGNER": 35, - "PROPERTY_OWNER": 25 - }, - "by_country": { - "United States": 45, - "Germany": 25, - "Switzerland": 15, - "United Kingdom": 12, - "Japan": 8 - }, - "top_manufacturers": [ - { - "id": 1, - "name": "Bolliger & Mabillard", - "ride_count": 180, - "ride_model_count": 15 - }, - { - "id": 5, - "name": "Intamin", - "ride_count": 250, - "ride_model_count": 35 - } - ], - "top_operators": [ - { - "id": 10, - "name": "Six Flags", - "park_count": 27, - "total_rides": 450 - }, - { - "id": 1, - "name": "Cedar Fair", - "park_count": 12, - "total_rides": 280 - } - ] -} -``` - -### Company Parks -```javascript -// Get parks operated by a company -const response = await fetch('/api/v1/companies/1/parks/'); - -// Response (200) -{ - "company": { - "id": 1, - "name": "Cedar Fair", - "slug": "cedar-fair" - }, - "count": 12, - "parks": [ - { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point", - "status": "OPERATING", - "coaster_count": 17, - "ride_count": 70, - "location": { - "city": "Sandusky", - "state": "Ohio", - "country": "United States" - } - }, - { - "id": 3, - "name": "Canada's Wonderland", - "slug": "canadas-wonderland", - "status": "OPERATING", - "coaster_count": 17, - "ride_count": 65, - "location": { - "city": "Vaughan", - "state": "Ontario", - "country": "Canada" - } - } - ] -} -``` - -### Company Rides -```javascript -// Get rides manufactured by a company -const response = await fetch('/api/v1/companies/5/rides/?role=MANUFACTURER'); - -// Response (200) -{ - "company": { - "id": 5, - "name": "Intamin", - "slug": "intamin" - }, - "role": "MANUFACTURER", - "count": 250, - "rides": [ - { - "id": 15, - "name": "Millennium Force", - "slug": "millennium-force", - "category": "RC", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "opening_date": "2000-05-13" - }, - { - "id": 25, - "name": "Top Thrill Dragster", - "slug": "top-thrill-dragster", - "category": "RC", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "opening_date": "2003-05-04" - } - ] -} -``` - -### Company Ride Models -```javascript -// Get ride models created by a manufacturer -const response = await fetch('/api/v1/companies/5/ride-models/'); - -// Response (200) -{ - "company": { - "id": 5, - "name": "Intamin", - "slug": "intamin" - }, - "count": 35, - "ride_models": [ - { - "id": 10, - "name": "Giga Coaster", - "slug": "giga-coaster", - "category": "RC", - "ride_count": 8, - "description": "Ultra-tall roller coaster over 300 feet" - }, - { - "id": 12, - "name": "Accelerator Coaster", - "slug": "accelerator-coaster", - "category": "RC", - "ride_count": 15, - "description": "Hydraulic launch coaster with rapid acceleration" - } - ] -} -``` - -## Park Areas API - -### Base Endpoints -``` -GET /api/v1/parks/{park_id}/areas/ -POST /api/v1/parks/{park_id}/areas/ -GET /api/v1/parks/{park_id}/areas/{id}/ -PUT /api/v1/parks/{park_id}/areas/{id}/ -PATCH /api/v1/parks/{park_id}/areas/{id}/ -DELETE /api/v1/parks/{park_id}/areas/{id}/ -``` - -### List Park Areas -```javascript -// Get areas within a park -const response = await fetch('/api/v1/parks/1/areas/'); - -// Response (200) -{ - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "count": 8, - "results": [ - { - "id": 1, - "name": "Frontier Town", - "slug": "frontier-town", - "description": "Wild West themed area featuring wooden coasters and western attractions", - "theme": "Wild West", - "opening_date": "1971-05-01", - "ride_count": 12, - "coaster_count": 3, - "notable_rides": [ - { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance" - }, - { - "id": 8, - "name": "Mine Ride", - "slug": "mine-ride" - } - ], - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" - }, - { - "id": 2, - "name": "Millennium Island", - "slug": "millennium-island", - "description": "Home to Millennium Force and other high-thrill attractions", - "theme": "Futuristic", - "opening_date": "2000-05-13", - "ride_count": 5, - "coaster_count": 2, - "notable_rides": [ - { - "id": 15, - "name": "Millennium Force", - "slug": "millennium-force" - } - ], - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" - } - ] -} -``` - -### Park Area Detail -```javascript -// Get detailed information about a park area -const response = await fetch('/api/v1/parks/1/areas/1/'); - -// Response (200) -{ - "id": 1, - "name": "Frontier Town", - "slug": "frontier-town", - "description": "Wild West themed area featuring wooden coasters and western attractions", - "theme": "Wild West", - "opening_date": "1971-05-01", - "closing_date": null, - "size_acres": 25.5, - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "ride_count": 12, - "coaster_count": 3, - "rides": [ - { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "category": "RC", - "status": "OPERATING" - }, - { - "id": 8, - "name": "Mine Ride", - "slug": "mine-ride", - "category": "RC", - "status": "OPERATING" - }, - { - "id": 25, - "name": "Thunder Canyon", - "slug": "thunder-canyon", - "category": "WR", - "status": "OPERATING" - } - ], - "dining_locations": [ - { - "name": "Frontier Inn", - "type": "Restaurant", - "cuisine": "American" - }, - { - "name": "Chuck Wagon", - "type": "Quick Service", - "cuisine": "BBQ" - } - ], - "shops": [ - { - "name": "Frontier Trading Post", - "type": "Gift Shop", - "specialty": "Western themed merchandise" - } - ], - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Create Park Area -```javascript -// Create a new area within a park -const response = await fetch('/api/v1/parks/1/areas/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - name: "Future World", - description: "Futuristic themed area with high-tech attractions", - theme: "Futuristic", - opening_date: "2025-05-01", - size_acres: 15.0 - }) -}); - -// Success response (201) -{ - "id": 9, - "name": "Future World", - "slug": "future-world", - "description": "Futuristic themed area with high-tech attractions", - "theme": "Futuristic", - "opening_date": "2025-05-01", - "size_acres": 15.0, - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "ride_count": 0, - "coaster_count": 0, - "created_at": "2024-01-28T15:30:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -## Reviews API - -### Base Endpoints -``` -GET /api/v1/reviews/ -POST /api/v1/reviews/ -GET /api/v1/reviews/{id}/ -PUT /api/v1/reviews/{id}/ -PATCH /api/v1/reviews/{id}/ -DELETE /api/v1/reviews/{id}/ -GET /api/v1/parks/{park_id}/reviews/ -POST /api/v1/parks/{park_id}/reviews/ -GET /api/v1/rides/{ride_id}/reviews/ -POST /api/v1/rides/{ride_id}/reviews/ -GET /api/v1/reviews/stats/ -``` - -### List Reviews -```javascript -// Get reviews with filtering -const response = await fetch('/api/v1/reviews/?content_type=ride&min_rating=4&ordering=-created_at'); - -// Response (200) -{ - "count": 150, - "next": "http://localhost:8000/api/v1/reviews/?page=2", - "previous": null, - "results": [ - { - "id": 1, - "rating": 5, - "title": "Absolutely incredible ride!", - "content": "Steel Vengeance is hands down the best roller coaster I've ever ridden. The airtime is insane and the ride is incredibly smooth for a hybrid coaster.", - "content_type": "ride", - "content_object": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - } - }, - "author": { - "id": 5, - "username": "coaster_enthusiast", - "display_name": "Coaster Enthusiast" - }, - "helpful_count": 25, - "is_verified": true, - "visit_date": "2024-01-15", - "created_at": "2024-01-16T10:30:00Z", - "updated_at": "2024-01-16T10:30:00Z" - } - ] -} -``` - -### Review Detail -```javascript -// Get detailed review information -const response = await fetch('/api/v1/reviews/1/'); - -// Response (200) -{ - "id": 1, - "rating": 5, - "title": "Absolutely incredible ride!", - "content": "Steel Vengeance is hands down the best roller coaster I've ever ridden. The airtime is insane and the ride is incredibly smooth for a hybrid coaster. The theming in Frontier Town really adds to the experience. I waited about 45 minutes but it was absolutely worth it. Can't wait to ride it again!", - "content_type": "ride", - "content_object": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance", - "category": "RC", - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - } - }, - "author": { - "id": 5, - "username": "coaster_enthusiast", - "display_name": "Coaster Enthusiast", - "avatar": { - "image_url": "https://imagedelivery.net/account-hash/avatar123/public" - } - }, - "helpful_count": 25, - "not_helpful_count": 2, - "is_verified": true, - "visit_date": "2024-01-15", - "wait_time_minutes": 45, - "ride_experience": { - "front_row": true, - "weather_conditions": "Sunny", - "crowd_level": "Moderate" - }, - "pros": [ - "Incredible airtime", - "Smooth ride experience", - "Great theming" - ], - "cons": [ - "Long wait times", - "Can be intense for some riders" - ], - "created_at": "2024-01-16T10:30:00Z", - "updated_at": "2024-01-16T10:30:00Z" -} -``` - -### Create Review -```javascript -// Create a new review for a ride -const response = await fetch('/api/v1/rides/1/reviews/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - rating: 4, - title: "Great coaster with amazing airtime", - content: "Really enjoyed this ride! The airtime hills are fantastic and the hybrid track provides a smooth experience. Only downside was the long wait time.", - visit_date: "2024-01-20", - wait_time_minutes: 60, - ride_experience: { - "front_row": false, - "weather_conditions": "Cloudy", - "crowd_level": "Busy" - }, - pros: ["Amazing airtime", "Smooth ride", "Great layout"], - cons: ["Long wait times", "Can be intimidating"] - }) -}); - -// Success response (201) -{ - "id": 25, - "rating": 4, - "title": "Great coaster with amazing airtime", - "content": "Really enjoyed this ride! The airtime hills are fantastic and the hybrid track provides a smooth experience. Only downside was the long wait time.", - "content_type": "ride", - "content_object": { - "id": 1, - "name": "Steel Vengeance", - "slug": "steel-vengeance" - }, - "author": { - "id": 1, - "username": "current_user", - "display_name": "Current User" - }, - "helpful_count": 0, - "not_helpful_count": 0, - "is_verified": false, - "visit_date": "2024-01-20", - "wait_time_minutes": 60, - "ride_experience": { - "front_row": false, - "weather_conditions": "Cloudy", - "crowd_level": "Busy" - }, - "pros": ["Amazing airtime", "Smooth ride", "Great layout"], - "cons": ["Long wait times", "Can be intimidating"], - "created_at": "2024-01-28T15:30:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### Park Reviews -```javascript -// Get reviews for a specific park -const response = await fetch('/api/v1/parks/1/reviews/?ordering=-rating'); - -// Response (200) -{ - "park": { - "id": 1, - "name": "Cedar Point", - "slug": "cedar-point" - }, - "count": 85, - "results": [ - { - "id": 15, - "rating": 5, - "title": "Best theme park in the world!", - "content": "Cedar Point truly lives up to its reputation as America's Roller Coast. The variety of coasters is incredible, from classic wooden coasters to modern steel giants. The park is well-maintained and the staff is friendly.", - "author": { - "id": 8, - "username": "theme_park_lover", - "display_name": "Theme Park Lover" - }, - "helpful_count": 42, - "is_verified": true, - "visit_date": "2024-01-10", - "created_at": "2024-01-11T16:00:00Z" - } - ] -} -``` - -### Review Statistics -```javascript -// Get review statistics -const response = await fetch('/api/v1/reviews/stats/'); - -// Response (200) -{ - "total_reviews": 25000, - "park_reviews": 8000, - "ride_reviews": 17000, - "average_rating": 4.2, - "reviews_by_rating": { - "1": 500, - "2": 1200, - "3": 3800, - "4": 8500, - "5": 11000 - }, - "reviews_this_month": 1250, - "verified_reviews": 15000, - "top_reviewed_parks": [ - { - "id": 1, - "name": "Cedar Point", - "review_count": 850, - "average_rating": 4.6 - }, - { - "id": 5, - "name": "Magic Kingdom", - "review_count": 720, - "average_rating": 4.8 - } - ], - "top_reviewed_rides": [ - { - "id": 1, - "name": "Steel Vengeance", - "review_count": 450, - "average_rating": 4.9 - }, - { - "id": 15, - "name": "Millennium Force", - "review_count": 380, - "average_rating": 4.7 - } - ] -} -``` - -## Moderation API - -### Base Endpoints -``` -GET /api/v1/moderation/queue/ -POST /api/v1/moderation/queue/ -GET /api/v1/moderation/queue/{id}/ -PATCH /api/v1/moderation/queue/{id}/ -DELETE /api/v1/moderation/queue/{id}/ -GET /api/v1/moderation/reports/ -POST /api/v1/moderation/reports/ -GET /api/v1/moderation/reports/{id}/ -PATCH /api/v1/moderation/reports/{id}/ -GET /api/v1/moderation/stats/ -GET /api/v1/moderation/actions/ -POST /api/v1/moderation/actions/ -``` - -### Moderation Queue -```javascript -// Get items in moderation queue (staff only) -const response = await fetch('/api/v1/moderation/queue/', { - headers: { - 'Authorization': 'Bearer your_staff_token_here' - } -}); - -// Response (200) -{ - "count": 25, - "next": "http://localhost:8000/api/v1/moderation/queue/?page=2", - "previous": null, - "results": [ - { - "id": 1, - "content_type": "photo", - "content_object": { - "id": 123, - "image_url": "https://imagedelivery.net/account-hash/abc123def456/public", - "caption": "Amazing ride photo", - "uploaded_by": { - "id": 5, - "username": "photographer" - } - }, - "status": "PENDING", - "priority": "NORMAL", - "submitted_at": "2024-01-28T14:00:00Z", - "submitted_by": { - "id": 5, - "username": "photographer", - "display_name": "Photographer" - }, - "flags": [ - { - "type": "INAPPROPRIATE_CONTENT", - "reason": "Photo may contain inappropriate content", - "reporter": { - "id": 8, - "username": "concerned_user" - } - } - ], - "auto_flagged": false, - "requires_review": true - }, - { - "id": 2, - "content_type": "review", - "content_object": { - "id": 25, - "title": "Terrible experience", - "content": "This ride was awful and the staff was rude...", - "rating": 1, - "author": { - "id": 12, - "username": "angry_visitor" - } - }, - "status": "PENDING", - "priority": "HIGH", - "submitted_at": "2024-01-28T13:30:00Z", - "submitted_by": { - "id": 12, - "username": "angry_visitor", - "display_name": "Angry Visitor" - }, - "flags": [ - { - "type": "SPAM", - "reason": "Multiple similar reviews from same user", - "reporter": null - } - ], - "auto_flagged": true, - "requires_review": true - } - ] -} -``` - -### Moderate Content -```javascript -// Approve or reject content in moderation queue -const response = await fetch('/api/v1/moderation/queue/1/', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_staff_token_here' - }, - body: JSON.stringify({ - status: "APPROVED", - moderator_notes: "Content reviewed and approved - no issues found", - action_taken: "APPROVE" - }) -}); - -// Success response (200) -{ - "id": 1, - "content_type": "photo", - "status": "APPROVED", - "priority": "NORMAL", - "moderator": { - "id": 2, - "username": "moderator_user", - "display_name": "Moderator User" - }, - "moderator_notes": "Content reviewed and approved - no issues found", - "action_taken": "APPROVE", - "reviewed_at": "2024-01-28T15:30:00Z", - "submitted_at": "2024-01-28T14:00:00Z" -} - -// Reject content -const rejectResponse = await fetch('/api/v1/moderation/queue/2/', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_staff_token_here' - }, - body: JSON.stringify({ - status: "REJECTED", - moderator_notes: "Content violates community guidelines - inappropriate language", - action_taken: "REJECT", - violation_type: "INAPPROPRIATE_LANGUAGE" - }) -}); - -// Rejection response (200) -{ - "id": 2, - "content_type": "review", - "status": "REJECTED", - "priority": "HIGH", - "moderator": { - "id": 2, - "username": "moderator_user", - "display_name": "Moderator User" - }, - "moderator_notes": "Content violates community guidelines - inappropriate language", - "action_taken": "REJECT", - "violation_type": "INAPPROPRIATE_LANGUAGE", - "reviewed_at": "2024-01-28T15:35:00Z", - "submitted_at": "2024-01-28T13:30:00Z" -} -``` - -### Report Content -```javascript -// Report inappropriate content -const response = await fetch('/api/v1/moderation/reports/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_token_here' - }, - body: JSON.stringify({ - content_type: "review", - content_id: 25, - report_type: "INAPPROPRIATE_CONTENT", - reason: "Review contains offensive language and personal attacks", - additional_details: "The review uses profanity and makes personal attacks against park staff" - }) -}); - -// Success response (201) -{ - "id": 15, - "content_type": "review", - "content_id": 25, - "report_type": "INAPPROPRIATE_CONTENT", - "reason": "Review contains offensive language and personal attacks", - "additional_details": "The review uses profanity and makes personal attacks against park staff", - "reporter": { - "id": 1, - "username": "current_user", - "display_name": "Current User" - }, - "status": "OPEN", - "created_at": "2024-01-28T15:30:00Z", - "updated_at": "2024-01-28T15:30:00Z" -} -``` - -### List Reports -```javascript -// Get reports (staff only) -const response = await fetch('/api/v1/moderation/reports/?status=OPEN', { - headers: { - 'Authorization': 'Bearer your_staff_token_here' - } -}); - -// Response (200) -{ - "count": 12, - "results": [ - { - "id": 15, - "content_type": "review", - "content_id": 25, - "content_object": { - "id": 25, - "title": "Terrible experience", - "content": "This ride was awful...", - "author": { - "id": 12, - "username": "angry_visitor" - } - }, - "report_type": "INAPPROPRIATE_CONTENT", - "reason": "Review contains offensive language and personal attacks", - "reporter": { - "id": 1, - "username": "concerned_user", - "display_name": "Concerned User" - }, - "status": "OPEN", - "priority": "HIGH", - "created_at": "2024-01-28T15:30:00Z", - "assigned_moderator": null - } - ] -} -``` - -### Moderation Statistics -```javascript -// Get moderation statistics (staff only) -const response = await fetch('/api/v1/moderation/stats/', { - headers: { - 'Authorization': 'Bearer your_staff_token_here' - } -}); - -// Response (200) -{ - "queue_stats": { - "total_pending": 25, - "total_approved_today": 45, - "total_rejected_today": 8, - "average_review_time_minutes": 15, - "by_content_type": { - "photo": 12, - "review": 8, - "park": 3, - "ride": 2 - }, - "by_priority": { - "LOW": 5, - "NORMAL": 15, - "HIGH": 4, - "URGENT": 1 - } - }, - "report_stats": { - "total_open_reports": 12, - "total_resolved_today": 18, - "by_report_type": { - "INAPPROPRIATE_CONTENT": 8, - "SPAM": 3, - "COPYRIGHT": 1, - "MISINFORMATION": 0 - }, - "by_status": { - "OPEN": 12, - "IN_PROGRESS": 3, - "RESOLVED": 150, - "DISMISSED": 25 - } - }, - "moderator_performance": [ - { - "moderator": { - "id": 2, - "username": "moderator_user", - "display_name": "Moderator User" - }, - "reviews_today": 25, - "average_review_time_minutes": 12, - "approval_rate": 0.85 - } - ], - "auto_moderation": { - "total_auto_flagged_today": 15, - "accuracy_rate": 0.92, - "false_positive_rate": 0.08 - } -} -``` - -### Moderation Actions -```javascript -// Get moderation action history -const response = await fetch('/api/v1/moderation/actions/?days=7', { - headers: { - 'Authorization': 'Bearer your_staff_token_here' - } -}); - -// Response (200) -{ - "count": 150, - "results": [ - { - "id": 1, - "action_type": "APPROVE", - "content_type": "photo", - "content_id": 123, - "moderator": { - "id": 2, - "username": "moderator_user", - "display_name": "Moderator User" - }, - "reason": "Content reviewed and approved - no issues found", - "created_at": "2024-01-28T15:30:00Z" - }, - { - "id": 2, - "action_type": "REJECT", - "content_type": "review", - "content_id": 25, - "moderator": { - "id": 2, - "username": "moderator_user", - "display_name": "Moderator User" - }, - "reason": "Content violates community guidelines - inappropriate language", - "violation_type": "INAPPROPRIATE_LANGUAGE", - "created_at": "2024-01-28T15:35:00Z" - } - ] -} -``` - -### Bulk Moderation Actions -```javascript -// Perform bulk moderation actions -const response = await fetch('/api/v1/moderation/actions/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your_staff_token_here' - }, - body: JSON.stringify({ - action_type: "APPROVE", - queue_item_ids: [1, 3, 5, 7], - moderator_notes: "Bulk approval of reviewed content" - }) -}); - -// Success response (201) -{ - "message": "Bulk action completed successfully", - "processed_items": 4, - "successful_actions": 4, - "failed_actions": 0, - "results": [ - { - "queue_item_id": 1, - "status": "SUCCESS", - "action": "APPROVED" - }, - { - "queue_item_id": 3, - "status": "SUCCESS", - "action": "APPROVED" - }, - { - "queue_item_id": 5, - "status": "SUCCESS", - "action": "APPROVED" - }, - { - "queue_item_id": 7, - "status": "SUCCESS", - "action": "APPROVED" - } - ] -} -``` - -### Report Types -Available report types for content: -- `INAPPROPRIATE_CONTENT` - Content contains inappropriate material -- `SPAM` - Content is spam or promotional -- `COPYRIGHT` - Content violates copyright -- `MISINFORMATION` - Content contains false information -- `HARASSMENT` - Content constitutes harassment -- `DUPLICATE` - Content is a duplicate -- `OFF_TOPIC` - Content is off-topic or irrelevant -- `OTHER` - Other issues not covered above - -### Moderation Status Types -- `PENDING` - Awaiting moderation review -- `APPROVED` - Content approved and published -- `REJECTED` - Content rejected and hidden -- `FLAGGED` - Content flagged for additional review -- `ESCALATED` - Content escalated to senior moderators - -### Priority Levels -- `LOW` - Low priority, can be reviewed when convenient -- `NORMAL` - Normal priority, standard review queue -- `HIGH` - High priority, needs prompt attention -- `URGENT` - Urgent priority, immediate attention required - -## Error Handling -- `200 OK` - Request successful -- `201 Created` - Resource created successfully -- `202 Accepted` - Request accepted for processing -- `204 No Content` - Request successful, no content to return -- `400 Bad Request` - Invalid request parameters -- `401 Unauthorized` - Authentication required -- `403 Forbidden` - Insufficient permissions -- `404 Not Found` - Resource not found -- `409 Conflict` - Resource conflict (e.g., duplicate slug) -- `422 Unprocessable Entity` - Validation errors -- `429 Too Many Requests` - Rate limit exceeded -- `500 Internal Server Error` - Server error - -### Error Response Format -```javascript -// Validation error (422) -{ - "detail": "Validation failed", - "errors": { - "name": ["This field is required."], - "opening_date": ["Date cannot be in the future."], - "min_height_in": ["Ensure this value is greater than or equal to 30."] - } -} - -// Authentication error (401) -{ - "detail": "Authentication credentials were not provided." -} - -// Permission error (403) -{ - "detail": "You do not have permission to perform this action." -} - -// Not found error (404) -{ - "detail": "Not found." -} - -// Rate limit error (429) -{ - "detail": "Request was throttled. Expected available in 60 seconds.", - "available_in": 60, - "throttle_type": "user" -} - -// Server error (500) -{ - "detail": "Internal server error", - "error_id": "error_abc123def456" -} -``` - -### Best Practices for Error Handling -```javascript -async function apiRequest(url, options = {}) { - try { - const response = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - ...options.headers - }, - ...options - }); - - // Handle different status codes - if (response.status === 429) { - const error = await response.json(); - throw new RateLimitError(error.detail, error.available_in); - } - - if (response.status === 422) { - const error = await response.json(); - throw new ValidationError(error.detail, error.errors); - } - - if (response.status === 401) { - // Redirect to login or refresh token - throw new AuthenticationError('Authentication required'); - } - - if (response.status === 403) { - throw new PermissionError('Insufficient permissions'); - } - - if (response.status === 404) { - throw new NotFoundError('Resource not found'); - } - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new APIError(error.detail || 'Request failed', response.status); - } - - return await response.json(); - } catch (error) { - if (error instanceof TypeError) { - throw new NetworkError('Network request failed'); - } - throw error; - } -} - -// Custom error classes -class APIError extends Error { - constructor(message, status) { - super(message); - this.name = 'APIError'; - this.status = status; - } -} - -class ValidationError extends APIError { - constructor(message, errors) { - super(message, 422); - this.name = 'ValidationError'; - this.errors = errors; - } -} - -class RateLimitError extends APIError { - constructor(message, retryAfter) { - super(message, 429); - this.name = 'RateLimitError'; - this.retryAfter = retryAfter; - } -} -``` - -## Performance Considerations - -### Caching Strategy -- **Stats API**: Cached for 1 hour, auto-invalidated on data changes -- **Maps API**: Cached for 5 minutes for location queries -- **Filter Options**: Cached for 30 minutes -- **Photo URLs**: Cloudflare Images provides automatic caching and CDN - -### Pagination Best Practices -```javascript -// Use appropriate page sizes -const defaultPageSize = 20; -const maxPageSize = 1000; - -// Implement infinite scroll -class InfiniteScroll { - constructor(apiEndpoint, pageSize = 20) { - this.apiEndpoint = apiEndpoint; - this.pageSize = pageSize; - this.currentPage = 1; - this.hasMore = true; - this.loading = false; - } - - async loadMore() { - if (this.loading || !this.hasMore) return; - - this.loading = true; - try { - const response = await fetch( - `${this.apiEndpoint}?page=${this.currentPage}&page_size=${this.pageSize}` - ); - const data = await response.json(); - - this.currentPage++; - this.hasMore = !!data.next; - - return data.results; - } finally { - this.loading = false; - } - } -} -``` - -### Query Optimization -```javascript -// Combine filters to reduce API calls -const optimizedQuery = { - search: 'steel', - category: 'RC', - status: 'OPERATING', - min_rating: 8.0, - ordering: '-average_rating', - page_size: 50 // Larger page size for fewer requests -}; - -// Use debouncing for search inputs -function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -const debouncedSearch = debounce(async (query) => { - const results = await apiRequest(`/api/v1/rides/?search=${encodeURIComponent(query)}`); - updateSearchResults(results); -}, 300); -``` - -### Image Optimization -```javascript -// Use appropriate image variants -const getImageUrl = (photo, size = 'medium') => { - if (!photo || !photo.image_variants) return null; +// Fetch latest 10 reviews +const response = await fetch('/api/v1/reviews/latest/?limit=10'); +const data = await response.json(); + +// Display reviews +data.results.forEach(review => { + console.log(`${review.user.display_name} rated ${review.subject_name}: ${review.rating}/10`); + console.log(`"${review.title}" - ${review.content_snippet}`); - // Choose appropriate size based on use case - const sizeMap = { - thumbnail: photo.image_variants.thumbnail, // 150x150 - medium: photo.image_variants.medium, // 500x500 - large: photo.image_variants.large, // 1000x1000 - public: photo.image_variants.public // Original size - }; - - return sizeMap[size] || photo.image_url; -}; - -// Lazy loading implementation -const lazyLoadImages = () => { - const images = document.querySelectorAll('img[data-src]'); - const imageObserver = new IntersectionObserver((entries, observer) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - const img = entry.target; - img.src = img.dataset.src; - img.classList.remove('lazy'); - observer.unobserve(img); - } - }); - }); - - images.forEach(img => imageObserver.observe(img)); -}; + if (review.type === 'ride') { + console.log(`Ride at ${review.park_name}`); + } +}); ``` -### Rate Limiting -```javascript -// Implement client-side rate limiting -class RateLimiter { - constructor(maxRequests = 100, windowMs = 60000) { - this.maxRequests = maxRequests; - this.windowMs = windowMs; - this.requests = []; - } +**Error Responses:** +- `400 Bad Request`: Invalid limit parameter +- `500 Internal Server Error`: Database or server error - canMakeRequest() { - const now = Date.now(); - this.requests = this.requests.filter(time => now - time < this.windowMs); - return this.requests.length < this.maxRequests; - } - - recordRequest() { - this.requests.push(Date.now()); - } -} - -const rateLimiter = new RateLimiter(); - -async function rateLimitedRequest(url, options) { - if (!rateLimiter.canMakeRequest()) { - throw new Error('Rate limit exceeded'); - } - - rateLimiter.recordRequest(); - return await apiRequest(url, options); -} -``` - -### Frontend Integration Examples - -#### React Hook for API Integration -```javascript -import { useState, useEffect, useCallback } from 'react'; - -export function useAPI(endpoint, options = {}) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - setLoading(true); - setError(null); - - try { - const response = await apiRequest(endpoint, options); - setData(response); - } catch (err) { - setError(err); - } finally { - setLoading(false); - } - }, [endpoint, JSON.stringify(options)]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; -} - -// Usage -function RidesList() { - const { data, loading, error } = useAPI('/api/v1/rides/', { - method: 'GET', - headers: { Authorization: 'Bearer token' } - }); - - if (loading) return