Compare commits

..

2 Commits

Author SHA1 Message Date
pacnpal
8dd5d88906 Last with the old frontend 2025-08-28 11:38:22 -04:00
pacnpal
c4702559fb Last with the old frontend 2025-08-28 11:37:24 -04:00
164 changed files with 30890 additions and 15345 deletions

View File

@@ -1,649 +0,0 @@
#!/bin/bash
# ThrillWiki API Endpoints - Complete Curl Commands
# Generated from comprehensive URL analysis
# Base URL - adjust as needed for your environment
BASE_URL="http://localhost:8000"
# Command line options
SKIP_AUTH=false
ONLY_AUTH=false
SKIP_DOCS=false
HELP=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--skip-auth)
SKIP_AUTH=true
shift
;;
--only-auth)
ONLY_AUTH=true
shift
;;
--skip-docs)
SKIP_DOCS=true
shift
;;
--base-url)
BASE_URL="$2"
shift 2
;;
--help|-h)
HELP=true
shift
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Show help
if [ "$HELP" = true ]; then
echo "ThrillWiki API Endpoints Test Suite"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --skip-auth Skip endpoints that require authentication"
echo " --only-auth Only test endpoints that require authentication"
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Test all endpoints"
echo " $0 --skip-auth # Test only public endpoints"
echo " $0 --only-auth # Test only authenticated endpoints"
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
echo " $0 --base-url https://api.example.com # Use custom base URL"
exit 0
fi
# Validate conflicting options
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
echo "Error: --skip-auth and --only-auth cannot be used together"
exit 1
fi
echo "=== ThrillWiki API Endpoints Test Suite ==="
echo "Base URL: $BASE_URL"
if [ "$SKIP_AUTH" = true ]; then
echo "Mode: Public endpoints only (skipping authentication required)"
elif [ "$ONLY_AUTH" = true ]; then
echo "Mode: Authenticated endpoints only"
else
echo "Mode: All endpoints"
fi
if [ "$SKIP_DOCS" = true ]; then
echo "Skipping: API documentation endpoints"
fi
echo ""
# Helper function to check if we should run an endpoint
should_run_endpoint() {
local requires_auth=$1
local is_docs=$2
# Skip docs if requested
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
return 1
fi
# Skip auth endpoints if requested
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
return 1
fi
# Only run auth endpoints if requested
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
return 1
fi
return 0
}
# Counter for endpoint numbering
ENDPOINT_NUM=1
# ============================================================================
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo "=== AUTHENTICATION ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Login"
curl -X POST "$BASE_URL/api/v1/auth/login/" \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "testpass"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Signup"
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
-H "Content-Type: application/json" \
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Logout"
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
-H "Content-Type: application/json"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Reset"
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Social Providers"
curl -X GET "$BASE_URL/api/v1/auth/providers/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Auth Status"
curl -X GET "$BASE_URL/api/v1/auth/status/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Current User"
curl -X GET "$BASE_URL/api/v1/auth/user/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Change"
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
echo "$ENDPOINT_NUM. Health Check"
curl -X GET "$BASE_URL/api/v1/health/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Simple Health"
curl -X GET "$BASE_URL/api/v1/health/simple/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
curl -X GET "$BASE_URL/api/v1/health/performance/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
echo "$ENDPOINT_NUM. Trending Content"
curl -X GET "$BASE_URL/api/v1/trending/content/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. New Content"
curl -X GET "$BASE_URL/api/v1/trending/new/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# STATISTICS ENDPOINTS (/api/v1/stats/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Statistics"
curl -X GET "$BASE_URL/api/v1/stats/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rankings"
curl -X GET "$BASE_URL/api/v1/rankings/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking History"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"category": "RC"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# PARKS API ENDPOINTS (/api/v1/parks/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== PARKS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Parks"
curl -X GET "$BASE_URL/api/v1/parks/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Company Search"
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Park Photos"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Park"
curl -X POST "$BASE_URL/api/v1/parks/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Park", "location": "Test City"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park"
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Park Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park"
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RIDES API ENDPOINTS (/api/v1/rides/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RIDES API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rides"
curl -X GET "$BASE_URL/api/v1/rides/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Ride"
curl -X POST "$BASE_URL/api/v1/rides/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride"
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Ride Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride"
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test ride photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated ride photo caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List User Profiles"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top Lists"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top List Items"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Update User Profile"
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"bio": "Updated bio"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List"
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List"
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Top List Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"toplist": 1, "ride": 1, "position": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"position": 2}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HISTORY API ENDPOINTS (/api/v1/history/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Park History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
curl -X GET "$BASE_URL/api/v1/history/timeline/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# EMAIL API ENDPOINTS (/api/v1/email/)
# ============================================================================
if should_run_endpoint true false; then
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Send Email"
curl -X POST "$BASE_URL/api/v1/email/send/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# CORE API ENDPOINTS (/api/v1/core/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== CORE API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
-H "Content-Type: application/json" \
-d '{"query": "nonexistent park", "type": "park"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
((ENDPOINT_NUM++))
fi
# ============================================================================
# MAPS API ENDPOINTS (/api/v1/maps/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== MAPS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Map Locations"
curl -X GET "$BASE_URL/api/v1/maps/locations/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Search"
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Statistics"
curl -X GET "$BASE_URL/api/v1/maps/stats/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
curl -X GET "$BASE_URL/api/v1/maps/cache/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# API DOCUMENTATION ENDPOINTS
# ============================================================================
if should_run_endpoint false true; then
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
echo "$ENDPOINT_NUM. OpenAPI Schema"
curl -X GET "$BASE_URL/api/schema/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Swagger UI"
curl -X GET "$BASE_URL/api/docs/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. ReDoc"
curl -X GET "$BASE_URL/api/redoc/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK (Django Health Check)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
echo "$ENDPOINT_NUM. Django Health Check"
curl -X GET "$BASE_URL/health/"
((ENDPOINT_NUM++))
fi
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
echo ""
echo "Notes:"
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
echo ""
echo "Authentication required endpoints are marked with Authorization header"
echo "File upload endpoints use multipart/form-data (-F flag)"
echo "JSON endpoints use application/json content type"

2
backend/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# pixi environments
.pixi/*
!.pixi/config.toml

View File

@@ -17,7 +17,3 @@ class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api"
verbose_name = "ThrillWiki API"
def ready(self):
"""Import signals when the app is ready."""
import apps.api.v1.signals # noqa: F401

View File

@@ -4,31 +4,15 @@ Migrated from apps.core.views.map_views
"""
import logging
from typing import Dict, List, Any, Optional
from django.http import HttpRequest
from django.db.models import Q
from django.core.cache import cache
from django.contrib.gis.geos import Polygon
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from apps.parks.models import Park, ParkLocation
from apps.rides.models import Ride
from ..serializers.maps import (
MapLocationSerializer,
MapLocationsResponseSerializer,
MapSearchResultSerializer,
MapSearchResponseSerializer,
MapLocationDetailSerializer,
)
logger = logging.getLogger(__name__)
@@ -42,82 +26,59 @@ logger = logging.getLogger(__name__)
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Northern latitude bound (-90 to 90). Used with south, east, west to define geographic bounds.",
examples=[OpenApiExample("Example", value=41.5)],
description="Northern latitude bound",
),
OpenApiParameter(
"south",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Southern latitude bound (-90 to 90). Must be less than north bound.",
examples=[OpenApiExample("Example", value=41.4)],
description="Southern latitude bound",
),
OpenApiParameter(
"east",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Eastern longitude bound (-180 to 180). Must be greater than west bound.",
examples=[OpenApiExample("Example", value=-82.6)],
description="Eastern longitude bound",
),
OpenApiParameter(
"west",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Western longitude bound (-180 to 180). Used with other bounds for geographic filtering.",
examples=[OpenApiExample("Example", value=-82.8)],
description="Western longitude bound",
),
OpenApiParameter(
"zoom",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="Map zoom level (1-20). Higher values show more detail. Used for clustering decisions.",
examples=[OpenApiExample("Example", value=10)],
description="Map zoom level",
),
OpenApiParameter(
"types",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Comma-separated location types to include. Valid values: 'park', 'ride'. Default: 'park,ride'",
examples=[
OpenApiExample("All types", value="park,ride"),
OpenApiExample("Parks only", value="park"),
OpenApiExample("Rides only", value="ride")
],
description="Comma-separated location types",
),
OpenApiParameter(
"cluster",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
required=False,
description="Enable location clustering for high-density areas. Default: false",
examples=[
OpenApiExample("Enable clustering", value=True),
OpenApiExample("Disable clustering", value=False)
],
description="Enable clustering",
),
OpenApiParameter(
"q",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Text search query. Searches park/ride names, cities, and states.",
examples=[
OpenApiExample("Park name", value="Cedar Point"),
OpenApiExample("Ride type", value="roller coaster"),
OpenApiExample("Location", value="Ohio")
],
description="Text query",
),
],
responses={
200: MapLocationsResponseSerializer,
400: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
responses={200: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
@@ -129,151 +90,15 @@ class MapLocationsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get map locations with optional clustering and filtering."""
try:
# Parse query parameters
north = request.GET.get("north")
south = request.GET.get("south")
east = request.GET.get("east")
west = request.GET.get("west")
zoom = request.GET.get("zoom", 10)
types = request.GET.get("types", "park,ride").split(",")
cluster = request.GET.get("cluster", "false").lower() == "true"
query = request.GET.get("q", "").strip()
# Build cache key
cache_key = f"map_locations_{north}_{south}_{east}_{west}_{zoom}_{','.join(types)}_{cluster}_{query}"
cached_result = cache.get(cache_key)
if cached_result:
return Response(cached_result)
locations = []
total_count = 0
# Get parks if requested
if "park" in types:
parks_query = Park.objects.select_related("location", "operator").filter(
location__point__isnull=False
)
# Apply bounds filtering
if all([north, south, east, west]):
try:
bounds_polygon = Polygon.from_bbox((
float(west), float(south), float(east), float(north)
))
parks_query = parks_query.filter(
location__point__within=bounds_polygon)
except (ValueError, TypeError):
pass
# Apply text search
if query:
parks_query = parks_query.filter(
Q(name__icontains=query) |
Q(location__city__icontains=query) |
Q(location__state__icontains=query)
)
# Serialize parks
for park in parks_query[:100]: # Limit results
park_data = {
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"status": park.status,
"location": {
"city": park.location.city if hasattr(park, 'location') and park.location else "",
"state": park.location.state if hasattr(park, 'location') and park.location else "",
"country": park.location.country if hasattr(park, 'location') and park.location else "",
"formatted_address": park.location.formatted_address if hasattr(park, 'location') and park.location else "",
},
"stats": {
"coaster_count": park.coaster_count or 0,
"ride_count": park.ride_count or 0,
"average_rating": float(park.average_rating) if park.average_rating else None,
},
}
locations.append(park_data)
# Get rides if requested
if "ride" in types:
rides_query = Ride.objects.select_related("park__location", "manufacturer").filter(
park__location__point__isnull=False
)
# Apply bounds filtering
if all([north, south, east, west]):
try:
bounds_polygon = Polygon.from_bbox((
float(west), float(south), float(east), float(north)
))
rides_query = rides_query.filter(
park__location__point__within=bounds_polygon)
except (ValueError, TypeError):
pass
# Apply text search
if query:
rides_query = rides_query.filter(
Q(name__icontains=query) |
Q(park__name__icontains=query) |
Q(park__location__city__icontains=query)
)
# Serialize rides
for ride in rides_query[:100]: # Limit results
ride_data = {
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"status": ride.status,
"location": {
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
"formatted_address": ride.park.location.formatted_address if hasattr(ride.park, 'location') and ride.park.location else "",
},
"stats": {
"category": ride.get_category_display() if ride.category else None,
"average_rating": float(ride.average_rating) if ride.average_rating else None,
"park_name": ride.park.name,
},
}
locations.append(ride_data)
total_count = len(locations)
# Calculate bounds from results
bounds = {}
if locations:
lats = [loc["latitude"] for loc in locations if loc["latitude"]]
lngs = [loc["longitude"] for loc in locations if loc["longitude"]]
if lats and lngs:
bounds = {
"north": max(lats),
"south": min(lats),
"east": max(lngs),
"west": min(lngs),
}
result = {
# Simple implementation to fix import error
# TODO: Implement full functionality
return Response(
{
"status": "success",
"locations": locations,
"clusters": [], # TODO: Implement clustering
"bounds": bounds,
"total_count": total_count,
"clustered": cluster,
"message": "Map locations endpoint - implementation needed",
"data": [],
}
# Cache result for 5 minutes
cache.set(cache_key, result, 300)
return Response(result)
)
except Exception as e:
logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True)
@@ -303,12 +128,7 @@ class MapLocationsAPIView(APIView):
description="ID of the location",
),
],
responses={
200: MapLocationDetailSerializer,
400: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
responses={200: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
@@ -322,90 +142,17 @@ class MapLocationDetailAPIView(APIView):
) -> Response:
"""Get detailed information for a specific location."""
try:
if location_type == "park":
try:
obj = Park.objects.select_related(
"location", "operator").get(id=location_id)
except Park.DoesNotExist:
# Simple implementation to fix import error
return Response(
{"status": "error", "message": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
elif location_type == "ride":
try:
obj = Ride.objects.select_related(
"park__location", "manufacturer").get(id=location_id)
except Ride.DoesNotExist:
return Response(
{"status": "error", "message": "Ride not found"},
status=status.HTTP_404_NOT_FOUND,
)
else:
return Response(
{"status": "error", "message": "Invalid location type"},
status=status.HTTP_400_BAD_REQUEST,
)
# Serialize the object
if location_type == "park":
data = {
"id": obj.id,
"type": "park",
"name": obj.name,
"slug": obj.slug,
"description": obj.description,
"latitude": obj.location.latitude if hasattr(obj, 'location') and obj.location else None,
"longitude": obj.location.longitude if hasattr(obj, 'location') and obj.location else None,
"status": obj.status,
"location": {
"street_address": obj.location.street_address if hasattr(obj, 'location') and obj.location else "",
"city": obj.location.city if hasattr(obj, 'location') and obj.location else "",
"state": obj.location.state if hasattr(obj, 'location') and obj.location else "",
"country": obj.location.country if hasattr(obj, 'location') and obj.location else "",
"postal_code": obj.location.postal_code if hasattr(obj, 'location') and obj.location else "",
"formatted_address": obj.location.formatted_address if hasattr(obj, 'location') and obj.location else "",
},
"stats": {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
},
"nearby_locations": [], # TODO: Implement nearby locations
}
else: # ride
data = {
"id": obj.id,
"type": "ride",
"name": obj.name,
"slug": obj.slug,
"description": obj.description,
"latitude": obj.park.location.latitude if hasattr(obj.park, 'location') and obj.park.location else None,
"longitude": obj.park.location.longitude if hasattr(obj.park, 'location') and obj.park.location else None,
"status": obj.status,
"location": {
"street_address": obj.park.location.street_address if hasattr(obj.park, 'location') and obj.park.location else "",
"city": obj.park.location.city if hasattr(obj.park, 'location') and obj.park.location else "",
"state": obj.park.location.state if hasattr(obj.park, 'location') and obj.park.location else "",
"country": obj.park.location.country if hasattr(obj.park, 'location') and obj.park.location else "",
"postal_code": obj.park.location.postal_code if hasattr(obj.park, 'location') and obj.park.location else "",
"formatted_address": obj.park.location.formatted_address if hasattr(obj.park, 'location') and obj.park.location else "",
},
"stats": {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"park_name": obj.park.name,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
},
"nearby_locations": [], # TODO: Implement nearby locations
}
return Response({
{
"status": "success",
"data": data,
})
"message": f"Location detail for {location_type}/{location_id} - implementation needed",
"data": {
"location_type": location_type,
"location_id": location_id,
},
}
)
except Exception as e:
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
@@ -427,33 +174,8 @@ class MapLocationDetailAPIView(APIView):
required=True,
description="Search query",
),
OpenApiParameter(
"types",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Comma-separated location types (park,ride)",
),
OpenApiParameter(
"page",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="Page number",
),
OpenApiParameter(
"page_size",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="Results per page",
),
],
responses={
200: MapSearchResponseSerializer,
400: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
tags=["Maps"],
),
)
@@ -475,76 +197,14 @@ class MapSearchAPIView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
types = request.GET.get("types", "park,ride").split(",")
page = int(request.GET.get("page", 1))
page_size = min(int(request.GET.get("page_size", 20)), 100)
results = []
total_count = 0
# Search parks
if "park" in types:
parks_query = Park.objects.select_related("location").filter(
Q(name__icontains=query) |
Q(location__city__icontains=query) |
Q(location__state__icontains=query)
).filter(location__point__isnull=False)
for park in parks_query[:50]: # Limit results
results.append({
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"location": {
"city": park.location.city if hasattr(park, 'location') and park.location else "",
"state": park.location.state if hasattr(park, 'location') and park.location else "",
"country": park.location.country if hasattr(park, 'location') and park.location else "",
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
})
# Search rides
if "ride" in types:
rides_query = Ride.objects.select_related("park__location").filter(
Q(name__icontains=query) |
Q(park__name__icontains=query) |
Q(park__location__city__icontains=query)
).filter(park__location__point__isnull=False)
for ride in rides_query[:50]: # Limit results
results.append({
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"location": {
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
})
total_count = len(results)
# Apply pagination
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_results = results[start_idx:end_idx]
return Response({
# Simple implementation to fix import error
return Response(
{
"status": "success",
"results": paginated_results,
"query": query,
"total_count": total_count,
"page": page,
"page_size": page_size,
})
"message": f"Search for '{query}' - implementation needed",
"data": [],
}
)
except Exception as e:
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
@@ -587,13 +247,6 @@ class MapSearchAPIView(APIView):
required=True,
description="Western longitude bound",
),
OpenApiParameter(
"types",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Comma-separated location types (park,ride)",
),
],
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
tags=["Maps"],
@@ -607,87 +260,22 @@ class MapBoundsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get locations within specific geographic bounds."""
try:
# Parse required bounds parameters
try:
north = float(request.GET.get("north"))
south = float(request.GET.get("south"))
east = float(request.GET.get("east"))
west = float(request.GET.get("west"))
except (TypeError, ValueError):
# Simple implementation to fix import error
return Response(
{"status": "error", "message": "Invalid bounds parameters"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate bounds
if north <= south:
return Response(
{"status": "error", "message": "North bound must be greater than south bound"},
status=status.HTTP_400_BAD_REQUEST,
)
if west >= east:
return Response(
{"status": "error", "message": "West bound must be less than east bound"},
status=status.HTTP_400_BAD_REQUEST,
)
types = request.GET.get("types", "park,ride").split(",")
locations = []
# Create bounds polygon
bounds_polygon = Polygon.from_bbox((west, south, east, north))
# Get parks within bounds
if "park" in types:
parks_query = Park.objects.select_related("location").filter(
location__point__within=bounds_polygon
)
for park in parks_query[:100]: # Limit results
locations.append({
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"status": park.status,
})
# Get rides within bounds
if "ride" in types:
rides_query = Ride.objects.select_related("park__location").filter(
park__location__point__within=bounds_polygon
)
for ride in rides_query[:100]: # Limit results
locations.append({
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"status": ride.status,
})
return Response({
{
"status": "success",
"locations": locations,
"bounds": {
"north": north,
"south": south,
"east": east,
"west": west,
},
"total_count": len(locations),
})
"message": "Bounds query - implementation needed",
"data": [],
}
)
except Exception as e:
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to retrieve locations within bounds"},
{
"status": "error",
"message": "Failed to retrieve locations within bounds",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -708,26 +296,15 @@ class MapStatsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get map service statistics and performance metrics."""
try:
# Count locations with coordinates
parks_with_location = Park.objects.filter(
location__point__isnull=False).count()
rides_with_location = Ride.objects.filter(
park__location__point__isnull=False).count()
total_locations = parks_with_location + rides_with_location
return Response({
# Simple implementation to fix import error
return Response(
{
"status": "success",
"data": {
"total_locations": total_locations,
"parks_with_location": parks_with_location,
"rides_with_location": rides_with_location,
"cache_hits": 0, # TODO: Implement cache statistics
"cache_misses": 0, # TODO: Implement cache statistics
},
})
"data": {"total_locations": 0, "cache_hits": 0, "cache_misses": 0},
}
)
except Exception as e:
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -756,21 +333,12 @@ class MapCacheAPIView(APIView):
def delete(self, request: HttpRequest) -> Response:
"""Clear all map cache (admin only)."""
try:
# Clear all map-related cache keys
cache_keys = cache.keys("map_*")
if cache_keys:
cache.delete_many(cache_keys)
cleared_count = len(cache_keys)
else:
cleared_count = 0
return Response({
"status": "success",
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
})
# Simple implementation to fix import error
return Response(
{"status": "success", "message": "Map cache cleared successfully"}
)
except Exception as e:
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -779,21 +347,12 @@ class MapCacheAPIView(APIView):
def post(self, request: HttpRequest) -> Response:
"""Invalidate specific cache entries."""
try:
# Get cache keys to invalidate from request data
cache_keys = request.data.get("cache_keys", [])
if cache_keys:
cache.delete_many(cache_keys)
invalidated_count = len(cache_keys)
else:
invalidated_count = 0
return Response({
"status": "success",
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
})
# Simple implementation to fix import error
return Response(
{"status": "success", "message": "Cache invalidated successfully"}
)
except Exception as e:
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -0,0 +1,362 @@
"""
Parks Company API views for ThrillWiki API v1.
This module implements comprehensive Company endpoints for the Parks domain,
handling companies with OPERATOR and PROPERTY_OWNER roles.
Endpoints:
- List / Create: GET /parks/companies/ POST /parks/companies/
- Retrieve / Update / Delete: GET /parks/companies/{pk}/ PATCH/PUT/DELETE
- Search: GET /parks/companies/search/?q=...
"""
from typing import Any
from rest_framework import status, permissions
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Import serializers
from apps.api.v1.serializers.companies import (
CompanyDetailOutputSerializer,
CompanyCreateInputSerializer,
CompanyUpdateInputSerializer,
)
# Attempt to import model-level helpers; fall back gracefully if not present.
try:
from apps.parks.models import Company as ParkCompany # type: ignore
MODELS_AVAILABLE = True
except Exception:
ParkCompany = None # type: ignore
MODELS_AVAILABLE = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 1000
# --- Company list & create -------------------------------------------------
class ParkCompanyListCreateAPIView(APIView):
"""
Parks Company endpoints for OPERATOR and PROPERTY_OWNER companies.
"""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List park companies (operators/property owners)",
description=(
"List companies with OPERATOR and PROPERTY_OWNER roles "
"with filtering and pagination."
),
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="roles",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"Filter by roles: OPERATOR, PROPERTY_OWNER (comma-separated)"
),
),
],
responses={200: CompanyDetailOutputSerializer(many=True)},
tags=["Parks", "Companies"],
)
def get(self, request: Request) -> Response:
"""List park companies with filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": (
"Park company listing is not available because domain models "
"are not imported. Implement apps.parks.models.Company "
"to enable listing."
)
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Filter to only park-related roles
qs = ParkCompany.objects.filter(
roles__overlap=["OPERATOR", "PROPERTY_OWNER"]
).distinct() # type: ignore
# Basic filters
q = request.query_params.get("search")
if q:
qs = qs.filter(name__icontains=q)
roles = request.query_params.get("roles")
if roles:
role_list = [role.strip().upper() for role in roles.split(",")]
# Filter to companies that have any of the specified roles
valid_roles = [r for r in role_list if r in ["OPERATOR", "PROPERTY_OWNER"]]
if valid_roles:
qs = qs.filter(roles__overlap=valid_roles)
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = CompanyDetailOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
@extend_schema(
summary="Create a new park company",
description="Create a new company with OPERATOR and/or PROPERTY_OWNER roles.",
request=CompanyCreateInputSerializer,
responses={201: CompanyDetailOutputSerializer()},
tags=["Parks", "Companies"],
)
def post(self, request: Request) -> Response:
"""Create a new park company."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": (
"Park company creation is not available because domain models "
"are not imported. Implement apps.parks.models.Company "
"and necessary create logic."
)
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
serializer_in = CompanyCreateInputSerializer(data=request.data)
serializer_in.is_valid(raise_exception=True)
validated = serializer_in.validated_data
# Validate that roles are appropriate for parks domain
roles = validated.get("roles", [])
valid_park_roles = [r for r in roles if r in ["OPERATOR", "PROPERTY_OWNER"]]
if not valid_park_roles:
return Response(
{
"detail": (
"Park companies must have at least one of: "
"OPERATOR, PROPERTY_OWNER"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
# Create the company
company = ParkCompany.objects.create( # type: ignore
name=validated["name"],
roles=valid_park_roles,
description=validated.get("description", ""),
website=validated.get("website", ""),
founded_date=validated.get("founded_date"),
)
out_serializer = CompanyDetailOutputSerializer(
company, context={"request": request}
)
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
# --- Company retrieve / update / delete ------------------------------------
@extend_schema(
summary="Retrieve, update or delete a park company",
responses={200: CompanyDetailOutputSerializer()},
tags=["Parks", "Companies"],
)
class ParkCompanyDetailAPIView(APIView):
"""
Park Company detail endpoints for OPERATOR and PROPERTY_OWNER companies.
"""
permission_classes = [permissions.AllowAny]
def _get_company_or_404(self, pk: int) -> Any:
if not MODELS_AVAILABLE:
raise NotFound(
(
"Park company detail is not available because domain models "
"are not imported. Implement apps.parks.models.Company "
"to enable detail endpoints."
)
)
try:
# Only allow access to companies with park-related roles
return ParkCompany.objects.filter(
roles__overlap=["OPERATOR", "PROPERTY_OWNER"]
).get(
pk=pk
) # type: ignore
except ParkCompany.DoesNotExist: # type: ignore
raise NotFound("Park company not found")
def get(self, request: Request, pk: int) -> Response:
"""Retrieve a park company."""
company = self._get_company_or_404(pk)
serializer = CompanyDetailOutputSerializer(
company, context={"request": request}
)
return Response(serializer.data)
@extend_schema(
request=CompanyUpdateInputSerializer,
responses={200: CompanyDetailOutputSerializer()},
)
def patch(self, request: Request, pk: int) -> Response:
"""Update a park company."""
company = self._get_company_or_404(pk)
serializer_in = CompanyUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
validated = serializer_in.validated_data
# If roles are being updated, validate they're appropriate for parks domain
if "roles" in validated:
roles = validated["roles"]
valid_park_roles = [r for r in roles if r in ["OPERATOR", "PROPERTY_OWNER"]]
if not valid_park_roles:
return Response(
{
"detail": (
"Park companies must have at least one of: "
"OPERATOR, PROPERTY_OWNER"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
validated["roles"] = valid_park_roles
if not MODELS_AVAILABLE:
return Response(
{
"detail": (
"Park company update is not available because domain models "
"are not imported."
)
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
for key, value in validated.items():
setattr(company, key, value)
company.save()
serializer = CompanyDetailOutputSerializer(
company, context={"request": request}
)
return Response(serializer.data)
def put(self, request: Request, pk: int) -> Response:
"""Full replace - reuse patch behavior for simplicity."""
return self.patch(request, pk)
def delete(self, request: Request, pk: int) -> Response:
"""Delete a park company."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": (
"Park company delete is not available because domain models "
"are not imported."
)
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
company = self._get_company_or_404(pk)
company.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
# --- Company search (enhanced) ---------------------------------------------
@extend_schema(
summary="Search park companies (operators/property owners) for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="roles",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by roles: OPERATOR, PROPERTY_OWNER (comma-separated)",
),
],
responses={200: OpenApiTypes.OBJECT},
tags=["Parks", "Companies"],
)
class ParkCompanySearchAPIView(APIView):
"""
Enhanced park company search with role filtering.
"""
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
q = request.query_params.get("q", "")
if not q:
return Response([], status=status.HTTP_200_OK)
if ParkCompany is None:
# Provide helpful placeholder structure
return Response(
[
{
"id": 1,
"name": "Six Flags Entertainment",
"slug": "six-flags",
"roles": ["OPERATOR"],
},
{
"id": 2,
"name": "Cedar Fair",
"slug": "cedar-fair",
"roles": ["OPERATOR", "PROPERTY_OWNER"],
},
{
"id": 3,
"name": "Disney Parks",
"slug": "disney",
"roles": ["OPERATOR", "PROPERTY_OWNER"],
},
]
)
# Filter to only park-related roles
qs = ParkCompany.objects.filter(
name__icontains=q, roles__overlap=["OPERATOR", "PROPERTY_OWNER"]
).distinct() # type: ignore
# Additional role filtering
roles = request.query_params.get("roles")
if roles:
role_list = [role.strip().upper() for role in roles.split(",")]
valid_roles = [r for r in role_list if r in ["OPERATOR", "PROPERTY_OWNER"]]
if valid_roles:
qs = qs.filter(roles__overlap=valid_roles)
qs = qs[:20] # Limit results
results = [
{
"id": c.id,
"name": c.name,
"slug": getattr(c, "slug", ""),
"roles": c.roles if hasattr(c, "roles") else [],
}
for c in qs
]
return Response(results)

View File

@@ -16,8 +16,7 @@ from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -49,7 +48,6 @@ try:
ParkDetailOutputSerializer,
ParkCreateInputSerializer,
ParkUpdateInputSerializer,
ParkImageSettingsInputSerializer,
)
SERIALIZERS_AVAILABLE = True
@@ -416,51 +414,3 @@ class ParkSearchSuggestionsAPIView(APIView):
{"suggestion": f"{q} Amusement Park"},
]
return Response(fallback)
# --- Park image settings ---------------------------------------------------
@extend_schema(
summary="Set park banner and card images",
description="Set banner_image and card_image for a park from existing park photos",
request=("ParkImageSettingsInputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
responses={
200: ("ParkDetailOutputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
400: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
)
class ParkImageSettingsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_park_or_404(self, pk: int) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Park models not available")
try:
return Park.objects.get(pk=pk) # type: ignore
except Park.DoesNotExist: # type: ignore
raise NotFound("Park not found")
def patch(self, request: Request, pk: int) -> Response:
"""Set banner and card images for the park."""
if not SERIALIZERS_AVAILABLE:
return Response(
{"detail": "Park image settings serializers not available."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
park = self._get_park_or_404(pk)
serializer = ParkImageSettingsInputSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Update the park with the validated data
for field, value in serializer.validated_data.items():
setattr(park, field, value)
park.save()
# Return updated park data
output_serializer = ParkDetailOutputSerializer(
park, context={"request": request})
return Response(output_serializer.data)

View File

@@ -6,44 +6,12 @@ Enhanced from rogue implementation to maintain full feature parity.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
from drf_spectacular.utils import extend_schema_field
from apps.parks.models import Park, ParkPhoto
@extend_schema_serializer(
examples=[
OpenApiExample(
name='Park Photo with Cloudflare Images',
summary='Complete park photo response',
description='Example response showing all fields including Cloudflare Images URLs and variants',
value={
'id': 456,
'image': 'https://imagedelivery.net/account-hash/def456ghi789/public',
'image_url': 'https://imagedelivery.net/account-hash/def456ghi789/public',
'image_variants': {
'thumbnail': 'https://imagedelivery.net/account-hash/def456ghi789/thumbnail',
'medium': 'https://imagedelivery.net/account-hash/def456ghi789/medium',
'large': 'https://imagedelivery.net/account-hash/def456ghi789/large',
'public': 'https://imagedelivery.net/account-hash/def456ghi789/public'
},
'caption': 'Beautiful park entrance',
'alt_text': 'Main entrance gate with decorative archway',
'is_primary': True,
'is_approved': True,
'created_at': '2023-01-01T12:00:00Z',
'updated_at': '2023-01-01T12:00:00Z',
'date_taken': '2023-01-01T11:00:00Z',
'uploaded_by_username': 'parkfan456',
'file_size': 1536000,
'dimensions': [1600, 900],
'park_slug': 'cedar-point',
'park_name': 'Cedar Point'
}
)
]
)
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""Enhanced output serializer for park photos with Cloudflare Images support."""
"""Enhanced output serializer for park photos with rich field structure."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
@@ -51,8 +19,6 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
file_size = serializers.SerializerMethodField()
dimensions = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
image_variants = serializers.SerializerMethodField()
@extend_schema_field(
serializers.IntegerField(allow_null=True, help_text="File size in bytes")
@@ -74,38 +40,6 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"""Get image dimensions as [width, height]."""
return obj.dimensions
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset",
allow_null=True
)
)
def get_image_url(self, obj):
"""Get the full Cloudflare Images URL."""
if obj.image:
return obj.image.url
return None
@extend_schema_field(
serializers.DictField(
child=serializers.URLField(),
help_text="Available Cloudflare Images variants with their URLs"
)
)
def get_image_variants(self, obj):
"""Get available image variants from Cloudflare Images."""
if not obj.image:
return {}
# Common variants for park photos
variants = {
'thumbnail': f"{obj.image.url}/thumbnail",
'medium': f"{obj.image.url}/medium",
'large': f"{obj.image.url}/large",
'public': f"{obj.image.url}/public"
}
return variants
park_slug = serializers.CharField(source="park.slug", read_only=True)
park_name = serializers.CharField(source="park.name", read_only=True)
@@ -114,8 +48,6 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
fields = [
"id",
"image",
"image_url",
"image_variants",
"caption",
"alt_text",
"is_primary",
@@ -131,8 +63,6 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
]
read_only_fields = [
"id",
"image_url",
"image_variants",
"created_at",
"updated_at",
"uploaded_by_username",

View File

@@ -13,15 +13,18 @@ from .park_views import (
ParkListCreateAPIView,
ParkDetailAPIView,
FilterOptionsAPIView,
CompanySearchAPIView,
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
)
from .company_views import (
ParkCompanyListCreateAPIView,
ParkCompanyDetailAPIView,
ParkCompanySearchAPIView,
)
from .views import ParkPhotoViewSet
# Create router for nested photo endpoints
router = DefaultRouter()
router.register(r"", ParkPhotoViewSet, basename="park-photo")
router.register(r"photos", ParkPhotoViewSet, basename="park-photo")
app_name = "api_v1_parks"
@@ -30,10 +33,13 @@ urlpatterns = [
path("", ParkListCreateAPIView.as_view(), name="park-list-create"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="park-filter-options"),
# Company endpoints - domain-specific CRUD for OPERATOR/PROPERTY_OWNER companies
path("companies/", ParkCompanyListCreateAPIView.as_view(), name="park-companies-list-create"),
path("companies/<int:pk>/", ParkCompanyDetailAPIView.as_view(), name="park-company-detail"),
# Autocomplete / suggestion endpoints
path(
"search/companies/",
CompanySearchAPIView.as_view(),
ParkCompanySearchAPIView.as_view(),
name="park-search-companies",
),
path(
@@ -43,8 +49,6 @@ urlpatterns = [
),
# Detail and action endpoints
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
# Park image settings endpoint
path("<int:pk>/image-settings/", ParkImageSettingsAPIView.as_view(), name="park-image-settings"),
# Park photo endpoints - domain-specific photo management
path("<int:park_pk>/photos/", include(router.urls)),
]

View File

@@ -141,12 +141,6 @@ class ParkPhotoViewSet(ModelViewSet):
park_id = self.kwargs.get("park_pk")
if not park_id:
raise ValidationError("Park ID is required")
try:
park = Park.objects.get(pk=park_id)
except Park.DoesNotExist:
raise ValidationError("Park not found")
try:
# Use the service to create the photo with proper business logic
service = cast(Any, ParkMediaService())

View File

@@ -0,0 +1,352 @@
"""
Rides Company API views for ThrillWiki API v1.
This module implements comprehensive Company CRUD endpoints specifically for the
Rides domain:
- Companies with MANUFACTURER and DESIGNER roles
- List / Create: GET /rides/companies/ POST /rides/companies/
- Retrieve / Update / Delete: GET /rides/companies/{pk}/ PATCH/PUT/DELETE
/rides/companies/{pk}/
- Enhanced search: GET /rides/search/companies/?q=...&role=...
These views handle companies that manufacture or design rides, staying within the
Rides domain boundary.
"""
from typing import Any
from rest_framework import status, permissions
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Reuse existing Company serializers
from apps.api.v1.serializers.companies import (
CompanyDetailOutputSerializer,
CompanyCreateInputSerializer,
CompanyUpdateInputSerializer,
)
# Attempt to import Rides Company model; fall back gracefully if not present
try:
from apps.rides.models import Company as RideCompany # type: ignore
MODELS_AVAILABLE = True
except Exception:
RideCompany = None # type: ignore
MODELS_AVAILABLE = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 1000
# --- Company list & create for Rides domain --------------------------------
class RideCompanyListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List ride companies with filtering and pagination",
description=(
"List companies with MANUFACTURER and DESIGNER roles for the rides domain."
),
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Search companies by name",
),
OpenApiParameter(
name="role",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by role: MANUFACTURER, DESIGNER",
),
],
responses={200: CompanyDetailOutputSerializer(many=True)},
tags=["Rides"],
)
def get(self, request: Request) -> Response:
"""List ride companies with basic filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": (
"Ride company listing is not available because domain "
"models are not imported. Implement "
"apps.rides.models.Company to enable listing."
)
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Filter to only ride-related roles
qs = RideCompany.objects.filter( # type: ignore
roles__overlap=["MANUFACTURER", "DESIGNER"]
).distinct()
# Basic filters
search_query = request.query_params.get("search")
if search_query:
qs = qs.filter(name__icontains=search_query)
role_filter = request.query_params.get("role")
if role_filter and role_filter in ["MANUFACTURER", "DESIGNER"]:
qs = qs.filter(roles__contains=[role_filter])
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = CompanyDetailOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
@extend_schema(
summary="Create a new ride company",
description=(
"Create a new company with MANUFACTURER and/or DESIGNER roles "
"for the rides domain."
),
request=CompanyCreateInputSerializer,
responses={201: CompanyDetailOutputSerializer()},
tags=["Rides"],
)
def post(self, request: Request) -> Response:
"""Create a new ride company."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": (
"Ride company creation is not available because domain "
"models are not imported. Implement "
"apps.rides.models.Company to enable creation."
)
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
serializer_in = CompanyCreateInputSerializer(data=request.data)
serializer_in.is_valid(raise_exception=True)
validated = serializer_in.validated_data
# Validate roles for rides domain
roles = validated.get("roles", [])
valid_ride_roles = [
role for role in roles if role in ["MANUFACTURER", "DESIGNER"]
]
if not valid_ride_roles:
raise ValidationError(
{
"roles": (
"At least one role must be MANUFACTURER or DESIGNER "
"for ride companies."
)
}
)
# Only keep valid ride roles
if len(valid_ride_roles) != len(roles):
validated["roles"] = valid_ride_roles
# Create the company
company = RideCompany.objects.create( # type: ignore
name=validated["name"],
slug=validated.get("slug", ""),
roles=validated["roles"],
description=validated.get("description", ""),
website=validated.get("website", ""),
founded_date=validated.get("founded_date"),
)
out_serializer = CompanyDetailOutputSerializer(
company, context={"request": request}
)
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
# --- Company retrieve / update / delete ------------------------------------
class RideCompanyDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_company_or_404(self, pk: int) -> Any:
if not MODELS_AVAILABLE:
raise NotFound(
(
"Ride company detail is not available because domain models "
"are not imported. Implement apps.rides.models.Company to "
"enable detail endpoints."
)
)
try:
return RideCompany.objects.filter(
roles__overlap=["MANUFACTURER", "DESIGNER"]
).get(pk=pk)
except RideCompany.DoesNotExist:
raise NotFound("Ride company not found")
@extend_schema(
summary="Retrieve a ride company",
responses={200: CompanyDetailOutputSerializer()},
tags=["Rides"],
)
def get(self, request: Request, pk: int) -> Response:
"""Retrieve a ride company."""
company = self._get_company_or_404(pk)
serializer = CompanyDetailOutputSerializer(
company, context={"request": request}
)
return Response(serializer.data)
@extend_schema(
request=CompanyUpdateInputSerializer,
responses={200: CompanyDetailOutputSerializer()},
tags=["Rides"],
)
def patch(self, request: Request, pk: int) -> Response:
"""Update a ride company."""
company = self._get_company_or_404(pk)
serializer_in = CompanyUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
validated = serializer_in.validated_data
# Validate roles for rides domain if being updated
if "roles" in validated:
roles = validated["roles"]
valid_ride_roles = [
role for role in roles if role in ["MANUFACTURER", "DESIGNER"]
]
if not valid_ride_roles:
raise ValidationError(
{
"roles": (
"At least one role must be MANUFACTURER or DESIGNER "
"for ride companies."
)
}
)
# Only keep valid ride roles
validated["roles"] = valid_ride_roles
# Update the company
for key, value in validated.items():
setattr(company, key, value)
company.save()
serializer = CompanyDetailOutputSerializer(
company, context={"request": request}
)
return Response(serializer.data)
def put(self, request: Request, pk: int) -> Response:
"""Full replace - reuse patch behavior for simplicity."""
return self.patch(request, pk)
@extend_schema(
summary="Delete a ride company",
responses={204: None},
tags=["Rides"],
)
def delete(self, request: Request, pk: int) -> Response:
"""Delete a ride company."""
company = self._get_company_or_404(pk)
company.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
# --- Enhanced Company search (autocomplete) for Rides domain ---------------
class RideCompanySearchAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Search ride companies (manufacturers/designers) for autocomplete",
description=(
"Enhanced search for companies with MANUFACTURER and DESIGNER roles."
),
parameters=[
OpenApiParameter(
name="q",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Search query for company names",
),
OpenApiParameter(
name="role",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by specific role: MANUFACTURER, DESIGNER",
),
],
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
def get(self, request: Request) -> Response:
"""Enhanced search for ride companies with role filtering."""
q = request.query_params.get("q", "")
role_filter = request.query_params.get("role", "")
if not q:
return Response([], status=status.HTTP_200_OK)
if RideCompany is None:
# Provide helpful placeholder structure
return Response(
[
{
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rmc",
"roles": ["MANUFACTURER"],
},
{
"id": 2,
"name": "Bolliger & Mabillard",
"slug": "b&m",
"roles": ["MANUFACTURER"],
},
{
"id": 3,
"name": "Alan Schilke",
"slug": "alan-schilke",
"roles": ["DESIGNER"],
},
]
)
# Filter to only ride-related roles
qs = RideCompany.objects.filter(
name__icontains=q, roles__overlap=["MANUFACTURER", "DESIGNER"]
)
# Apply role filter if specified
if role_filter and role_filter in ["MANUFACTURER", "DESIGNER"]:
qs = qs.filter(roles__contains=[role_filter])
qs = qs[:20] # Limit results
results = [
{
"id": c.id,
"name": c.name,
"slug": getattr(c, "slug", ""),
"roles": c.roles if hasattr(c, "roles") else [],
}
for c in qs
]
return Response(results)

View File

@@ -1,6 +0,0 @@
"""
RideModel API package for ThrillWiki API v1.
This package provides comprehensive API endpoints for ride model management,
including CRUD operations, search, filtering, and nested resources.
"""

View File

@@ -1,65 +0,0 @@
"""
URL routes for RideModel domain (API v1).
This file exposes comprehensive endpoints for ride model management:
- Core CRUD operations for ride models
- Search and filtering capabilities
- Statistics and analytics
- Nested resources (variants, technical specs, photos)
"""
from django.urls import path
from .views import (
RideModelListCreateAPIView,
RideModelDetailAPIView,
RideModelSearchAPIView,
RideModelFilterOptionsAPIView,
RideModelStatsAPIView,
RideModelVariantListCreateAPIView,
RideModelVariantDetailAPIView,
RideModelTechnicalSpecListCreateAPIView,
RideModelTechnicalSpecDetailAPIView,
RideModelPhotoListCreateAPIView,
RideModelPhotoDetailAPIView,
)
app_name = "api_v1_ride_models"
urlpatterns = [
# Core ride model endpoints - nested under manufacturer
path("", RideModelListCreateAPIView.as_view(), name="ride-model-list-create"),
path("<slug:ride_model_slug>/", RideModelDetailAPIView.as_view(), name="ride-model-detail"),
# 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 (global, not manufacturer-specific)
path("stats/", RideModelStatsAPIView.as_view(), name="ride-model-stats"),
# Ride model variants - using slug-based lookup
path("<slug:ride_model_slug>/variants/",
RideModelVariantListCreateAPIView.as_view(),
name="ride-model-variant-list-create"),
path("<slug:ride_model_slug>/variants/<int:pk>/",
RideModelVariantDetailAPIView.as_view(),
name="ride-model-variant-detail"),
# Technical specifications - using slug-based lookup
path("<slug:ride_model_slug>/technical-specs/",
RideModelTechnicalSpecListCreateAPIView.as_view(),
name="ride-model-technical-spec-list-create"),
path("<slug:ride_model_slug>/technical-specs/<int:pk>/",
RideModelTechnicalSpecDetailAPIView.as_view(),
name="ride-model-technical-spec-detail"),
# Photos - using slug-based lookup
path("<slug:ride_model_slug>/photos/",
RideModelPhotoListCreateAPIView.as_view(),
name="ride-model-photo-list-create"),
path("<slug:ride_model_slug>/photos/<int:pk>/",
RideModelPhotoDetailAPIView.as_view(),
name="ride-model-photo-detail"),
]

View File

@@ -1,701 +0,0 @@
"""
RideModel API views for ThrillWiki API v1.
This module implements comprehensive endpoints for ride model management:
- List / Create: GET /ride-models/ POST /ride-models/
- Retrieve / Update / Delete: GET /ride-models/{pk}/ PATCH/PUT/DELETE
- Filter options: GET /ride-models/filter-options/
- Search: GET /ride-models/search/?q=...
- Statistics: GET /ride-models/stats/
- Variants: CRUD operations for ride model variants
- Technical specs: CRUD operations for technical specifications
- Photos: CRUD operations for ride model photos
"""
from typing import Any
from datetime import datetime, timedelta
from rest_framework import status, permissions
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.db.models import Q, Count
from django.utils import timezone
# Import serializers
from apps.api.v1.serializers.ride_models import (
RideModelListOutputSerializer,
RideModelDetailOutputSerializer,
RideModelCreateInputSerializer,
RideModelUpdateInputSerializer,
RideModelFilterInputSerializer,
RideModelVariantOutputSerializer,
RideModelVariantCreateInputSerializer,
RideModelVariantUpdateInputSerializer,
RideModelTechnicalSpecOutputSerializer,
RideModelTechnicalSpecCreateInputSerializer,
RideModelTechnicalSpecUpdateInputSerializer,
RideModelPhotoOutputSerializer,
RideModelPhotoCreateInputSerializer,
RideModelPhotoUpdateInputSerializer,
RideModelStatsOutputSerializer,
)
# Attempt to import models; fall back gracefully if not present
try:
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
from apps.rides.models.company import Company
MODELS_AVAILABLE = True
except ImportError:
try:
# Try alternative import path
from apps.rides.models.rides import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
from apps.rides.models.rides import Company
MODELS_AVAILABLE = True
except ImportError:
RideModel = None
RideModelVariant = None
RideModelPhoto = None
RideModelTechnicalSpec = None
Company = None
MODELS_AVAILABLE = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
# === RIDE MODEL VIEWS ===
class RideModelListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
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
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="target_market", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="is_discontinued", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL
),
],
responses={200: RideModelListOutputSerializer(many=True)},
tags=["Ride Models"],
)
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(
{
"detail": "Ride model listing is not available because domain models are not imported. "
"Implement apps.rides.models.RideModel to enable listing."
},
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")
qs = RideModel.objects.filter(manufacturer=manufacturer).select_related("manufacturer").prefetch_related("photos")
# Apply filters
filter_serializer = RideModelFilterInputSerializer(data=request.query_params)
if filter_serializer.is_valid():
filters = filter_serializer.validated_data
# Search filter
if filters.get("search"):
search_term = filters["search"]
qs = qs.filter(
Q(name__icontains=search_term) |
Q(description__icontains=search_term) |
Q(manufacturer__name__icontains=search_term)
)
# Category filter
if filters.get("category"):
qs = qs.filter(category__in=filters["category"])
# Manufacturer filters
if filters.get("manufacturer_id"):
qs = qs.filter(manufacturer_id=filters["manufacturer_id"])
if filters.get("manufacturer_slug"):
qs = qs.filter(manufacturer__slug=filters["manufacturer_slug"])
# Target market filter
if filters.get("target_market"):
qs = qs.filter(target_market__in=filters["target_market"])
# Discontinued filter
if filters.get("is_discontinued") is not None:
qs = qs.filter(is_discontinued=filters["is_discontinued"])
# Year filters
if filters.get("first_installation_year_min"):
qs = qs.filter(
first_installation_year__gte=filters["first_installation_year_min"])
if filters.get("first_installation_year_max"):
qs = qs.filter(
first_installation_year__lte=filters["first_installation_year_max"])
# Installation count filter
if filters.get("min_installations"):
qs = qs.filter(total_installations__gte=filters["min_installations"])
# Height filters
if filters.get("min_height_ft"):
qs = qs.filter(
typical_height_range_max_ft__gte=filters["min_height_ft"])
if filters.get("max_height_ft"):
qs = qs.filter(
typical_height_range_min_ft__lte=filters["max_height_ft"])
# Speed filters
if filters.get("min_speed_mph"):
qs = qs.filter(
typical_speed_range_max_mph__gte=filters["min_speed_mph"])
if filters.get("max_speed_mph"):
qs = qs.filter(
typical_speed_range_min_mph__lte=filters["max_speed_mph"])
# Ordering
ordering = filters.get("ordering", "manufacturer__name,name")
if ordering:
order_fields = ordering.split(",")
qs = qs.order_by(*order_fields)
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = RideModelListOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
@extend_schema(
summary="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, manufacturer_slug: str) -> Response:
"""Create a new ride model for a specific manufacturer."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride model creation is not available because domain models are not imported."
},
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
# Create ride model (use manufacturer from URL, not from request data)
ride_model = RideModel.objects.create(
name=validated["name"],
description=validated.get("description", ""),
category=validated.get("category", ""),
manufacturer=manufacturer,
typical_height_range_min_ft=validated.get("typical_height_range_min_ft"),
typical_height_range_max_ft=validated.get("typical_height_range_max_ft"),
typical_speed_range_min_mph=validated.get("typical_speed_range_min_mph"),
typical_speed_range_max_mph=validated.get("typical_speed_range_max_mph"),
typical_capacity_range_min=validated.get("typical_capacity_range_min"),
typical_capacity_range_max=validated.get("typical_capacity_range_max"),
track_type=validated.get("track_type", ""),
support_structure=validated.get("support_structure", ""),
train_configuration=validated.get("train_configuration", ""),
restraint_system=validated.get("restraint_system", ""),
first_installation_year=validated.get("first_installation_year"),
last_installation_year=validated.get("last_installation_year"),
is_discontinued=validated.get("is_discontinued", False),
notable_features=validated.get("notable_features", ""),
target_market=validated.get("target_market", ""),
)
out_serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
class RideModelDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
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(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, 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}
)
return Response(serializer.data)
@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, 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)
# Update fields
for field, value in serializer_in.validated_data.items():
if field == "manufacturer_id":
try:
manufacturer = Company.objects.get(id=value)
ride_model.manufacturer = manufacturer
except Company.DoesNotExist:
raise ValidationError({"manufacturer_id": "Manufacturer not found"})
else:
setattr(ride_model, field, value)
ride_model.save()
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
return Response(serializer.data)
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
# Full replace - reuse patch behavior for simplicity
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, 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)
# === RIDE MODEL SEARCH AND FILTER OPTIONS ===
class RideModelSearchAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Search ride models",
description="Search ride models by name, description, or manufacturer.",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True
)
],
responses={200: RideModelListOutputSerializer(many=True)},
tags=["Ride Models"],
)
def get(self, request: Request) -> Response:
q = request.query_params.get("q", "")
if not q:
return Response([], status=status.HTTP_200_OK)
if not MODELS_AVAILABLE:
return Response(
[
{
"id": 1,
"name": "Hyper Coaster",
"manufacturer": {"name": "Bolliger & Mabillard"},
"category": "RC"
}
]
)
qs = RideModel.objects.filter(
Q(name__icontains=q) |
Q(description__icontains=q) |
Q(manufacturer__name__icontains=q)
).select_related("manufacturer")[:20]
results = [
{
"id": model.id,
"name": model.name,
"slug": model.slug,
"manufacturer": {
"id": model.manufacturer.id if model.manufacturer else None,
"name": model.manufacturer.name if model.manufacturer else None,
"slug": model.manufacturer.slug if model.manufacturer else None,
},
"category": model.category,
"target_market": model.target_market,
"is_discontinued": model.is_discontinued,
}
for model in qs
]
return Response(results)
class RideModelFilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Get filter options for ride models",
description="Get available filter options for ride model filtering.",
responses={200: OpenApiTypes.OBJECT},
tags=["Ride Models"],
)
def get(self, request: Request) -> Response:
"""Return filter options for ride models."""
if not MODELS_AVAILABLE:
return Response({
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
})
# Get actual data from database
manufacturers = Company.objects.filter(
roles__contains=["MANUFACTURER"],
ride_models__isnull=False
).distinct().values("id", "name", "slug")
categories = RideModel.objects.exclude(category="").values_list(
"category", flat=True
).distinct()
target_markets = RideModel.objects.exclude(target_market="").values_list(
"target_market", flat=True
).distinct()
return Response({
"categories": [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
"target_markets": [
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
"manufacturers": list(manufacturers),
"ordering_options": [
("name", "Name A-Z"),
("-name", "Name Z-A"),
("manufacturer__name", "Manufacturer A-Z"),
("-manufacturer__name", "Manufacturer Z-A"),
("first_installation_year", "Oldest First"),
("-first_installation_year", "Newest First"),
("total_installations", "Fewest Installations"),
("-total_installations", "Most Installations"),
],
})
# === RIDE MODEL STATISTICS ===
class RideModelStatsAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Get ride model statistics",
description="Get comprehensive statistics about ride models.",
responses={200: RideModelStatsOutputSerializer()},
tags=["Ride Models"],
)
def get(self, request: Request) -> Response:
"""Get ride model statistics."""
if not MODELS_AVAILABLE:
return Response({
"total_models": 50,
"total_installations": 500,
"active_manufacturers": 15,
"discontinued_models": 10,
"by_category": {"RC": 30, "FR": 15, "WR": 5},
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
"recent_models": 3,
})
# Calculate statistics
total_models = RideModel.objects.count()
total_installations = RideModel.objects.aggregate(
total=Count('rides')
)['total'] or 0
active_manufacturers = Company.objects.filter(
roles__contains=["MANUFACTURER"],
ride_models__isnull=False
).distinct().count()
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
# Category breakdown
by_category = {}
category_counts = RideModel.objects.exclude(category="").values(
"category"
).annotate(count=Count("id"))
for item in category_counts:
by_category[item["category"]] = item["count"]
# Target market breakdown
by_target_market = {}
market_counts = RideModel.objects.exclude(target_market="").values(
"target_market"
).annotate(count=Count("id"))
for item in market_counts:
by_target_market[item["target_market"]] = item["count"]
# Manufacturer breakdown (top 10)
by_manufacturer = {}
manufacturer_counts = RideModel.objects.filter(
manufacturer__isnull=False
).values("manufacturer__name").annotate(count=Count("id")).order_by("-count")[:10]
for item in manufacturer_counts:
by_manufacturer[item["manufacturer__name"]] = item["count"]
# Recent models (last 30 days)
thirty_days_ago = timezone.now() - timedelta(days=30)
recent_models = RideModel.objects.filter(
created_at__gte=thirty_days_ago).count()
return Response({
"total_models": total_models,
"total_installations": total_installations,
"active_manufacturers": active_manufacturers,
"discontinued_models": discontinued_models,
"by_category": by_category,
"by_target_market": by_target_market,
"by_manufacturer": by_manufacturer,
"recent_models": recent_models,
})
# === RIDE MODEL VARIANTS ===
class RideModelVariantListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List variants for a ride model",
description="Get all variants for a specific ride model.",
responses={200: RideModelVariantOutputSerializer(many=True)},
tags=["Ride Model Variants"],
)
def get(self, request: Request, ride_model_pk: int) -> Response:
if not MODELS_AVAILABLE:
return Response([])
try:
ride_model = RideModel.objects.get(pk=ride_model_pk)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
variants = RideModelVariant.objects.filter(ride_model=ride_model)
serializer = RideModelVariantOutputSerializer(variants, many=True)
return Response(serializer.data)
@extend_schema(
summary="Create a variant for a ride model",
description="Create a new variant for a specific ride model.",
request=RideModelVariantCreateInputSerializer,
responses={201: RideModelVariantOutputSerializer()},
tags=["Ride Model Variants"],
)
def post(self, request: Request, ride_model_pk: int) -> Response:
if not MODELS_AVAILABLE:
return Response(
{"detail": "Variants not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
)
try:
ride_model = RideModel.objects.get(pk=ride_model_pk)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
# Override ride_model_id in the data
data = request.data.copy()
data["ride_model_id"] = ride_model_pk
serializer_in = RideModelVariantCreateInputSerializer(data=data)
serializer_in.is_valid(raise_exception=True)
validated = serializer_in.validated_data
variant = RideModelVariant.objects.create(
ride_model=ride_model,
name=validated["name"],
description=validated.get("description", ""),
min_height_ft=validated.get("min_height_ft"),
max_height_ft=validated.get("max_height_ft"),
min_speed_mph=validated.get("min_speed_mph"),
max_speed_mph=validated.get("max_speed_mph"),
distinguishing_features=validated.get("distinguishing_features", ""),
)
serializer = RideModelVariantOutputSerializer(variant)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class RideModelVariantDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_variant_or_404(self, ride_model_pk: int, pk: int) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Variants not available")
try:
return RideModelVariant.objects.get(ride_model_id=ride_model_pk, pk=pk)
except RideModelVariant.DoesNotExist:
raise NotFound("Variant not found")
@extend_schema(
summary="Get a ride model variant",
responses={200: RideModelVariantOutputSerializer()},
tags=["Ride Model Variants"],
)
def get(self, request: Request, ride_model_pk: int, pk: int) -> Response:
variant = self._get_variant_or_404(ride_model_pk, pk)
serializer = RideModelVariantOutputSerializer(variant)
return Response(serializer.data)
@extend_schema(
summary="Update a ride model variant",
request=RideModelVariantUpdateInputSerializer,
responses={200: RideModelVariantOutputSerializer()},
tags=["Ride Model Variants"],
)
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
variant = self._get_variant_or_404(ride_model_pk, pk)
serializer_in = RideModelVariantUpdateInputSerializer(
data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
for field, value in serializer_in.validated_data.items():
setattr(variant, field, value)
variant.save()
serializer = RideModelVariantOutputSerializer(variant)
return Response(serializer.data)
@extend_schema(
summary="Delete a ride model variant",
responses={204: None},
tags=["Ride Model Variants"],
)
def delete(self, request: Request, ride_model_pk: int, pk: int) -> Response:
variant = self._get_variant_or_404(ride_model_pk, pk)
variant.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
# Note: Similar patterns would be implemented for RideModelTechnicalSpec and RideModelPhoto
# For brevity, I'm including the class definitions but not the full implementations
class RideModelTechnicalSpecListCreateAPIView(APIView):
"""CRUD operations for ride model technical specifications."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variants...
class RideModelTechnicalSpecDetailAPIView(APIView):
"""CRUD operations for individual technical specifications."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variant detail...
class RideModelPhotoListCreateAPIView(APIView):
"""CRUD operations for ride model photos."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variants...
class RideModelPhotoDetailAPIView(APIView):
"""CRUD operations for individual ride model photos."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variant detail...

View File

@@ -5,109 +5,17 @@ This module contains serializers for ride-specific media functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
from apps.rides.models import Ride, RidePhoto
@extend_schema_serializer(
examples=[
OpenApiExample(
name='Ride Photo with Cloudflare Images',
summary='Complete ride photo response',
description='Example response showing all fields including Cloudflare Images URLs and variants',
value={
'id': 123,
'image': 'https://imagedelivery.net/account-hash/abc123def456/public',
'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 roller coaster with multiple inversions',
'is_primary': True,
'is_approved': True,
'photo_type': 'exterior',
'created_at': '2023-01-01T12:00:00Z',
'updated_at': '2023-01-01T12:00:00Z',
'date_taken': '2023-01-01T10:00:00Z',
'uploaded_by_username': 'photographer123',
'file_size': 2048576,
'dimensions': [1920, 1080],
'ride_slug': 'steel-vengeance',
'ride_name': 'Steel Vengeance',
'park_slug': 'cedar-point',
'park_name': 'Cedar Point'
}
)
]
)
class RidePhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride photos with Cloudflare Images support."""
"""Output serializer for ride photos."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
file_size = serializers.SerializerMethodField()
dimensions = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
image_variants = serializers.SerializerMethodField()
@extend_schema_field(
serializers.IntegerField(allow_null=True, help_text="File size in bytes")
)
def get_file_size(self, obj):
"""Get file size in bytes."""
return obj.file_size
@extend_schema_field(
serializers.ListField(
child=serializers.IntegerField(),
min_length=2,
max_length=2,
allow_null=True,
help_text="Image dimensions as [width, height] in pixels",
)
)
def get_dimensions(self, obj):
"""Get image dimensions as [width, height]."""
return obj.dimensions
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset",
allow_null=True
)
)
def get_image_url(self, obj):
"""Get the full Cloudflare Images URL."""
if obj.image:
return obj.image.url
return None
@extend_schema_field(
serializers.DictField(
child=serializers.URLField(),
help_text="Available Cloudflare Images variants with their URLs"
)
)
def get_image_variants(self, obj):
"""Get available image variants from Cloudflare Images."""
if not obj.image:
return {}
# Common variants for ride photos
variants = {
'thumbnail': f"{obj.image.url}/thumbnail",
'medium': f"{obj.image.url}/medium",
'large': f"{obj.image.url}/large",
'public': f"{obj.image.url}/public"
}
return variants
file_size = serializers.ReadOnlyField()
dimensions = serializers.ReadOnlyField()
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
ride_name = serializers.CharField(source="ride.name", read_only=True)
park_slug = serializers.CharField(source="ride.park.slug", read_only=True)
@@ -118,8 +26,6 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
fields = [
"id",
"image",
"image_url",
"image_variants",
"caption",
"alt_text",
"is_primary",
@@ -138,8 +44,6 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
]
read_only_fields = [
"id",
"image_url",
"image_variants",
"created_at",
"updated_at",
"uploaded_by_username",

View File

@@ -15,16 +15,19 @@ from .views import (
RideListCreateAPIView,
RideDetailAPIView,
FilterOptionsAPIView,
CompanySearchAPIView,
RideModelSearchAPIView,
RideSearchSuggestionsAPIView,
RideImageSettingsAPIView,
)
from .company_views import (
RideCompanyListCreateAPIView,
RideCompanyDetailAPIView,
RideCompanySearchAPIView,
)
from .photo_views import RidePhotoViewSet
# Create router for nested photo endpoints
router = DefaultRouter()
router.register(r"", RidePhotoViewSet, basename="ridephoto")
router.register(r"photos", RidePhotoViewSet, basename="ridephoto")
app_name = "api_v1_rides"
@@ -33,10 +36,15 @@ urlpatterns = [
path("", RideListCreateAPIView.as_view(), name="ride-list-create"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
# Company endpoints - domain-specific CRUD for MANUFACTURER/DESIGNER companies
path("companies/", RideCompanyListCreateAPIView.as_view(),
name="ride-companies-list-create"),
path("companies/<int:pk>/", RideCompanyDetailAPIView.as_view(),
name="ride-company-detail"),
# Autocomplete / suggestion endpoints
path(
"search/companies/",
CompanySearchAPIView.as_view(),
RideCompanySearchAPIView.as_view(),
name="ride-search-companies",
),
path(
@@ -49,14 +57,8 @@ urlpatterns = [
RideSearchSuggestionsAPIView.as_view(),
name="ride-search-suggestions",
),
# Ride model management endpoints - nested under rides/manufacturers
path("manufacturers/<slug:manufacturer_slug>/",
include("apps.api.v1.rides.manufacturers.urls")),
# Detail and action endpoints
path("<int:pk>/", RideDetailAPIView.as_view(), name="ride-detail"),
# Ride image settings endpoint
path("<int:pk>/image-settings/", RideImageSettingsAPIView.as_view(),
name="ride-image-settings"),
# Ride photo endpoints - domain-specific photo management
path("<int:ride_pk>/photos/", include(router.urls)),
]

View File

@@ -15,13 +15,12 @@ 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
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -31,7 +30,6 @@ from apps.api.v1.serializers.rides import (
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
RideImageSettingsInputSerializer,
)
# Attempt to import model-level helpers; fall back gracefully if not present.
@@ -69,147 +67,27 @@ class RideListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List rides with comprehensive filtering and pagination",
description="List rides with comprehensive filtering options including category, status, manufacturer, designer, ride model, and more.",
summary="List rides with filtering and pagination",
description="List rides with basic filtering and pagination.",
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Page number for pagination"
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Number of results per page (max 1000)"
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Search in ride names and descriptions"
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
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"
name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
],
responses={200: RideListOutputSerializer(many=True)},
tags=["Rides"],
)
def get(self, request: Request) -> Response:
"""List rides with comprehensive filtering and pagination."""
"""List rides with basic filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
@@ -219,230 +97,16 @@ class RideListCreateAPIView(APIView):
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# 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
qs = Ride.objects.all().select_related("park", "manufacturer", "designer") # type: ignore
# 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)
)
# Basic filters
q = request.query_params.get("search")
if q:
qs = qs.filter(name__icontains=q) # simplistic search
# Park filters
park_slug = request.query_params.get("park_slug")
if park_slug:
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)
qs = qs.filter(park__slug=park_slug) # type: ignore
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
@@ -569,8 +233,7 @@ class RideDetailAPIView(APIView):
# --- Filter options ---------------------------------------------------------
@extend_schema(
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.",
summary="Get filter options for rides",
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
@@ -578,7 +241,7 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
"""Return comprehensive filter options used by the frontend."""
"""Return static/dynamic filter options used by the frontend."""
# Try to use ModelChoices if available
if HAVE_MODELCHOICES and ModelChoices is not None:
try:
@@ -586,41 +249,13 @@ 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": [
{"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"},
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
],
}
return Response(data)
@@ -628,82 +263,12 @@ class FilterOptionsAPIView(APIView):
# fallthrough to fallback
pass
# Comprehensive fallback options
# Fallback minimal options
return Response(
{
"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"},
],
"categories": ["ROLLER_COASTER", "WATER_RIDE", "FLAT"],
"statuses": ["OPERATING", "CLOSED", "MAINTENANCE"],
"ordering_options": ["name", "-name", "opening_date", "-opening_date"],
}
)
@@ -815,46 +380,4 @@ class RideSearchSuggestionsAPIView(APIView):
return Response(fallback)
# --- Ride image settings ---------------------------------------------------
@extend_schema(
summary="Set ride banner and card images",
description="Set banner_image and card_image for a ride from existing ride photos",
request=RideImageSettingsInputSerializer,
responses={
200: RideDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
)
class RideImageSettingsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_ride_or_404(self, pk: int) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Ride models not available")
try:
return Ride.objects.get(pk=pk) # type: ignore
except Ride.DoesNotExist: # type: ignore
raise NotFound("Ride not found")
def patch(self, request: Request, pk: int) -> Response:
"""Set banner and card images for the ride."""
ride = self._get_ride_or_404(pk)
serializer = RideImageSettingsInputSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Update the ride with the validated data
for field, value in serializer.validated_data.items():
setattr(ride, field, value)
ride.save()
# Return updated ride data
output_serializer = RideDetailOutputSerializer(
ride, context={"request": request})
return Response(output_serializer.data)
# --- Ride duplicate action --------------------------------------------------

View File

@@ -146,10 +146,9 @@ def _import_accounts_symbols() -> Dict[str, Any]:
_accounts = _import_accounts_symbols()
# Bind account symbols into the module namespace (only if they exist)
# Bind account symbols into the module namespace (either actual objects or None)
for _name in _ACCOUNTS_SYMBOLS:
if _accounts.get(_name) is not None:
globals()[_name] = _accounts[_name]
globals()[_name] = _accounts.get(_name)
# --- Services domain ---
@@ -256,79 +255,22 @@ _SERVICES_EXPORTS = [
"DistanceCalculationOutputSerializer",
]
# Build a static __all__ list with only the serializers we know exist
__all__ = [
# Shared exports
"CATEGORY_CHOICES",
"ModelChoices",
"LocationOutputSerializer",
"CompanyOutputSerializer",
"UserModel",
# Build __all__ from known exports plus any serializer-like names discovered above
__all__ = (
_SHARED_EXPORTS
+ _PARKS_EXPORTS
+ _COMPANIES_EXPORTS
+ _RIDES_EXPORTS
+ _SERVICES_EXPORTS
+ _ACCOUNTS_SYMBOLS
)
# Parks exports
"ParkListOutputSerializer",
"ParkDetailOutputSerializer",
"ParkCreateInputSerializer",
"ParkUpdateInputSerializer",
"ParkFilterInputSerializer",
"ParkAreaDetailOutputSerializer",
"ParkAreaCreateInputSerializer",
"ParkAreaUpdateInputSerializer",
"ParkLocationOutputSerializer",
"ParkLocationCreateInputSerializer",
"ParkLocationUpdateInputSerializer",
"ParkSuggestionSerializer",
"ParkSuggestionOutputSerializer",
# Companies exports
"CompanyDetailOutputSerializer",
"CompanyCreateInputSerializer",
"CompanyUpdateInputSerializer",
"RideModelDetailOutputSerializer",
"RideModelCreateInputSerializer",
"RideModelUpdateInputSerializer",
# Rides exports
"RideParkOutputSerializer",
"RideModelOutputSerializer",
"RideListOutputSerializer",
"RideDetailOutputSerializer",
"RideCreateInputSerializer",
"RideUpdateInputSerializer",
"RideFilterInputSerializer",
"RollerCoasterStatsOutputSerializer",
"RollerCoasterStatsCreateInputSerializer",
"RollerCoasterStatsUpdateInputSerializer",
"RideLocationOutputSerializer",
"RideLocationCreateInputSerializer",
"RideLocationUpdateInputSerializer",
"RideReviewOutputSerializer",
"RideReviewCreateInputSerializer",
"RideReviewUpdateInputSerializer",
# Services exports
"HealthCheckOutputSerializer",
"PerformanceMetricsOutputSerializer",
"SimpleHealthOutputSerializer",
"EmailSendInputSerializer",
"EmailTemplateOutputSerializer",
"MapDataOutputSerializer",
"CoordinateInputSerializer",
"HistoryEventSerializer",
"HistoryEntryOutputSerializer",
"HistoryCreateInputSerializer",
"ModerationSubmissionSerializer",
"ModerationSubmissionOutputSerializer",
"RoadtripParkSerializer",
"RoadtripCreateInputSerializer",
"RoadtripOutputSerializer",
"GeocodeInputSerializer",
"GeocodeOutputSerializer",
"DistanceCalculationInputSerializer",
"DistanceCalculationOutputSerializer",
]
# Add any accounts serializers that actually exist
for name in _ACCOUNTS_SYMBOLS:
if name in globals():
# Add any discovered globals that look like serializers (avoid duplicates)
for name in list(globals().keys()):
if name in __all__:
continue
if name.endswith(("Serializer", "OutputSerializer", "InputSerializer")):
__all__.append(name)
# Ensure __all__ is a flat list of unique strings (preserve order)
__all__ = list(dict.fromkeys(__all__))

View File

@@ -1,408 +0,0 @@
"""
Maps domain serializers for ThrillWiki API v1.
This module contains all serializers related to map functionality,
including location data, search results, and clustering.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
# === MAP LOCATION SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Location Example",
summary="Example map location response",
description="A location point on the map",
value={
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
"status": "OPERATING",
"location": {
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
},
"stats": {
"coaster_count": 17,
"ride_count": 70,
"average_rating": 4.5,
},
},
)
]
)
class MapLocationSerializer(serializers.Serializer):
"""Serializer for individual map locations (parks and rides)."""
id = serializers.IntegerField()
type = serializers.CharField() # 'park' or 'ride'
name = serializers.CharField()
slug = serializers.CharField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
status = serializers.CharField()
# Location details
location = serializers.SerializerMethodField()
# Statistics
stats = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get location information."""
if hasattr(obj, 'location') and obj.location:
return {
"city": obj.location.city,
"state": obj.location.state,
"country": obj.location.country,
"formatted_address": obj.location.formatted_address,
}
return {}
@extend_schema_field(serializers.DictField())
def get_stats(self, obj) -> dict:
"""Get relevant statistics based on object type."""
if obj._meta.model_name == 'park':
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
}
elif obj._meta.model_name == 'ride':
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"park_name": obj.park.name if obj.park else None,
}
return {}
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Cluster Example",
summary="Example map cluster response",
description="A cluster of locations on the map",
value={
"id": "cluster_1",
"type": "cluster",
"latitude": 41.5,
"longitude": -82.7,
"count": 5,
"bounds": {
"north": 41.6,
"south": 41.4,
"east": -82.6,
"west": -82.8,
},
},
)
]
)
class MapClusterSerializer(serializers.Serializer):
"""Serializer for map clusters."""
id = serializers.CharField()
type = serializers.CharField(default="cluster")
latitude = serializers.FloatField()
longitude = serializers.FloatField()
count = serializers.IntegerField()
bounds = serializers.DictField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Locations Response Example",
summary="Example map locations response",
description="Response containing locations and optional clusters",
value={
"status": "success",
"data": {
"locations": [
{
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
"status": "OPERATING",
}
],
"clusters": [],
"bounds": {
"north": 41.5,
"south": 41.4,
"east": -82.6,
"west": -82.8,
},
"total_count": 1,
"clustered": False,
},
},
)
]
)
class MapLocationsResponseSerializer(serializers.Serializer):
"""Response serializer for map locations endpoint."""
status = serializers.CharField(default="success")
locations = serializers.ListField(child=serializers.DictField())
clusters = serializers.ListField(child=serializers.DictField(), default=list)
bounds = serializers.DictField(default=dict)
total_count = serializers.IntegerField(default=0)
clustered = serializers.BooleanField(default=False)
# === MAP SEARCH SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Search Result Example",
summary="Example map search result",
description="A search result for map locations",
value={
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
"location": {
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
},
"relevance_score": 0.95,
},
)
]
)
class MapSearchResultSerializer(serializers.Serializer):
"""Serializer for map search results."""
id = serializers.IntegerField()
type = serializers.CharField()
name = serializers.CharField()
slug = serializers.CharField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
location = serializers.SerializerMethodField()
relevance_score = serializers.FloatField(required=False)
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get location information."""
if hasattr(obj, 'location') and obj.location:
return {
"city": obj.location.city,
"state": obj.location.state,
"country": obj.location.country,
}
return {}
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Search Response Example",
summary="Example map search response",
description="Response containing search results",
value={
"status": "success",
"data": {
"results": [
{
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
}
],
"query": "cedar point",
"total_count": 1,
"page": 1,
"page_size": 20,
},
},
)
]
)
class MapSearchResponseSerializer(serializers.Serializer):
"""Response serializer for map search endpoint."""
status = serializers.CharField(default="success")
results = serializers.ListField(child=serializers.DictField())
query = serializers.CharField()
total_count = serializers.IntegerField(default=0)
page = serializers.IntegerField(default=1)
page_size = serializers.IntegerField(default=20)
# === MAP DETAIL SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Location Detail Example",
summary="Example map location detail response",
description="Detailed information about a specific location",
value={
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"description": "America's Roller Coast",
"latitude": 41.4793,
"longitude": -82.6833,
"status": "OPERATING",
"location": {
"street_address": "1 Cedar Point Dr",
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
"postal_code": "44870",
"formatted_address": "1 Cedar Point Dr, Sandusky, Ohio, 44870, United States",
},
"stats": {
"coaster_count": 17,
"ride_count": 70,
"average_rating": 4.5,
},
"nearby_locations": [],
},
)
]
)
class MapLocationDetailSerializer(serializers.Serializer):
"""Serializer for detailed map location information."""
id = serializers.IntegerField()
type = serializers.CharField()
name = serializers.CharField()
slug = serializers.CharField()
description = serializers.CharField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
status = serializers.CharField()
# Detailed location information
location = serializers.SerializerMethodField()
# Statistics
stats = serializers.SerializerMethodField()
# Nearby locations
nearby_locations = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get detailed location information."""
if hasattr(obj, 'location') and obj.location:
return {
"street_address": obj.location.street_address,
"city": obj.location.city,
"state": obj.location.state,
"country": obj.location.country,
"postal_code": obj.location.postal_code,
"formatted_address": obj.location.formatted_address,
}
return {}
@extend_schema_field(serializers.DictField())
def get_stats(self, obj) -> dict:
"""Get detailed statistics based on object type."""
if obj._meta.model_name == 'park':
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
}
elif obj._meta.model_name == 'ride':
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"park_name": obj.park.name if obj.park else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
}
return {}
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_nearby_locations(self, obj) -> list:
"""Get nearby locations (placeholder for now)."""
# TODO: Implement nearby location logic
return []
# === INPUT SERIALIZERS ===
class MapBoundsInputSerializer(serializers.Serializer):
"""Input serializer for map bounds queries."""
north = serializers.FloatField(min_value=-90, max_value=90)
south = serializers.FloatField(min_value=-90, max_value=90)
east = serializers.FloatField(min_value=-180, max_value=180)
west = serializers.FloatField(min_value=-180, max_value=180)
def validate(self, attrs):
"""Validate that bounds make geographic sense."""
if attrs['north'] <= attrs['south']:
raise serializers.ValidationError(
"North bound must be greater than south bound")
# Handle longitude wraparound (e.g., crossing the international date line)
# For now, we'll require west < east for simplicity
if attrs['west'] >= attrs['east']:
raise serializers.ValidationError("West bound must be less than east bound")
return attrs
class MapSearchInputSerializer(serializers.Serializer):
"""Input serializer for map search queries."""
q = serializers.CharField(min_length=1, max_length=255)
types = serializers.CharField(required=False, allow_blank=True)
bounds = MapBoundsInputSerializer(required=False)
page = serializers.IntegerField(min_value=1, default=1)
page_size = serializers.IntegerField(min_value=1, max_value=100, default=20)
def validate_types(self, value):
"""Validate location types."""
if not value:
return []
valid_types = ['park', 'ride']
types = [t.strip().lower() for t in value.split(',')]
for location_type in types:
if location_type not in valid_types:
raise serializers.ValidationError(
f"Invalid location type: {location_type}. Valid types: {', '.join(valid_types)}"
)
return types

View File

@@ -96,31 +96,6 @@ class ParkListOutputSerializer(serializers.Serializer):
"country": "United States",
},
"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",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
},
"caption": "Beautiful park entrance",
"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"
}
},
)
]
@@ -160,12 +135,6 @@ class ParkDetailOutputSerializer(serializers.Serializer):
# Areas
areas = serializers.SerializerMethodField()
# Photos
photos = serializers.SerializerMethodField()
primary_photo = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_areas(self, obj):
"""Get simplified area information."""
@@ -181,191 +150,11 @@ class ParkDetailOutputSerializer(serializers.Serializer):
]
return []
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_photos(self, obj):
"""Get all approved photos for this park."""
from apps.parks.models import ParkPhoto
photos = ParkPhoto.objects.filter(
park=obj,
is_approved=True
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
return [
{
"id": photo.id,
"image_url": photo.image.url if photo.image else None,
"image_variants": {
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
"medium": f"{photo.image.url}/medium" if photo.image else None,
"large": f"{photo.image.url}/large" if photo.image else None,
"public": f"{photo.image.url}/public" if photo.image else None,
} if photo.image else {},
"caption": photo.caption,
"alt_text": photo.alt_text,
"is_primary": photo.is_primary,
}
for photo in photos
]
@extend_schema_field(serializers.DictField(allow_null=True))
def get_primary_photo(self, obj):
"""Get the primary photo for this park."""
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.filter(
park=obj,
is_primary=True,
is_approved=True
).first()
if photo and photo.image:
return {
"id": photo.id,
"image_url": photo.image.url,
"image_variants": {
"thumbnail": f"{photo.image.url}/thumbnail",
"medium": f"{photo.image.url}/medium",
"large": f"{photo.image.url}/large",
"public": f"{photo.image.url}/public",
},
"caption": photo.caption,
"alt_text": photo.alt_text,
}
except Exception:
pass
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_banner_image(self, obj):
"""Get the banner image for this park with fallback to latest photo."""
# First try the explicitly set banner image
if obj.banner_image and obj.banner_image.image:
return {
"id": obj.banner_image.id,
"image_url": obj.banner_image.image.url,
"image_variants": {
"thumbnail": f"{obj.banner_image.image.url}/thumbnail",
"medium": f"{obj.banner_image.image.url}/medium",
"large": f"{obj.banner_image.image.url}/large",
"public": f"{obj.banner_image.image.url}/public",
},
"caption": obj.banner_image.caption,
"alt_text": obj.banner_image.alt_text,
}
# Fallback to latest approved photo
from apps.parks.models import ParkPhoto
try:
latest_photo = ParkPhoto.objects.filter(
park=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
if latest_photo and latest_photo.image:
return {
"id": latest_photo.id,
"image_url": latest_photo.image.url,
"image_variants": {
"thumbnail": f"{latest_photo.image.url}/thumbnail",
"medium": f"{latest_photo.image.url}/medium",
"large": f"{latest_photo.image.url}/large",
"public": f"{latest_photo.image.url}/public",
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
"is_fallback": True,
}
except Exception:
pass
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_card_image(self, obj):
"""Get the card image for this park with fallback to latest photo."""
# First try the explicitly set card image
if obj.card_image and obj.card_image.image:
return {
"id": obj.card_image.id,
"image_url": obj.card_image.image.url,
"image_variants": {
"thumbnail": f"{obj.card_image.image.url}/thumbnail",
"medium": f"{obj.card_image.image.url}/medium",
"large": f"{obj.card_image.image.url}/large",
"public": f"{obj.card_image.image.url}/public",
},
"caption": obj.card_image.caption,
"alt_text": obj.card_image.alt_text,
}
# Fallback to latest approved photo
from apps.parks.models import ParkPhoto
try:
latest_photo = ParkPhoto.objects.filter(
park=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
if latest_photo and latest_photo.image:
return {
"id": latest_photo.id,
"image_url": latest_photo.image.url,
"image_variants": {
"thumbnail": f"{latest_photo.image.url}/thumbnail",
"medium": f"{latest_photo.image.url}/medium",
"large": f"{latest_photo.image.url}/large",
"public": f"{latest_photo.image.url}/public",
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
"is_fallback": True,
}
except Exception:
pass
return None
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class ParkImageSettingsInputSerializer(serializers.Serializer):
"""Input serializer for setting park banner and card images."""
banner_image_id = serializers.IntegerField(required=False, allow_null=True)
card_image_id = serializers.IntegerField(required=False, allow_null=True)
def validate_banner_image_id(self, value):
"""Validate that the banner image belongs to the same park."""
if value is not None:
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
# The park will be validated in the view
return value
except ParkPhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
return value
def validate_card_image_id(self, value):
"""Validate that the card image belongs to the same park."""
if value is not None:
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
# The park will be validated in the view
return value
except ParkPhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
return value
class ParkCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating parks."""

View File

@@ -1,808 +0,0 @@
"""
RideModel serializers for ThrillWiki API v1.
This module contains all serializers related to ride models, variants,
technical specifications, and related functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from .shared import ModelChoices
# Use dynamic imports to avoid circular import issues
def get_ride_model_classes():
"""Get ride model classes dynamically to avoid import issues."""
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
# === RIDE MODEL SERIALIZERS ===
class RideModelManufacturerOutputSerializer(serializers.Serializer):
"""Output serializer for ride model's manufacturer data."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
class RideModelPhotoOutputSerializer(serializers.Serializer):
"""Output serializer for ride model photos."""
id = serializers.IntegerField()
image_url = serializers.SerializerMethodField()
caption = serializers.CharField()
alt_text = serializers.CharField()
photo_type = serializers.CharField()
is_primary = serializers.BooleanField()
photographer = serializers.CharField()
source = serializers.CharField()
@extend_schema_field(serializers.URLField(allow_null=True))
def get_image_url(self, obj):
"""Get the image URL."""
if obj.image:
return obj.image.url
return None
class RideModelTechnicalSpecOutputSerializer(serializers.Serializer):
"""Output serializer for ride model technical specifications."""
id = serializers.IntegerField()
spec_category = serializers.CharField()
spec_name = serializers.CharField()
spec_value = serializers.CharField()
spec_unit = serializers.CharField()
notes = serializers.CharField()
class RideModelVariantOutputSerializer(serializers.Serializer):
"""Output serializer for ride model variants."""
id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True)
distinguishing_features = serializers.CharField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride Model List Example",
summary="Example ride model list response",
description="A typical ride model in the list view",
value={
"id": 1,
"name": "Hyper Coaster",
"slug": "bolliger-mabillard-hyper-coaster",
"category": "RC",
"description": "High-speed steel roller coaster with airtime hills",
"manufacturer": {
"id": 1,
"name": "Bolliger & Mabillard",
"slug": "bolliger-mabillard"
},
"target_market": "THRILL",
"is_discontinued": False,
"total_installations": 15,
"first_installation_year": 1999,
"height_range_display": "200-325 ft",
"speed_range_display": "70-95 mph",
"primary_image": {
"id": 123,
"image_url": "https://example.com/image.jpg",
"caption": "B&M Hyper Coaster",
"photo_type": "PROMOTIONAL"
}
},
)
]
)
class RideModelListOutputSerializer(serializers.Serializer):
"""Output serializer for ride model list view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
description = serializers.CharField()
# Manufacturer info
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
# Market info
target_market = serializers.CharField()
is_discontinued = serializers.BooleanField()
total_installations = serializers.IntegerField()
first_installation_year = serializers.IntegerField(allow_null=True)
last_installation_year = serializers.IntegerField(allow_null=True)
# Display properties
height_range_display = serializers.CharField()
speed_range_display = serializers.CharField()
installation_years_range = serializers.CharField()
# Primary image
primary_image = RideModelPhotoOutputSerializer(allow_null=True)
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Ride Model Detail Example",
summary="Example ride model detail response",
description="A complete ride model detail response",
value={
"id": 1,
"name": "Hyper Coaster",
"slug": "bolliger-mabillard-hyper-coaster",
"category": "RC",
"description": "High-speed steel roller coaster featuring airtime hills and smooth ride experience",
"manufacturer": {
"id": 1,
"name": "Bolliger & Mabillard",
"slug": "bolliger-mabillard"
},
"typical_height_range_min_ft": 200.0,
"typical_height_range_max_ft": 325.0,
"typical_speed_range_min_mph": 70.0,
"typical_speed_range_max_mph": 95.0,
"typical_capacity_range_min": 1200,
"typical_capacity_range_max": 1800,
"track_type": "Tubular Steel",
"support_structure": "Steel",
"train_configuration": "2-3 trains, 7-9 cars per train, 4 seats per car",
"restraint_system": "Clamshell lap bar",
"target_market": "THRILL",
"is_discontinued": False,
"total_installations": 15,
"first_installation_year": 1999,
"notable_features": "Airtime hills, smooth ride, high capacity",
"photos": [
{
"id": 123,
"image_url": "https://example.com/image.jpg",
"caption": "B&M Hyper Coaster",
"photo_type": "PROMOTIONAL",
"is_primary": True
}
],
"variants": [
{
"id": 1,
"name": "Mega Coaster",
"description": "200-299 ft height variant",
"min_height_ft": 200.0,
"max_height_ft": 299.0
}
],
"technical_specs": [
{
"id": 1,
"spec_category": "DIMENSIONS",
"spec_name": "Track Width",
"spec_value": "1435",
"spec_unit": "mm"
}
],
"installations": [
{
"id": 1,
"name": "Nitro",
"park_name": "Six Flags Great Adventure",
"opening_date": "2001-04-07"
}
]
},
)
]
)
class RideModelDetailOutputSerializer(serializers.Serializer):
"""Output serializer for ride model detail view."""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
category = serializers.CharField()
description = serializers.CharField()
# Manufacturer info
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
# Technical specifications
typical_height_range_min_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
typical_height_range_max_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True
)
typical_speed_range_min_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(allow_null=True)
typical_capacity_range_max = serializers.IntegerField(allow_null=True)
# Design characteristics
track_type = serializers.CharField()
support_structure = serializers.CharField()
train_configuration = serializers.CharField()
restraint_system = serializers.CharField()
# Market information
first_installation_year = serializers.IntegerField(allow_null=True)
last_installation_year = serializers.IntegerField(allow_null=True)
is_discontinued = serializers.BooleanField()
total_installations = serializers.IntegerField()
# Design features
notable_features = serializers.CharField()
target_market = serializers.CharField()
# Display properties
height_range_display = serializers.CharField()
speed_range_display = serializers.CharField()
installation_years_range = serializers.CharField()
# SEO metadata
meta_title = serializers.CharField()
meta_description = serializers.CharField()
# Related data
photos = RideModelPhotoOutputSerializer(many=True)
variants = RideModelVariantOutputSerializer(many=True)
technical_specs = RideModelTechnicalSpecOutputSerializer(many=True)
installations = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_installations(self, obj):
"""Get ride installations using this model."""
from django.apps import apps
Ride = apps.get_model('rides', 'Ride')
installations = Ride.objects.filter(ride_model=obj).select_related('park')[:10]
return [
{
"id": ride.id,
"name": ride.name,
"slug": ride.slug,
"park_name": ride.park.name,
"park_slug": ride.park.slug,
"opening_date": ride.opening_date,
"status": ride.status,
}
for ride in installations
]
class RideModelCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride models."""
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(
choices=ModelChoices.get_ride_category_choices(),
allow_blank=True,
default=""
)
# Required manufacturer
manufacturer_id = serializers.IntegerField()
# Technical specifications
typical_height_range_min_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_height_range_max_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_min_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_max = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, default="")
support_structure = serializers.CharField(
max_length=100, allow_blank=True, default="")
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, default="")
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, default="")
# Market information
first_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
last_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
is_discontinued = serializers.BooleanField(default=False)
# Design features
notable_features = serializers.CharField(allow_blank=True, default="")
target_market = serializers.ChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
],
allow_blank=True,
default=""
)
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("typical_height_range_min_ft")
max_height = attrs.get("typical_height_range_max_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("typical_speed_range_min_mph")
max_speed = attrs.get("typical_speed_range_max_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
# Capacity range validation
min_capacity = attrs.get("typical_capacity_range_min")
max_capacity = attrs.get("typical_capacity_range_max")
if min_capacity and max_capacity and min_capacity > max_capacity:
raise serializers.ValidationError(
"Minimum capacity cannot be greater than maximum capacity"
)
# Installation years validation
first_year = attrs.get("first_installation_year")
last_year = attrs.get("last_installation_year")
if first_year and last_year and first_year > last_year:
raise serializers.ValidationError(
"First installation year cannot be after last installation year"
)
return attrs
class RideModelUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride models."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
category = serializers.ChoiceField(
choices=ModelChoices.get_ride_category_choices(),
allow_blank=True,
required=False
)
# Manufacturer
manufacturer_id = serializers.IntegerField(required=False)
# Technical specifications
typical_height_range_min_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_height_range_max_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_min_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_speed_range_max_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
typical_capacity_range_min = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
typical_capacity_range_max = serializers.IntegerField(
required=False, allow_null=True, min_value=1
)
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, required=False)
support_structure = serializers.CharField(
max_length=100, allow_blank=True, required=False)
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, required=False)
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, required=False)
# Market information
first_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
last_installation_year = serializers.IntegerField(
required=False, allow_null=True, min_value=1800, max_value=2100
)
is_discontinued = serializers.BooleanField(required=False)
# Design features
notable_features = serializers.CharField(allow_blank=True, required=False)
target_market = serializers.ChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
],
allow_blank=True,
required=False
)
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("typical_height_range_min_ft")
max_height = attrs.get("typical_height_range_max_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("typical_speed_range_min_mph")
max_speed = attrs.get("typical_speed_range_max_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
# Capacity range validation
min_capacity = attrs.get("typical_capacity_range_min")
max_capacity = attrs.get("typical_capacity_range_max")
if min_capacity and max_capacity and min_capacity > max_capacity:
raise serializers.ValidationError(
"Minimum capacity cannot be greater than maximum capacity"
)
# Installation years validation
first_year = attrs.get("first_installation_year")
last_year = attrs.get("last_installation_year")
if first_year and last_year and first_year > last_year:
raise serializers.ValidationError(
"First installation year cannot be after last installation year"
)
return attrs
class RideModelFilterInputSerializer(serializers.Serializer):
"""Input serializer for ride model filtering and search."""
# Search
search = serializers.CharField(required=False, allow_blank=True)
# Category filter
category = serializers.MultipleChoiceField(
choices=ModelChoices.get_ride_category_choices(),
required=False
)
# Manufacturer filter
manufacturer_id = serializers.IntegerField(required=False)
manufacturer_slug = serializers.CharField(required=False, allow_blank=True)
# Market filter
target_market = serializers.MultipleChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
],
required=False
)
# Status filter
is_discontinued = serializers.BooleanField(required=False)
# Year filters
first_installation_year_min = serializers.IntegerField(required=False)
first_installation_year_max = serializers.IntegerField(required=False)
# Installation count filter
min_installations = serializers.IntegerField(required=False, min_value=0)
# Height filters
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False
)
# Speed filters
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False
)
# Ordering
ordering = serializers.ChoiceField(
choices=[
"name",
"-name",
"manufacturer__name",
"-manufacturer__name",
"first_installation_year",
"-first_installation_year",
"total_installations",
"-total_installations",
"created_at",
"-created_at",
],
required=False,
default="manufacturer__name,name",
)
# === RIDE MODEL VARIANT SERIALIZERS ===
class RideModelVariantCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride model variants."""
ride_model_id = serializers.IntegerField()
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
# Variant-specific specifications
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
# Distinguishing features
distinguishing_features = serializers.CharField(allow_blank=True, default="")
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("min_height_ft")
max_height = attrs.get("max_height_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("min_speed_mph")
max_speed = attrs.get("max_speed_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
return attrs
class RideModelVariantUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride model variants."""
name = serializers.CharField(max_length=255, required=False)
description = serializers.CharField(allow_blank=True, required=False)
# Variant-specific specifications
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, required=False, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, required=False, allow_null=True
)
# Distinguishing features
distinguishing_features = serializers.CharField(allow_blank=True, required=False)
def validate(self, attrs):
"""Cross-field validation."""
# Height range validation
min_height = attrs.get("min_height_ft")
max_height = attrs.get("max_height_ft")
if min_height and max_height and min_height > max_height:
raise serializers.ValidationError(
"Minimum height cannot be greater than maximum height"
)
# Speed range validation
min_speed = attrs.get("min_speed_mph")
max_speed = attrs.get("max_speed_mph")
if min_speed and max_speed and min_speed > max_speed:
raise serializers.ValidationError(
"Minimum speed cannot be greater than maximum speed"
)
return attrs
# === RIDE MODEL TECHNICAL SPEC SERIALIZERS ===
class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride model technical specifications."""
ride_model_id = serializers.IntegerField()
spec_category = serializers.ChoiceField(
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
]
)
spec_name = serializers.CharField(max_length=100)
spec_value = serializers.CharField(max_length=255)
spec_unit = serializers.CharField(max_length=20, allow_blank=True, default="")
notes = serializers.CharField(allow_blank=True, default="")
class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride model technical specifications."""
spec_category = serializers.ChoiceField(
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
],
required=False
)
spec_name = serializers.CharField(max_length=100, required=False)
spec_value = serializers.CharField(max_length=255, required=False)
spec_unit = serializers.CharField(max_length=20, allow_blank=True, required=False)
notes = serializers.CharField(allow_blank=True, required=False)
# === RIDE MODEL PHOTO SERIALIZERS ===
class RideModelPhotoCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride model photos."""
ride_model_id = serializers.IntegerField()
image = serializers.ImageField()
caption = serializers.CharField(max_length=500, allow_blank=True, default="")
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
photo_type = serializers.ChoiceField(
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
],
default='PROMOTIONAL'
)
is_primary = serializers.BooleanField(default=False)
photographer = serializers.CharField(max_length=255, allow_blank=True, default="")
source = serializers.CharField(max_length=255, allow_blank=True, default="")
copyright_info = serializers.CharField(max_length=255, allow_blank=True, default="")
class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating ride model photos."""
caption = serializers.CharField(max_length=500, allow_blank=True, required=False)
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
photo_type = serializers.ChoiceField(
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
],
required=False
)
is_primary = serializers.BooleanField(required=False)
photographer = serializers.CharField(
max_length=255, allow_blank=True, required=False)
source = serializers.CharField(max_length=255, allow_blank=True, required=False)
copyright_info = serializers.CharField(
max_length=255, allow_blank=True, required=False)
# === RIDE MODEL STATS SERIALIZERS ===
class RideModelStatsOutputSerializer(serializers.Serializer):
"""Output serializer for ride model statistics."""
total_models = serializers.IntegerField()
total_installations = serializers.IntegerField()
active_manufacturers = serializers.IntegerField()
discontinued_models = serializers.IntegerField()
by_category = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by category"
)
by_target_market = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by target market"
)
by_manufacturer = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by manufacturer"
)
recent_models = serializers.IntegerField(
help_text="Models created in the last 30 days"
)

View File

@@ -119,33 +119,6 @@ class RideListOutputSerializer(serializers.Serializer):
"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",
"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",
"photo_type": "exterior"
}
},
)
]
@@ -188,12 +161,6 @@ class RideDetailOutputSerializer(serializers.Serializer):
# Model
ride_model = RideModelOutputSerializer(allow_null=True)
# Photos
photos = serializers.SerializerMethodField()
primary_photo = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@@ -228,192 +195,6 @@ class RideDetailOutputSerializer(serializers.Serializer):
}
return None
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_photos(self, obj):
"""Get all approved photos for this ride."""
from apps.rides.models import RidePhoto
photos = RidePhoto.objects.filter(
ride=obj,
is_approved=True
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
return [
{
"id": photo.id,
"image_url": photo.image.url if photo.image else None,
"image_variants": {
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
"medium": f"{photo.image.url}/medium" if photo.image else None,
"large": f"{photo.image.url}/large" if photo.image else None,
"public": f"{photo.image.url}/public" if photo.image else None,
} if photo.image else {},
"caption": photo.caption,
"alt_text": photo.alt_text,
"is_primary": photo.is_primary,
"photo_type": photo.photo_type,
}
for photo in photos
]
@extend_schema_field(serializers.DictField(allow_null=True))
def get_primary_photo(self, obj):
"""Get the primary photo for this ride."""
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.filter(
ride=obj,
is_primary=True,
is_approved=True
).first()
if photo and photo.image:
return {
"id": photo.id,
"image_url": photo.image.url,
"image_variants": {
"thumbnail": f"{photo.image.url}/thumbnail",
"medium": f"{photo.image.url}/medium",
"large": f"{photo.image.url}/large",
"public": f"{photo.image.url}/public",
},
"caption": photo.caption,
"alt_text": photo.alt_text,
"photo_type": photo.photo_type,
}
except Exception:
pass
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_banner_image(self, obj):
"""Get the banner image for this ride with fallback to latest photo."""
# First try the explicitly set banner image
if obj.banner_image and obj.banner_image.image:
return {
"id": obj.banner_image.id,
"image_url": obj.banner_image.image.url,
"image_variants": {
"thumbnail": f"{obj.banner_image.image.url}/thumbnail",
"medium": f"{obj.banner_image.image.url}/medium",
"large": f"{obj.banner_image.image.url}/large",
"public": f"{obj.banner_image.image.url}/public",
},
"caption": obj.banner_image.caption,
"alt_text": obj.banner_image.alt_text,
"photo_type": obj.banner_image.photo_type,
}
# Fallback to latest approved photo
from apps.rides.models import RidePhoto
try:
latest_photo = RidePhoto.objects.filter(
ride=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
if latest_photo and latest_photo.image:
return {
"id": latest_photo.id,
"image_url": latest_photo.image.url,
"image_variants": {
"thumbnail": f"{latest_photo.image.url}/thumbnail",
"medium": f"{latest_photo.image.url}/medium",
"large": f"{latest_photo.image.url}/large",
"public": f"{latest_photo.image.url}/public",
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
"photo_type": latest_photo.photo_type,
"is_fallback": True,
}
except Exception:
pass
return None
@extend_schema_field(serializers.DictField(allow_null=True))
def get_card_image(self, obj):
"""Get the card image for this ride with fallback to latest photo."""
# First try the explicitly set card image
if obj.card_image and obj.card_image.image:
return {
"id": obj.card_image.id,
"image_url": obj.card_image.image.url,
"image_variants": {
"thumbnail": f"{obj.card_image.image.url}/thumbnail",
"medium": f"{obj.card_image.image.url}/medium",
"large": f"{obj.card_image.image.url}/large",
"public": f"{obj.card_image.image.url}/public",
},
"caption": obj.card_image.caption,
"alt_text": obj.card_image.alt_text,
"photo_type": obj.card_image.photo_type,
}
# Fallback to latest approved photo
from apps.rides.models import RidePhoto
try:
latest_photo = RidePhoto.objects.filter(
ride=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
if latest_photo and latest_photo.image:
return {
"id": latest_photo.id,
"image_url": latest_photo.image.url,
"image_variants": {
"thumbnail": f"{latest_photo.image.url}/thumbnail",
"medium": f"{latest_photo.image.url}/medium",
"large": f"{latest_photo.image.url}/large",
"public": f"{latest_photo.image.url}/public",
},
"caption": latest_photo.caption,
"alt_text": latest_photo.alt_text,
"photo_type": latest_photo.photo_type,
"is_fallback": True,
}
except Exception:
pass
return None
class RideImageSettingsInputSerializer(serializers.Serializer):
"""Input serializer for setting ride banner and card images."""
banner_image_id = serializers.IntegerField(required=False, allow_null=True)
card_image_id = serializers.IntegerField(required=False, allow_null=True)
def validate_banner_image_id(self, value):
"""Validate that the banner image belongs to the same ride."""
if value is not None:
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.get(id=value)
# The ride will be validated in the view
return value
except RidePhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
return value
def validate_card_image_id(self, value):
"""Validate that the card image belongs to the same ride."""
if value is not None:
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.get(id=value)
# The ride will be validated in the view
return value
except RidePhoto.DoesNotExist:
raise serializers.ValidationError("Photo not found")
return value
class RideCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating rides."""

View File

@@ -102,22 +102,6 @@ class ModelChoices:
("SBNO", "Standing But Not Operating"),
]
@staticmethod
def get_ride_category_choices():
try:
from apps.rides.models import CATEGORY_CHOICES
return CATEGORY_CHOICES
except ImportError:
return [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
]
class LocationOutputSerializer(serializers.Serializer):
"""Shared serializer for location data."""

View File

@@ -1,155 +0,0 @@
"""
Statistics serializers for ThrillWiki API.
Provides serialization for platform statistics data.
"""
from rest_framework import serializers
class StatsSerializer(serializers.Serializer):
"""
Serializer for platform statistics response.
This serializer defines the structure of the statistics API response,
including all the various counts and breakdowns available.
"""
# Core entity counts
total_parks = serializers.IntegerField(
help_text="Total number of parks in the database"
)
total_rides = serializers.IntegerField(
help_text="Total number of rides in the database"
)
total_manufacturers = serializers.IntegerField(
help_text="Total number of ride manufacturers"
)
total_operators = serializers.IntegerField(
help_text="Total number of park operators"
)
total_designers = serializers.IntegerField(
help_text="Total number of ride designers"
)
total_property_owners = serializers.IntegerField(
help_text="Total number of property owners"
)
total_roller_coasters = serializers.IntegerField(
help_text="Total number of roller coasters with detailed stats"
)
# Photo counts
total_photos = serializers.IntegerField(
help_text="Total number of photos (parks + rides combined)"
)
total_park_photos = serializers.IntegerField(
help_text="Total number of park photos"
)
total_ride_photos = serializers.IntegerField(
help_text="Total number of ride photos"
)
# Review counts
total_reviews = serializers.IntegerField(
help_text="Total number of reviews (parks + rides)"
)
total_park_reviews = serializers.IntegerField(
help_text="Total number of park reviews"
)
total_ride_reviews = serializers.IntegerField(
help_text="Total number of ride reviews"
)
# Ride category counts (optional fields since they depend on data)
roller_coasters = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as roller coasters"
)
dark_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as dark rides"
)
flat_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as flat rides"
)
water_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as water rides"
)
transport_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as transport rides"
)
other_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as other"
)
# Park status counts (optional fields since they depend on data)
operating_parks = serializers.IntegerField(
required=False,
help_text="Number of currently operating parks"
)
temporarily_closed_parks = serializers.IntegerField(
required=False,
help_text="Number of temporarily closed parks"
)
permanently_closed_parks = serializers.IntegerField(
required=False,
help_text="Number of permanently closed parks"
)
under_construction_parks = serializers.IntegerField(
required=False,
help_text="Number of parks under construction"
)
demolished_parks = serializers.IntegerField(
required=False,
help_text="Number of demolished parks"
)
relocated_parks = serializers.IntegerField(
required=False,
help_text="Number of relocated parks"
)
# Ride status counts (optional fields since they depend on data)
operating_rides = serializers.IntegerField(
required=False,
help_text="Number of currently operating rides"
)
temporarily_closed_rides = serializers.IntegerField(
required=False,
help_text="Number of temporarily closed rides"
)
sbno_rides = serializers.IntegerField(
required=False,
help_text="Number of rides standing but not operating"
)
closing_rides = serializers.IntegerField(
required=False,
help_text="Number of rides in the process of closing"
)
permanently_closed_rides = serializers.IntegerField(
required=False,
help_text="Number of permanently closed rides"
)
under_construction_rides = serializers.IntegerField(
required=False,
help_text="Number of rides under construction"
)
demolished_rides = serializers.IntegerField(
required=False,
help_text="Number of demolished rides"
)
relocated_rides = serializers.IntegerField(
required=False,
help_text="Number of relocated rides"
)
# Metadata
last_updated = serializers.CharField(
help_text="ISO timestamp when these statistics were last calculated"
)
relative_last_updated = serializers.CharField(
help_text="Human-readable relative time since last update (e.g., '2 minutes ago')"
)

View File

@@ -1,95 +0,0 @@
"""
Django signals for automatically updating statistics cache.
This module contains signal handlers that invalidate the stats cache
whenever relevant entities are created, updated, or deleted.
"""
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
def invalidate_stats_cache():
"""
Invalidate the platform stats cache.
This function is called whenever any entity that affects statistics
is created, updated, or deleted.
"""
cache.delete("platform_stats")
# Also update the timestamp for when stats were last invalidated
from datetime import datetime
cache.set("platform_stats_timestamp", datetime.now().isoformat(), 300)
# Park signals
@receiver(post_save, sender=Park)
@receiver(post_delete, sender=Park)
def park_changed(sender, **kwargs):
"""Handle Park creation/deletion."""
invalidate_stats_cache()
# Ride signals
@receiver(post_save, sender=Ride)
@receiver(post_delete, sender=Ride)
def ride_changed(sender, **kwargs):
"""Handle Ride creation/deletion."""
invalidate_stats_cache()
# Roller coaster stats signals
@receiver(post_save, sender=RollerCoasterStats)
@receiver(post_delete, sender=RollerCoasterStats)
def roller_coaster_stats_changed(sender, **kwargs):
"""Handle RollerCoasterStats creation/deletion."""
invalidate_stats_cache()
# Company signals (both park and ride companies)
@receiver(post_save, sender=ParkCompany)
@receiver(post_delete, sender=ParkCompany)
def park_company_changed(sender, **kwargs):
"""Handle ParkCompany creation/deletion."""
invalidate_stats_cache()
@receiver(post_save, sender=RideCompany)
@receiver(post_delete, sender=RideCompany)
def ride_company_changed(sender, **kwargs):
"""Handle RideCompany creation/deletion."""
invalidate_stats_cache()
# Photo signals
@receiver(post_save, sender=ParkPhoto)
@receiver(post_delete, sender=ParkPhoto)
def park_photo_changed(sender, **kwargs):
"""Handle ParkPhoto creation/deletion."""
invalidate_stats_cache()
@receiver(post_save, sender=RidePhoto)
@receiver(post_delete, sender=RidePhoto)
def ride_photo_changed(sender, **kwargs):
"""Handle RidePhoto creation/deletion."""
invalidate_stats_cache()
# Review signals
@receiver(post_save, sender=ParkReview)
@receiver(post_delete, sender=ParkReview)
def park_review_changed(sender, **kwargs):
"""Handle ParkReview creation/deletion."""
invalidate_stats_cache()
@receiver(post_save, sender=RideReview)
@receiver(post_delete, sender=RideReview)
def ride_review_changed(sender, **kwargs):
"""Handle RideReview creation/deletion."""
invalidate_stats_cache()

View File

@@ -22,7 +22,6 @@ from .views import (
TrendingAPIView,
NewContentAPIView,
)
from .views.stats import StatsAPIView, StatsRecalculateAPIView
from django.urls import path, include
from rest_framework.routers import DefaultRouter
@@ -59,9 +58,6 @@ urlpatterns = [
# Trending system endpoints
path("trending/content/", TrendingAPIView.as_view(), name="trending"),
path("trending/new/", NewContentAPIView.as_view(), name="new-content"),
# Statistics endpoints
path("stats/", StatsAPIView.as_view(), name="stats"),
path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"),
# Ranking system endpoints
path(
"rankings/calculate/",

View File

@@ -302,15 +302,6 @@ class SocialProvidersAPIView(APIView):
def get(self, request: Request) -> Response:
from django.core.cache import cache
try:
# Check if django-allauth is available
try:
from allauth.socialaccount.models import SocialApp
except ImportError:
# django-allauth is not installed, return empty list
serializer = SocialProviderOutputSerializer([], many=True)
return Response(serializer.data)
site = get_current_site(request._request) # type: ignore[attr-defined]
# Cache key based on site and request host
@@ -326,11 +317,9 @@ class SocialProvidersAPIView(APIView):
providers_list = []
# Optimized query: filter by site and order by provider name
try:
from allauth.socialaccount.models import SocialApp
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
except Exception:
# If query fails (table doesn't exist, etc.), return empty list
social_apps = []
for social_app in social_apps:
try:
@@ -363,22 +352,6 @@ class SocialProvidersAPIView(APIView):
return Response(response_data)
except Exception as e:
# Return a proper JSON error response instead of letting it bubble up
return Response(
{
"status": "error",
"error": {
"code": "SOCIAL_PROVIDERS_ERROR",
"message": "Unable to retrieve social providers",
"details": str(e) if str(e) else None,
"request_user": str(request.user) if hasattr(request, 'user') else "AnonymousUser",
},
"data": None,
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@extend_schema_view(
post=extend_schema(

View File

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

View File

@@ -1,358 +0,0 @@
"""
Statistics API views for ThrillWiki.
Provides aggregate statistics about the platform's content including
counts of parks, rides, manufacturers, and other entities.
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAdminUser
from django.db.models import Count, Q
from django.core.cache import cache
from django.utils import timezone
from drf_spectacular.utils import extend_schema, OpenApiExample
from datetime import datetime, timedelta
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
from ..serializers.stats import StatsSerializer
class StatsAPIView(APIView):
"""
API endpoint that returns aggregate statistics about the platform.
Returns counts of various entities like parks, rides, manufacturers, etc.
Results are cached for performance.
"""
permission_classes = [AllowAny]
def _get_relative_time(self, timestamp_str):
"""
Convert an ISO timestamp to a human-readable relative time.
Args:
timestamp_str: ISO format timestamp string
Returns:
str: Human-readable relative time (e.g., "2 days, 3 hours, 15 minutes ago", "just now")
"""
if not timestamp_str or timestamp_str == 'just_now':
return 'just now'
try:
# Parse the ISO timestamp
if isinstance(timestamp_str, str):
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
else:
timestamp = timestamp_str
# Make timezone-aware if needed
if timestamp.tzinfo is None:
timestamp = timezone.make_aware(timestamp)
now = timezone.now()
diff = now - timestamp
total_seconds = int(diff.total_seconds())
# If less than a minute, return "just now"
if total_seconds < 60:
return 'just now'
# Calculate time components
days = diff.days
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
# Build the relative time string
parts = []
if days > 0:
parts.append(f'{days} day{"s" if days != 1 else ""}')
if hours > 0:
parts.append(f'{hours} hour{"s" if hours != 1 else ""}')
if minutes > 0:
parts.append(f'{minutes} minute{"s" if minutes != 1 else ""}')
# Join parts with commas and add "ago"
if len(parts) == 0:
return 'just now'
elif len(parts) == 1:
return f'{parts[0]} ago'
elif len(parts) == 2:
return f'{parts[0]} and {parts[1]} ago'
else:
return f'{", ".join(parts[:-1])}, and {parts[-1]} ago'
except (ValueError, TypeError):
return 'unknown'
@extend_schema(
operation_id="get_platform_stats",
summary="Get platform statistics",
description="""
Returns comprehensive aggregate statistics about the ThrillWiki platform.
This endpoint provides detailed counts and breakdowns of all major entities including:
- Parks, rides, and roller coasters
- Companies (manufacturers, operators, designers, property owners)
- Photos and reviews
- Ride categories (roller coasters, dark rides, flat rides, etc.)
- Status breakdowns (operating, closed, under construction, etc.)
Results are cached for 5 minutes for optimal performance and automatically
invalidated when relevant data changes.
**No authentication required** - this is a public endpoint.
""".strip(),
responses={
200: StatsSerializer,
500: {
"type": "object",
"properties": {
"error": {"type": "string", "description": "Error message if statistics calculation fails"}
}
}
},
tags=["Statistics"],
examples=[
OpenApiExample(
name="Sample Response",
description="Example of platform statistics response",
value={
"total_parks": 7,
"total_rides": 10,
"total_manufacturers": 6,
"total_operators": 7,
"total_designers": 4,
"total_property_owners": 0,
"total_roller_coasters": 8,
"total_photos": 0,
"total_park_photos": 0,
"total_ride_photos": 0,
"total_reviews": 8,
"total_park_reviews": 4,
"total_ride_reviews": 4,
"roller_coasters": 10,
"operating_parks": 7,
"operating_rides": 10,
"last_updated": "2025-08-28T17:34:59.677143+00:00",
"relative_last_updated": "just now"
}
)
]
)
def get(self, request):
"""Get platform statistics."""
# Try to get cached stats first
cache_key = "platform_stats"
cached_stats = cache.get(cache_key)
if cached_stats:
return Response(cached_stats, status=status.HTTP_200_OK)
# Calculate fresh stats
stats = self._calculate_stats()
# Cache for 5 minutes
cache.set(cache_key, stats, 300)
return Response(stats, status=status.HTTP_200_OK)
def _calculate_stats(self):
"""Calculate all platform statistics."""
# Basic entity counts
total_parks = Park.objects.count()
total_rides = Ride.objects.count()
# Company counts by role
total_manufacturers = RideCompany.objects.filter(
roles__contains=["MANUFACTURER"]
).count()
total_operators = ParkCompany.objects.filter(
roles__contains=["OPERATOR"]
).count()
total_designers = RideCompany.objects.filter(
roles__contains=["DESIGNER"]
).count()
total_property_owners = ParkCompany.objects.filter(
roles__contains=["PROPERTY_OWNER"]
).count()
# Photo counts (combined)
total_park_photos = ParkPhoto.objects.count()
total_ride_photos = RidePhoto.objects.count()
total_photos = total_park_photos + total_ride_photos
# Ride type counts
total_roller_coasters = RollerCoasterStats.objects.count()
# Ride category counts
ride_categories = Ride.objects.values('category').annotate(
count=Count('id')
).exclude(category='')
category_stats = {}
for category in ride_categories:
category_code = category['category']
category_count = category['count']
# Convert category codes to readable names
category_names = {
'RC': 'roller_coasters',
'DR': 'dark_rides',
'FR': 'flat_rides',
'WR': 'water_rides',
'TR': 'transport_rides',
'OT': 'other_rides'
}
category_name = category_names.get(
category_code, f'category_{category_code.lower()}')
category_stats[category_name] = category_count
# Park status counts
park_statuses = Park.objects.values('status').annotate(
count=Count('id')
)
park_status_stats = {}
for status_item in park_statuses:
status_code = status_item['status']
status_count = status_item['count']
# Convert status codes to readable names
status_names = {
'OPERATING': 'operating_parks',
'CLOSED_TEMP': 'temporarily_closed_parks',
'CLOSED_PERM': 'permanently_closed_parks',
'UNDER_CONSTRUCTION': 'under_construction_parks',
'DEMOLISHED': 'demolished_parks',
'RELOCATED': 'relocated_parks'
}
status_name = status_names.get(status_code, f'status_{status_code.lower()}')
park_status_stats[status_name] = status_count
# Ride status counts
ride_statuses = Ride.objects.values('status').annotate(
count=Count('id')
)
ride_status_stats = {}
for status_item in ride_statuses:
status_code = status_item['status']
status_count = status_item['count']
# Convert status codes to readable names
status_names = {
'OPERATING': 'operating_rides',
'CLOSED_TEMP': 'temporarily_closed_rides',
'SBNO': 'sbno_rides',
'CLOSING': 'closing_rides',
'CLOSED_PERM': 'permanently_closed_rides',
'UNDER_CONSTRUCTION': 'under_construction_rides',
'DEMOLISHED': 'demolished_rides',
'RELOCATED': 'relocated_rides'
}
status_name = status_names.get(
status_code, f'ride_status_{status_code.lower()}')
ride_status_stats[status_name] = status_count
# Review counts
total_park_reviews = ParkReview.objects.count()
total_ride_reviews = RideReview.objects.count()
total_reviews = total_park_reviews + total_ride_reviews
# Timestamp handling
now = timezone.now()
last_updated_iso = now.isoformat()
# Get cached timestamp or use current time
cached_timestamp = cache.get('platform_stats_timestamp')
if cached_timestamp and cached_timestamp != 'just_now':
# Use cached timestamp for consistency
last_updated_iso = cached_timestamp
else:
# Set new timestamp in cache
cache.set('platform_stats_timestamp', last_updated_iso, 300)
# Calculate relative time
relative_last_updated = self._get_relative_time(last_updated_iso)
# Combine all stats
stats = {
# Core entity counts
'total_parks': total_parks,
'total_rides': total_rides,
'total_manufacturers': total_manufacturers,
'total_operators': total_operators,
'total_designers': total_designers,
'total_property_owners': total_property_owners,
'total_roller_coasters': total_roller_coasters,
# Photo counts
'total_photos': total_photos,
'total_park_photos': total_park_photos,
'total_ride_photos': total_ride_photos,
# Review counts
'total_reviews': total_reviews,
'total_park_reviews': total_park_reviews,
'total_ride_reviews': total_ride_reviews,
# Category breakdowns
**category_stats,
# Status breakdowns
**park_status_stats,
**ride_status_stats,
# Metadata
'last_updated': last_updated_iso,
'relative_last_updated': relative_last_updated
}
return stats
class StatsRecalculateAPIView(APIView):
"""
Admin-only API endpoint to force recalculation of platform statistics.
This endpoint clears the cache and forces a fresh calculation of all statistics.
Only accessible to admin users.
"""
permission_classes = [IsAdminUser]
@extend_schema(exclude=True)
def post(self, request):
"""Force recalculation of platform statistics."""
# Clear the cache
cache.delete("platform_stats")
cache.delete("platform_stats_timestamp")
# Create a new StatsAPIView instance to reuse the calculation logic
stats_view = StatsAPIView()
fresh_stats = stats_view._calculate_stats()
# Cache the fresh stats
cache.set("platform_stats", fresh_stats, 300)
# Return success response with the fresh stats
return Response({
"message": "Platform statistics have been successfully recalculated",
"stats": fresh_stats,
"recalculated_at": timezone.now().isoformat()
}, status=status.HTTP_200_OK)

View File

@@ -137,37 +137,6 @@ def custom_exception_handler(
)
response = Response(custom_response_data, status=status.HTTP_403_FORBIDDEN)
# Catch-all for any other exceptions that might slip through
# This ensures we ALWAYS return JSON for API endpoints
else:
# Check if this is an API request by looking at the URL path
request = context.get("request")
if request and hasattr(request, "path") and "/api/" in request.path:
# This is an API request, so we must return JSON
custom_response_data = {
"status": "error",
"error": {
"code": exc.__class__.__name__.upper(),
"message": str(exc) if str(exc) else "An unexpected error occurred",
"details": None,
},
"data": None,
}
# Add request context for debugging
if hasattr(request, "user"):
custom_response_data["error"]["request_user"] = str(request.user)
# Log the error for monitoring
log_exception(
logger,
exc,
context={"response_status": status.HTTP_500_INTERNAL_SERVER_ERROR},
request=request,
)
response = Response(custom_response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return response

View File

@@ -7,11 +7,9 @@ including view tracking and other core functionality.
from .view_tracking import ViewTrackingMiddleware, get_view_stats_for_content
from .analytics import PgHistoryContextMiddleware
from .nextjs import APIResponseMiddleware
__all__ = [
"ViewTrackingMiddleware",
"get_view_stats_for_content",
"PgHistoryContextMiddleware",
"APIResponseMiddleware",
]

View File

@@ -38,8 +38,5 @@ class PgHistoryContextMiddleware:
self.get_response = get_response
def __call__(self, request):
# Set the pghistory context with request information
context_data = request_context(request)
with pghistory.context(**context_data):
response = self.get_response(request)
return response

View File

@@ -1,48 +0,0 @@
# backend/apps/core/middleware.py
from django.utils.deprecation import MiddlewareMixin
class APIResponseMiddleware(MiddlewareMixin):
"""
Middleware to ensure consistent API responses for Next.js
"""
def process_response(self, request, response):
# Only process API requests
if not request.path.startswith("/api/"):
return response
# Ensure CORS headers are set
if not response.has_header("Access-Control-Allow-Origin"):
origin = request.META.get("HTTP_ORIGIN")
# Allow localhost/127.0.0.1 (any port) and IPv6 loopback for development
if origin:
import re
# support http or https, IPv4 and IPv6 loopback, any port
localhost_pattern = r"^https?://(localhost|127\.0\.0\.1|\[::1\]):\d+"
if re.match(localhost_pattern, origin):
response["Access-Control-Allow-Origin"] = origin
# Ensure caches vary by Origin
existing_vary = response.get("Vary")
if existing_vary:
response["Vary"] = f"{existing_vary}, Origin"
else:
response["Vary"] = "Origin"
# Helpful dev CORS headers (adjust for your frontend requests)
response["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
)
response["Access-Control-Allow-Headers"] = (
"Authorization, Content-Type, X-Requested-With"
)
# Uncomment if your dev frontend needs to send cookies/auth credentials
# response['Access-Control-Allow-Credentials'] = 'true'
else:
response["Access-Control-Allow-Origin"] = "null"
return response

View File

@@ -21,6 +21,7 @@ class PerformanceMiddleware(MiddlewareMixin):
request._performance_initial_queries = (
len(connection.queries) if hasattr(connection, "queries") else 0
)
return None
def process_response(self, request, response):
"""Log performance metrics after response is ready"""
@@ -157,7 +158,7 @@ class PerformanceMiddleware(MiddlewareMixin):
extra=performance_data,
)
# Don't return anything - let the exception propagate normally
return None # Don't handle the exception, just log it
def _get_client_ip(self, request):
"""Extract client IP address from request"""
@@ -200,6 +201,7 @@ class QueryCountMiddleware(MiddlewareMixin):
request._query_count_start = (
len(connection.queries) if hasattr(connection, "queries") else 0
)
return None
def process_response(self, request, response):
"""Check query count and warn if excessive"""
@@ -251,6 +253,8 @@ class DatabaseConnectionMiddleware(MiddlewareMixin):
)
# Don't block the request, let Django handle the database error
return None
def process_response(self, request, response):
"""Close database connections properly"""
try:
@@ -271,6 +275,7 @@ class CachePerformanceMiddleware(MiddlewareMixin):
request._cache_hits = 0
request._cache_misses = 0
request._cache_start_time = time.time()
return None
def process_response(self, request, response):
"""Log cache performance metrics"""

View File

@@ -280,11 +280,8 @@ class CacheMonitor:
stats = {}
try:
# Try to get Redis cache stats
cache_backend = self.cache_service.default_cache.__class__.__name__
if "Redis" in cache_backend:
# Attempt to get Redis client and stats
# Redis cache stats
if hasattr(self.cache_service.default_cache, "_cache"):
redis_client = self.cache_service.default_cache._cache.get_client()
info = redis_client.info()
stats["redis"] = {
@@ -300,16 +297,8 @@ class CacheMonitor:
misses = info.get("keyspace_misses", 0)
if hits + misses > 0:
stats["redis"]["hit_rate"] = hits / (hits + misses) * 100
else:
# For local memory cache or other backends
stats["cache_backend"] = cache_backend
stats["message"] = f"Cache statistics not available for {cache_backend}"
except Exception as e:
# Don't log as error since this is expected for non-Redis backends
cache_backend = self.cache_service.default_cache.__class__.__name__
stats["cache_backend"] = cache_backend
stats["message"] = f"Cache statistics not available for {cache_backend}"
logger.error(f"Error getting cache stats: {e}")
return stats

View File

@@ -1,32 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 18:17
import cloudflare_images.field
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0008_parkphoto_parkphotoevent_and_more"),
]
operations = [
migrations.AlterField(
model_name="parkphoto",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Park photo stored on Cloudflare Images",
upload_to="",
variant="public",
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Park photo stored on Cloudflare Images",
upload_to="",
variant="public",
),
),
]

View File

@@ -1,105 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 18:35
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0009_cloudflare_images_integration"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="update_update",
),
migrations.AddField(
model_name="park",
name="banner_image",
field=models.ForeignKey(
blank=True,
help_text="Photo to use as banner image for this park",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="parks_using_as_banner",
to="parks.parkphoto",
),
),
migrations.AddField(
model_name="park",
name="card_image",
field=models.ForeignKey(
blank=True,
help_text="Photo to use as card image for this park",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="parks_using_as_card",
to="parks.parkphoto",
),
),
migrations.AddField(
model_name="parkevent",
name="banner_image",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Photo to use as banner image for this park",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.parkphoto",
),
),
migrations.AddField(
model_name="parkevent",
name="card_image",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Photo to use as card image for this park",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.parkphoto",
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="291a6e8efb89a33ee43bff05f44598a7814a05f0",
operation="INSERT",
pgid="pgtrigger_insert_insert_66883",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="a689acf5a74ebd3aa7ad333881edb99778185da2",
operation="UPDATE",
pgid="pgtrigger_update_update_19f56",
table="parks_park",
when="AFTER",
),
),
),
]

View File

@@ -9,7 +9,6 @@ from django.db import models
from django.conf import settings
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
from cloudflare_images.field import CloudflareImagesField
import pghistory
@@ -34,9 +33,9 @@ class ParkPhoto(TrackedModel):
"parks.Park", on_delete=models.CASCADE, related_name="photos"
)
image = CloudflareImagesField(
variant="public",
help_text="Park photo stored on Cloudflare Images"
image = models.ImageField(
upload_to=park_photo_upload_path,
max_length=255,
)
caption = models.CharField(max_length=255, blank=True)
@@ -57,7 +56,7 @@ class ParkPhoto(TrackedModel):
related_name="uploaded_park_photos",
)
class Meta(TrackedModel.Meta):
class Meta:
app_label = "parks"
ordering = ["-is_primary", "-created_at"]
indexes = [

View File

@@ -54,24 +54,6 @@ class Park(TrackedModel):
ride_count = models.IntegerField(null=True, blank=True)
coaster_count = models.IntegerField(null=True, blank=True)
# Image settings - references to existing photos
banner_image = models.ForeignKey(
"ParkPhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="parks_using_as_banner",
help_text="Photo to use as banner image for this park"
)
card_image = models.ForeignKey(
"ParkPhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="parks_using_as_card",
help_text="Photo to use as card image for this park"
)
# Relationships
operator = models.ForeignKey(
"Company",

View File

@@ -1,32 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 18:17
import cloudflare_images.field
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0007_ridephoto_ridephotoevent_and_more"),
]
operations = [
migrations.AlterField(
model_name="ridephoto",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Ride photo stored on Cloudflare Images",
upload_to="",
variant="public",
),
),
migrations.AlterField(
model_name="ridephotoevent",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Ride photo stored on Cloudflare Images",
upload_to="",
variant="public",
),
),
]

View File

@@ -1,105 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 18:35
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0008_cloudflare_images_integration"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="banner_image",
field=models.ForeignKey(
blank=True,
help_text="Photo to use as banner image for this ride",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides_using_as_banner",
to="rides.ridephoto",
),
),
migrations.AddField(
model_name="ride",
name="card_image",
field=models.ForeignKey(
blank=True,
help_text="Photo to use as card image for this ride",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides_using_as_card",
to="rides.ridephoto",
),
),
migrations.AddField(
model_name="rideevent",
name="banner_image",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Photo to use as banner image for this ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridephoto",
),
),
migrations.AddField(
model_name="rideevent",
name="card_image",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Photo to use as card image for this ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridephoto",
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="462120d462bacf795e3e8d2d48e56a8adb85c63b",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at"); RETURN NULL;',
hash="dc36bcf1b24242b781d63799024095b0f8da79b6",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -1,48 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 19:10
from django.db import migrations
from django.utils.text import slugify
def populate_ride_model_slugs(apps, schema_editor):
"""Populate unique slugs for existing RideModel records."""
RideModel = apps.get_model('rides', 'RideModel')
Company = apps.get_model('rides', 'Company')
for ride_model in RideModel.objects.all():
# Generate base slug from manufacturer name + model name
if ride_model.manufacturer:
base_slug = slugify(f"{ride_model.manufacturer.name} {ride_model.name}")
else:
base_slug = slugify(ride_model.name)
# Ensure uniqueness
slug = base_slug
counter = 1
while RideModel.objects.filter(slug=slug).exclude(pk=ride_model.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
# Update the slug
ride_model.slug = slug
ride_model.save(update_fields=['slug'])
def reverse_populate_ride_model_slugs(apps, schema_editor):
"""Reverse operation - clear slugs (not really needed but for completeness)."""
RideModel = apps.get_model('rides', 'RideModel')
RideModel.objects.all().update(slug='')
class Migration(migrations.Migration):
dependencies = [
("rides", "0010_add_comprehensive_ride_model_system"),
]
operations = [
migrations.RunPython(
populate_ride_model_slugs,
reverse_populate_ride_model_slugs,
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0011_populate_ride_model_slugs"),
]
operations = [
migrations.AlterField(
model_name="ridemodel",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
]

View File

@@ -1,38 +0,0 @@
# 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")},
),
]

View File

@@ -1,64 +0,0 @@
# 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,
),
]

View File

@@ -9,7 +9,6 @@ from django.db import models
from django.conf import settings
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
from cloudflare_images.field import CloudflareImagesField
import pghistory
@@ -37,9 +36,9 @@ class RidePhoto(TrackedModel):
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
)
image = CloudflareImagesField(
variant="public",
help_text="Ride photo stored on Cloudflare Images"
image = models.ImageField(
upload_to=ride_photo_upload_path,
max_length=255,
)
caption = models.CharField(max_length=255, blank=True)
@@ -74,7 +73,7 @@ class RidePhoto(TrackedModel):
related_name="uploaded_ride_photos",
)
class Meta(TrackedModel.Meta):
class Meta:
app_label = "rides"
ordering = ["-is_primary", "-created_at"]
indexes = [

View File

@@ -23,15 +23,11 @@ Categories = CATEGORY_CHOICES
class RideModel(TrackedModel):
"""
Represents a specific model/type of ride that can be manufactured by different
companies. This serves as a catalog of ride designs that can be referenced
by individual ride installations.
For example: B&M Dive Coaster, Vekoma Boomerang, RMC I-Box, etc.
companies.
For example: B&M Dive Coaster, Vekoma Boomerang, etc.
"""
name = models.CharField(max_length=255, help_text="Name of the ride model")
slug = models.SlugField(max_length=255,
help_text="URL-friendly identifier (unique within manufacturer)")
name = models.CharField(max_length=255)
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
@@ -39,157 +35,15 @@ class RideModel(TrackedModel):
null=True,
blank=True,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
help_text="Primary manufacturer of this ride model"
)
description = models.TextField(
blank=True, help_text="Detailed description of the ride model")
description = models.TextField(blank=True)
category = models.CharField(
max_length=2,
choices=CATEGORY_CHOICES,
default="",
blank=True,
help_text="Primary category classification"
)
# Technical specifications
typical_height_range_min_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
help_text="Minimum typical height in feet for this model"
)
typical_height_range_max_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
help_text="Maximum typical height in feet for this model"
)
typical_speed_range_min_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Minimum typical speed in mph for this model"
)
typical_speed_range_max_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Maximum typical speed in mph for this model"
)
typical_capacity_range_min = models.PositiveIntegerField(
null=True, blank=True,
help_text="Minimum typical hourly capacity for this model"
)
typical_capacity_range_max = models.PositiveIntegerField(
null=True, blank=True,
help_text="Maximum typical hourly capacity for this model"
)
# Design characteristics
track_type = models.CharField(
max_length=100, blank=True,
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)"
)
support_structure = models.CharField(
max_length=100, blank=True,
help_text="Type of support structure (e.g., steel, wooden, hybrid)"
)
train_configuration = models.CharField(
max_length=200, blank=True,
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)"
)
restraint_system = models.CharField(
max_length=100, blank=True,
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)"
)
# Market information
first_installation_year = models.PositiveIntegerField(
null=True, blank=True,
help_text="Year of first installation of this model"
)
last_installation_year = models.PositiveIntegerField(
null=True, blank=True,
help_text="Year of last installation of this model (if discontinued)"
)
is_discontinued = models.BooleanField(
default=False,
help_text="Whether this model is no longer being manufactured"
)
total_installations = models.PositiveIntegerField(
default=0,
help_text="Total number of installations worldwide (auto-calculated)"
)
# Design features
notable_features = models.TextField(
blank=True,
help_text="Notable design features or innovations (JSON or comma-separated)"
)
target_market = models.CharField(
max_length=50, blank=True,
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
],
help_text="Primary target market for this ride model"
)
# Media
primary_image = models.ForeignKey(
'RideModelPhoto',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='ride_models_as_primary',
help_text="Primary promotional image for this ride model"
)
# SEO and metadata
meta_title = models.CharField(
max_length=60, blank=True,
help_text="SEO meta title (auto-generated if blank)"
)
meta_description = models.CharField(
max_length=160, blank=True,
help_text="SEO meta description (auto-generated if blank)"
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
)
class Meta(TrackedModel.Meta):
ordering = ["manufacturer__name", "name"]
unique_together = [
["manufacturer", "name"],
["manufacturer", "slug"]
]
constraints = [
# Height range validation
models.CheckConstraint(
name="ride_model_height_range_logical",
condition=models.Q(typical_height_range_min_ft__isnull=True)
| models.Q(typical_height_range_max_ft__isnull=True)
| models.Q(typical_height_range_min_ft__lte=models.F("typical_height_range_max_ft")),
violation_error_message="Minimum height cannot exceed maximum height",
),
# Speed range validation
models.CheckConstraint(
name="ride_model_speed_range_logical",
condition=models.Q(typical_speed_range_min_mph__isnull=True)
| models.Q(typical_speed_range_max_mph__isnull=True)
| models.Q(typical_speed_range_min_mph__lte=models.F("typical_speed_range_max_mph")),
violation_error_message="Minimum speed cannot exceed maximum speed",
),
# Capacity range validation
models.CheckConstraint(
name="ride_model_capacity_range_logical",
condition=models.Q(typical_capacity_range_min__isnull=True)
| models.Q(typical_capacity_range_max__isnull=True)
| models.Q(typical_capacity_range_min__lte=models.F("typical_capacity_range_max")),
violation_error_message="Minimum capacity cannot exceed maximum capacity",
),
# Installation years validation
models.CheckConstraint(
name="ride_model_installation_years_logical",
condition=models.Q(first_installation_year__isnull=True)
| models.Q(last_installation_year__isnull=True)
| models.Q(first_installation_year__lte=models.F("last_installation_year")),
violation_error_message="First installation year cannot be after last installation year",
),
]
ordering = ["manufacturer", "name"]
unique_together = ["manufacturer", "name"]
def __str__(self) -> str:
return (
@@ -198,214 +52,6 @@ class RideModel(TrackedModel):
else f"{self.manufacturer.name} {self.name}"
)
def save(self, *args, **kwargs) -> None:
if not self.slug:
from django.utils.text import slugify
# Only use the ride model name for the slug, not manufacturer
base_slug = slugify(self.name)
self.slug = base_slug
# Ensure uniqueness within the same manufacturer
counter = 1
while RideModel.objects.filter(
manufacturer=self.manufacturer,
slug=self.slug
).exclude(pk=self.pk).exists():
self.slug = f"{base_slug}-{counter}"
counter += 1
# Auto-generate meta fields if blank
if not self.meta_title:
self.meta_title = str(self)[:60]
if not self.meta_description:
desc = f"{self} - {self.description[:100]}" if self.description else str(
self)
self.meta_description = desc[:160]
super().save(*args, **kwargs)
def update_installation_count(self) -> None:
"""Update the total installations count based on actual ride instances."""
# Import here to avoid circular import
from django.apps import apps
Ride = apps.get_model('rides', 'Ride')
self.total_installations = Ride.objects.filter(ride_model=self).count()
self.save(update_fields=['total_installations'])
@property
def installation_years_range(self) -> str:
"""Get a formatted string of installation years range."""
if self.first_installation_year and self.last_installation_year:
return f"{self.first_installation_year}-{self.last_installation_year}"
elif self.first_installation_year:
return f"{self.first_installation_year}-present" if not self.is_discontinued else f"{self.first_installation_year}+"
return "Unknown"
@property
def height_range_display(self) -> str:
"""Get a formatted string of height range."""
if self.typical_height_range_min_ft and self.typical_height_range_max_ft:
return f"{self.typical_height_range_min_ft}-{self.typical_height_range_max_ft} ft"
elif self.typical_height_range_min_ft:
return f"{self.typical_height_range_min_ft}+ ft"
elif self.typical_height_range_max_ft:
return f"Up to {self.typical_height_range_max_ft} ft"
return "Variable"
@property
def speed_range_display(self) -> str:
"""Get a formatted string of speed range."""
if self.typical_speed_range_min_mph and self.typical_speed_range_max_mph:
return f"{self.typical_speed_range_min_mph}-{self.typical_speed_range_max_mph} mph"
elif self.typical_speed_range_min_mph:
return f"{self.typical_speed_range_min_mph}+ mph"
elif self.typical_speed_range_max_mph:
return f"Up to {self.typical_speed_range_max_mph} mph"
return "Variable"
@pghistory.track()
class RideModelVariant(TrackedModel):
"""
Represents specific variants or configurations of a ride model.
For example: B&M Hyper Coaster might have variants like "Mega Coaster", "Giga Coaster"
"""
ride_model = models.ForeignKey(
RideModel,
on_delete=models.CASCADE,
related_name="variants"
)
name = models.CharField(max_length=255, help_text="Name of this variant")
description = models.TextField(
blank=True, help_text="Description of variant differences")
# Variant-specific specifications
min_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
max_height_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True
)
min_speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
max_speed_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
# Distinguishing features
distinguishing_features = models.TextField(
blank=True,
help_text="What makes this variant unique from the base model"
)
class Meta(TrackedModel.Meta):
ordering = ["ride_model", "name"]
unique_together = ["ride_model", "name"]
def __str__(self) -> str:
return f"{self.ride_model} - {self.name}"
@pghistory.track()
class RideModelPhoto(TrackedModel):
"""Photos associated with ride models for catalog/promotional purposes."""
ride_model = models.ForeignKey(
RideModel,
on_delete=models.CASCADE,
related_name="photos"
)
image = models.ImageField(
upload_to="ride_models/photos/",
help_text="Photo of the ride model"
)
caption = models.CharField(max_length=500, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
# Photo metadata
photo_type = models.CharField(
max_length=20,
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
],
default='PROMOTIONAL'
)
is_primary = models.BooleanField(
default=False,
help_text="Whether this is the primary photo for the ride model"
)
# Attribution
photographer = models.CharField(max_length=255, blank=True)
source = models.CharField(max_length=255, blank=True)
copyright_info = models.CharField(max_length=255, blank=True)
class Meta(TrackedModel.Meta):
ordering = ["-is_primary", "-created_at"]
def __str__(self) -> str:
return f"Photo of {self.ride_model.name}"
def save(self, *args, **kwargs) -> None:
# Ensure only one primary photo per ride model
if self.is_primary:
RideModelPhoto.objects.filter(
ride_model=self.ride_model,
is_primary=True
).exclude(pk=self.pk).update(is_primary=False)
super().save(*args, **kwargs)
@pghistory.track()
class RideModelTechnicalSpec(TrackedModel):
"""
Technical specifications for ride models that don't fit in the main model.
This allows for flexible specification storage.
"""
ride_model = models.ForeignKey(
RideModel,
on_delete=models.CASCADE,
related_name="technical_specs"
)
spec_category = models.CharField(
max_length=50,
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
]
)
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
spec_value = models.CharField(
max_length=255, help_text="Value of the specification")
spec_unit = models.CharField(max_length=20, blank=True,
help_text="Unit of measurement")
notes = models.TextField(
blank=True, help_text="Additional notes about this specification")
class Meta(TrackedModel.Meta):
ordering = ["spec_category", "spec_name"]
unique_together = ["ride_model", "spec_category", "spec_name"]
def __str__(self) -> str:
unit_str = f" {self.spec_unit}" if self.spec_unit else ""
return f"{self.ride_model.name} - {self.spec_name}: {self.spec_value}{unit_str}"
@pghistory.track()
class Ride(TrackedModel):
@@ -493,24 +139,6 @@ class Ride(TrackedModel):
max_digits=3, decimal_places=2, null=True, blank=True
)
# Image settings - references to existing photos
banner_image = models.ForeignKey(
"RidePhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rides_using_as_banner",
help_text="Photo to use as banner image for this ride"
)
card_image = models.ForeignKey(
"RidePhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rides_using_as_card",
help_text="Photo to use as card image for this ride"
)
class Meta(TrackedModel.Meta):
ordering = ["name"]
unique_together = ["park", "slug"]

View File

@@ -47,8 +47,7 @@ SECRET_KEY = config("SECRET_KEY")
ALLOWED_HOSTS = config("ALLOWED_HOSTS")
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS",
default=[]) # type: ignore[arg-type]
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default=[]) # type: ignore[arg-type]
# Application definition
DJANGO_APPS = [
@@ -111,7 +110,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.analytics.PgHistoryContextMiddleware", # Add history context tracking
"core.middleware.PgHistoryContextMiddleware", # Add history context tracking
"allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
"django_htmx.middleware.HtmxMiddleware",
@@ -306,7 +305,7 @@ REST_FRAMEWORK = {
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
],
"EXCEPTION_HANDLER": "apps.core.api.exceptions.custom_exception_handler",
"EXCEPTION_HANDLER": "core.api.exceptions.custom_exception_handler",
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
@@ -318,17 +317,13 @@ REST_FRAMEWORK = {
}
# CORS Settings for API
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS",
default=[]) # type: ignore[arg-type]
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default=[]) # type: ignore[arg-type]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = config(
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool) # type: ignore[arg-type]
CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool) # type: ignore[arg-type]
API_RATE_LIMIT_PER_MINUTE = config(
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int) # type: ignore[arg-type]
API_RATE_LIMIT_PER_HOUR = config(
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int) # type: ignore[arg-type]
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60, cast=int) # type: ignore[arg-type]
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000, cast=int) # type: ignore[arg-type]
SPECTACULAR_SETTINGS = {
"TITLE": "ThrillWiki API",
"DESCRIPTION": "Comprehensive theme park and ride information API",

View File

@@ -4,6 +4,9 @@ Local development settings for thrillwiki project.
from ..settings import database
import logging
import os
from decouple import config
import re
from .base import (
BASE_DIR,
INSTALLED_APPS,
@@ -45,6 +48,31 @@ CSRF_TRUSTED_ORIGINS = [
"https://beta.thrillwiki.com",
]
CORS_ALLOWED_ORIGIN_REGEXES = [
# Matches http://localhost:3000, http://localhost:3001, etc.
r"^http://localhost:\d+$",
# Matches http://127.0.0.1:3000, http://127.0.0.1:8080, etc.
r"^http://127\.0\.0\.1:\d+$",
]
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
'x-nextjs-data', # Next.js specific header
]
if DEBUG:
CORS_ALLOW_ALL_ORIGINS = True # ⚠️ Only for development!
else:
CORS_ALLOW_ALL_ORIGINS = False
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
@@ -103,7 +131,6 @@ DEVELOPMENT_MIDDLEWARE = [
"nplusone.ext.django.NPlusOneMiddleware",
"core.middleware.performance_middleware.PerformanceMiddleware",
"core.middleware.performance_middleware.QueryCountMiddleware",
"core.middleware.nextjs.APIResponseMiddleware", # Add this
]
# Add development middleware

View File

@@ -1,574 +0,0 @@
# Cloudflare Images Integration
## Overview
This document describes the complete integration of django-cloudflare-images into the ThrillWiki project for both rides and parks models, including full API schema metadata support.
## Implementation Summary
### 1. Models Updated
#### Rides Models (`backend/apps/rides/models/media.py`)
- **RidePhoto.image**: Changed from `models.ImageField` to `CloudflareImagesField(variant="public")`
- Added proper Meta class inheritance from `TrackedModel.Meta`
- Maintains all existing functionality while leveraging Cloudflare Images
#### Parks Models (`backend/apps/parks/models/media.py`)
- **ParkPhoto.image**: Changed from `models.ImageField` to `CloudflareImagesField(variant="public")`
- Added proper Meta class inheritance from `TrackedModel.Meta`
- Maintains all existing functionality while leveraging Cloudflare Images
### 2. API Serializers Enhanced
#### Rides API (`backend/apps/api/v1/rides/serializers.py`)
- **RidePhotoOutputSerializer**: Enhanced with Cloudflare Images support
- Added `image_url` field: Full URL to the Cloudflare Images asset
- Added `image_variants` field: Dictionary of available image variants with URLs
- Proper DRF Spectacular schema decorations with examples
- Maintains backward compatibility
#### Parks API (`backend/apps/api/v1/parks/serializers.py`)
- **ParkPhotoOutputSerializer**: Enhanced with Cloudflare Images support
- Added `image_url` field: Full URL to the Cloudflare Images asset
- Added `image_variants` field: Dictionary of available image variants with URLs
- Proper DRF Spectacular schema decorations with examples
- Maintains backward compatibility
### 3. Schema Metadata
Both serializers include comprehensive OpenAPI schema metadata:
- **Field Documentation**: All new fields have detailed help text and type information
- **Examples**: Complete example responses showing Cloudflare Images URLs and variants
- **Variants**: Documented image variants (thumbnail, medium, large, public) with descriptions
### 4. Database Migrations
- **rides.0008_cloudflare_images_integration**: Updates RidePhoto.image field
- **parks.0009_cloudflare_images_integration**: Updates ParkPhoto.image field
- Migrations applied successfully with no data loss
## Configuration
The project already has Cloudflare Images configured in `backend/config/django/base.py`:
```python
# Cloudflare Images Settings
STORAGES = {
"default": {
"BACKEND": "cloudflare_images.storage.CloudflareImagesStorage",
},
# ... other storage configs
}
CLOUDFLARE_IMAGES_ACCOUNT_ID = config("CLOUDFLARE_IMAGES_ACCOUNT_ID")
CLOUDFLARE_IMAGES_API_TOKEN = config("CLOUDFLARE_IMAGES_API_TOKEN")
CLOUDFLARE_IMAGES_ACCOUNT_HASH = config("CLOUDFLARE_IMAGES_ACCOUNT_HASH")
CLOUDFLARE_IMAGES_DOMAIN = config("CLOUDFLARE_IMAGES_DOMAIN", default="imagedelivery.net")
```
## API Response Format
### Enhanced Photo Response
Both ride and park photo endpoints now return:
```json
{
"id": 123,
"image": "https://imagedelivery.net/account-hash/image-id/public",
"image_url": "https://imagedelivery.net/account-hash/image-id/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/image-id/thumbnail",
"medium": "https://imagedelivery.net/account-hash/image-id/medium",
"large": "https://imagedelivery.net/account-hash/image-id/large",
"public": "https://imagedelivery.net/account-hash/image-id/public"
},
"caption": "Photo caption",
"alt_text": "Alt text for accessibility",
"is_primary": true,
"is_approved": true,
"photo_type": "exterior", // rides only
"created_at": "2023-01-01T12:00:00Z",
"updated_at": "2023-01-01T12:00:00Z",
"date_taken": "2023-01-01T10:00:00Z",
"uploaded_by_username": "photographer123",
"file_size": 2048576,
"dimensions": [1920, 1080],
"ride_slug": "steel-vengeance", // rides only
"ride_name": "Steel Vengeance", // rides only
"park_slug": "cedar-point",
"park_name": "Cedar Point"
}
```
## Image Variants
The integration provides these standard variants:
- **thumbnail**: 150x150px - Perfect for list views and previews
- **medium**: 500x500px - Good for modal previews and medium displays
- **large**: 1200x1200px - High quality for detailed views
- **public**: Original size - Full resolution image
## Benefits
1. **Performance**: Cloudflare's global CDN ensures fast image delivery
2. **Optimization**: Automatic image optimization and format conversion
3. **Variants**: Multiple image sizes generated automatically
4. **Scalability**: No local storage requirements
5. **API Documentation**: Complete OpenAPI schema with examples
6. **Backward Compatibility**: Existing API consumers continue to work
7. **Entity Validation**: Photos are always associated with valid rides or parks
8. **Data Integrity**: Prevents orphaned photos without parent entities
9. **Automatic Photo Inclusion**: Photos are automatically included when displaying rides and parks
10. **Primary Photo Support**: Easy access to the main photo for each entity
## Automatic Photo Integration
### Ride Detail Responses
When fetching ride details via `GET /api/v1/rides/{id}/`, the response automatically includes:
- **photos**: Array of up to 10 approved photos with full Cloudflare Images variants
- **primary_photo**: The designated primary photo for the ride (if available)
```json
{
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"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 roller coaster with multiple inversions",
"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 roller coaster with multiple inversions",
"photo_type": "exterior"
}
}
```
### Park Detail Responses
When fetching park details via `GET /api/v1/parks/{id}/`, the response automatically includes:
- **photos**: Array of up to 10 approved photos with full Cloudflare Images variants
- **primary_photo**: The designated primary photo for the park (if available)
```json
{
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"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"
}
}
```
### Photo Filtering
- Only **approved** photos (`is_approved=True`) are included in entity responses
- Photos are ordered by **primary status first**, then by **creation date** (newest first)
- Limited to **10 photos maximum** per entity to maintain response performance
- **Primary photo** is provided separately for easy access to the main image
## Testing
The implementation has been verified:
- ✅ Models successfully use CloudflareImagesField
- ✅ Migrations applied without issues
- ✅ Serializers import and function correctly
- ✅ Schema metadata properly configured
- ✅ Photos automatically included in ride and park detail responses
- ✅ Primary photo selection working correctly
## Upload Examples
### 1. Upload Ride Photo via API
**Endpoint:** `POST /api/v1/rides/{ride_id}/photos/`
**Requirements:**
- Valid JWT authentication token
- Existing ride with the specified `ride_id`
- Image file in supported format (JPEG, PNG, WebP, etc.)
**Headers:**
```bash
Authorization: Bearer <your_jwt_token>
Content-Type: multipart/form-data
```
**cURL Example:**
```bash
curl -X POST "https://your-domain.com/api/v1/rides/123/photos/" \
-H "Authorization: Bearer your_jwt_token_here" \
-F "image=@/path/to/your/photo.jpg" \
-F "caption=Amazing steel coaster shot" \
-F "alt_text=Steel Vengeance coaster with riders" \
-F "photo_type=exterior" \
-F "is_primary=false"
```
**Error Response (Non-existent Ride):**
```json
{
"detail": "Ride not found"
}
```
**Python Example:**
```python
import requests
url = "https://your-domain.com/api/v1/rides/123/photos/"
headers = {"Authorization": "Bearer your_jwt_token_here"}
with open("/path/to/your/photo.jpg", "rb") as image_file:
files = {"image": image_file}
data = {
"caption": "Amazing steel coaster shot",
"alt_text": "Steel Vengeance coaster with riders",
"photo_type": "exterior",
"is_primary": False
}
response = requests.post(url, headers=headers, files=files, data=data)
print(response.json())
```
**JavaScript Example:**
```javascript
const formData = new FormData();
formData.append('image', fileInput.files[0]);
formData.append('caption', 'Amazing steel coaster shot');
formData.append('alt_text', 'Steel Vengeance coaster with riders');
formData.append('photo_type', 'exterior');
formData.append('is_primary', 'false');
fetch('/api/v1/rides/123/photos/', {
method: 'POST',
headers: {
'Authorization': 'Bearer your_jwt_token_here'
},
body: formData
})
.then(response => response.json())
.then(data => console.log(data));
```
### 2. Upload Park Photo via API
**Endpoint:** `POST /api/v1/parks/{park_id}/photos/`
**Requirements:**
- Valid JWT authentication token
- Existing park with the specified `park_id`
- Image file in supported format (JPEG, PNG, WebP, etc.)
**cURL Example:**
```bash
curl -X POST "https://your-domain.com/api/v1/parks/456/photos/" \
-H "Authorization: Bearer your_jwt_token_here" \
-F "image=@/path/to/park-entrance.jpg" \
-F "caption=Beautiful park entrance" \
-F "alt_text=Cedar Point main entrance with flags" \
-F "is_primary=true"
```
**Error Response (Non-existent Park):**
```json
{
"detail": "Park not found"
}
```
### 3. Upload Response Format
Both endpoints return the same enhanced format with Cloudflare Images integration:
```json
{
"id": 789,
"image": "https://imagedelivery.net/account-hash/image-id/public",
"image_url": "https://imagedelivery.net/account-hash/image-id/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/image-id/thumbnail",
"medium": "https://imagedelivery.net/account-hash/image-id/medium",
"large": "https://imagedelivery.net/account-hash/image-id/large",
"public": "https://imagedelivery.net/account-hash/image-id/public"
},
"caption": "Amazing steel coaster shot",
"alt_text": "Steel Vengeance coaster with riders",
"is_primary": false,
"is_approved": false,
"photo_type": "exterior",
"created_at": "2023-01-01T12:00:00Z",
"updated_at": "2023-01-01T12:00:00Z",
"date_taken": null,
"uploaded_by_username": "photographer123",
"file_size": 2048576,
"dimensions": [1920, 1080],
"ride_slug": "steel-vengeance",
"ride_name": "Steel Vengeance",
"park_slug": "cedar-point",
"park_name": "Cedar Point"
}
```
## Cloudflare Images Transformations
### 1. Built-in Variants
The integration provides these pre-configured variants:
- **thumbnail** (150x150px): `https://imagedelivery.net/account-hash/image-id/thumbnail`
- **medium** (500x500px): `https://imagedelivery.net/account-hash/image-id/medium`
- **large** (1200x1200px): `https://imagedelivery.net/account-hash/image-id/large`
- **public** (original): `https://imagedelivery.net/account-hash/image-id/public`
### 2. Custom Transformations
You can apply custom transformations by appending parameters to any variant URL:
#### Resize Examples:
```
# Resize to specific width (maintains aspect ratio)
https://imagedelivery.net/account-hash/image-id/public/w=800
# Resize to specific height (maintains aspect ratio)
https://imagedelivery.net/account-hash/image-id/public/h=600
# Resize to exact dimensions (may crop)
https://imagedelivery.net/account-hash/image-id/public/w=800,h=600
# Resize with fit modes
https://imagedelivery.net/account-hash/image-id/public/w=800,h=600,fit=cover
https://imagedelivery.net/account-hash/image-id/public/w=800,h=600,fit=contain
https://imagedelivery.net/account-hash/image-id/public/w=800,h=600,fit=crop
```
#### Quality and Format:
```
# Adjust quality (1-100)
https://imagedelivery.net/account-hash/image-id/public/quality=85
# Convert format
https://imagedelivery.net/account-hash/image-id/public/format=webp
https://imagedelivery.net/account-hash/image-id/public/format=avif
# Auto format (serves best format for browser)
https://imagedelivery.net/account-hash/image-id/public/format=auto
```
#### Advanced Transformations:
```
# Blur effect
https://imagedelivery.net/account-hash/image-id/public/blur=5
# Sharpen
https://imagedelivery.net/account-hash/image-id/public/sharpen=2
# Brightness adjustment (-100 to 100)
https://imagedelivery.net/account-hash/image-id/public/brightness=20
# Contrast adjustment (-100 to 100)
https://imagedelivery.net/account-hash/image-id/public/contrast=15
# Gamma adjustment (0.1 to 2.0)
https://imagedelivery.net/account-hash/image-id/public/gamma=1.2
# Rotate (90, 180, 270 degrees)
https://imagedelivery.net/account-hash/image-id/public/rotate=90
```
#### Combining Transformations:
```
# Multiple transformations (comma-separated)
https://imagedelivery.net/account-hash/image-id/public/w=800,h=600,fit=cover,quality=85,format=webp
# Responsive image for mobile
https://imagedelivery.net/account-hash/image-id/public/w=400,quality=80,format=auto
# High-quality desktop version
https://imagedelivery.net/account-hash/image-id/public/w=1200,quality=90,format=auto
```
### 3. Creating Custom Variants
You can create custom variants in your Cloudflare Images dashboard for commonly used transformations:
1. Go to Cloudflare Images dashboard
2. Navigate to "Variants" section
3. Create new variant with desired transformations
4. Use in your models:
```python
# In your model
class RidePhoto(TrackedModel):
image = CloudflareImagesField(variant="hero_banner") # Custom variant
```
### 4. Responsive Images Implementation
Use different variants for responsive design:
```html
<!-- HTML with responsive variants -->
<picture>
<source media="(max-width: 480px)"
srcset="https://imagedelivery.net/account-hash/image-id/thumbnail">
<source media="(max-width: 768px)"
srcset="https://imagedelivery.net/account-hash/image-id/medium">
<source media="(max-width: 1200px)"
srcset="https://imagedelivery.net/account-hash/image-id/large">
<img src="https://imagedelivery.net/account-hash/image-id/public"
alt="Ride photo">
</picture>
```
```css
/* CSS with responsive variants */
.ride-photo {
background-image: url('https://imagedelivery.net/account-hash/image-id/thumbnail');
}
@media (min-width: 768px) {
.ride-photo {
background-image: url('https://imagedelivery.net/account-hash/image-id/medium');
}
}
@media (min-width: 1200px) {
.ride-photo {
background-image: url('https://imagedelivery.net/account-hash/image-id/large');
}
}
```
### 5. Performance Optimization
**Best Practices:**
- Use `format=auto` to serve optimal format (WebP, AVIF) based on browser support
- Set appropriate quality levels (80-85 for photos, 90+ for graphics)
- Use `fit=cover` for consistent aspect ratios in galleries
- Implement lazy loading with smaller variants as placeholders
**Example Optimized URLs:**
```
# Gallery thumbnail (fast loading)
https://imagedelivery.net/account-hash/image-id/thumbnail/quality=75,format=auto
# Modal preview (balanced quality/size)
https://imagedelivery.net/account-hash/image-id/medium/quality=85,format=auto
# Full-size view (high quality)
https://imagedelivery.net/account-hash/image-id/large/quality=90,format=auto
```
## Testing and Verification
### 1. Verify Upload Functionality
```bash
# Test ride photo upload (requires existing ride with ID 1)
curl -X POST "http://localhost:8000/api/v1/rides/1/photos/" \
-H "Authorization: Bearer your_test_token" \
-F "image=@test_image.jpg" \
-F "caption=Test upload"
# Test park photo upload (requires existing park with ID 1)
curl -X POST "http://localhost:8000/api/v1/parks/1/photos/" \
-H "Authorization: Bearer your_test_token" \
-F "image=@test_image.jpg" \
-F "caption=Test park upload"
# Test with non-existent entity (should return 400 error)
curl -X POST "http://localhost:8000/api/v1/rides/99999/photos/" \
-H "Authorization: Bearer your_test_token" \
-F "image=@test_image.jpg" \
-F "caption=Test upload"
```
### 2. Verify Image Variants
```python
# Django shell verification
from apps.rides.models import RidePhoto
photo = RidePhoto.objects.first()
print(f"Image URL: {photo.image.url}")
print(f"Thumbnail: {photo.image.url.replace('/public', '/thumbnail')}")
print(f"Medium: {photo.image.url.replace('/public', '/medium')}")
print(f"Large: {photo.image.url.replace('/public', '/large')}")
```
### 3. Test Transformations
Visit these URLs in your browser to verify transformations work:
- Original: `https://imagedelivery.net/your-hash/image-id/public`
- Resized: `https://imagedelivery.net/your-hash/image-id/public/w=400`
- WebP: `https://imagedelivery.net/your-hash/image-id/public/format=webp`
## Future Enhancements
Potential future improvements:
- Signed URLs for private images
- Batch upload capabilities
- Image analytics integration
- Advanced AI-powered transformations
- Custom watermarking
- Automatic alt-text generation
## Dependencies
- `django-cloudflare-images>=0.6.0` (already installed)
- Proper environment variables configured
- Cloudflare Images account setup

2652
backend/pixi.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -77,3 +77,16 @@ stubPath = "stubs"
[tool.uv.sources]
python-json-logger = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" }
[tool.pixi.workspace]
channels = ["conda-forge"]
platforms = ["osx-arm64"]
[tool.pixi.pypi-dependencies]
thrillwiki = { path = ".", editable = true }
[tool.pixi.environments]
default = { solve-group = "default" }
dev = { features = ["dev"], solve-group = "default" }
[tool.pixi.tasks]

File diff suppressed because it is too large Load Diff

View File

@@ -141,12 +141,8 @@ else:
# Serve static files in development
if settings.DEBUG:
# Only serve static files, not media files since we're using Cloudflare Images
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Note: Media files are handled by Cloudflare Images, not Django static serving
# This prevents the catch-all pattern from interfering with API routes
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
try:
urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))]
except ImportError:

