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
Loading...
; - if (error) return
Error: {error.message}
; - - return ( -
- {data?.results?.map(ride => ( -
{ride.name}
- ))} -
- ); -} -``` - -#### Vue.js Composable -```javascript -import { ref, reactive, computed } from 'vue'; - -export function useAPI(endpoint, options = {}) { - const data = ref(null); - const loading = ref(false); - const error = ref(null); - - const fetchData = async () => { - loading.value = true; - error.value = null; - - try { - const response = await apiRequest(endpoint, options); - data.value = response; - } catch (err) { - error.value = err; - } finally { - loading.value = false; - } - }; - - return { - data: computed(() => data.value), - loading: computed(() => loading.value), - error: computed(() => error.value), - fetchData - }; -} -``` - -This comprehensive documentation covers ALL available API endpoints, responses, and integration patterns for the ThrillWiki frontend. It includes detailed examples for every endpoint, error handling strategies, performance optimization techniques, and frontend framework integration examples. +**Notes:** +- Reviews are filtered to only show published reviews (`is_published=True`) +- Results are sorted by creation date (newest first) +- Content snippets are intelligently truncated at word boundaries +- Avatar URLs fall back to default avatar if user hasn't uploaded one +- The endpoint combines reviews from both parks and rides into a single chronological feed diff --git a/test_manual_trigger.py b/test_manual_trigger.py new file mode 100644 index 00000000..129f4c34 --- /dev/null +++ b/test_manual_trigger.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +Test script for the manual trending content calculation trigger endpoint. +""" + +import requests +import json +import time +from datetime import datetime + +# Configuration +BASE_URL = "http://localhost:8000" +ADMIN_USERNAME = "admin" +ADMIN_PASSWORD = "admin" # We'll need to check what the password is + + +def login_and_get_token(): + """Login and get authentication token.""" + login_url = f"{BASE_URL}/api/v1/auth/login/" + + login_data = { + "username": ADMIN_USERNAME, + "password": ADMIN_PASSWORD + } + + print(f"๐Ÿ” Attempting to login as {ADMIN_USERNAME}...") + response = requests.post(login_url, json=login_data) + + if response.status_code == 200: + data = response.json() + token = data.get('token') + print(f"โœ… Login successful! Token: {token[:20]}...") + return token + else: + print(f"โŒ Login failed: {response.status_code}") + print(f"Response: {response.text}") + return None + + +def test_trigger_endpoint(token): + """Test the manual trigger endpoint.""" + trigger_url = f"{BASE_URL}/api/v1/trending/calculate/" + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + print(f"\n๐Ÿš€ Testing manual trigger endpoint...") + print(f"URL: {trigger_url}") + + response = requests.post(trigger_url, headers=headers) + + print(f"Status Code: {response.status_code}") + print(f"Response Headers: {dict(response.headers)}") + + try: + response_data = response.json() + print(f"Response Body: {json.dumps(response_data, indent=2)}") + + if response.status_code == 202: + print("โœ… Manual trigger successful!") + return response_data + else: + print(f"โŒ Manual trigger failed with status {response.status_code}") + return None + + except json.JSONDecodeError: + print(f"โŒ Invalid JSON response: {response.text}") + return None + + +def test_trending_endpoints(): + """Test the trending content endpoints to see the results.""" + print(f"\n๐Ÿ“Š Testing trending content endpoints...") + + # Test trending content endpoint + trending_url = f"{BASE_URL}/api/v1/trending/content/" + print(f"Testing: {trending_url}") + + response = requests.get(trending_url) + print(f"Trending Content Status: {response.status_code}") + + if response.status_code == 200: + data = response.json() + print(f"Trending Parks: {len(data.get('trending_parks', []))}") + print(f"Trending Rides: {len(data.get('trending_rides', []))}") + + # Show first few trending items + if data.get('trending_parks'): + print( + f"First trending park: {data['trending_parks'][0].get('name', 'Unknown')}") + if data.get('trending_rides'): + print( + f"First trending ride: {data['trending_rides'][0].get('name', 'Unknown')}") + + # Test new content endpoint + new_content_url = f"{BASE_URL}/api/v1/trending/new/" + print(f"\nTesting: {new_content_url}") + + response = requests.get(new_content_url) + print(f"New Content Status: {response.status_code}") + + if response.status_code == 200: + data = response.json() + print(f"Recently Added: {len(data.get('recently_added', []))}") + print(f"Newly Opened: {len(data.get('newly_opened', []))}") + print(f"Upcoming: {len(data.get('upcoming', []))}") + + # Show the newly_opened structure to verify our changes + if data.get('newly_opened'): + print(f"\n๐ŸŽข First newly opened item structure:") + first_item = data['newly_opened'][0] + print(f" Name: {first_item.get('name')}") + # Should be park name, not location + print(f" Park: {first_item.get('park')}") + # Should be date_opened, not location + print(f" Date Opened: {first_item.get('date_opened')}") + print(f" Category: {first_item.get('category')}") + print(f" Slug: {first_item.get('slug')}") + + # Verify location field is NOT present + if 'location' in first_item: + print( + f" โŒ ERROR: 'location' field still present: {first_item['location']}") + else: + print(f" โœ… SUCCESS: 'location' field removed as requested") + + +def test_unauthorized_access(): + """Test that non-admin users cannot access the trigger endpoint.""" + print(f"\n๐Ÿ”’ Testing unauthorized access...") + + trigger_url = f"{BASE_URL}/api/v1/trending/calculate/" + + # Test without authentication + print("Testing without authentication...") + response = requests.post(trigger_url) + print(f"No auth status: {response.status_code}") + + # Test with invalid token + print("Testing with invalid token...") + headers = {"Authorization": "Bearer invalid_token_123"} + response = requests.post(trigger_url, headers=headers) + print(f"Invalid token status: {response.status_code}") + + if response.status_code in [401, 403]: + print("โœ… Unauthorized access properly blocked") + else: + print(f"โŒ Unauthorized access not properly blocked: {response.status_code}") + + +def main(): + """Main test function.""" + print("๐Ÿงช ThrillWiki Manual Trigger Endpoint Test") + print("=" * 50) + + # First test unauthorized access + test_unauthorized_access() + + # Try to login and get token + token = login_and_get_token() + + if not token: + print("โŒ Cannot proceed without authentication token") + return + + # Test the manual trigger endpoint + trigger_result = test_trigger_endpoint(token) + + if trigger_result: + print(f"\nโณ Waiting 10 seconds for tasks to process...") + time.sleep(10) + + # Test the trending endpoints to see results + test_trending_endpoints() + + print(f"\n๐Ÿ Test completed at {datetime.now()}") + + +if __name__ == "__main__": + main()