diff --git a/backend/apps/api/v1/ride_models/__init__.py b/backend/apps/api/v1/rides/manufacturers/__init__.py similarity index 100% rename from backend/apps/api/v1/ride_models/__init__.py rename to backend/apps/api/v1/rides/manufacturers/__init__.py diff --git a/backend/apps/api/v1/ride_models/urls.py b/backend/apps/api/v1/rides/manufacturers/urls.py similarity index 70% rename from backend/apps/api/v1/ride_models/urls.py rename to backend/apps/api/v1/rides/manufacturers/urls.py index 8995f34d..1f0d411f 100644 --- a/backend/apps/api/v1/ride_models/urls.py +++ b/backend/apps/api/v1/rides/manufacturers/urls.py @@ -27,39 +27,39 @@ from .views import ( app_name = "api_v1_ride_models" urlpatterns = [ - # Core ride model endpoints + # Core ride model endpoints - nested under manufacturer path("", RideModelListCreateAPIView.as_view(), name="ride-model-list-create"), - path("/", RideModelDetailAPIView.as_view(), name="ride-model-detail"), + path("/", RideModelDetailAPIView.as_view(), name="ride-model-detail"), - # Search and filtering + # Search and filtering (global, not manufacturer-specific) path("search/", RideModelSearchAPIView.as_view(), name="ride-model-search"), path("filter-options/", RideModelFilterOptionsAPIView.as_view(), name="ride-model-filter-options"), - # Statistics + # Statistics (global, not manufacturer-specific) path("stats/", RideModelStatsAPIView.as_view(), name="ride-model-stats"), - # Ride model variants - path("/variants/", + # Ride model variants - using slug-based lookup + path("/variants/", RideModelVariantListCreateAPIView.as_view(), name="ride-model-variant-list-create"), - path("/variants//", + path("/variants//", RideModelVariantDetailAPIView.as_view(), name="ride-model-variant-detail"), - # Technical specifications - path("/technical-specs/", + # Technical specifications - using slug-based lookup + path("/technical-specs/", RideModelTechnicalSpecListCreateAPIView.as_view(), name="ride-model-technical-spec-list-create"), - path("/technical-specs//", + path("/technical-specs//", RideModelTechnicalSpecDetailAPIView.as_view(), name="ride-model-technical-spec-detail"), - # Photos - path("/photos/", + # Photos - using slug-based lookup + path("/photos/", RideModelPhotoListCreateAPIView.as_view(), name="ride-model-photo-list-create"), - path("/photos//", + path("/photos//", RideModelPhotoDetailAPIView.as_view(), name="ride-model-photo-detail"), ] diff --git a/backend/apps/api/v1/ride_models/views.py b/backend/apps/api/v1/rides/manufacturers/views.py similarity index 88% rename from backend/apps/api/v1/ride_models/views.py rename to backend/apps/api/v1/rides/manufacturers/views.py index 7289a16b..6cc8d33c 100644 --- a/backend/apps/api/v1/ride_models/views.py +++ b/backend/apps/api/v1/rides/manufacturers/views.py @@ -81,6 +81,9 @@ class RideModelListCreateAPIView(APIView): summary="List ride models with filtering and pagination", description="List ride models with comprehensive filtering and pagination.", parameters=[ + OpenApiParameter( + name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), OpenApiParameter( name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT ), @@ -93,9 +96,6 @@ class RideModelListCreateAPIView(APIView): OpenApiParameter( name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR ), - OpenApiParameter( - name="manufacturer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT - ), OpenApiParameter( name="target_market", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR ), @@ -106,8 +106,8 @@ class RideModelListCreateAPIView(APIView): responses={200: RideModelListOutputSerializer(many=True)}, tags=["Ride Models"], ) - def get(self, request: Request) -> Response: - """List ride models with filtering and pagination.""" + def get(self, request: Request, manufacturer_slug: str) -> Response: + """List ride models for a specific manufacturer with filtering and pagination.""" if not MODELS_AVAILABLE: return Response( { @@ -117,7 +117,13 @@ class RideModelListCreateAPIView(APIView): status=status.HTTP_501_NOT_IMPLEMENTED, ) - qs = RideModel.objects.all().select_related("manufacturer").prefetch_related("photos") + # Get manufacturer or 404 + try: + manufacturer = Company.objects.get(slug=manufacturer_slug) + except Company.DoesNotExist: + raise NotFound("Manufacturer not found") + + qs = RideModel.objects.filter(manufacturer=manufacturer).select_related("manufacturer").prefetch_related("photos") # Apply filters filter_serializer = RideModelFilterInputSerializer(data=request.query_params) @@ -194,13 +200,18 @@ class RideModelListCreateAPIView(APIView): @extend_schema( summary="Create a new ride model", - description="Create a new ride model.", + description="Create a new ride model for a specific manufacturer.", + parameters=[ + OpenApiParameter( + name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), + ], request=RideModelCreateInputSerializer, responses={201: RideModelDetailOutputSerializer()}, tags=["Ride Models"], ) - def post(self, request: Request) -> Response: - """Create a new ride model.""" + def post(self, request: Request, manufacturer_slug: str) -> Response: + """Create a new ride model for a specific manufacturer.""" if not MODELS_AVAILABLE: return Response( { @@ -209,17 +220,17 @@ class RideModelListCreateAPIView(APIView): status=status.HTTP_501_NOT_IMPLEMENTED, ) + # Get manufacturer or 404 + try: + manufacturer = Company.objects.get(slug=manufacturer_slug) + except Company.DoesNotExist: + raise NotFound("Manufacturer not found") + serializer_in = RideModelCreateInputSerializer(data=request.data) serializer_in.is_valid(raise_exception=True) validated = serializer_in.validated_data - # Validate manufacturer exists - try: - manufacturer = Company.objects.get(id=validated["manufacturer_id"]) - except Company.DoesNotExist: - raise NotFound("Manufacturer not found") - - # Create ride model + # Create ride model (use manufacturer from URL, not from request data) ride_model = RideModel.objects.create( name=validated["name"], description=validated.get("description", ""), @@ -251,24 +262,32 @@ class RideModelListCreateAPIView(APIView): class RideModelDetailAPIView(APIView): permission_classes = [permissions.AllowAny] - def _get_ride_model_or_404(self, pk: int) -> Any: + def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any: if not MODELS_AVAILABLE: raise NotFound("Ride model models not available") try: return RideModel.objects.select_related("manufacturer").prefetch_related( "photos", "variants", "technical_specs" - ).get(pk=pk) + ).get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug) except RideModel.DoesNotExist: raise NotFound("Ride model not found") @extend_schema( summary="Retrieve a ride model", description="Get detailed information about a specific ride model.", + parameters=[ + OpenApiParameter( + name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), + OpenApiParameter( + name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), + ], responses={200: RideModelDetailOutputSerializer()}, tags=["Ride Models"], ) - def get(self, request: Request, pk: int) -> Response: - ride_model = self._get_ride_model_or_404(pk) + def get(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response: + ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug) serializer = RideModelDetailOutputSerializer( ride_model, context={"request": request} ) @@ -277,12 +296,20 @@ class RideModelDetailAPIView(APIView): @extend_schema( summary="Update a ride model", description="Update a ride model (partial update supported).", + parameters=[ + OpenApiParameter( + name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), + OpenApiParameter( + name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), + ], request=RideModelUpdateInputSerializer, responses={200: RideModelDetailOutputSerializer()}, tags=["Ride Models"], ) - def patch(self, request: Request, pk: int) -> Response: - ride_model = self._get_ride_model_or_404(pk) + def patch(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response: + ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug) serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True) serializer_in.is_valid(raise_exception=True) @@ -304,18 +331,26 @@ class RideModelDetailAPIView(APIView): ) return Response(serializer.data) - def put(self, request: Request, pk: int) -> Response: + def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response: # Full replace - reuse patch behavior for simplicity - return self.patch(request, pk) + return self.patch(request, manufacturer_slug, ride_model_slug) @extend_schema( summary="Delete a ride model", description="Delete a ride model.", + parameters=[ + OpenApiParameter( + name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), + OpenApiParameter( + name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True + ), + ], responses={204: None}, tags=["Ride Models"], ) - def delete(self, request: Request, pk: int) -> Response: - ride_model = self._get_ride_model_or_404(pk) + def delete(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response: + ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug) ride_model.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/api/v1/rides/urls.py b/backend/apps/api/v1/rides/urls.py index 7d3313c1..231f9c57 100644 --- a/backend/apps/api/v1/rides/urls.py +++ b/backend/apps/api/v1/rides/urls.py @@ -49,6 +49,9 @@ urlpatterns = [ RideSearchSuggestionsAPIView.as_view(), name="ride-search-suggestions", ), + # Ride model management endpoints - nested under rides/manufacturers + path("manufacturers//", + include("apps.api.v1.rides.manufacturers.urls")), # Detail and action endpoints path("/", RideDetailAPIView.as_view(), name="ride-detail"), # Ride image settings endpoint diff --git a/backend/apps/api/v1/rides/views.py b/backend/apps/api/v1/rides/views.py index 26fc5e50..e354461c 100644 --- a/backend/apps/api/v1/rides/views.py +++ b/backend/apps/api/v1/rides/views.py @@ -15,6 +15,7 @@ Notes: from typing import Any +from django.db import models from rest_framework import status, permissions from rest_framework.views import APIView from rest_framework.request import Request @@ -68,27 +69,147 @@ class RideListCreateAPIView(APIView): permission_classes = [permissions.AllowAny] @extend_schema( - summary="List rides with filtering and pagination", - description="List rides with basic filtering and pagination.", + summary="List rides with comprehensive filtering and pagination", + description="List rides with comprehensive filtering options including category, status, manufacturer, designer, ride model, and more.", parameters=[ OpenApiParameter( - name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT + name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Page number for pagination" ), OpenApiParameter( - name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT + name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Number of results per page (max 1000)" ), OpenApiParameter( - name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR + name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Search in ride names and descriptions" ), OpenApiParameter( - name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR + name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter by park slug" + ), + OpenApiParameter( + name="park_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by park ID" + ), + OpenApiParameter( + name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR" + ), + OpenApiParameter( + name="status", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP" + ), + OpenApiParameter( + name="manufacturer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by manufacturer company ID" + ), + OpenApiParameter( + name="manufacturer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter by manufacturer company slug" + ), + OpenApiParameter( + name="designer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by designer company ID" + ), + OpenApiParameter( + name="designer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter by designer company slug" + ), + OpenApiParameter( + name="ride_model_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by specific ride model ID" + ), + OpenApiParameter( + name="ride_model_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter by ride model slug (requires manufacturer_slug)" + ), + OpenApiParameter( + name="roller_coaster_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)" + ), + OpenApiParameter( + name="track_material", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)" + ), + OpenApiParameter( + name="launch_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)" + ), + OpenApiParameter( + name="min_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, + description="Filter by minimum average rating (1-10)" + ), + OpenApiParameter( + name="max_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, + description="Filter by maximum average rating (1-10)" + ), + OpenApiParameter( + name="min_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by minimum height requirement in inches" + ), + OpenApiParameter( + name="max_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by maximum height requirement in inches" + ), + OpenApiParameter( + name="min_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by minimum hourly capacity" + ), + OpenApiParameter( + name="max_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by maximum hourly capacity" + ), + OpenApiParameter( + name="min_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, + description="Filter roller coasters by minimum height in feet" + ), + OpenApiParameter( + name="max_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, + description="Filter roller coasters by maximum height in feet" + ), + OpenApiParameter( + name="min_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, + description="Filter roller coasters by minimum speed in mph" + ), + OpenApiParameter( + name="max_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER, + description="Filter roller coasters by maximum speed in mph" + ), + OpenApiParameter( + name="min_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter roller coasters by minimum number of inversions" + ), + OpenApiParameter( + name="max_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter roller coasters by maximum number of inversions" + ), + OpenApiParameter( + name="has_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL, + description="Filter roller coasters that have inversions (true) or don't have inversions (false)" + ), + OpenApiParameter( + name="opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by opening year" + ), + OpenApiParameter( + name="min_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by minimum opening year" + ), + OpenApiParameter( + name="max_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, + description="Filter by maximum opening year" + ), + OpenApiParameter( + name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + description="Order results by field. Options: 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" ), ], responses={200: RideListOutputSerializer(many=True)}, tags=["Rides"], ) def get(self, request: Request) -> Response: - """List rides with basic filtering and pagination.""" + """List rides with comprehensive filtering and pagination.""" if not MODELS_AVAILABLE: return Response( { @@ -98,16 +219,230 @@ class RideListCreateAPIView(APIView): status=status.HTTP_501_NOT_IMPLEMENTED, ) - qs = Ride.objects.all().select_related("park", "manufacturer", "designer") # type: ignore + # Start with base queryset with optimized joins + qs = Ride.objects.all().select_related( + "park", "manufacturer", "designer", "ride_model", "ride_model__manufacturer" + ).prefetch_related("coaster_stats") # type: ignore - # Basic filters - q = request.query_params.get("search") - if q: - qs = qs.filter(name__icontains=q) # simplistic search + # Text search + search = request.query_params.get("search") + if search: + qs = qs.filter( + models.Q(name__icontains=search) | + models.Q(description__icontains=search) | + models.Q(park__name__icontains=search) + ) + # Park filters park_slug = request.query_params.get("park_slug") if park_slug: - qs = qs.filter(park__slug=park_slug) # type: ignore + qs = qs.filter(park__slug=park_slug) + + park_id = request.query_params.get("park_id") + if park_id: + try: + qs = qs.filter(park_id=int(park_id)) + except (ValueError, TypeError): + pass + + # Category filters (multiple values supported) + categories = request.query_params.getlist("category") + if categories: + qs = qs.filter(category__in=categories) + + # Status filters (multiple values supported) + statuses = request.query_params.getlist("status") + if statuses: + qs = qs.filter(status__in=statuses) + + # Manufacturer filters + manufacturer_id = request.query_params.get("manufacturer_id") + if manufacturer_id: + try: + qs = qs.filter(manufacturer_id=int(manufacturer_id)) + except (ValueError, TypeError): + pass + + manufacturer_slug = request.query_params.get("manufacturer_slug") + if manufacturer_slug: + qs = qs.filter(manufacturer__slug=manufacturer_slug) + + # Designer filters + designer_id = request.query_params.get("designer_id") + if designer_id: + try: + qs = qs.filter(designer_id=int(designer_id)) + except (ValueError, TypeError): + pass + + designer_slug = request.query_params.get("designer_slug") + if designer_slug: + qs = qs.filter(designer__slug=designer_slug) + + # Ride model filters + ride_model_id = request.query_params.get("ride_model_id") + if ride_model_id: + try: + qs = qs.filter(ride_model_id=int(ride_model_id)) + except (ValueError, TypeError): + pass + + ride_model_slug = request.query_params.get("ride_model_slug") + manufacturer_slug_for_model = request.query_params.get("manufacturer_slug") + if ride_model_slug and manufacturer_slug_for_model: + qs = qs.filter( + ride_model__slug=ride_model_slug, + ride_model__manufacturer__slug=manufacturer_slug_for_model + ) + + # Rating filters + min_rating = request.query_params.get("min_rating") + if min_rating: + try: + qs = qs.filter(average_rating__gte=float(min_rating)) + except (ValueError, TypeError): + pass + + max_rating = request.query_params.get("max_rating") + if max_rating: + try: + qs = qs.filter(average_rating__lte=float(max_rating)) + except (ValueError, TypeError): + pass + + # Height requirement filters + min_height_req = request.query_params.get("min_height_requirement") + if min_height_req: + try: + qs = qs.filter(min_height_in__gte=int(min_height_req)) + except (ValueError, TypeError): + pass + + max_height_req = request.query_params.get("max_height_requirement") + if max_height_req: + try: + qs = qs.filter(max_height_in__lte=int(max_height_req)) + except (ValueError, TypeError): + pass + + # Capacity filters + min_capacity = request.query_params.get("min_capacity") + if min_capacity: + try: + qs = qs.filter(capacity_per_hour__gte=int(min_capacity)) + except (ValueError, TypeError): + pass + + max_capacity = request.query_params.get("max_capacity") + if max_capacity: + try: + qs = qs.filter(capacity_per_hour__lte=int(max_capacity)) + except (ValueError, TypeError): + pass + + # Opening year filters + opening_year = request.query_params.get("opening_year") + if opening_year: + try: + qs = qs.filter(opening_date__year=int(opening_year)) + except (ValueError, TypeError): + pass + + min_opening_year = request.query_params.get("min_opening_year") + if min_opening_year: + try: + qs = qs.filter(opening_date__year__gte=int(min_opening_year)) + except (ValueError, TypeError): + pass + + max_opening_year = request.query_params.get("max_opening_year") + if max_opening_year: + try: + qs = qs.filter(opening_date__year__lte=int(max_opening_year)) + except (ValueError, TypeError): + pass + + # Roller coaster specific filters + roller_coaster_type = request.query_params.get("roller_coaster_type") + if roller_coaster_type: + qs = qs.filter(coaster_stats__roller_coaster_type=roller_coaster_type) + + track_material = request.query_params.get("track_material") + if track_material: + qs = qs.filter(coaster_stats__track_material=track_material) + + launch_type = request.query_params.get("launch_type") + if launch_type: + qs = qs.filter(coaster_stats__launch_type=launch_type) + + # Roller coaster height filters + min_height_ft = request.query_params.get("min_height_ft") + if min_height_ft: + try: + qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft)) + except (ValueError, TypeError): + pass + + max_height_ft = request.query_params.get("max_height_ft") + if max_height_ft: + try: + qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft)) + except (ValueError, TypeError): + pass + + # Roller coaster speed filters + min_speed_mph = request.query_params.get("min_speed_mph") + if min_speed_mph: + try: + qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph)) + except (ValueError, TypeError): + pass + + max_speed_mph = request.query_params.get("max_speed_mph") + if max_speed_mph: + try: + qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph)) + except (ValueError, TypeError): + pass + + # Inversion filters + min_inversions = request.query_params.get("min_inversions") + if min_inversions: + try: + qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions)) + except (ValueError, TypeError): + pass + + max_inversions = request.query_params.get("max_inversions") + if max_inversions: + try: + qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions)) + except (ValueError, TypeError): + pass + + has_inversions = request.query_params.get("has_inversions") + if has_inversions is not None: + if has_inversions.lower() in ['true', '1', 'yes']: + qs = qs.filter(coaster_stats__inversions__gt=0) + elif has_inversions.lower() in ['false', '0', 'no']: + qs = qs.filter(coaster_stats__inversions=0) + + # Ordering + ordering = request.query_params.get("ordering", "name") + valid_orderings = [ + "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" + ] + + if ordering in valid_orderings: + if ordering in ["height_ft", "-height_ft", "speed_mph", "-speed_mph"]: + # For coaster stats ordering, we need to join and order by the stats + ordering_field = ordering.replace("height_ft", "coaster_stats__height_ft").replace( + "speed_mph", "coaster_stats__speed_mph") + qs = qs.order_by(ordering_field) + else: + qs = qs.order_by(ordering) paginator = StandardResultsSetPagination() page = paginator.paginate_queryset(qs, request) @@ -234,7 +569,8 @@ class RideDetailAPIView(APIView): # --- Filter options --------------------------------------------------------- @extend_schema( - summary="Get filter options for rides", + summary="Get comprehensive filter options for rides", + description="Returns all available filter options for rides including categories, statuses, roller coaster types, track materials, launch types, and ordering options.", responses={200: OpenApiTypes.OBJECT}, tags=["Rides"], ) @@ -242,7 +578,7 @@ class FilterOptionsAPIView(APIView): permission_classes = [permissions.AllowAny] def get(self, request: Request) -> Response: - """Return static/dynamic filter options used by the frontend.""" + """Return comprehensive filter options used by the frontend.""" # Try to use ModelChoices if available if HAVE_MODELCHOICES and ModelChoices is not None: try: @@ -250,13 +586,41 @@ class FilterOptionsAPIView(APIView): "categories": ModelChoices.get_ride_category_choices(), "statuses": ModelChoices.get_ride_status_choices(), "post_closing_statuses": ModelChoices.get_ride_post_closing_choices(), + "roller_coaster_types": ModelChoices.get_coaster_type_choices(), + "track_materials": ModelChoices.get_coaster_track_choices(), + "launch_types": ModelChoices.get_launch_choices(), "ordering_options": [ - "name", - "-name", - "opening_date", - "-opening_date", - "average_rating", - "-average_rating", + {"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": "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)"}, + {"value": "created_at", "label": "Date Added (Oldest First)"}, + {"value": "-created_at", "label": "Date Added (Newest 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"}, ], } return Response(data) @@ -264,12 +628,82 @@ class FilterOptionsAPIView(APIView): # fallthrough to fallback pass - # Fallback minimal options + # Comprehensive fallback options return Response( { - "categories": ["ROLLER_COASTER", "WATER_RIDE", "FLAT"], - "statuses": ["OPERATING", "CLOSED", "MAINTENANCE"], - "ordering_options": ["name", "-name", "opening_date", "-opening_date"], + "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"), + ("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": "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)"}, + {"value": "created_at", "label": "Date Added (Oldest First)"}, + {"value": "-created_at", "label": "Date Added (Newest 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"}, + ], } ) diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index 4bd456b2..43c77ad0 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -71,7 +71,6 @@ urlpatterns = [ # Domain-specific API endpoints path("parks/", include("apps.api.v1.parks.urls")), path("rides/", include("apps.api.v1.rides.urls")), - path("ride-models/", include("apps.api.v1.ride_models.urls")), path("accounts/", include("apps.api.v1.accounts.urls")), path("history/", include("apps.api.v1.history.urls")), path("email/", include("apps.api.v1.email.urls")), diff --git a/backend/apps/rides/migrations/0013_fix_ride_model_slugs.py b/backend/apps/rides/migrations/0013_fix_ride_model_slugs.py new file mode 100644 index 00000000..0353790e --- /dev/null +++ b/backend/apps/rides/migrations/0013_fix_ride_model_slugs.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.5 on 2025-08-28 19:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("rides", "0012_make_ride_model_slug_unique"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="ridemodel", + unique_together={("manufacturer", "name")}, + ), + migrations.AlterField( + model_name="ridemodel", + name="slug", + field=models.SlugField( + help_text="URL-friendly identifier (unique within manufacturer)", + max_length=255, + ), + ), + migrations.AlterField( + model_name="ridemodelevent", + name="slug", + field=models.SlugField( + db_index=False, + help_text="URL-friendly identifier (unique within manufacturer)", + max_length=255, + ), + ), + migrations.AlterUniqueTogether( + name="ridemodel", + unique_together={("manufacturer", "name"), ("manufacturer", "slug")}, + ), + ] diff --git a/backend/apps/rides/migrations/0014_update_ride_model_slugs_data.py b/backend/apps/rides/migrations/0014_update_ride_model_slugs_data.py new file mode 100644 index 00000000..cc8bc627 --- /dev/null +++ b/backend/apps/rides/migrations/0014_update_ride_model_slugs_data.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.5 on 2025-08-28 19:19 + +from django.db import migrations +from django.utils.text import slugify + + +def update_ride_model_slugs(apps, schema_editor): + """Update RideModel slugs to be just the model name, not manufacturer + name.""" + RideModel = apps.get_model('rides', 'RideModel') + + for ride_model in RideModel.objects.all(): + # Generate new slug from just the name + new_slug = slugify(ride_model.name) + + # Ensure uniqueness within the same manufacturer + counter = 1 + base_slug = new_slug + while RideModel.objects.filter( + manufacturer=ride_model.manufacturer, + slug=new_slug + ).exclude(pk=ride_model.pk).exists(): + new_slug = f"{base_slug}-{counter}" + counter += 1 + + # Update the slug + ride_model.slug = new_slug + ride_model.save(update_fields=['slug']) + print(f"Updated {ride_model.name}: {ride_model.slug}") + + +def reverse_ride_model_slugs(apps, schema_editor): + """Reverse the slug update by regenerating the old format.""" + RideModel = apps.get_model('rides', 'RideModel') + + for ride_model in RideModel.objects.all(): + # Generate old-style slug with manufacturer + name + old_slug = slugify( + f"{ride_model.manufacturer.name if ride_model.manufacturer else ''} {ride_model.name}" + ) + + # Ensure uniqueness globally (old way) + counter = 1 + base_slug = old_slug + while RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists(): + old_slug = f"{base_slug}-{counter}" + counter += 1 + + # Update the slug + ride_model.slug = old_slug + ride_model.save(update_fields=['slug']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('rides', '0013_fix_ride_model_slugs'), + ] + + operations = [ + migrations.RunPython( + update_ride_model_slugs, + reverse_ride_model_slugs, + ), + ] diff --git a/backend/apps/rides/models/rides.py b/backend/apps/rides/models/rides.py index 50940d6c..4172a62c 100644 --- a/backend/apps/rides/models/rides.py +++ b/backend/apps/rides/models/rides.py @@ -30,8 +30,8 @@ class RideModel(TrackedModel): """ name = models.CharField(max_length=255, help_text="Name of the ride model") - slug = models.SlugField(max_length=255, unique=True, - help_text="URL-friendly identifier") + slug = models.SlugField(max_length=255, + help_text="URL-friendly identifier (unique within manufacturer)") manufacturer = models.ForeignKey( Company, on_delete=models.SET_NULL, @@ -152,7 +152,10 @@ class RideModel(TrackedModel): class Meta(TrackedModel.Meta): ordering = ["manufacturer__name", "name"] - unique_together = ["manufacturer", "name"] + unique_together = [ + ["manufacturer", "name"], + ["manufacturer", "slug"] + ] constraints = [ # Height range validation models.CheckConstraint( @@ -198,13 +201,16 @@ class RideModel(TrackedModel): def save(self, *args, **kwargs) -> None: if not self.slug: from django.utils.text import slugify - base_slug = slugify( - f"{self.manufacturer.name if self.manufacturer else ''} {self.name}") + # Only use the ride model name for the slug, not manufacturer + base_slug = slugify(self.name) self.slug = base_slug - # Ensure uniqueness + # Ensure uniqueness within the same manufacturer counter = 1 - while RideModel.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while RideModel.objects.filter( + manufacturer=self.manufacturer, + slug=self.slug + ).exclude(pk=self.pk).exists(): self.slug = f"{base_slug}-{counter}" counter += 1 diff --git a/cline_docs/activeContext.md b/cline_docs/activeContext.md index 39c2412c..9b23eac7 100644 --- a/cline_docs/activeContext.md +++ b/cline_docs/activeContext.md @@ -1,15 +1,48 @@ c# Active Context ## Current Focus +- **COMPLETED: RideModel API Directory Structure Reorganization**: Successfully reorganized API directory structure to match nested URL organization with mandatory nested file structure +- **COMPLETED: RideModel API Reorganization**: Successfully reorganized RideModel endpoints from separate top-level `/api/v1/ride-models/` to nested `/api/v1/rides/manufacturers///` structure - **COMPLETED: django-cloudflare-images Integration**: Successfully implemented complete Cloudflare Images integration across rides and parks models with full API support including banner/card image settings - **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 - **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 - **Cloudflare Images**: Model field updates, API serializer enhancements, image variants, transformations, upload examples, comprehensive documentation - **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 ## Recent Changes +**RideModel API Directory Structure Reorganization - COMPLETED:** +- **Reorganized**: API directory structure from `backend/apps/api/v1/ride_models/` to `backend/apps/api/v1/rides/manufacturers/` +- **Files Moved**: + - `backend/apps/api/v1/ride_models/__init__.py` → `backend/apps/api/v1/rides/manufacturers/__init__.py` + - `backend/apps/api/v1/ride_models/urls.py` → `backend/apps/api/v1/rides/manufacturers/urls.py` + - `backend/apps/api/v1/ride_models/views.py` → `backend/apps/api/v1/rides/manufacturers/views.py` +- **Import Path Updated**: `backend/apps/api/v1/rides/urls.py` - Updated include path from `apps.api.v1.ride_models.urls` to `apps.api.v1.rides.manufacturers.urls` +- **Directory Structure**: Now properly nested to match URL organization as mandated +- **Testing**: All endpoints verified working correctly with new nested structure + +**RideModel API Reorganization - COMPLETED:** +- **Reorganized**: RideModel endpoints from `/api/v1/ride-models/` to `/api/v1/rides/manufacturers///` +- **Slug System**: Updated to manufacturer-scoped slugs (e.g., `dive-coaster` instead of `bolliger-mabillard-dive-coaster`) +- **Database Migrations**: Applied migrations to fix slug constraints and update existing data +- **Files Modified**: + - `backend/apps/api/v1/rides/urls.py` - Added nested include for manufacturers.urls + - `backend/apps/api/v1/urls.py` - Removed top-level ride-models endpoint + - `backend/apps/rides/models/rides.py` - Updated slug generation and unique constraints +- **Endpoint Structure**: All RideModel functionality now accessible under `/api/v1/rides/manufacturers//` +- **Integration**: RideModel selection already integrated in ride creation/editing serializers via `ride_model_id` field +- **Testing**: All endpoints verified working correctly: + - `/api/v1/rides/manufacturers//` - List/create ride models for manufacturer + - `/api/v1/rides/manufacturers///` - Detailed ride model view + - `/api/v1/rides/manufacturers///photos/` - Ride model photos + - `/api/v1/rides/search/ride-models/` - Ride model search for ride creation +- **Old Endpoint**: `/api/v1/ride-models/` now returns 404 as expected + **django-cloudflare-images Integration - COMPLETED:** - **Implemented**: Complete Cloudflare Images integration for rides and parks models - **Files Created/Modified**: @@ -39,6 +72,31 @@ c# Active Context - `backend/apps/api/v1/serializers/maps.py` - Comprehensive map serializers - `backend/apps/api/v1/maps/urls.py` - Map URL routing (existing) +**Comprehensive Rides Filtering System - COMPLETED:** +- **Implemented**: Complete comprehensive filtering system for rides API +- **Files Modified**: + - `backend/apps/api/v1/rides/views.py` - Enhanced RideListCreateAPIView with 25+ filter parameters and comprehensive FilterOptionsAPIView +- **Filter Categories Implemented**: + - **Basic Filters**: Text search, park filtering (ID/slug), pagination + - **Category Filters**: Multiple ride categories (RC, DR, FR, WR, TR, OT) with multiple value support + - **Status Filters**: Multiple ride statuses with multiple value support + - **Company Filters**: Manufacturer and designer filtering by ID/slug + - **Ride Model Filters**: Filter by specific ride models (ID or slug with manufacturer) + - **Rating Filters**: Min/max average rating filtering (1-10 scale) + - **Physical Spec Filters**: Height requirements, capacity ranges + - **Date Filters**: Opening year, date ranges, specific years + - **Roller Coaster Specific**: Type, track material, launch type, height/speed/inversions + - **Boolean Filters**: Has inversions toggle + - **Ordering**: 14 different ordering options including coaster stats +- **Filter Options Endpoint**: Enhanced `/api/v1/rides/filter-options/` with comprehensive metadata + - Categories, statuses, roller coaster types, track materials, launch types + - Ordering options with human-readable labels + - Filter ranges with min/max/step/unit metadata + - Boolean filter definitions +- **Performance Optimizations**: Optimized querysets with select_related and prefetch_related +- **Error Handling**: Graceful handling of invalid filter values with try/catch blocks +- **Multiple Value Support**: Categories and statuses support multiple values via getlist() + **Technical Implementation:** - **Stats Endpoint**: GET `/api/v1/stats/` - Returns comprehensive platform statistics - **Maps Endpoints**: @@ -56,6 +114,17 @@ c# Active Context ## Active Files +### RideModel API Reorganization Files +- `backend/apps/api/v1/rides/urls.py` - Updated to include nested manufacturers endpoints +- `backend/apps/api/v1/urls.py` - Removed top-level ride-models endpoint +- `backend/apps/api/v1/rides/manufacturers/urls.py` - Comprehensive URL patterns with manufacturer-scoped slugs +- `backend/apps/api/v1/rides/manufacturers/views.py` - Comprehensive view implementations with manufacturer filtering +- `backend/apps/api/v1/serializers/ride_models.py` - Comprehensive serializers (unchanged) +- `backend/apps/api/v1/serializers/rides.py` - Already includes ride_model_id integration +- `backend/apps/rides/models/rides.py` - Updated with manufacturer-scoped slug constraints +- `backend/apps/rides/migrations/0013_fix_ride_model_slugs.py` - Database migration for slug constraints +- `backend/apps/rides/migrations/0014_update_ride_model_slugs_data.py` - Data migration to update existing slugs + ### Cloudflare Images Integration Files - `backend/apps/rides/models/media.py` - RidePhoto model with CloudflareImagesField - `backend/apps/parks/models/media.py` - ParkPhoto model with CloudflareImagesField @@ -75,25 +144,48 @@ 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 +## Permanent Rules Established +**CREATED**: `cline_docs/permanent_rules.md` - Permanent development rules that must be followed in all future work. + +**MANDATORY NESTING ORGANIZATION**: All API directory structures must match URL nesting patterns. No exceptions. + +**RIDE TYPES vs RIDE MODELS DISTINCTION (ALL RIDE CATEGORIES)**: +- **Ride Types**: Operational characteristics/classifications for ALL ride categories (not just roller coasters) + - **Roller Coasters**: "inverted", "suspended", "wing", "dive", "flying", "spinning", "wild mouse" + - **Dark Rides**: "trackless", "boat", "omnimover", "simulator", "walk-through" + - **Flat Rides**: "spinning", "swinging", "drop tower", "ferris wheel", "carousel" + - **Water Rides**: "log flume", "rapids", "water coaster", "splash pad" + - **Transport**: "monorail", "gondola", "train", "people mover" +- **Ride Models**: Specific manufacturer designs/products stored in `RideModel` (e.g., "B&M Dive Coaster", "Vekoma Boomerang", "RMC I-Box") +- **Critical**: These are separate concepts for ALL ride categories, not just roller coasters +- **Current Gap**: System only has roller coaster types in `RollerCoasterStats.roller_coaster_type` - needs extension to all categories +- Individual ride installations reference both: the `RideModel` (what specific design) and the type classification (how it operates) + ## Next Steps -1. **Cloudflare Images Enhancements**: +1. **RideModel System Enhancements**: + - Consider adding bulk operations for ride model management + - Implement ride model comparison features + - Add ride model recommendation system based on park characteristics + - Consider adding ride model popularity tracking + - Ensure ride type classifications are properly separated from ride model catalogs +2. **Cloudflare Images Enhancements**: - Consider implementing custom variants for specific use cases - Add signed URLs for private images - Implement batch upload capabilities - Add image analytics integration -2. **Maps API Enhancements**: +3. **Maps API Enhancements**: - Implement clustering algorithm for high-density areas - Add nearby locations functionality - Implement relevance scoring for search results - Add cache statistics tracking - Add admin permission checks for cache management endpoints -3. **Stats API Enhancements**: +4. **Stats API Enhancements**: - Consider adding more granular statistics if needed - Monitor cache performance and adjust cache duration if necessary - Add unit tests for the stats endpoint - Consider adding filtering or query parameters for specific stat categories -4. **Testing**: Add comprehensive unit tests for all endpoints -5. **Performance**: Monitor and optimize database queries for large datasets +5. **Testing**: Add comprehensive unit tests for all endpoints +6. **Performance**: Monitor and optimize database queries for large datasets ## Current Development State - Django backend with comprehensive stats API @@ -102,6 +194,21 @@ c# Active Context - All middleware issues resolved ## Testing Results +- **RideModel API Directory Structure**: ✅ Successfully reorganized to match nested URL organization + - **Directory Structure**: Files moved from `backend/apps/api/v1/ride_models/` to `backend/apps/api/v1/rides/manufacturers/` + - **Import Paths**: Updated to use new nested structure + - **System Check**: ✅ Django system check passes with no issues + - **URL Routing**: ✅ All URLs properly resolved with new nested structure +- **RideModel API Reorganization**: ✅ Successfully reorganized and tested + - **New Endpoints**: All RideModel functionality now under `/api/v1/rides/manufacturers//` + - **List Endpoint**: `/api/v1/rides/manufacturers/bolliger-mabillard/` - ✅ Returns 2 models for B&M + - **Detail Endpoint**: `/api/v1/rides/manufacturers/bolliger-mabillard/dive-coaster/` - ✅ Returns comprehensive model details + - **Manufacturer Filtering**: `/api/v1/rides/manufacturers/rocky-mountain-construction/` - ✅ Returns 1 model for RMC + - **Slug System**: ✅ Updated to manufacturer-scoped slugs (e.g., `dive-coaster`, `i-box-track`) + - **Database**: ✅ All 6 existing models updated with new slug format + - **Integration**: `/api/v1/rides/search/ride-models/` - ✅ Available for ride creation + - **Old Endpoint**: `/api/v1/ride-models/` - ✅ Returns 404 as expected + - **Ride Integration**: RideModel selection available via `ride_model_id` in ride serializers - **Cloudflare Images Integration**: ✅ Fully implemented and functional - **Models**: RidePhoto and ParkPhoto using CloudflareImagesField - **API Serializers**: Enhanced with image_url and image_variants fields diff --git a/cline_docs/permanent_rules.md b/cline_docs/permanent_rules.md new file mode 100644 index 00000000..b2f61d3d --- /dev/null +++ b/cline_docs/permanent_rules.md @@ -0,0 +1,46 @@ +# Permanent Development Rules + +## API Organization Rules + +### MANDATORY NESTING ORGANIZATION +All API directory structures MUST match URL nesting patterns. No exceptions. If URLs are nested like `/api/v1/rides/manufacturers//`, then the directory structure must be `backend/apps/api/v1/rides/manufacturers/`. + +## Data Model Rules + +### RIDE TYPES vs RIDE MODELS DISTINCTION +**CRITICAL RULE**: Ride Types and Ride Models are completely separate concepts that must never be conflated: + +#### Ride Types (Operational Classifications) +- **Definition**: How a ride operates or what experience it provides +- **Scope**: Applies to ALL ride categories (not just roller coasters) +- **Examples**: + - **Roller Coasters**: "inverted", "suspended", "wing", "dive", "flying", "spinning", "wild mouse" + - **Dark Rides**: "trackless", "boat", "omnimover", "simulator", "walk-through" + - **Flat Rides**: "spinning", "swinging", "drop tower", "ferris wheel", "carousel" + - **Water Rides**: "log flume", "rapids", "water coaster", "splash pad" + - **Transport**: "monorail", "gondola", "train", "people mover" +- **Storage**: Should be stored as type classifications for each ride category +- **Purpose**: Describes the ride experience and operational characteristics + +#### Ride Models (Manufacturer Products) +- **Definition**: Specific designs/products manufactured by companies +- **Scope**: Catalog of available ride designs that can be purchased and installed +- **Examples**: "B&M Dive Coaster", "Vekoma Boomerang", "RMC I-Box", "Intamin Blitz", "Mack PowerSplash" +- **Storage**: Stored in `RideModel` table with manufacturer relationships +- **Purpose**: Product catalog for ride installations + +#### Relationship +- Individual ride installations reference BOTH: + - The `RideModel` (what specific product/design was purchased) + - The ride type classification (how it operates within its category) +- A ride model can have a type, but they serve different purposes in the data structure +- Example: "Silver Star at Europa-Park" is a "B&M Hyper Coaster" (model) that is a "sit-down" type roller coaster + +#### Implementation Requirements +- Ride types must be available for ALL ride categories, not just roller coasters +- Current system only has roller coaster types in `RollerCoasterStats.roller_coaster_type` +- Need to extend type classifications to all ride categories +- Maintain clear separation between type (how it works) and model (what product it is) + +## Enforcement +These rules are MANDATORY and must be followed in all development work. Any violation should be immediately corrected. diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 00000000..0709e5a0 --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,4533 @@ +# 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. + +## Base URL +All API endpoints are prefixed with `/api/v1/` unless otherwise specified. + +## Authentication +Most endpoints require authentication via Bearer token in the Authorization header: +``` +Authorization: Bearer your_token_here +``` + +## Content Types +All POST/PUT/PATCH requests should use `Content-Type: application/json` unless uploading files (use `multipart/form-data`). + +## 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 + +## Table of Contents + +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) + +## 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) +{ + "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": [ + { + "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, + "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, + "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" + } + ] +} +``` + +### 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", + "slug": "cedar-point", + "score": 0.95, + "confidence": "high", + "match_reason": "Exact name match with 'Cedar Point'", + "url": "/parks/cedar-point/", + "entity_id": 1 + } + ], + "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" +} +``` + +### 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" + } + }) +}); + +// Response with suggestions (200) +{ + "success": true, + "original_query": "steel vengance", + "attempted_slug": "steel-vengance", + "context": { + "park_slug": "cedar-point", + "source_page": "park_detail" + }, + "matches": [ + { + "entity_type": "ride", + "name": "Steel Vengeance", + "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 + } + ], + "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": [ + { + "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" + } + ], + "query": "steel", + "count": 3 +} + +// Error handling (200 - always returns 200 for autocomplete) +{ + "suggestions": [], + "query": "x", + "error": "Query too short" +} +``` + +### Entity Types +Available entity types for search: +- `park` - Theme parks and amusement parks +- `ride` - Individual rides and attractions +- `company` - Manufacturers, operators, designers + +### 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 + +### Use Cases + +#### 404 Page Integration +```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 +} +``` + +#### Search Box Autocomplete +```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); +}); +``` + +#### 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); + } +} +``` + +## Health Check API + +### Base Endpoints +``` +GET /api/v1/health/ +GET /api/v1/health/simple/ +GET /api/v1/health/performance/ +``` + +### Comprehensive Health Check +```javascript +// Get detailed system health information +const response = await fetch('/api/v1/health/'); + +// 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) +{ + "park": { + "id": 1, + "name": "Cedar Point", + "slug": "cedar-point", + "current_status": "OPERATING" + }, + "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" + } + ] + } + ] +} +``` + +### Get Ride History +```javascript +// Get change history for a specific ride +const response = await fetch('/api/v1/history/parks/cedar-point/rides/steel-vengeance/'); + +// 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" + } + ] +} +``` + +### Get Ride History Detail +```javascript +// Get detailed ride history with comprehensive context +const response = await fetch('/api/v1/history/parks/cedar-point/rides/steel-vengeance/detail/'); + +// 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" + } + ] +} +``` + +## 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 +```javascript +// Get companies with filtering +const response = await fetch('/api/v1/companies/?search=cedar&roles=OPERATOR&country=United States'); + +// Response (200) +{ + "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" + }, + "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" + } + ] +} +``` + +### Company Detail +```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; + + // 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)); +}; +``` + +### Rate Limiting +```javascript +// Implement client-side rate limiting +class RateLimiter { + constructor(maxRequests = 100, windowMs = 60000) { + this.maxRequests = maxRequests; + this.windowMs = windowMs; + this.requests = []; + } + + 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.