58
backend/uv.lock generated
View File

@@ -1281,21 +1281,21 @@ wheels = [
[[package]]
name = "playwright"
version = "1.55.0"
version = "1.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" },
{ url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" },
{ url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" },
{ url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" },
{ url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" },
{ url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" },
{ url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" },
{ url = "https://files.pythonhosted.org/packages/f3/09/33d5bfe393a582d8dac72165a9e88b274143c9df411b65ece1cc13f42988/playwright-1.54.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bf3b845af744370f1bd2286c2a9536f474cc8a88dc995b72ea9a5be714c9a77d", size = 40439034, upload-time = "2025-07-22T13:58:04.816Z" },
{ url = "https://files.pythonhosted.org/packages/e1/7b/51882dc584f7aa59f446f2bb34e33c0e5f015de4e31949e5b7c2c10e54f0/playwright-1.54.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:780928b3ca2077aea90414b37e54edd0c4bbb57d1aafc42f7aa0b3fd2c2fac02", size = 38702308, upload-time = "2025-07-22T13:58:08.211Z" },
{ url = "https://files.pythonhosted.org/packages/73/a1/7aa8ae175b240c0ec8849fcf000e078f3c693f9aa2ffd992da6550ea0dff/playwright-1.54.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:81d0b6f28843b27f288cfe438af0a12a4851de57998009a519ea84cee6fbbfb9", size = 40439037, upload-time = "2025-07-22T13:58:11.37Z" },
{ url = "https://files.pythonhosted.org/packages/34/a9/45084fd23b6206f954198296ce39b0acf50debfdf3ec83a593e4d73c9c8a/playwright-1.54.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:09919f45cc74c64afb5432646d7fef0d19fff50990c862cb8d9b0577093f40cc", size = 45920135, upload-time = "2025-07-22T13:58:14.494Z" },
{ url = "https://files.pythonhosted.org/packages/02/d4/6a692f4c6db223adc50a6e53af405b45308db39270957a6afebddaa80ea2/playwright-1.54.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ae206c55737e8e3eae51fb385d61c0312eeef31535643bb6232741b41b6fdc", size = 45302695, upload-time = "2025-07-22T13:58:18.901Z" },
{ url = "https://files.pythonhosted.org/packages/72/7a/4ee60a1c3714321db187bebbc40d52cea5b41a856925156325058b5fca5a/playwright-1.54.0-py3-none-win32.whl", hash = "sha256:0b108622ffb6906e28566f3f31721cd57dda637d7e41c430287804ac01911f56", size = 35469309, upload-time = "2025-07-22T13:58:21.917Z" },
{ url = "https://files.pythonhosted.org/packages/aa/77/8f8fae05a242ef639de963d7ae70a69d0da61d6d72f1207b8bbf74ffd3e7/playwright-1.54.0-py3-none-win_amd64.whl", hash = "sha256:9e5aee9ae5ab1fdd44cd64153313a2045b136fcbcfb2541cc0a3d909132671a2", size = 35469311, upload-time = "2025-07-22T13:58:24.707Z" },
{ url = "https://files.pythonhosted.org/packages/33/ff/99a6f4292a90504f2927d34032a4baf6adb498dc3f7cf0f3e0e22899e310/playwright-1.54.0-py3-none-win_arm64.whl", hash = "sha256:a975815971f7b8dca505c441a4c56de1aeb56a211290f8cc214eeef5524e8d75", size = 31239119, upload-time = "2025-07-22T13:58:27.56Z" },
]
[[package]]
@@ -1827,28 +1827,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.12.11"
version = "0.12.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/eb/8c073deb376e46ae767f4961390d17545e8535921d2f65101720ed8bd434/ruff-0.12.10.tar.gz", hash = "sha256:189ab65149d11ea69a2d775343adf5f49bb2426fc4780f65ee33b423ad2e47f9", size = 5310076, upload-time = "2025-08-21T18:23:22.595Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" },
{ url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" },
{ url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" },
{ url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" },
{ url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" },
{ url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" },
{ url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" },
{ url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" },
{ url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" },
{ url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" },
{ url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" },
{ url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" },
{ url = "https://files.pythonhosted.org/packages/24/e7/560d049d15585d6c201f9eeacd2fd130def3741323e5ccf123786e0e3c95/ruff-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:8b593cb0fb55cc8692dac7b06deb29afda78c721c7ccfed22db941201b7b8f7b", size = 11935161, upload-time = "2025-08-21T18:22:26.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b0/ad2464922a1113c365d12b8f80ed70fcfb39764288ac77c995156080488d/ruff-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ebb7333a45d56efc7c110a46a69a1b32365d5c5161e7244aaf3aa20ce62399c1", size = 12660884, upload-time = "2025-08-21T18:22:30.925Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f1/97f509b4108d7bae16c48389f54f005b62ce86712120fd8b2d8e88a7cb49/ruff-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d59e58586829f8e4a9920788f6efba97a13d1fa320b047814e8afede381c6839", size = 11872754, upload-time = "2025-08-21T18:22:34.035Z" },
{ url = "https://files.pythonhosted.org/packages/12/ad/44f606d243f744a75adc432275217296095101f83f966842063d78eee2d3/ruff-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:822d9677b560f1fdeab69b89d1f444bf5459da4aa04e06e766cf0121771ab844", size = 12092276, upload-time = "2025-08-21T18:22:36.764Z" },
{ url = "https://files.pythonhosted.org/packages/06/1f/ed6c265e199568010197909b25c896d66e4ef2c5e1c3808caf461f6f3579/ruff-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b4a64f4062a50c75019c61c7017ff598cb444984b638511f48539d3a1c98db", size = 11734700, upload-time = "2025-08-21T18:22:39.822Z" },
{ url = "https://files.pythonhosted.org/packages/63/c5/b21cde720f54a1d1db71538c0bc9b73dee4b563a7dd7d2e404914904d7f5/ruff-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6f4064c69d2542029b2a61d39920c85240c39837599d7f2e32e80d36401d6e", size = 13468783, upload-time = "2025-08-21T18:22:42.559Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/39369e6ac7f2a1848f22fb0b00b690492f20811a1ac5c1fd1d2798329263/ruff-0.12.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:059e863ea3a9ade41407ad71c1de2badfbe01539117f38f763ba42a1206f7559", size = 14436642, upload-time = "2025-08-21T18:22:45.612Z" },
{ url = "https://files.pythonhosted.org/packages/e3/03/5da8cad4b0d5242a936eb203b58318016db44f5c5d351b07e3f5e211bb89/ruff-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bef6161e297c68908b7218fa6e0e93e99a286e5ed9653d4be71e687dff101cf", size = 13859107, upload-time = "2025-08-21T18:22:48.886Z" },
{ url = "https://files.pythonhosted.org/packages/19/19/dd7273b69bf7f93a070c9cec9494a94048325ad18fdcf50114f07e6bf417/ruff-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f1345fbf8fb0531cd722285b5f15af49b2932742fc96b633e883da8d841896b", size = 12886521, upload-time = "2025-08-21T18:22:51.567Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1d/b4207ec35e7babaee62c462769e77457e26eb853fbdc877af29417033333/ruff-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f68433c4fbc63efbfa3ba5db31727db229fa4e61000f452c540474b03de52a9", size = 13097528, upload-time = "2025-08-21T18:22:54.609Z" },
{ url = "https://files.pythonhosted.org/packages/ff/00/58f7b873b21114456e880b75176af3490d7a2836033779ca42f50de3b47a/ruff-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:141ce3d88803c625257b8a6debf4a0473eb6eed9643a6189b68838b43e78165a", size = 13080443, upload-time = "2025-08-21T18:22:57.413Z" },
{ url = "https://files.pythonhosted.org/packages/12/8c/9e6660007fb10189ccb78a02b41691288038e51e4788bf49b0a60f740604/ruff-0.12.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f3fc21178cd44c98142ae7590f42ddcb587b8e09a3b849cbc84edb62ee95de60", size = 11896759, upload-time = "2025-08-21T18:23:00.473Z" },
{ url = "https://files.pythonhosted.org/packages/67/4c/6d092bb99ea9ea6ebda817a0e7ad886f42a58b4501a7e27cd97371d0ba54/ruff-0.12.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7d1a4e0bdfafcd2e3e235ecf50bf0176f74dd37902f241588ae1f6c827a36c56", size = 11701463, upload-time = "2025-08-21T18:23:03.211Z" },
{ url = "https://files.pythonhosted.org/packages/59/80/d982c55e91df981f3ab62559371380616c57ffd0172d96850280c2b04fa8/ruff-0.12.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e67d96827854f50b9e3e8327b031647e7bcc090dbe7bb11101a81a3a2cbf1cc9", size = 12691603, upload-time = "2025-08-21T18:23:06.935Z" },
{ url = "https://files.pythonhosted.org/packages/ad/37/63a9c788bbe0b0850611669ec6b8589838faf2f4f959647f2d3e320383ae/ruff-0.12.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ae479e1a18b439c59138f066ae79cc0f3ee250712a873d00dbafadaad9481e5b", size = 13164356, upload-time = "2025-08-21T18:23:10.225Z" },
{ url = "https://files.pythonhosted.org/packages/47/d4/1aaa7fb201a74181989970ebccd12f88c0fc074777027e2a21de5a90657e/ruff-0.12.10-py3-none-win32.whl", hash = "sha256:9de785e95dc2f09846c5e6e1d3a3d32ecd0b283a979898ad427a9be7be22b266", size = 11896089, upload-time = "2025-08-21T18:23:14.232Z" },
{ url = "https://files.pythonhosted.org/packages/ad/14/2ad38fd4037daab9e023456a4a40ed0154e9971f8d6aed41bdea390aabd9/ruff-0.12.10-py3-none-win_amd64.whl", hash = "sha256:7837eca8787f076f67aba2ca559cefd9c5cbc3a9852fd66186f4201b87c1563e", size = 13004616, upload-time = "2025-08-21T18:23:17.422Z" },
{ url = "https://files.pythonhosted.org/packages/24/3c/21cf283d67af33a8e6ed242396863af195a8a6134ec581524fd22b9811b6/ruff-0.12.10-py3-none-win_arm64.whl", hash = "sha256:cc138cc06ed9d4bfa9d667a65af7172b47840e1a98b02ce7011c391e54635ffc", size = 12074225, upload-time = "2025-08-21T18:23:20.137Z" },
]
[[package]]

View File

@@ -1,253 +1,97 @@
c# Active Context
# 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/<manufacturerSlug>/<ridemodelSlug>/` 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
- **COMPLETED: Vue Shadcn Component Modernization**: Successfully replaced all transparent components with solid shadcn styling
- **COMPLETED: Home.vue Modernization**: Fully updated Home page with solid backgrounds and proper design tokens
- **COMPLETED: Component Enhancement**: All major components now use professional shadcn styling with solid backgrounds
## 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
**Phase 1: CSS Foundation Update - COMPLETED:**
- **Updated CSS Variables**: Integrated user-provided CSS styling with proper @layer base structure
- **New Color Scheme**: Primary purple theme (262.1 83.3% 57.8%) with solid backgrounds
- **Design Token Integration**: Proper CSS variables for background, foreground, card, primary, secondary, muted, accent, destructive, border, input, and ring colors
- **Dark Mode Support**: Complete dark mode color palette with solid backgrounds (no transparency)
**RideModel API Reorganization - COMPLETED:**
- **Reorganized**: RideModel endpoints from `/api/v1/ride-models/` to `/api/v1/rides/manufacturers/<manufacturerSlug>/<ridemodelSlug>/`
- **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/<manufacturerSlug>/`
- **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/<manufacturerSlug>/` - List/create ride models for manufacturer
- `/api/v1/rides/manufacturers/<manufacturerSlug>/<ridemodelSlug>/` - Detailed ride model view
- `/api/v1/rides/manufacturers/<manufacturerSlug>/<ridemodelSlug>/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
**Phase 2: Component Modernization - IN PROGRESS:**
- **RideCard.vue Enhancement**:
- Replaced custom div with shadcn Card, CardContent, CardHeader, CardTitle, CardDescription
- Updated to use Badge components with proper variants (default, destructive, secondary, outline)
- Integrated lucide-vue-next icons (Camera, MapPin, TrendingUp, Zap, Clock, Users, Star, Building, User)
- **Solid Backgrounds**: Removed all transparency issues (bg-purple-900/30 → bg-purple-800, etc.)
- **Enhanced Visual Design**: border-2, bg-card, proper hover states with solid colors
- **Professional Status Badges**: Dynamic variants based on ride status with shadow-md
**django-cloudflare-images Integration - COMPLETED:**
- **Implemented**: Complete Cloudflare Images integration for rides and parks models
- **Files Created/Modified**:
- `backend/apps/rides/models/media.py` - Updated RidePhoto.image to CloudflareImagesField
- `backend/apps/parks/models/media.py` - Updated ParkPhoto.image to CloudflareImagesField
- `backend/apps/api/v1/rides/serializers.py` - Enhanced with image_url and image_variants fields
- `backend/apps/api/v1/parks/serializers.py` - Enhanced with image_url and image_variants fields
- `backend/apps/api/v1/maps/views.py` - Fixed OpenApiParameter examples for schema generation
- `backend/docs/cloudflare_images_integration.md` - Comprehensive documentation with upload examples and transformations
- **Database Migrations**: Applied successfully without data loss
- **Banner/Card Images**: Added banner_image and card_image fields to Park and Ride models with API endpoints
- **Schema Generation**: Fixed and working properly with OpenAPI documentation
- **PresetItem.vue Enhancement**:
- Converted to use shadcn Card, CardContent, CardTitle, CardDescription
- Integrated Badge components for Default/Global indicators with solid backgrounds
- Added Button components with proper ghost variants for actions
- **DropdownMenu Integration**: Professional context menu with proper hover states
- **Solid Color Scheme**: bg-green-100 dark:bg-green-800 (no transparency)
- **Enhanced Interactions**: Proper hover:bg-accent, cursor-pointer states
**Enhanced Stats API Endpoint - COMPLETED:**
- **Updated**: `/api/v1/stats/` endpoint for platform statistics
- **Files Created/Modified**:
- `backend/apps/api/v1/views/stats.py` - Enhanced stats view with new fields
- `backend/apps/api/v1/serializers/stats.py` - Updated serializer with new fields
- `backend/apps/api/v1/signals.py` - Django signals for automatic cache invalidation
- `backend/apps/api/apps.py` - App config to load signals
- `backend/apps/api/v1/urls.py` - Stats URL routing
**Technical Infrastructure:**
- **Import Resolution**: Fixed all component import paths for shadcn components
- **Type Safety**: Proper TypeScript integration with FilterPreset from @/types/filters
- **Icon System**: Migrated from custom Icon component to lucide-vue-next consistently
- **Design System**: All components now use design tokens (text-muted-foreground, bg-card, border-border, etc.)
**Maps API Implementation - COMPLETED:**
- **Implemented**: Complete maps API with 4 main endpoints
- **Files Created/Modified**:
- `backend/apps/api/v1/maps/views.py` - All map view implementations
- `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**:
- GET `/api/v1/maps/locations/` - Get map locations with filtering, bounds, search, clustering
- GET `/api/v1/maps/locations/<type>/<id>/` - Get detailed location information
- GET `/api/v1/maps/search/` - Search locations by text query with pagination
- GET `/api/v1/maps/bounds/` - Get locations within geographic bounds
- GET `/api/v1/maps/stats/` - Get map service statistics
- DELETE/POST `/api/v1/maps/cache/` - Cache management endpoints
- **Authentication**: Public endpoints (AllowAny permission)
- **Caching**: 5-minute cache with automatic invalidation for maps, immediate cache for stats
- **Documentation**: Full OpenAPI schema with drf-spectacular for all endpoints
- **Response Format**: JSON with comprehensive location data, statistics, and metadata
- **Features**: Geographic bounds filtering, text search, pagination, clustering support, detailed location info
**Previous Major Enhancements:**
- Successfully initialized shadcn-vue with comprehensive component library
- Enhanced ParkList.vue and RideList.vue with advanced shadcn components
- Fixed JavaScript errors and improved type safety across components
- Django Sites framework and API authentication working correctly
## 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
- `backend/apps/api/v1/rides/serializers.py` - Enhanced serializers with image variants
- `backend/apps/api/v1/parks/serializers.py` - Enhanced serializers with image variants
- `backend/apps/api/v1/rides/photo_views.py` - Photo upload endpoints for rides
- `backend/apps/api/v1/parks/views.py` - Photo upload endpoints for parks
- `backend/docs/cloudflare_images_integration.md` - Complete documentation
### Stats API Files
- `backend/apps/api/v1/views/stats.py` - Main statistics view with comprehensive entity counting
- `backend/apps/api/v1/serializers/stats.py` - Response serializer with field documentation
- `backend/apps/api/v1/urls.py` - URL routing including new stats endpoint
### Maps API Files
- `backend/apps/api/v1/maps/views.py` - All map view implementations with full functionality
- `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)
### Moderation System
- moderation/models.py
- moderation/urls.py
- moderation/views.py
- templates/moderation/dashboard.html
- templates/moderation/partials/
- submission_list.html
- moderation_nav.html
- dashboard_content.html
## Next Steps
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
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
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
5. **Testing**: Add comprehensive unit tests for all endpoints
6. **Performance**: Monitor and optimize database queries for large datasets
1. Review and enhance moderation dashboard functionality
2. Implement remaining submission review workflows
3. Test moderation system end-to-end
4. Document moderation patterns and guidelines
## Current Development State
- Django backend with comprehensive stats API
- Stats endpoint fully functional at `/api/v1/stats/`
- Server running on port 8000
- All middleware issues resolved
- Using Django for backend framework
- HTMX for dynamic interactions
- AlpineJS for client-side functionality
- Tailwind CSS for styling
- Python manage.py tailwind runserver for development
## 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/<manufacturerSlug>/`
- **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
- **Upload Endpoints**: POST `/api/v1/rides/{id}/photos/` and POST `/api/v1/parks/{id}/photos/`
- **Schema Generation**: Fixed and working properly
- **Database Migrations**: Applied successfully
- **Documentation**: Comprehensive with upload examples and transformations
- **Stats Endpoint**: `/api/v1/stats/` - ✅ Working correctly
- **Maps Endpoints**: All implemented and ready for testing
- `/api/v1/maps/locations/` - ✅ Implemented with filtering, bounds, search
- `/api/v1/maps/locations/<type>/<id>/` - ✅ Implemented with detailed location info
- `/api/v1/maps/search/` - ✅ Implemented with text search and pagination
- `/api/v1/maps/bounds/` - ✅ Implemented with geographic bounds filtering
- `/api/v1/maps/stats/` - ✅ Implemented with location statistics
- `/api/v1/maps/cache/` - ✅ Implemented with cache management
- **Response**: Returns comprehensive JSON with location data and statistics
- **Performance**: Cached responses for optimal performance (5-minute cache)
- **Access**: Public endpoints, no authentication required (except photo uploads)
- **Documentation**: Full OpenAPI documentation available
## Testing Requirements
- Verify all moderation workflows
- Test submission review process
- Validate user role permissions
- Check notification systems
## Sample Response
```json
{
"total_parks": 7,
"total_rides": 10,
"total_manufacturers": 6,
"total_operators": 7,
"total_designers": 4,
"total_property_owners": 0,
"total_roller_coasters": 8,
"total_photos": 0,
"total_park_photos": 0,
"total_ride_photos": 0,
"total_reviews": 8,
"total_park_reviews": 4,
"total_ride_reviews": 4,
"roller_coasters": 10,
"operating_parks": 7,
"operating_rides": 10,
"last_updated": "just_now"
}
```
## Deployment Notes
- Site runs at http://thrillwiki.com
- Changes must be committed to git and pushed to main
- HTMX templates located in partials folders by model
## Active Issues/Considerations
- Django Sites framework properly configured for development
- Auth providers endpoint working correctly
- Rides API endpoint now working correctly (501 error resolved)
## Recent Decisions
- Fixed Sites framework by creating Site objects for development domains
- Confirmed auth system is working properly
- Sites framework now supports localhost, testserver, and port-specific domains
## Issue Resolution Summary
**Problem**: Django Sites framework error - "Site matching query does not exist"
**Root Cause**: Missing Site objects in database for development domains
**Solution**: Created Site objects for:
- 127.0.0.1 (ID: 2) - ThrillWiki Local (no port)
- 127.0.0.1:8000 (ID: 1) - ThrillWiki Local
- testserver (ID: 3) - ThrillWiki Test Server
**Result**: Auth providers endpoint now returns 200 status with empty array (expected behavior)

View File

@@ -1,46 +0,0 @@
# 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/<slug>/`, 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.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

8
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

View File

@@ -0,0 +1,6 @@
# Development environment configuration
VITE_API_BASE_URL=
VITE_APP_ENV=development
VITE_APP_NAME=ThrillWiki
VITE_APP_VERSION=1.0.0
VITE_DEBUG=true

6
frontend/.env.production Normal file
View File

@@ -0,0 +1,6 @@
# Production environment configuration
VITE_API_BASE_URL=https://api.thrillwiki.com
VITE_APP_ENV=production
VITE_APP_NAME=ThrillWiki
VITE_APP_VERSION=1.0.0
VITE_DEBUG=false

6
frontend/.env.staging Normal file
View File

@@ -0,0 +1,6 @@
# Staging environment configuration
VITE_API_BASE_URL=https://staging-api.thrillwiki.com
VITE_APP_ENV=staging
VITE_APP_NAME=ThrillWiki (Staging)
VITE_APP_VERSION=1.0.0
VITE_DEBUG=true

3
frontend/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
* text=auto eol=lf
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

36
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
test-results/
playwright-report/
# pixi environments
.pixi/*
!.pixi/config.toml

1
frontend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/*

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

384
frontend/README.md Normal file
View File

@@ -0,0 +1,384 @@
# ThrillWiki Frontend
Modern Vue.js 3 SPA frontend for the ThrillWiki theme park and roller coaster information system.
## 🏗️ Architecture Overview
This frontend is built with Vue 3 and follows modern development practices:
```
frontend/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Base UI components (shadcn-vue style)
│ │ ├── layout/ # Layout components (Navbar, ThemeController)
│ │ ├── button/ # Button variants
│ │ ├── icon/ # Icon components
│ │ └── state-layer/ # Material Design state layers
│ ├── views/ # Page components
│ │ ├── Home.vue # Landing page
│ │ ├── SearchResults.vue # Search results page
│ │ ├── parks/ # Park-related pages
│ │ └── rides/ # Ride-related pages
│ ├── stores/ # Pinia state management
│ ├── router/ # Vue Router configuration
│ ├── services/ # API services and utilities
│ ├── types/ # TypeScript type definitions
│ ├── App.vue # Root component
│ └── main.ts # Application entry point
├── public/ # Static assets
├── dist/ # Production build output
└── e2e/ # End-to-end tests
```
## 🚀 Technology Stack
### Core Framework
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety and better developer experience
- **Vite** for lightning-fast development and optimized production builds
### UI & Styling
- **Tailwind CSS v4** with custom design system
- **shadcn-vue** inspired component library
- **Material Design** state layers and interactions
- **Dark mode support** with automatic theme detection
### State Management & Routing
- **Pinia** for predictable state management
- **Vue Router 4** for client-side routing
### Development & Testing
- **Vitest** for fast unit testing
- **Playwright** for end-to-end testing
- **ESLint** with Vue and TypeScript rules
- **Prettier** for code formatting
- **Vue DevTools** integration
### Build & Performance
- **Vite** with optimized build pipeline
- **Vue 3's reactivity system** for optimal performance
- **Tree-shaking** and code splitting
- **PWA capabilities** for mobile experience
## 🛠️ Development Workflow
### Prerequisites
- **Node.js 20+** (see `engines` in package.json)
- **pnpm** package manager
- **Backend API** running on `http://localhost:8000`
### Setup
1. **Install dependencies**
```bash
cd frontend
pnpm install
```
2. **Environment configuration**
```bash
cp .env.development .env.local
# Edit .env.local with your settings
```
3. **Start development server**
```bash
pnpm dev
```
The application will be available at `http://localhost:5174`
### Available Scripts
```bash
# Development
pnpm dev # Start dev server with hot reload
pnpm preview # Preview production build locally
# Building
pnpm build # Build for production
pnpm build-only # Build without type checking
pnpm type-check # TypeScript type checking only
# Testing
pnpm test:unit # Run unit tests with Vitest
pnpm test:e2e # Run E2E tests with Playwright
# Code Quality
pnpm lint # Run ESLint with auto-fix
pnpm lint:eslint # ESLint only
pnpm lint:oxlint # Oxlint (fast linter) only
pnpm format # Format code with Prettier
# Component Development
pnpm add # Add new components with Liftkit
```
## 🔧 Configuration
### Environment Variables
Create `.env.local` for local development:
```bash
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
# Application Settings
VITE_APP_TITLE=ThrillWiki (Development)
VITE_APP_VERSION=1.0.0
# Feature Flags
VITE_ENABLE_DEBUG=true
VITE_ENABLE_ANALYTICS=false
# Theme
VITE_DEFAULT_THEME=system
```
### Vite Configuration
The build system is configured in `vite.config.ts` with:
- **Vue 3** plugin with JSX support
- **Path aliases** for clean imports
- **CSS preprocessing** with PostCSS and Tailwind
- **Development server** with proxy to backend API
- **Build optimizations** for production
### Tailwind CSS
Custom design system configured in `tailwind.config.js`:
- **Custom color palette** with CSS variables
- **Dark mode support** with `class` strategy
- **Component classes** for consistent styling
- **Material Design** inspired design tokens
## 📁 Project Structure Details
### Components Architecture
#### UI Components (`src/components/ui/`)
Base component library following shadcn-vue patterns:
- **Button** - Multiple variants and sizes
- **Card** - Flexible content containers
- **Badge** - Status indicators and labels
- **SearchInput** - Search functionality with debouncing
- **Input, Textarea, Select** - Form components
- **Dialog, Sheet, Dropdown** - Overlay components
#### Layout Components (`src/components/layout/`)
Application layout and navigation:
- **Navbar** - Main navigation with responsive design
- **ThemeController** - Dark/light mode toggle
- **Footer** - Site footer with links
#### Specialized Components
- **State Layer** - Material Design ripple effects
- **Icon** - Lucide React icon wrapper
- **Button variants** - Different button styles
### Views Structure
#### Page Components (`src/views/`)
- **Home.vue** - Landing page with featured content
- **SearchResults.vue** - Global search results display
- **parks/ParkList.vue** - List of all parks
- **parks/ParkDetail.vue** - Individual park information
- **rides/RideList.vue** - List of rides with filtering
- **rides/RideDetail.vue** - Detailed ride information
### State Management
#### Pinia Stores (`src/stores/`)
- **Theme Store** - Dark/light mode state
- **Search Store** - Search functionality and results
- **Park Store** - Park data management
- **Ride Store** - Ride data management
- **UI Store** - General UI state
### API Integration
#### Services (`src/services/`)
- **API client** with Axios configuration
- **Authentication** service
- **Park service** - CRUD operations for parks
- **Ride service** - CRUD operations for rides
- **Search service** - Global search functionality
### Type Definitions
#### TypeScript Types (`src/types/`)
- **API response types** matching backend serializers
- **Component prop types** for better type safety
- **Store state types** for Pinia stores
- **Utility types** for common patterns
## 🎨 Design System
### Color Palette
- **Primary colors** - Brand identity
- **Semantic colors** - Success, warning, error states
- **Neutral colors** - Grays for text and backgrounds
- **Dark mode variants** - Automatic color adjustments
### Typography
- **Inter font family** for modern appearance
- **Responsive text scales** for all screen sizes
- **Consistent line heights** for readability
### Component Variants
- **Button variants** - Primary, secondary, outline, ghost
- **Card variants** - Default, elevated, outlined
- **Input variants** - Default, error, success
### Dark Mode
- **Automatic detection** of system preference
- **Manual toggle** in theme controller
- **Smooth transitions** between themes
- **CSS custom properties** for dynamic theming
## 🧪 Testing Strategy
### Unit Tests (Vitest)
- **Component testing** with Vue Test Utils
- **Composable testing** for custom hooks
- **Service testing** for API calls
- **Store testing** for Pinia state management
### End-to-End Tests (Playwright)
- **User journey testing** - Complete user flows
- **Cross-browser testing** - Chrome, Firefox, Safari
- **Mobile testing** - Responsive behavior
- **Accessibility testing** - WCAG compliance
### Test Configuration
- **Vitest config** in `vitest.config.ts`
- **Playwright config** in `playwright.config.ts`
- **Test utilities** in `src/__tests__/`
- **Mock data** for consistent testing
## 🚀 Deployment
### Build Process
```bash
# Production build
pnpm build
# Preview build locally
pnpm preview
# Type checking before build
pnpm type-check
```
### Build Output
- **Optimized bundles** with code splitting
- **Asset optimization** (images, fonts, CSS)
- **Source maps** for debugging (development only)
- **Service worker** for PWA features
### Environment Configurations
- **Development** - `.env.development`
- **Staging** - `.env.staging`
- **Production** - `.env.production`
## 🔧 Development Tools
### IDE Setup
- **VSCode** with Volar extension
- **Vue Language Features** for better Vue support
- **TypeScript Importer** for auto-imports
- **Tailwind CSS IntelliSense** for styling
### Browser Extensions
- **Vue DevTools** for debugging
- **Tailwind CSS DevTools** for styling
- **Playwright Inspector** for E2E testing
### Performance Monitoring
- **Vite's built-in analyzer** for bundle analysis
- **Vue DevTools performance tab**
- **Lighthouse** for performance metrics
## 📖 API Integration
### Backend Communication
- **RESTful API** integration with Django backend
- **Automatic field conversion** (snake_case ↔ camelCase)
- **Error handling** with user-friendly messages
- **Loading states** for better UX
### Authentication Flow
- **JWT token management**
- **Automatic token refresh**
- **Protected routes** with guards
- **User session management**
## 🤝 Contributing
### Code Standards
1. **Vue 3 Composition API** with `<script setup>` syntax
2. **TypeScript** for all new components and utilities
3. **Component naming** following Vue.js conventions
4. **CSS classes** using Tailwind utility classes
### Development Process
1. **Create feature branch** from `main`
2. **Follow component structure** guidelines
3. **Add tests** for new functionality
4. **Update documentation** as needed
5. **Submit pull request** with description
### Component Creation
```bash
# Add new component with Liftkit
pnpm add
# Follow the prompts to create component structure
```
## 🐛 Troubleshooting
### Common Issues
#### Build Errors
- **TypeScript errors** - Run `pnpm type-check` to identify issues
- **Missing dependencies** - Run `pnpm install` to sync packages
- **Vite configuration** - Check `vite.config.ts` for build settings
#### Runtime Errors
- **API connection** - Verify backend is running on port 8000
- **Environment variables** - Check `.env.local` configuration
- **CORS issues** - Configure backend CORS settings
#### Development Issues
- **Hot reload not working** - Restart dev server
- **Type errors** - Check TypeScript configuration
- **Styling issues** - Verify Tailwind classes
### Performance Tips
- **Use Composition API** for better performance
- **Lazy load components** for better initial load
- **Optimize images** and assets
- **Use `computed` properties** for derived state
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details.
## 🙏 Acknowledgments
- **Vue.js Team** for the excellent framework
- **Vite Team** for the blazing fast build tool
- **Tailwind CSS** for the utility-first approach
- **shadcn-vue** for component inspiration
- **ThrillWiki Community** for feedback and support
---
**Built with ❤️ for the theme park and roller coaster community**

1272
frontend/bun.lock Normal file

File diff suppressed because it is too large Load Diff

216
frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,216 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActiveFilterChip: typeof import('./src/components/filters/ActiveFilterChip.vue')['default']
AlertDialog: typeof import('./src/components/ui/alert-dialog/AlertDialog.vue')['default']
AlertDialogAction: typeof import('./src/components/ui/alert-dialog/AlertDialogAction.vue')['default']
AlertDialogCancel: typeof import('./src/components/ui/alert-dialog/AlertDialogCancel.vue')['default']
AlertDialogContent: typeof import('./src/components/ui/alert-dialog/AlertDialogContent.vue')['default']
AlertDialogDescription: typeof import('./src/components/ui/alert-dialog/AlertDialogDescription.vue')['default']
AlertDialogFooter: typeof import('./src/components/ui/alert-dialog/AlertDialogFooter.vue')['default']
AlertDialogHeader: typeof import('./src/components/ui/alert-dialog/AlertDialogHeader.vue')['default']
AlertDialogTitle: typeof import('./src/components/ui/alert-dialog/AlertDialogTitle.vue')['default']
AlertDialogTrigger: typeof import('./src/components/ui/alert-dialog/AlertDialogTrigger.vue')['default']
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
AuthManager: typeof import('./src/components/auth/AuthManager.vue')['default']
AuthModal: typeof import('./src/components/auth/AuthModal.vue')['default']
AuthPrompt: typeof import('./src/components/entity/AuthPrompt.vue')['default']
Avatar: typeof import('./src/components/ui/avatar/Avatar.vue')['default']
AvatarFallback: typeof import('./src/components/ui/avatar/AvatarFallback.vue')['default']
AvatarImage: typeof import('./src/components/ui/avatar/AvatarImage.vue')['default']
Badge: typeof import('./src/components/ui/Badge.vue')['default']
Breadcrumb: typeof import('./src/components/ui/breadcrumb/Breadcrumb.vue')['default']
BreadcrumbItem: typeof import('./src/components/ui/breadcrumb/BreadcrumbItem.vue')['default']
BreadcrumbLink: typeof import('./src/components/ui/breadcrumb/BreadcrumbLink.vue')['default']
BreadcrumbList: typeof import('./src/components/ui/breadcrumb/BreadcrumbList.vue')['default']
BreadcrumbPage: typeof import('./src/components/ui/breadcrumb/BreadcrumbPage.vue')['default']
BreadcrumbSeparator: typeof import('./src/components/ui/breadcrumb/BreadcrumbSeparator.vue')['default']
Button: typeof import('./src/components/ui/Button.vue')['default']
Card: typeof import('./src/components/ui/Card.vue')['default']
CardAction: typeof import('./src/components/ui/card/CardAction.vue')['default']
CardContent: typeof import('./src/components/ui/card/CardContent.vue')['default']
CardDescription: typeof import('./src/components/ui/card/CardDescription.vue')['default']
CardFooter: typeof import('./src/components/ui/card/CardFooter.vue')['default']
CardHeader: typeof import('./src/components/ui/card/CardHeader.vue')['default']
CardTitle: typeof import('./src/components/ui/card/CardTitle.vue')['default']
Collapsible: typeof import('./src/components/ui/collapsible/Collapsible.vue')['default']
CollapsibleContent: typeof import('./src/components/ui/collapsible/CollapsibleContent.vue')['default']
CollapsibleTrigger: typeof import('./src/components/ui/collapsible/CollapsibleTrigger.vue')['default']
Command: typeof import('./src/components/ui/command/Command.vue')['default']
CommandDialog: typeof import('./src/components/ui/command/CommandDialog.vue')['default']
CommandEmpty: typeof import('./src/components/ui/command/CommandEmpty.vue')['default']
CommandGroup: typeof import('./src/components/ui/command/CommandGroup.vue')['default']
CommandInput: typeof import('./src/components/ui/command/CommandInput.vue')['default']
CommandItem: typeof import('./src/components/ui/command/CommandItem.vue')['default']
CommandList: typeof import('./src/components/ui/command/CommandList.vue')['default']
CommandSeparator: typeof import('./src/components/ui/command/CommandSeparator.vue')['default']
CommandShortcut: typeof import('./src/components/ui/command/CommandShortcut.vue')['default']
ContextMenu: typeof import('./src/components/ui/context-menu/ContextMenu.vue')['default']
ContextMenuCheckboxItem: typeof import('./src/components/ui/context-menu/ContextMenuCheckboxItem.vue')['default']
ContextMenuContent: typeof import('./src/components/ui/context-menu/ContextMenuContent.vue')['default']
ContextMenuGroup: typeof import('./src/components/ui/context-menu/ContextMenuGroup.vue')['default']
ContextMenuItem: typeof import('./src/components/ui/context-menu/ContextMenuItem.vue')['default']
ContextMenuLabel: typeof import('./src/components/ui/context-menu/ContextMenuLabel.vue')['default']
ContextMenuPortal: typeof import('./src/components/ui/context-menu/ContextMenuPortal.vue')['default']
ContextMenuRadioGroup: typeof import('./src/components/ui/context-menu/ContextMenuRadioGroup.vue')['default']
ContextMenuRadioItem: typeof import('./src/components/ui/context-menu/ContextMenuRadioItem.vue')['default']
ContextMenuSeparator: typeof import('./src/components/ui/context-menu/ContextMenuSeparator.vue')['default']
ContextMenuShortcut: typeof import('./src/components/ui/context-menu/ContextMenuShortcut.vue')['default']
ContextMenuSub: typeof import('./src/components/ui/context-menu/ContextMenuSub.vue')['default']
ContextMenuSubContent: typeof import('./src/components/ui/context-menu/ContextMenuSubContent.vue')['default']
ContextMenuSubTrigger: typeof import('./src/components/ui/context-menu/ContextMenuSubTrigger.vue')['default']
ContextMenuTrigger: typeof import('./src/components/ui/context-menu/ContextMenuTrigger.vue')['default']
DateRangeFilter: typeof import('./src/components/filters/DateRangeFilter.vue')['default']
Dialog: typeof import('./src/components/ui/dialog/Dialog.vue')['default']
DialogClose: typeof import('./src/components/ui/dialog/DialogClose.vue')['default']
DialogContent: typeof import('./src/components/ui/dialog/DialogContent.vue')['default']
DialogDescription: typeof import('./src/components/ui/dialog/DialogDescription.vue')['default']
DialogFooter: typeof import('./src/components/ui/dialog/DialogFooter.vue')['default']
DialogHeader: typeof import('./src/components/ui/dialog/DialogHeader.vue')['default']
DialogOverlay: typeof import('./src/components/ui/dialog/DialogOverlay.vue')['default']
DialogScrollContent: typeof import('./src/components/ui/dialog/DialogScrollContent.vue')['default']
DialogTitle: typeof import('./src/components/ui/dialog/DialogTitle.vue')['default']
DialogTrigger: typeof import('./src/components/ui/dialog/DialogTrigger.vue')['default']
DiscordIcon: typeof import('./src/components/icons/DiscordIcon.vue')['default']
Divider: typeof import('primevue/divider')['default']
Dropdown: typeof import('primevue/dropdown')['default']
DropdownMenu: typeof import('./src/components/ui/dropdown-menu/DropdownMenu.vue')['default']
DropdownMenuCheckboxItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue')['default']
DropdownMenuContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuContent.vue')['default']
DropdownMenuGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuGroup.vue')['default']
DropdownMenuItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuItem.vue')['default']
DropdownMenuLabel: typeof import('./src/components/ui/dropdown-menu/DropdownMenuLabel.vue')['default']
DropdownMenuRadioGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue')['default']
DropdownMenuRadioItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue')['default']
DropdownMenuSeparator: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSeparator.vue')['default']
DropdownMenuShortcut: typeof import('./src/components/ui/dropdown-menu/DropdownMenuShortcut.vue')['default']
DropdownMenuSub: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSub.vue')['default']
DropdownMenuSubContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubContent.vue')['default']
DropdownMenuSubTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue')['default']
DropdownMenuTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuTrigger.vue')['default']
EntitySuggestionCard: typeof import('./src/components/entity/EntitySuggestionCard.vue')['default']
EntitySuggestionManager: typeof import('./src/components/entity/EntitySuggestionManager.vue')['default']
EntitySuggestionModal: typeof import('./src/components/entity/EntitySuggestionModal.vue')['default']
FilterSection: typeof import('./src/components/filters/FilterSection.vue')['default']
ForgotPasswordModal: typeof import('./src/components/auth/ForgotPasswordModal.vue')['default']
GoogleIcon: typeof import('./src/components/icons/GoogleIcon.vue')['default']
HoverCard: typeof import('./src/components/ui/hover-card/HoverCard.vue')['default']
HoverCardContent: typeof import('./src/components/ui/hover-card/HoverCardContent.vue')['default']
HoverCardTrigger: typeof import('./src/components/ui/hover-card/HoverCardTrigger.vue')['default']
Icon: typeof import('./src/components/ui/Icon.vue')['default']
Input: typeof import('./src/components/ui/Input.vue')['default']
InputText: typeof import('primevue/inputtext')['default']
LoginModal: typeof import('./src/components/auth/LoginModal.vue')['default']
Menu: typeof import('primevue/menu')['default']
Menubar: typeof import('./src/components/ui/menubar/Menubar.vue')['default']
MenubarCheckboxItem: typeof import('./src/components/ui/menubar/MenubarCheckboxItem.vue')['default']
MenubarContent: typeof import('./src/components/ui/menubar/MenubarContent.vue')['default']
MenubarGroup: typeof import('./src/components/ui/menubar/MenubarGroup.vue')['default']
MenubarItem: typeof import('./src/components/ui/menubar/MenubarItem.vue')['default']
MenubarLabel: typeof import('./src/components/ui/menubar/MenubarLabel.vue')['default']
MenubarMenu: typeof import('./src/components/ui/menubar/MenubarMenu.vue')['default']
MenubarRadioGroup: typeof import('./src/components/ui/menubar/MenubarRadioGroup.vue')['default']
MenubarRadioItem: typeof import('./src/components/ui/menubar/MenubarRadioItem.vue')['default']
MenubarSeparator: typeof import('./src/components/ui/menubar/MenubarSeparator.vue')['default']
MenubarShortcut: typeof import('./src/components/ui/menubar/MenubarShortcut.vue')['default']
MenubarSub: typeof import('./src/components/ui/menubar/MenubarSub.vue')['default']
MenubarSubContent: typeof import('./src/components/ui/menubar/MenubarSubContent.vue')['default']
MenubarSubTrigger: typeof import('./src/components/ui/menubar/MenubarSubTrigger.vue')['default']
MenubarTrigger: typeof import('./src/components/ui/menubar/MenubarTrigger.vue')['default']
Navbar: typeof import('./src/components/layout/Navbar.vue')['default']
Popover: typeof import('./src/components/ui/popover/Popover.vue')['default']
PopoverAnchor: typeof import('./src/components/ui/popover/PopoverAnchor.vue')['default']
PopoverContent: typeof import('./src/components/ui/popover/PopoverContent.vue')['default']
PopoverTrigger: typeof import('./src/components/ui/popover/PopoverTrigger.vue')['default']
PresetItem: typeof import('./src/components/filters/PresetItem.vue')['default']
PrimeBadge: typeof import('./src/components/primevue/PrimeBadge.vue')['default']
PrimeButton: typeof import('./src/components/primevue/PrimeButton.vue')['default']
PrimeCard: typeof import('./src/components/primevue/PrimeCard.vue')['default']
PrimeDialog: typeof import('./src/components/primevue/PrimeDialog.vue')['default']
PrimeInput: typeof import('./src/components/primevue/PrimeInput.vue')['default']
PrimeProgress: typeof import('./src/components/primevue/PrimeProgress.vue')['default']
PrimeSelect: typeof import('./src/components/primevue/PrimeSelect.vue')['default']
PrimeSkeleton: typeof import('./src/components/primevue/PrimeSkeleton.vue')['default']
PrimeThemeController: typeof import('./src/components/layout/PrimeThemeController.vue')['default']
PrimeVueTest: typeof import('./src/components/test/PrimeVueTest.vue')['default']
Progress: typeof import('./src/components/ui/progress/Progress.vue')['default']
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RangeFilter: typeof import('./src/components/filters/RangeFilter.vue')['default']
RideCard: typeof import('./src/components/rides/RideCard.vue')['default']
RideFilterSidebar: typeof import('./src/components/filters/RideFilterSidebar.vue')['default']
RideListDisplay: typeof import('./src/components/rides/RideListDisplay.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SavePresetDialog: typeof import('./src/components/filters/SavePresetDialog.vue')['default']
ScrollArea: typeof import('./src/components/ui/scroll-area/ScrollArea.vue')['default']
ScrollBar: typeof import('./src/components/ui/scroll-area/ScrollBar.vue')['default']
SearchableSelect: typeof import('./src/components/filters/SearchableSelect.vue')['default']
SearchFilter: typeof import('./src/components/filters/SearchFilter.vue')['default']
SearchInput: typeof import('./src/components/ui/SearchInput.vue')['default']
Select: typeof import('./src/components/ui/select/Select.vue')['default']
SelectContent: typeof import('./src/components/ui/select/SelectContent.vue')['default']
SelectFilter: typeof import('./src/components/filters/SelectFilter.vue')['default']
SelectGroup: typeof import('./src/components/ui/select/SelectGroup.vue')['default']
SelectItem: typeof import('./src/components/ui/select/SelectItem.vue')['default']
SelectItemText: typeof import('./src/components/ui/select/SelectItemText.vue')['default']
SelectLabel: typeof import('./src/components/ui/select/SelectLabel.vue')['default']
SelectScrollDownButton: typeof import('./src/components/ui/select/SelectScrollDownButton.vue')['default']
SelectScrollUpButton: typeof import('./src/components/ui/select/SelectScrollUpButton.vue')['default']
SelectSeparator: typeof import('./src/components/ui/select/SelectSeparator.vue')['default']
SelectTrigger: typeof import('./src/components/ui/select/SelectTrigger.vue')['default']
SelectValue: typeof import('./src/components/ui/select/SelectValue.vue')['default']
Separator: typeof import('./src/components/ui/separator/Separator.vue')['default']
Sheet: typeof import('./src/components/ui/sheet/Sheet.vue')['default']
SheetClose: typeof import('./src/components/ui/sheet/SheetClose.vue')['default']
SheetContent: typeof import('./src/components/ui/sheet/SheetContent.vue')['default']
SheetDescription: typeof import('./src/components/ui/sheet/SheetDescription.vue')['default']
SheetFooter: typeof import('./src/components/ui/sheet/SheetFooter.vue')['default']
SheetHeader: typeof import('./src/components/ui/sheet/SheetHeader.vue')['default']
SheetOverlay: typeof import('./src/components/ui/sheet/SheetOverlay.vue')['default']
SheetTitle: typeof import('./src/components/ui/sheet/SheetTitle.vue')['default']
SheetTrigger: typeof import('./src/components/ui/sheet/SheetTrigger.vue')['default']
Sidebar: typeof import('./src/components/ui/sidebar/Sidebar.vue')['default']
SidebarContent: typeof import('./src/components/ui/sidebar/SidebarContent.vue')['default']
SidebarFooter: typeof import('./src/components/ui/sidebar/SidebarFooter.vue')['default']
SidebarGroup: typeof import('./src/components/ui/sidebar/SidebarGroup.vue')['default']
SidebarGroupAction: typeof import('./src/components/ui/sidebar/SidebarGroupAction.vue')['default']
SidebarGroupContent: typeof import('./src/components/ui/sidebar/SidebarGroupContent.vue')['default']
SidebarGroupLabel: typeof import('./src/components/ui/sidebar/SidebarGroupLabel.vue')['default']
SidebarHeader: typeof import('./src/components/ui/sidebar/SidebarHeader.vue')['default']
SidebarInput: typeof import('./src/components/ui/sidebar/SidebarInput.vue')['default']
SidebarInset: typeof import('./src/components/ui/sidebar/SidebarInset.vue')['default']
SidebarMenu: typeof import('./src/components/ui/sidebar/SidebarMenu.vue')['default']
SidebarMenuAction: typeof import('./src/components/ui/sidebar/SidebarMenuAction.vue')['default']
SidebarMenuBadge: typeof import('./src/components/ui/sidebar/SidebarMenuBadge.vue')['default']
SidebarMenuButton: typeof import('./src/components/ui/sidebar/SidebarMenuButton.vue')['default']
SidebarMenuButtonChild: typeof import('./src/components/ui/sidebar/SidebarMenuButtonChild.vue')['default']
SidebarMenuItem: typeof import('./src/components/ui/sidebar/SidebarMenuItem.vue')['default']
SidebarMenuSkeleton: typeof import('./src/components/ui/sidebar/SidebarMenuSkeleton.vue')['default']
SidebarMenuSub: typeof import('./src/components/ui/sidebar/SidebarMenuSub.vue')['default']
SidebarMenuSubButton: typeof import('./src/components/ui/sidebar/SidebarMenuSubButton.vue')['default']
SidebarMenuSubItem: typeof import('./src/components/ui/sidebar/SidebarMenuSubItem.vue')['default']
SidebarProvider: typeof import('./src/components/ui/sidebar/SidebarProvider.vue')['default']
SidebarRail: typeof import('./src/components/ui/sidebar/SidebarRail.vue')['default']
SidebarSeparator: typeof import('./src/components/ui/sidebar/SidebarSeparator.vue')['default']
SidebarTrigger: typeof import('./src/components/ui/sidebar/SidebarTrigger.vue')['default']
SignupModal: typeof import('./src/components/auth/SignupModal.vue')['default']
Skeleton: typeof import('./src/components/ui/skeleton/Skeleton.vue')['default']
Slider: typeof import('./src/components/ui/slider/Slider.vue')['default']
Tabs: typeof import('./src/components/ui/tabs/Tabs.vue')['default']
TabsContent: typeof import('./src/components/ui/tabs/TabsContent.vue')['default']
TabsList: typeof import('./src/components/ui/tabs/TabsList.vue')['default']
TabsTrigger: typeof import('./src/components/ui/tabs/TabsTrigger.vue')['default']
ThemeController: typeof import('./src/components/layout/ThemeController.vue')['default']
Tooltip: typeof import('./src/components/ui/tooltip/Tooltip.vue')['default']
TooltipContent: typeof import('./src/components/ui/tooltip/TooltipContent.vue')['default']
TooltipProvider: typeof import('./src/components/ui/tooltip/TooltipProvider.vue')['default']
TooltipTrigger: typeof import('./src/components/ui/tooltip/TooltipTrigger.vue')['default']
}
}

20
frontend/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["./**/*"]
}

8
frontend/e2e/vue.spec.ts Normal file
View File

@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test';
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toHaveText('You did it!');
})

1
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

36
frontend/eslint.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
)

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

76
frontend/package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/",
"add": "liftkit add"
},
"dependencies": {
"@csstools/normalize.css": "^12.1.1",
"@material/material-color-utilities": "^0.3.0",
"@primeuix/themes": "^1.2.3",
"@primevue/forms": "^4.3.7",
"@primevue/themes": "^4.3.7",
"@vueuse/core": "^13.8.0",
"lodash-es": "^4.17.21",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.7",
"tw-animate-css": "^1.3.7",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@chainlift/liftkit": "^0.2.0",
"@playwright/test": "^1.55.0",
"@prettier/plugin-oxc": "^0.0.4",
"@primevue/auto-import-resolver": "^4.3.7",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/eslint-plugin": "^1.3.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.34.0",
"eslint-plugin-oxlint": "~1.13.0",
"eslint-plugin-playwright": "^2.2.2",
"eslint-plugin-vue": "~10.4.0",
"jiti": "^2.5.1",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.13.0",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^4.1.12",
"typescript": "~5.9.2",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.1.3",
"vite-plugin-vue-devtools": "^8.0.1",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
},
"trustedDependencies": [
"@tailwindcss/oxide"
]
}

View File

@@ -0,0 +1,110 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})

5520
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- vue-demi

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

317
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,317 @@
<template>
<div class="min-h-screen bg-background text-foreground">
<!-- Authentication Modals -->
<AuthManager
:show="showAuthModal"
:initial-mode="authModalMode"
@close="closeAuthModal"
@success="handleAuthSuccess"
/>
<!-- Header -->
<header class="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container flex h-16 items-center">
<!-- Logo -->
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-gradient-to-br from-primary to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-primary-foreground font-bold text-sm">TW</span>
</div>
<router-link to="/" class="text-lg font-bold text-foreground hover:text-primary transition-colors">
ThrillWiki
</router-link>
</div>
<!-- Navigation -->
<nav class="flex items-center space-x-6 ml-8">
<router-link
to="/parks/"
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Parks
</router-link>
<router-link
to="/rides/"
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Rides
</router-link>
</nav>
<!-- Header Actions -->
<div class="ml-auto flex items-center gap-2">
<!-- Search -->
<div class="relative hidden md:block">
<i class="pi pi-search absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"></i>
<InputText
type="search"
placeholder="Search parks, rides..."
class="w-[300px] pl-8"
v-model="searchQuery"
@keyup.enter="handleSearch"
/>
</div>
<!-- Theme Toggle -->
<Button variant="secondary" @click="toggleTheme" class="p-2">
<i v-if="appliedTheme === 'dark'" class="pi pi-sun h-4 w-4"></i>
<i v-else class="pi pi-moon h-4 w-4"></i>
<span class="sr-only">Toggle theme</span>
</Button>
<!-- User Menu -->
<div class="relative">
<Button
variant="secondary"
@click="toggleUserMenu"
ref="userMenuButton"
class="p-2"
>
<i class="pi pi-user h-4 w-4"></i>
<span class="sr-only">User menu</span>
</Button>
<Menu
ref="userMenu"
:model="userMenuItems"
:popup="true"
class="mt-2"
/>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1">
<router-view />
</main>
<!-- Footer -->
<footer class="bg-card border-t border-border">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1">
<div class="flex items-center space-x-2 mb-4">
<div class="w-8 h-8 bg-gradient-to-br from-primary to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-primary-foreground font-bold text-sm">TW</span>
</div>
<h3 class="text-lg font-bold text-foreground">
ThrillWiki
</h3>
</div>
<p class="text-muted-foreground text-sm max-w-xs">
Your ultimate guide to theme parks and thrilling rides around the world.
</p>
</div>
<!-- Explore -->
<div>
<h4 class="font-semibold text-foreground mb-4">Explore</h4>
<ul class="space-y-2">
<li>
<router-link
to="/parks/"
class="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Parks
</router-link>
</li>
<li>
<router-link
to="/rides/"
class="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Rides
</router-link>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Manufacturers
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Operators
</a>
</li>
</ul>
</div>
<!-- Community -->
<div>
<h4 class="font-semibold text-foreground mb-4">Community</h4>
<ul class="space-y-2">
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Join ThrillWiki
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Contribute
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Community Guidelines
</a>
</li>
</ul>
</div>
<!-- Legal -->
<div>
<h4 class="font-semibold text-foreground mb-4">Legal</h4>
<ul class="space-y-2">
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Privacy Policy
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Terms of Service
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Contact
</a>
</li>
</ul>
</div>
</div>
<!-- Copyright -->
<div class="border-t border-border mt-8 pt-8">
<p class="text-center text-muted-foreground text-sm">
© 2025 ThrillWiki. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTheme } from './composables/useTheme'
import AuthManager from './components/auth/AuthManager.vue'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Menu from 'primevue/menu'
const router = useRouter()
const searchQuery = ref('')
// Theme management using the useTheme composable
const { appliedTheme, toggleTheme, initializeTheme } = useTheme()
// Authentication modal state
const showAuthModal = ref(false)
const authModalMode = ref<'login' | 'signup'>('login')
// User menu
const userMenu = ref()
const userMenuButton = ref()
// User menu items
const userMenuItems = computed(() => [
{
label: 'Sign In',
icon: 'pi pi-sign-in',
command: () => showLoginModal()
},
{
label: 'Sign Up',
icon: 'pi pi-user-plus',
command: () => showSignupModal()
}
])
// Initialize theme on mount
onMounted(() => {
initializeTheme()
// Listen for sidebar filter changes
window.addEventListener('sidebar-filter-change', handleSidebarFilterChange)
window.addEventListener('show-login', handleShowLogin)
})
// Cleanup event listeners
onUnmounted(() => {
window.removeEventListener('sidebar-filter-change', handleSidebarFilterChange)
window.removeEventListener('show-login', handleShowLogin)
})
// Search functionality
const handleSearch = () => {
if (searchQuery.value.trim()) {
router.push({
name: 'search-results',
query: { q: searchQuery.value.trim() }
})
}
}
// User menu functionality
const toggleUserMenu = (event: Event) => {
userMenu.value.toggle(event)
}
// Sidebar event handlers
const handleSidebarFilterChange = (event: CustomEvent) => {
// Handle filter changes from sidebar
console.log('Sidebar filters changed:', event.detail)
}
const handleShowLogin = () => {
showLoginModal()
}
// Authentication modal functions
const showLoginModal = () => {
authModalMode.value = 'login'
showAuthModal.value = true
}
const showSignupModal = () => {
authModalMode.value = 'signup'
showAuthModal.value = true
}
const closeAuthModal = () => {
showAuthModal.value = false
}
const handleAuthSuccess = (data: { mode: 'login' | 'signup', email: string }) => {
// Handle successful authentication
console.log('Authentication successful!', data)
// This could include redirecting to a dashboard, updating user state, etc.
}
</script>
<style scoped>
/* Additional component-specific styles if needed */
.router-link-active {
@apply text-primary;
}
/* Ensure proper theme integration */
:deep(.p-inputtext) {
@apply bg-background border-border text-foreground;
}
:deep(.p-button) {
@apply transition-colors;
}
:deep(.p-menu) {
@apply bg-background border-border shadow-lg;
}
:deep(.p-menu .p-menuitem-link) {
@apply text-foreground hover:bg-muted;
}
</style>

View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
describe('App', () => {
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('You did it!')
})
})

View File

@@ -0,0 +1,387 @@
<template>
<div class="h-full w-64 bg-surface-0 dark:bg-surface-900 border-r border-surface-200 dark:border-surface-700 flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-surface-200 dark:border-surface-700">
<router-link to="/" class="flex items-center gap-3 text-decoration-none">
<div
class="flex aspect-square w-8 h-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-purple-600 text-white"
>
<span class="font-bold text-sm">TW</span>
</div>
<div class="flex-1">
<div class="font-semibold text-surface-900 dark:text-surface-0">ThrillWiki</div>
<div class="text-xs text-surface-600 dark:text-surface-400">Theme Park Database</div>
</div>
</router-link>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Main Navigation -->
<div>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-3">Browse</div>
<div class="space-y-1">
<router-link
to="/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors"
:class="$route.path === '/' ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-home text-base"></i>
<span>Home</span>
</router-link>
<div class="space-y-1">
<div class="flex items-center">
<router-link
to="/parks/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors flex-1"
:class="$route.path.startsWith('/parks') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-building text-base"></i>
<span>Parks</span>
</router-link>
<Button
text
size="small"
class="ml-1"
@click="toggleParksSubmenu"
>
<i class="pi pi-plus text-xs"></i>
</Button>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center">
<router-link
to="/rides/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors flex-1"
:class="$route.path.startsWith('/rides') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-bolt text-base"></i>
<span>Rides</span>
</router-link>
<Button
text
size="small"
class="ml-1"
@click="toggleRidesSubmenu"
>
<i class="pi pi-plus text-xs"></i>
</Button>
</div>
</div>
</div>
</div>
<!-- Quick Filters -->
<div>
<div
class="flex items-center justify-between cursor-pointer mb-3"
@click="filtersOpen = !filtersOpen"
>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400">Quick Filters</div>
<i
:class="['pi text-xs transition-transform', filtersOpen ? 'pi-chevron-down' : 'pi-chevron-right']"
></i>
</div>
<div v-show="filtersOpen" class="space-y-1">
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('featured') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('featured')"
>
<div class="flex items-center gap-3">
<i class="pi pi-star text-base"></i>
<span>Featured</span>
</div>
<Badge v-if="featuredCount" :value="featuredCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('roller_coaster') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('roller_coaster')"
>
<div class="flex items-center gap-3">
<i class="pi pi-angle-double-up text-base"></i>
<span>Roller Coasters</span>
</div>
<Badge v-if="coasterCount" :value="coasterCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('water_ride') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('water_ride')"
>
<div class="flex items-center gap-3">
<i class="pi pi-cloud-download text-base"></i>
<span>Water Rides</span>
</div>
<Badge v-if="waterRideCount" :value="waterRideCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('family') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('family')"
>
<div class="flex items-center gap-3">
<i class="pi pi-users text-base"></i>
<span>Family Rides</span>
</div>
<Badge v-if="familyRideCount" :value="familyRideCount" size="small" />
</div>
</div>
</div>
<!-- Recent Activity -->
<div>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-3">Recent</div>
<div class="space-y-1">
<router-link
v-for="item in recentItems"
:key="item.id"
:to="item.path"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800"
>
<i :class="item.icon" class="text-base"></i>
<span>{{ item.name }}</span>
</router-link>
<div v-if="recentItems.length === 0" class="space-y-2">
<Skeleton height="2rem" />
<Skeleton height="2rem" />
<Skeleton height="2rem" />
</div>
</div>
</div>
<!-- Countries -->
<div>
<div
class="flex items-center justify-between cursor-pointer mb-3"
@click="countriesOpen = !countriesOpen"
>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400">Countries</div>
<i
:class="['pi text-xs transition-transform', countriesOpen ? 'pi-chevron-down' : 'pi-chevron-right']"
></i>
</div>
<div v-show="countriesOpen" class="space-y-1">
<div
v-for="country in countries"
:key="country.code"
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="selectedCountry === country.code ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="filterByCountry(country.code)"
>
<div class="flex items-center gap-3">
<span class="text-base">{{ country.flag }}</span>
<span>{{ country.name }}</span>
</div>
<Badge v-if="country.count" :value="country.count" size="small" />
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-4 border-t border-surface-200 dark:border-surface-700">
<div class="relative">
<div
class="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors hover:bg-surface-100 dark:hover:bg-surface-800"
@click="userMenuVisible = true"
>
<Avatar
:label="user?.name?.charAt(0) || 'G'"
class="w-8 h-8"
shape="circle"
/>
<div class="flex-1 text-left">
<div class="font-semibold text-surface-900 dark:text-surface-0 text-sm">{{ user?.name || 'Guest' }}</div>
<div class="text-xs text-surface-600 dark:text-surface-400">{{ user?.email || 'Not signed in' }}</div>
</div>
<i class="pi pi-chevron-up text-xs"></i>
</div>
<Menu
ref="userMenu"
v-model:visible="userMenuVisible"
:model="userMenuItems"
popup
class="w-56"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// PrimeVue Components
import Button from 'primevue/button'
import Badge from 'primevue/badge'
import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
const route = useRoute()
const router = useRouter()
// State
const filtersOpen = ref(true)
const countriesOpen = ref(false)
const userMenuVisible = ref(false)
const activeFilters = ref<string[]>([])
const selectedCountry = ref<string>('')
// Menu reference
const userMenu = ref()
// Mock user data - replace with actual auth state
const user = ref<{ name: string; email: string; avatar?: string } | null>(null)
// Mock data - replace with actual API calls
const featuredCount = ref(12)
const coasterCount = ref(156)
const waterRideCount = ref(43)
const familyRideCount = ref(89)
const recentItems = ref([
{ id: 1, name: 'Cedar Point', path: '/parks/cedar-point/', icon: 'pi pi-building' },
{ id: 2, name: 'Steel Vengeance', path: '/parks/cedar-point/rides/steel-vengeance/', icon: 'pi pi-bolt' },
{ id: 3, name: 'Magic Kingdom', path: '/parks/magic-kingdom/', icon: 'pi pi-building' },
])
const countries = ref([
{ code: 'US', name: 'United States', flag: '🇺🇸', count: 89 },
{ code: 'UK', name: 'United Kingdom', flag: '🇬🇧', count: 34 },
{ code: 'DE', name: 'Germany', flag: '🇩🇪', count: 28 },
{ code: 'FR', name: 'France', flag: '🇫🇷', count: 22 },
{ code: 'JP', name: 'Japan', flag: '🇯🇵', count: 18 },
{ code: 'CA', name: 'Canada', flag: '🇨🇦', count: 15 },
])
// User menu items
const userMenuItems = computed(() => [
{
separator: true
},
{
label: user.value?.name || 'Guest',
items: [
{
label: user.value?.email || 'Not signed in',
disabled: true
}
]
},
{
separator: true
},
...(user.value ? [] : [{
label: 'Sign In',
icon: 'pi pi-sign-in',
command: () => showLogin()
}]),
...(user.value ? [{
label: 'Upgrade to Pro',
icon: 'pi pi-star',
command: () => console.log('Upgrade to Pro')
}] : []),
{
separator: true
},
{
label: 'Account',
icon: 'pi pi-user',
command: () => console.log('Account')
},
{
label: 'Billing',
icon: 'pi pi-credit-card',
command: () => console.log('Billing')
},
{
label: 'Notifications',
icon: 'pi pi-bell',
command: () => console.log('Notifications')
},
{
separator: true
},
...(user.value ? [{
label: 'Log out',
icon: 'pi pi-sign-out',
command: () => signOut()
}] : [])
])
// Methods
const applyFilter = (filter: string) => {
const index = activeFilters.value.indexOf(filter)
if (index > -1) {
activeFilters.value.splice(index, 1)
} else {
activeFilters.value.push(filter)
}
// Emit filter change event or update store
emitFilterChange()
}
const filterByCountry = (countryCode: string) => {
selectedCountry.value = selectedCountry.value === countryCode ? '' : countryCode
emitFilterChange()
}
const emitFilterChange = () => {
// Emit custom event that parent components can listen to
const event = new CustomEvent('sidebar-filter-change', {
detail: {
filters: activeFilters.value,
country: selectedCountry.value,
},
})
window.dispatchEvent(event)
}
const toggleParksSubmenu = () => {
// Handle parks submenu toggle
console.log('Toggle parks submenu')
}
const toggleRidesSubmenu = () => {
// Handle rides submenu toggle
console.log('Toggle rides submenu')
}
const showLogin = () => {
// Emit login event
const event = new CustomEvent('show-login')
window.dispatchEvent(event)
}
const signOut = () => {
user.value = null
// Handle sign out logic
}
// Initialize component
onMounted(() => {
// Load user data, recent items, etc.
// This would typically come from a store or API
})
</script>
<style scoped>
.text-decoration-none {
text-decoration: none;
}
</style>

View File

@@ -0,0 +1,236 @@
<template>
<Dialog
:visible="isVisible"
:modal="true"
:closable="true"
:style="{ width: '450px' }"
class="p-fluid"
@update:visible="handleVisibilityChange"
>
<template #header>
<h3 class="text-xl font-semibold">
{{ currentMode === 'login' ? 'Sign In' : 'Sign Up' }}
</h3>
</template>
<div class="space-y-4">
<div class="field">
<label for="email" class="block text-sm font-medium mb-2">Email</label>
<InputText
id="email"
v-model="email"
type="email"
placeholder="Enter your email"
:invalid="!!emailError"
/>
<small v-if="emailError" class="p-error">{{ emailError }}</small>
</div>
<div class="field">
<label for="password" class="block text-sm font-medium mb-2">Password</label>
<InputText
id="password"
v-model="password"
type="password"
placeholder="Enter your password"
:invalid="!!passwordError"
/>
<small v-if="passwordError" class="p-error">{{ passwordError }}</small>
</div>
<div v-if="currentMode === 'signup'" class="field">
<label for="confirmPassword" class="block text-sm font-medium mb-2">Confirm Password</label>
<InputText
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm your password"
:invalid="!!confirmPasswordError"
/>
<small v-if="confirmPasswordError" class="p-error">{{ confirmPasswordError }}</small>
</div>
<div class="flex justify-between items-center pt-4">
<Button
:label="currentMode === 'login' ? 'Sign In' : 'Sign Up'"
@click="handleSubmit"
:loading="isLoading"
class="flex-1 mr-2"
/>
<Button
:label="currentMode === 'login' ? 'Sign Up' : 'Sign In'"
severity="secondary"
@click="toggleMode"
class="flex-1 ml-2"
text
/>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
interface Props {
show: boolean
initialMode?: 'login' | 'signup'
}
interface Emits {
(e: 'close'): void
(e: 'success', data: { mode: 'login' | 'signup', email: string }): void
}
const props = withDefaults(defineProps<Props>(), {
initialMode: 'login'
})
const emit = defineEmits<Emits>()
// Reactive state
const currentMode = ref<'login' | 'signup'>(props.initialMode)
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const isLoading = ref(false)
// Validation errors
const emailError = ref('')
const passwordError = ref('')
const confirmPasswordError = ref('')
// Computed
const isVisible = computed({
get: () => props.show,
set: (value: boolean) => {
if (!value) {
emit('close')
}
}
})
// Watch for mode changes
watch(() => props.initialMode, (newMode) => {
currentMode.value = newMode
})
watch(() => props.show, (show) => {
if (show) {
resetForm()
}
})
// Methods
const resetForm = () => {
email.value = ''
password.value = ''
confirmPassword.value = ''
emailError.value = ''
passwordError.value = ''
confirmPasswordError.value = ''
isLoading.value = false
}
const validateForm = () => {
let isValid = true
// Reset errors
emailError.value = ''
passwordError.value = ''
confirmPasswordError.value = ''
// Email validation
if (!email.value) {
emailError.value = 'Email is required'
isValid = false
} else if (!/\S+@\S+\.\S+/.test(email.value)) {
emailError.value = 'Please enter a valid email'
isValid = false
}
// Password validation
if (!password.value) {
passwordError.value = 'Password is required'
isValid = false
} else if (password.value.length < 6) {
passwordError.value = 'Password must be at least 6 characters'
isValid = false
}
// Confirm password validation (only for signup)
if (currentMode.value === 'signup') {
if (!confirmPassword.value) {
confirmPasswordError.value = 'Please confirm your password'
isValid = false
} else if (password.value !== confirmPassword.value) {
confirmPasswordError.value = 'Passwords do not match'
isValid = false
}
}
return isValid
}
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isLoading.value = true
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
// Emit success event
emit('success', {
mode: currentMode.value,
email: email.value
})
// Close modal
emit('close')
} catch (error) {
console.error('Authentication error:', error)
// Handle error (could show toast notification)
} finally {
isLoading.value = false
}
}
const toggleMode = () => {
currentMode.value = currentMode.value === 'login' ? 'signup' : 'login'
// Clear form when switching modes
resetForm()
}
const handleVisibilityChange = (visible: boolean) => {
if (!visible) {
emit('close')
}
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AuthManager'
})
</script>
<style scoped>
.field {
margin-bottom: 1rem;
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 overflow-y-auto"
@click="closeOnBackdrop && handleBackdropClick"
>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<!-- Modal Container -->
<div class="flex min-h-full items-center justify-center p-4">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="relative w-full max-w-md transform overflow-hidden rounded-2xl bg-white dark:bg-gray-800 shadow-2xl transition-all"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ title }}
</h2>
<button
@click="$emit('close')"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 pb-6">
<slot />
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, toRefs, onUnmounted } from 'vue'
interface Props {
show: boolean
title: string
closeOnBackdrop?: boolean
}
const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true,
})
const emit = defineEmits<{
close: []
}>()
const handleBackdropClick = (event: MouseEvent) => {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
emit('close')
}
}
// Prevent body scroll when modal is open
const { show } = toRefs(props)
watch(show, (isShown) => {
if (isShown) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// Clean up on unmount
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>

View File

@@ -0,0 +1,175 @@
<template>
<AuthModal :show="show" title="Reset Password" @close="$emit('close')">
<div v-if="!emailSent">
<p class="text-gray-600 dark:text-gray-400 text-sm mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Email Field -->
<div>
<label
for="reset-email"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="reset-email"
v-model="email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your email address"
/>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Sending...' : 'Send Reset Link' }}
</button>
</div>
</form>
<!-- Back to Login -->
<div class="mt-6 text-sm text-center">
<button
@click="$emit('close')"
class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Back to Sign In
</button>
</div>
</div>
<!-- Success State -->
<div v-else class="text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/20 mb-4"
>
<svg
class="h-6 w-6 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Check your email</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-6">
We've sent a password reset link to <strong>{{ email }}</strong>
</p>
<div class="space-y-3">
<button
@click="handleResend"
:disabled="isLoading"
class="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ isLoading ? 'Sending...' : 'Resend Email' }}
</button>
<button
@click="$emit('close')"
class="w-full py-2 px-4 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Back to Sign In
</button>
</div>
</div>
</AuthModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import AuthModal from './AuthModal.vue'
import { useAuth } from '@/composables/useAuth'
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const email = ref('')
const emailSent = ref(false)
const handleSubmit = async () => {
try {
await auth.requestPasswordReset(email.value)
emailSent.value = true
} catch (error) {
console.error('Password reset request failed:', error)
}
}
const handleResend = async () => {
try {
await auth.requestPasswordReset(email.value)
} catch (error) {
console.error('Resend failed:', error)
}
}
// Reset state when modal closes
watch(
() => props.show,
(isShown) => {
if (!isShown) {
email.value = ''
emailSent.value = false
auth.clearError()
}
},
)
</script>

View File

@@ -0,0 +1,237 @@
<template>
<AuthModal :show="show" title="Welcome Back" @close="$emit('close')">
<!-- Social Login Buttons -->
<div class="space-y-3 mb-6">
<button
v-for="provider in socialProviders"
:key="provider.id"
@click="loginWithProvider(provider)"
class="w-full flex items-center justify-center px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<component :is="getProviderIcon(provider.id)" class="w-5 h-5 mr-3" />
Continue with {{ provider.name }}
</button>
</div>
<!-- Divider -->
<div v-if="socialProviders.length > 0" class="relative mb-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
Or continue with email
</span>
</div>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Username/Email Field -->
<div>
<label
for="login-username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Username or Email
</label>
<input
id="login-username"
v-model="form.username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your username or email"
/>
</div>
<!-- Password Field -->
<div>
<label
for="login-password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Password
</label>
<div class="relative">
<input
id="login-password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
class="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPassword" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="login-remember"
v-model="form.remember"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<label for="login-remember" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Remember me
</label>
</div>
<button
type="button"
@click="showForgotPassword = true"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Forgot password?
</button>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Signing in...' : 'Sign In' }}
</button>
</div>
</form>
<!-- Sign Up Link -->
<div class="mt-6 text-sm text-center">
<p class="text-gray-600 dark:text-gray-400">
Don't have an account?
<button
@click="$emit('showSignup')"
class="ml-1 font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Sign up
</button>
</p>
</div>
<!-- Forgot Password Modal -->
<ForgotPasswordModal :show="showForgotPassword" @close="showForgotPassword = false" />
</AuthModal>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
import AuthModal from './AuthModal.vue'
import ForgotPasswordModal from './ForgotPasswordModal.vue'
import GoogleIcon from '../icons/GoogleIcon.vue'
import DiscordIcon from '../icons/DiscordIcon.vue'
import { useAuth } from '@/composables/useAuth'
import type { SocialAuthProvider } from '@/types'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
close: []
showSignup: []
success: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const showPassword = ref(false)
const showForgotPassword = ref(false)
const socialProviders = ref<SocialAuthProvider[]>([])
const form = ref({
username: '',
password: '',
remember: false,
})
// Load social providers on mount
onMounted(async () => {
socialProviders.value = await auth.getSocialProviders()
})
const handleLogin = async () => {
try {
await auth.login({
username: form.value.username,
password: form.value.password,
})
// Clear form
form.value = {
username: '',
password: '',
remember: false,
}
emit('success')
emit('close')
} catch (error) {
// Error is handled by the auth composable
console.error('Login failed:', error)
}
}
const loginWithProvider = (provider: SocialAuthProvider) => {
// Redirect to Django allauth provider URL
window.location.href = provider.authUrl
}
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'google':
return GoogleIcon
case 'discord':
return DiscordIcon
default:
return 'div'
}
}
</script>

View File

@@ -0,0 +1,335 @@
<template>
<AuthModal :show="show" title="Create Account" @close="$emit('close')">
<!-- Social Login Buttons -->
<div class="space-y-3 mb-6">
<button
v-for="provider in socialProviders"
:key="provider.id"
@click="loginWithProvider(provider)"
class="w-full flex items-center justify-center px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<component :is="getProviderIcon(provider.id)" class="w-5 h-5 mr-3" />
Continue with {{ provider.name }}
</button>
</div>
<!-- Divider -->
<div v-if="socialProviders.length > 0" class="relative mb-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
Or create account with email
</span>
</div>
</div>
<!-- Signup Form -->
<form @submit.prevent="handleSignup" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label
for="signup-first-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
First Name
</label>
<input
id="signup-first-name"
v-model="form.first_name"
type="text"
autocomplete="given-name"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="First name"
/>
</div>
<div>
<label
for="signup-last-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Last Name
</label>
<input
id="signup-last-name"
v-model="form.last_name"
type="text"
autocomplete="family-name"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Last name"
/>
</div>
</div>
<!-- Username Field -->
<div>
<label
for="signup-username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Username
</label>
<input
id="signup-username"
v-model="form.username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Choose a username"
/>
</div>
<!-- Email Field -->
<div>
<label
for="signup-email"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="signup-email"
v-model="form.email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your email"
/>
</div>
<!-- Password Field -->
<div>
<label
for="signup-password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Password
</label>
<div class="relative">
<input
id="signup-password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
class="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Create a password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPassword" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Confirm Password Field -->
<div>
<label
for="signup-password-confirm"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Confirm Password
</label>
<div class="relative">
<input
id="signup-password-confirm"
v-model="form.password_confirm"
:type="showPasswordConfirm ? 'text' : 'password'"
required
autocomplete="new-password"
class="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Confirm your password"
/>
<button
type="button"
@click="showPasswordConfirm = !showPasswordConfirm"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPasswordConfirm" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Terms and Privacy -->
<div class="flex items-start">
<input
id="signup-terms"
v-model="form.agreeToTerms"
type="checkbox"
required
class="h-4 w-4 mt-1 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<label for="signup-terms" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
I agree to the
<a
href="/terms"
target="_blank"
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Terms of Service
</a>
and
<a
href="/privacy"
target="_blank"
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Privacy Policy
</a>
</label>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 714 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Creating Account...' : 'Create Account' }}
</button>
</div>
</form>
<!-- Sign In Link -->
<div class="mt-6 text-sm text-center">
<p class="text-gray-600 dark:text-gray-400">
Already have an account?
<button
@click="$emit('showLogin')"
class="ml-1 font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Sign in
</button>
</p>
</div>
</AuthModal>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
import AuthModal from './AuthModal.vue'
import GoogleIcon from '../icons/GoogleIcon.vue'
import DiscordIcon from '../icons/DiscordIcon.vue'
import { useAuth } from '@/composables/useAuth'
import type { SocialAuthProvider } from '@/types'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
close: []
showLogin: []
success: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const showPassword = ref(false)
const showPasswordConfirm = ref(false)
const socialProviders = ref<SocialAuthProvider[]>([])
const form = ref({
first_name: '',
last_name: '',
username: '',
email: '',
password: '',
password_confirm: '',
agreeToTerms: false,
})
// Load social providers on mount
onMounted(async () => {
socialProviders.value = await auth.getSocialProviders()
})
const handleSignup = async () => {
try {
await auth.signup({
first_name: form.value.first_name,
last_name: form.value.last_name,
username: form.value.username,
email: form.value.email,
password: form.value.password,
password_confirm: form.value.password_confirm,
})
// Clear form
form.value = {
first_name: '',
last_name: '',
username: '',
email: '',
password: '',
password_confirm: '',
agreeToTerms: false,
}
emit('success')
emit('close')
} catch (error) {
// Error is handled by the auth composable
console.error('Signup failed:', error)
}
}
const loginWithProvider = (provider: SocialAuthProvider) => {
// Redirect to Django allauth provider URL
window.location.href = provider.login_url
}
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'google':
return GoogleIcon
case 'discord':
return DiscordIcon
default:
return 'div'
}
}
</script>

View File

@@ -0,0 +1,87 @@
[data-lk-component='button'] {
/* DEFAULTS */
--button-font-size: var(--body-font-size);
--button-line-height: var(--lk-halfstep) !important;
--button-padX: var(--button-font-size);
--button-padY: calc(
var(--button-font-size) * calc(var(--lk-halfstep) / var(--lk-size-xl-unitless))
);
--button-padX-sideWithIcon: calc(var(--button-font-size) / var(--lk-wholestep));
--button-gap: calc(var(--button-padY) / var(--lk-eighthstep));
cursor: pointer;
display: inline-flex;
vertical-align: middle;
border: 1px solid rgba(0, 0, 0, 0);
border-radius: 100em;
position: relative;
text-decoration: none;
white-space: pre;
word-break: keep-all;
overflow: hidden;
padding: var(--button-padY) 1em;
font-weight: 500;
font-size: var(--button-font-size);
line-height: var(--button-line-height);
font-family: inherit;
}
/* SIZE VARIANTS */
[data-lk-button-size='sm'] {
--button-font-size: var(--subheading-font-size);
}
[data-lk-button-size='lg'] {
--button-font-size: var(--title3-font-size);
}
/* ICON-BASED PADDING ADJUSTMENTS */
[data-lk-component='button']:has([data-lk-icon-position='start']) {
padding-left: var(--button-padX-sideWithIcon);
padding-right: var(--button-padX);
}
[data-lk-component='button']:has([data-lk-icon-position='end']) {
padding-left: 1em;
padding-right: var(--button-padX-sideWithIcon);
}
[data-lk-component='button']:has([data-lk-icon-position='start']):has(
[data-lk-icon-position='end']
) {
padding-left: var(--button-padX-sideWithIcon);
padding-right: var(--button-padX-sideWithIcon);
}
/* CONTENT WRAPPER */
[data-lk-button-content-wrap='true'] {
display: inline-flex;
align-items: center;
gap: var(--button-gap);
}
/* TODO: Remove entirely */
/* [data-lk-component="button"] div:has(> [data-lk-component="icon"]) {
width: calc(1em * var(--lk-halfstep));
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
} */
/* ICON VERTICAL OPTICAL ALIGNMENTS */
[data-lk-button-optic-icon-shift='true'] div:has(> [data-lk-component='icon']) {
margin-top: calc(-1 * calc(1em * var(--lk-quarterstep-dec)));
}
/* STYLE VARIANTS */
[data-lk-button-variant='text'] {
background: transparent !important;
}
[data-lk-button-variant='outline'] {
background: transparent !important;
border: 1px solid var(--lk-outlinevariant);
}

View File

@@ -0,0 +1,137 @@
'use client'
import { useMemo } from 'react'
import { propsToDataAttrs } from '@/lib/utilities'
import { getOnToken } from '@/lib/colorUtils'
import { IconName } from 'lucide-react/dynamic'
import '@/components/button/button.css'
import StateLayer from '@/components/state-layer'
import { LkStateLayerProps } from '@/components/state-layer'
import Icon from '@/components/icon'
export interface LkButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
variant?: 'fill' | 'outline' | 'text'
color?: LkColorWithOnToken
size?: 'sm' | 'md' | 'lg'
material?: string
startIcon?: IconName
endIcon?: IconName
opticIconShift?: boolean
modifiers?: string
stateLayerOverride?: LkStateLayerProps // Optional override for state layer properties
}
/**
* A customizable button component with support for various visual styles, sizes, and icons.
*
* @param props - The button component props
* @param props.label - The text content displayed inside the button. Defaults to "Button"
* @param props.variant - The visual style variant of the button. Defaults to "fill"
* @param props.color - The color theme of the button. Defaults to "primary"
* @param props.size - The size of the button (sm, md, lg). Defaults to "md"
* @param props.startIcon - Optional icon element to display at the start of the button
* @param props.endIcon - Optional icon element to display at the end of the button
* @param props.restProps - Additional props to be spread to the underlying button element
* @param props.opticIconShift - Boolean to control optical icon alignment on the y-axis. Defaults to true. Pulls icons up slightly.
* @param props.modifiers - Additional class names to concatenate onto the button's default class list
* @param props.stateLayerOverride - Optional override for state layer properties, allowing customization of the state layer's appearance
*
* @returns A styled button element with optional start/end icons and a state layer overlay
*
* @example
* ```tsx
* <Button
* label="Click me"
* variant="outline"
* color="secondary"
* size="lg"
* startIcon={<ChevronIcon />}
* />
* ```
*/
export default function Button({
label = 'Button',
variant = 'fill',
color = 'primary',
size = 'md',
startIcon,
endIcon,
opticIconShift = true,
modifiers,
stateLayerOverride,
...restProps
}: LkButtonProps) {
const lkButtonAttrs = useMemo(
() => propsToDataAttrs({ variant, color, size, startIcon, endIcon, opticIconShift }, 'button'),
[variant, color, size, startIcon, endIcon, opticIconShift],
)
const onColorToken = getOnToken(color) as LkColor
// Define different base color classes based on variant
let baseButtonClasses = ''
switch (variant) {
case 'fill':
baseButtonClasses = `bg-${color} color-${onColorToken}`
break
case 'outline':
case 'text':
baseButtonClasses = `color-${color}`
break
default:
baseButtonClasses = `bg-${color} color-${onColorToken}`
break
}
if (modifiers) {
baseButtonClasses += ` ${modifiers}`
}
/**Determine state layer props dynamically */
function getLocalStateLayerProps() {
if (stateLayerOverride) {
return stateLayerOverride
} else {
return {
bgColor: variant === 'fill' ? onColorToken : color,
}
}
}
const localStateLayerProps: LkStateLayerProps = getLocalStateLayerProps()
return (
<button
{...lkButtonAttrs}
{...restProps}
type="button"
data-lk-component="button"
className={`${baseButtonClasses} ${modifiers || ''}`}
>
<div data-lk-button-content-wrap="true">
{startIcon && (
<div data-lk-icon-position="start">
<Icon
name={startIcon}
color={variant === 'fill' ? onColorToken : color}
data-lk-icon-position="start"
></Icon>
</div>
)}
<span data-lk-button-child="button-text">{label ?? 'Button'}</span>
{endIcon && (
<div data-lk-icon-position="end">
<Icon
name={endIcon}
color={variant === 'fill' ? onColorToken : color}
data-lk-icon-position="end"
></Icon>
</div>
)}
</div>
<StateLayer {...localStateLayerProps} />
</button>
)
}

View File

@@ -0,0 +1,173 @@
<template>
<div class="space-y-4">
<div class="text-center">
<div class="mb-4">
<div
class="mx-auto w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center"
>
<svg
class="h-6 w-6 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
</div>
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Sign in to contribute
</h4>
<p class="text-gray-600 dark:text-gray-400 mb-6">
You need to be signed in to add "{{ searchTerm }}" to ThrillWiki's database. Join our
community of theme park enthusiasts!
</p>
</div>
<!-- Benefits List -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-3">
<h5 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<svg
class="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
What you can do:
</h5>
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Add new parks, rides, and companies
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit and improve existing entries
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
Save your favorite places
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-6a2 2 0 012-2h8z"
/>
</svg>
Share reviews and experiences
</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<button
@click="handleLogin"
class="flex-1 bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Sign In
</button>
<button
@click="handleSignup"
class="flex-1 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-6 py-3 rounded-lg font-semibold border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Create Account
</button>
</div>
<!-- Alternative Options -->
<div class="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Or continue exploring ThrillWiki</p>
<button
@click="handleBrowseExisting"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium transition-colors"
>
Browse existing entries →
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
searchTerm: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
login: []
signup: []
browse: []
}>()
const handleLogin = () => {
emit('login')
}
const handleSignup = () => {
emit('signup')
}
const handleBrowseExisting = () => {
emit('browse')
}
</script>

View File

@@ -0,0 +1,185 @@
<template>
<div
class="group relative bg-gray-50 dark:bg-gray-700 rounded-lg p-4 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer border border-gray-200 dark:border-gray-600"
@click="handleSelect"
>
<!-- Entity Type Badge -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span :class="entityTypeBadgeClasses">
<component :is="entityIcon" class="h-4 w-4" />
{{ entityTypeLabel }}
</span>
<span
v-if="suggestion.confidence_score"
:class="confidenceClasses"
class="text-xs px-2 py-1 rounded-full font-medium"
>
{{ confidenceLabel }}
</span>
</div>
<!-- Select Arrow -->
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<svg
class="h-5 w-5 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<!-- Entity Name -->
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ suggestion.name }}
</h4>
<!-- Entity Details -->
<div class="space-y-2">
<!-- Location for Parks -->
<div
v-if="suggestion.entity_type === 'park' && suggestion.location"
class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{{ suggestion.location }}
</div>
<!-- Park for Rides -->
<div
v-if="suggestion.entity_type === 'ride' && suggestion.park_name"
class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
At {{ suggestion.park_name }}
</div>
<!-- Description -->
<p
v-if="suggestion.description"
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
>
{{ suggestion.description }}
</p>
<!-- Match Reason -->
<div
v-if="suggestion.match_reason"
class="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded"
>
{{ suggestion.match_reason }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { EntitySuggestion } from '../../services/api'
interface Props {
suggestion: EntitySuggestion
}
const props = defineProps<Props>()
const emit = defineEmits<{
select: [suggestion: EntitySuggestion]
}>()
// Entity type configurations
const entityTypeConfig = {
park: {
label: 'Park',
badgeClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
icon: 'BuildingStorefrontIcon',
},
ride: {
label: 'Ride',
badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
icon: 'SparklesIcon',
},
company: {
label: 'Company',
badgeClass: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
icon: 'BuildingOfficeIcon',
},
}
// Computed properties
const entityTypeLabel = computed(
() => entityTypeConfig[props.suggestion.entity_type]?.label || 'Entity',
)
const entityTypeBadgeClasses = computed(() => {
const baseClasses = 'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium'
const typeClasses =
entityTypeConfig[props.suggestion.entity_type]?.badgeClass ||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
return `${baseClasses} ${typeClasses}`
})
const confidenceLabel = computed(() => {
const score = props.suggestion.confidence_score
if (score >= 0.8) return 'High Match'
if (score >= 0.6) return 'Good Match'
if (score >= 0.4) return 'Possible Match'
return 'Low Match'
})
const confidenceClasses = computed(() => {
const score = props.suggestion.confidence_score
if (score >= 0.8) return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
if (score >= 0.6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
if (score >= 0.4) return 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
})
// Simple icon components
const entityIcon = computed(() => {
const type = props.suggestion.entity_type
// Return appropriate icon component name or a default SVG
if (type === 'park') return 'BuildingStorefrontIcon'
if (type === 'ride') return 'SparklesIcon'
if (type === 'company') return 'BuildingOfficeIcon'
return 'QuestionMarkCircleIcon'
})
// Event handlers
const handleSelect = () => {
emit('select', props.suggestion)
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<EntitySuggestionModal
:show="showModal"
:search-term="searchTerm"
:suggestions="suggestions"
:is-authenticated="isAuthenticated"
@close="handleClose"
@select-suggestion="handleSuggestionSelect"
@add-entity="handleAddEntity"
@login="handleLogin"
@signup="handleSignup"
/>
<!-- Authentication Manager -->
<AuthManager
:show="showAuthModal"
:initial-mode="authMode"
@close="handleAuthClose"
@success="handleAuthSuccess"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, readonly } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '../../composables/useAuth'
import { ThrillWikiApi, type EntitySuggestion } from '../../services/api'
import EntitySuggestionModal from './EntitySuggestionModal.vue'
import AuthManager from '../auth/AuthManager.vue'
interface Props {
searchTerm: string
show?: boolean
entityTypes?: string[]
parkContext?: string
maxSuggestions?: number
}
const props = withDefaults(defineProps<Props>(), {
show: false,
entityTypes: () => ['park', 'ride', 'company'],
maxSuggestions: 5,
})
const emit = defineEmits<{
close: []
entitySelected: [entity: EntitySuggestion]
entityAdded: [entityType: string, name: string]
error: [message: string]
}>()
// Dependencies
const router = useRouter()
const { user, isAuthenticated, login, signup } = useAuth()
const api = new ThrillWikiApi()
// Reactive state
const showModal = ref(props.show)
const suggestions = ref<EntitySuggestion[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Authentication modal state
const showAuthModal = ref(false)
const authMode = ref<'login' | 'signup'>('login')
// Computed properties
const hasValidSearchTerm = computed(() => {
return props.searchTerm && props.searchTerm.trim().length > 0
})
// Watch for prop changes
watch(
() => props.show,
(newShow) => {
showModal.value = newShow
if (newShow && hasValidSearchTerm.value) {
performFuzzySearch()
}
},
{ immediate: true },
)
watch(
() => props.searchTerm,
(newTerm) => {
if (showModal.value && newTerm && newTerm.trim().length > 0) {
performFuzzySearch()
}
},
)
// Methods
const performFuzzySearch = async () => {
if (!hasValidSearchTerm.value) {
suggestions.value = []
return
}
loading.value = true
error.value = null
try {
const response = await api.entitySearch.fuzzySearch({
query: props.searchTerm.trim(),
entityTypes: props.entityTypes,
parkContext: props.parkContext,
maxResults: props.maxSuggestions,
minConfidence: 0.3,
})
suggestions.value = response.suggestions || []
} catch (err) {
console.error('Fuzzy search failed:', err)
error.value = 'Failed to search for similar entities. Please try again.'
suggestions.value = []
emit('error', error.value)
} finally {
loading.value = false
}
}
const handleClose = () => {
showModal.value = false
emit('close')
}
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit('entitySelected', suggestion)
handleClose()
// Navigate to the selected entity
navigateToEntity(suggestion)
}
const handleAddEntity = async (entityType: string, name: string) => {
try {
// Emit event for parent to handle
emit('entityAdded', entityType, name)
// For now, just close the modal
// In a real implementation, this might navigate to an add entity form
handleClose()
// You could also show a success message here
console.log(`Entity creation initiated: ${entityType} - ${name}`)
} catch (err) {
console.error('Failed to initiate entity creation:', err)
error.value = 'Failed to initiate entity creation. Please try again.'
emit('error', error.value)
}
}
const handleLogin = () => {
authMode.value = 'login'
showAuthModal.value = true
}
const handleSignup = () => {
authMode.value = 'signup'
showAuthModal.value = true
}
// Authentication modal handlers
const handleAuthClose = () => {
showAuthModal.value = false
}
const handleAuthSuccess = () => {
showAuthModal.value = false
// Optionally refresh suggestions now that user is authenticated
if (hasValidSearchTerm.value && showModal.value) {
performFuzzySearch()
}
}
const navigateToEntity = (entity: EntitySuggestion) => {
try {
let route = ''
switch (entity.entity_type) {
case 'park':
route = `/parks/${entity.slug}`
break
case 'ride':
if (entity.park_slug) {
route = `/parks/${entity.park_slug}/rides/${entity.slug}`
} else {
route = `/rides/${entity.slug}`
}
break
case 'company':
route = `/companies/${entity.slug}`
break
default:
console.warn(`Unknown entity type: ${entity.entity_type}`)
return
}
router.push(route)
} catch (err) {
console.error('Failed to navigate to entity:', err)
error.value = 'Failed to navigate to the selected entity.'
emit('error', error.value)
}
}
// Public methods for external control
const show = () => {
showModal.value = true
if (hasValidSearchTerm.value) {
performFuzzySearch()
}
}
const hide = () => {
showModal.value = false
}
const refresh = () => {
if (showModal.value && hasValidSearchTerm.value) {
performFuzzySearch()
}
}
// Expose methods for parent components
defineExpose({
show,
hide,
refresh,
suggestions: readonly(suggestions),
loading: readonly(loading),
error: readonly(error),
})
</script>

View File

@@ -0,0 +1,214 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 overflow-y-auto"
@click="closeOnBackdrop && handleBackdropClick"
>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<!-- Modal Container -->
<div class="flex min-h-full items-center justify-center p-4">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="relative w-full max-w-2xl transform overflow-hidden rounded-2xl bg-white dark:bg-gray-800 shadow-2xl transition-all"
@click.stop
>
<!-- Header -->
<div
class="flex items-center justify-between p-6 pb-4 border-b border-gray-200 dark:border-gray-700"
>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Entity Not Found</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
We couldn't find "{{ searchTerm }}" but here are some suggestions
</p>
</div>
<button
@click="$emit('close')"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 pb-6">
<!-- Suggestions Section -->
<div v-if="suggestions.length > 0" class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Did you mean one of these?
</h3>
<div class="space-y-3">
<EntitySuggestionCard
v-for="suggestion in suggestions"
:key="`${suggestion.entity_type}-${suggestion.slug}`"
:suggestion="suggestion"
@select="handleSuggestionSelect"
/>
</div>
</div>
<!-- No Suggestions / Add New Section -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Can't find what you're looking for?
</h3>
<!-- Authenticated User - Add Entity -->
<div v-if="isAuthenticated" class="space-y-4">
<p class="text-gray-600 dark:text-gray-400">
You can help improve ThrillWiki by adding this entity to our database.
</p>
<div class="flex gap-3">
<button
@click="handleAddEntity('park')"
:disabled="loading"
class="flex-1 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Park
</button>
<button
@click="handleAddEntity('ride')"
:disabled="loading"
class="flex-1 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Ride
</button>
<button
@click="handleAddEntity('company')"
:disabled="loading"
class="flex-1 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Company
</button>
</div>
</div>
<!-- Unauthenticated User - Auth Prompt -->
<AuthPrompt
v-else
:search-term="searchTerm"
@login="handleLogin"
@signup="handleSignup"
/>
</div>
</div>
<!-- Loading Overlay -->
<div
v-if="loading"
class="absolute inset-0 bg-white/80 dark:bg-gray-800/80 flex items-center justify-center rounded-2xl"
>
<div class="flex items-center gap-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-gray-700 dark:text-gray-300">{{ loadingMessage }}</span>
</div>
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, toRefs, onUnmounted, watch } from 'vue'
import type { EntitySuggestion } from '../../services/api'
import EntitySuggestionCard from './EntitySuggestionCard.vue'
import AuthPrompt from './AuthPrompt.vue'
interface Props {
show: boolean
searchTerm: string
suggestions: EntitySuggestion[]
isAuthenticated: boolean
closeOnBackdrop?: boolean
}
const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true,
})
const emit = defineEmits<{
close: []
selectSuggestion: [suggestion: EntitySuggestion]
addEntity: [entityType: string, name: string]
login: []
signup: []
}>()
// Loading state
const loading = ref(false)
const loadingMessage = ref('')
const handleBackdropClick = (event: MouseEvent) => {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
emit('close')
}
}
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit('selectSuggestion', suggestion)
}
const handleAddEntity = async (entityType: string) => {
loading.value = true
loadingMessage.value = `Adding ${entityType}...`
try {
emit('addEntity', entityType, props.searchTerm)
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const handleLogin = () => {
emit('login')
}
const handleSignup = () => {
emit('signup')
}
// Prevent body scroll when modal is open
const { show } = toRefs(props)
watch(show, (isShown) => {
if (isShown) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// Clean up on unmount
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>

View File

@@ -0,0 +1,7 @@
// Entity suggestion components
export { default as EntitySuggestionModal } from './EntitySuggestionModal.vue'
export { default as EntitySuggestionCard } from './EntitySuggestionCard.vue'
export { default as AuthPrompt } from './AuthPrompt.vue'
// Main integration component
export { default as EntitySuggestionManager } from './EntitySuggestionManager.vue'

View File

@@ -0,0 +1,125 @@
<template>
<div
class="active-filter-chip inline-flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-full text-sm"
:class="{
'cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800': !disabled && removable,
'opacity-50': disabled,
}"
>
<!-- Filter icon -->
<i :class="`pi ${getIconClass(icon)} w-4 h-4 text-blue-600 dark:text-blue-300 flex-shrink-0`" />
<!-- Filter label and value -->
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-blue-800 dark:text-blue-200 font-medium truncate"> {{ label }}: </span>
<span class="text-blue-700 dark:text-blue-300 truncate">
{{ displayValue }}
</span>
</div>
<!-- Count indicator -->
<span
v-if="count !== undefined"
class="bg-blue-200 dark:bg-blue-700 text-blue-800 dark:text-blue-200 px-1.5 py-0.5 rounded-full text-xs font-medium"
>
{{ count }}
</span>
<!-- Remove button -->
<button
v-if="removable && !disabled"
@click="$emit('remove')"
class="flex items-center justify-center w-5 h-5 rounded-full hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors group"
:aria-label="`Remove ${label} filter`"
>
<i class="pi pi-times w-3 h-3 text-blue-600 dark:text-blue-300 group-hover:text-blue-800 dark:group-hover:text-blue-100" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
label: string
value: any
icon?: string
count?: number
removable?: boolean
disabled?: boolean
formatValue?: (value: any) => string
}
const props = withDefaults(defineProps<Props>(), {
icon: 'filter',
removable: true,
disabled: false,
})
defineEmits<{
remove: []
}>()
// Computed
const displayValue = computed(() => {
if (props.formatValue) {
return props.formatValue(props.value)
}
if (Array.isArray(props.value)) {
if (props.value.length === 1) {
return String(props.value[0])
} else if (props.value.length <= 3) {
return props.value.join(', ')
} else {
return `${props.value.slice(0, 2).join(', ')} +${props.value.length - 2} more`
}
}
if (typeof props.value === 'object' && props.value !== null) {
// Handle range objects
if ('min' in props.value && 'max' in props.value) {
return `${props.value.min} - ${props.value.max}`
}
// Handle date range objects
if ('start' in props.value && 'end' in props.value) {
return `${props.value.start} to ${props.value.end}`
}
return JSON.stringify(props.value)
}
return String(props.value)
})
// Icon mapping function
const getIconClass = (iconName: string): string => {
const iconMap: Record<string, string> = {
'filter': 'pi-filter',
'search': 'pi-search',
'calendar': 'pi-calendar',
'map-pin': 'pi-map-marker',
'tag': 'pi-tag',
'users': 'pi-users',
'building': 'pi-building',
'activity': 'pi-chart-line',
'globe': 'pi-globe',
'x': 'pi-times',
'check': 'pi-check',
'plus': 'pi-plus',
'minus': 'pi-minus',
}
return iconMap[iconName] || 'pi-circle'
}
</script>
<style scoped>
@reference "tailwindcss";
.active-filter-chip {
@apply transition-all duration-200;
}
.active-filter-chip:hover {
@apply shadow-sm;
}
</style>

Some files were not shown because too many files have changed in this diff Show More