Compare commits

...

5 Commits

Author SHA1 Message Date
pacnpal
7b9f64be72 ok 2025-08-28 23:20:22 -04:00
pacnpal
ac745cc541 ok 2025-08-28 23:20:09 -04:00
pacnpal
02ac587216 Refactor code structure and remove redundant sections for improved readability and maintainability 2025-08-28 16:01:24 -04:00
pacnpal
67db0aa46e feat(rides): populate slugs for existing RideModel records and ensure uniqueness
- Added migration 0011 to populate unique slugs for existing RideModel records based on manufacturer and model names.
- Implemented logic to ensure slug uniqueness during population.
- Added reverse migration to clear slugs if needed.

feat(rides): enforce unique slugs for RideModel

- Created migration 0012 to alter the slug field in RideModel to be unique.
- Updated the slug field to include help text and a maximum length of 255 characters.

docs: integrate Cloudflare Images into rides and parks models

- Updated RidePhoto and ParkPhoto models to use CloudflareImagesField for image storage.
- Enhanced API serializers for rides and parks to support Cloudflare Images, including new fields for image URLs and variants.
- Provided comprehensive OpenAPI schema metadata for new fields.
- Documented database migrations for the integration.
- Detailed configuration settings for Cloudflare Images.
- Updated API response formats to include Cloudflare Images URLs and variants.
- Added examples for uploading photos via API and outlined testing procedures.
2025-08-28 15:12:39 -04:00
pacnpal
715e284b3e Removed VueJS frontend and dramatically enhanced API 2025-08-28 14:01:28 -04:00
177 changed files with 13724 additions and 27696 deletions

1
.nvmrc
View File

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

649
api_endpoints_curl_commands.sh Executable file
View File

@@ -0,0 +1,649 @@
#!/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"

View File

@@ -29,3 +29,6 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000
# Feature Flags
ENABLE_DEBUG_TOOLBAR=True
ENABLE_SILK_PROFILER=False
# Frontend Configuration
FRONTEND_DOMAIN=https://thrillwiki.com

View File

@@ -17,3 +17,7 @@ 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,15 +4,31 @@ 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
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
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__)
@@ -26,59 +42,82 @@ logger = logging.getLogger(__name__)
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Northern latitude bound",
description="Northern latitude bound (-90 to 90). Used with south, east, west to define geographic bounds.",
examples=[OpenApiExample("Example", value=41.5)],
),
OpenApiParameter(
"south",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Southern latitude bound",
description="Southern latitude bound (-90 to 90). Must be less than north bound.",
examples=[OpenApiExample("Example", value=41.4)],
),
OpenApiParameter(
"east",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Eastern longitude bound",
description="Eastern longitude bound (-180 to 180). Must be greater than west bound.",
examples=[OpenApiExample("Example", value=-82.6)],
),
OpenApiParameter(
"west",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Western longitude bound",
description="Western longitude bound (-180 to 180). Used with other bounds for geographic filtering.",
examples=[OpenApiExample("Example", value=-82.8)],
),
OpenApiParameter(
"zoom",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="Map zoom level",
description="Map zoom level (1-20). Higher values show more detail. Used for clustering decisions.",
examples=[OpenApiExample("Example", value=10)],
),
OpenApiParameter(
"types",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Comma-separated location types",
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")
],
),
OpenApiParameter(
"cluster",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
required=False,
description="Enable clustering",
description="Enable location clustering for high-density areas. Default: false",
examples=[
OpenApiExample("Enable clustering", value=True),
OpenApiExample("Disable clustering", value=False)
],
),
OpenApiParameter(
"q",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Text query",
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")
],
),
],
responses={200: OpenApiTypes.OBJECT},
responses={
200: MapLocationsResponseSerializer,
400: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Maps"],
),
)
@@ -90,16 +129,152 @@ class MapLocationsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get map locations with optional clustering and filtering."""
try:
# Simple implementation to fix import error
# TODO: Implement full functionality
return Response(
{
"status": "success",
"message": "Map locations endpoint - implementation needed",
"data": [],
}
# 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 = {
"status": "success",
"locations": locations,
"clusters": [], # TODO: Implement clustering
"bounds": bounds,
"total_count": total_count,
"clustered": cluster,
}
# 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)
return Response(
@@ -128,7 +303,12 @@ class MapLocationsAPIView(APIView):
description="ID of the location",
),
],
responses={200: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT},
responses={
200: MapLocationDetailSerializer,
400: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Maps"],
),
)
@@ -142,17 +322,90 @@ class MapLocationDetailAPIView(APIView):
) -> Response:
"""Get detailed information for a specific location."""
try:
# Simple implementation to fix import error
if location_type == "park":
try:
obj = Park.objects.select_related(
"location", "operator").get(id=location_id)
except Park.DoesNotExist:
return Response(
{
"status": "success",
"message": f"Location detail for {location_type}/{location_id} - implementation needed",
"data": {
"location_type": location_type,
"location_id": location_id,
},
}
{"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,
})
except Exception as e:
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
@@ -174,8 +427,33 @@ 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: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
responses={
200: MapSearchResponseSerializer,
400: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Maps"],
),
)
@@ -197,14 +475,76 @@ class MapSearchAPIView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Simple implementation to fix import error
return Response(
{
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({
"status": "success",
"message": f"Search for '{query}' - implementation needed",
"data": [],
}
)
"results": paginated_results,
"query": query,
"total_count": total_count,
"page": page,
"page_size": page_size,
})
except Exception as e:
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
@@ -247,6 +587,13 @@ 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"],
@@ -260,22 +607,87 @@ class MapBoundsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get locations within specific geographic bounds."""
try:
# Simple implementation to fix import error
# 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):
return Response(
{
"status": "success",
"message": "Bounds query - implementation needed",
"data": [],
}
{"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),
})
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,
)
@@ -296,15 +708,26 @@ class MapStatsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get map service statistics and performance metrics."""
try:
# Simple implementation to fix import error
return Response(
{
# 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({
"status": "success",
"data": {"total_locations": 0, "cache_hits": 0, "cache_misses": 0},
}
)
"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
},
})
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,
@@ -333,12 +756,21 @@ class MapCacheAPIView(APIView):
def delete(self, request: HttpRequest) -> Response:
"""Clear all map cache (admin only)."""
try:
# Simple implementation to fix import error
return Response(
{"status": "success", "message": "Map cache cleared successfully"}
)
# 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.",
})
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,
@@ -347,12 +779,21 @@ class MapCacheAPIView(APIView):
def post(self, request: HttpRequest) -> Response:
"""Invalidate specific cache entries."""
try:
# Simple implementation to fix import error
return Response(
{"status": "success", "message": "Cache invalidated successfully"}
)
# 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.",
})
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

@@ -16,7 +16,8 @@ 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 rest_framework.exceptions import NotFound, ValidationError
from rest_framework.decorators import action
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -48,6 +49,7 @@ try:
ParkDetailOutputSerializer,
ParkCreateInputSerializer,
ParkUpdateInputSerializer,
ParkImageSettingsInputSerializer,
)
SERIALIZERS_AVAILABLE = True
@@ -414,3 +416,51 @@ 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,12 +6,44 @@ Enhanced from rogue implementation to maintain full feature parity.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
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 rich field structure."""
"""Enhanced output serializer for park photos with Cloudflare Images support."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
@@ -19,6 +51,8 @@ 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")
@@ -40,6 +74,38 @@ 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)
@@ -48,6 +114,8 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
fields = [
"id",
"image",
"image_url",
"image_variants",
"caption",
"alt_text",
"is_primary",
@@ -63,6 +131,8 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
]
read_only_fields = [
"id",
"image_url",
"image_variants",
"created_at",
"updated_at",
"uploaded_by_username",

View File

@@ -15,12 +15,13 @@ from .park_views import (
FilterOptionsAPIView,
CompanySearchAPIView,
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
)
from .views import ParkPhotoViewSet
# Create router for nested photo endpoints
router = DefaultRouter()
router.register(r"photos", ParkPhotoViewSet, basename="park-photo")
router.register(r"", ParkPhotoViewSet, basename="park-photo")
app_name = "api_v1_parks"
@@ -42,6 +43,8 @@ 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,6 +141,12 @@ 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,6 @@
"""
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

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

@@ -0,0 +1,701 @@
"""
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,17 +5,109 @@ 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."""
"""Output serializer for ride photos with Cloudflare Images support."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
file_size = serializers.ReadOnlyField()
dimensions = serializers.ReadOnlyField()
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
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)
@@ -26,6 +118,8 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
fields = [
"id",
"image",
"image_url",
"image_variants",
"caption",
"alt_text",
"is_primary",
@@ -44,6 +138,8 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
]
read_only_fields = [
"id",
"image_url",
"image_variants",
"created_at",
"updated_at",
"uploaded_by_username",

View File

@@ -18,12 +18,13 @@ from .views import (
CompanySearchAPIView,
RideModelSearchAPIView,
RideSearchSuggestionsAPIView,
RideImageSettingsAPIView,
)
from .photo_views import RidePhotoViewSet
# Create router for nested photo endpoints
router = DefaultRouter()
router.register(r"photos", RidePhotoViewSet, basename="ridephoto")
router.register(r"", RidePhotoViewSet, basename="ridephoto")
app_name = "api_v1_rides"
@@ -48,8 +49,14 @@ 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,12 +15,13 @@ 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
from rest_framework.exceptions import NotFound, ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -30,6 +31,7 @@ from apps.api.v1.serializers.rides import (
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
RideImageSettingsInputSerializer,
)
# Attempt to import model-level helpers; fall back gracefully if not present.
@@ -67,27 +69,147 @@ class RideListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List rides with filtering and pagination",
description="List rides with basic filtering and pagination.",
summary="List rides with comprehensive filtering and pagination",
description="List rides with comprehensive filtering options including category, status, manufacturer, designer, ride model, and more.",
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Page number for pagination"
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Number of results per page (max 1000)"
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Search in ride names and descriptions"
),
OpenApiParameter(
name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by park slug"
),
OpenApiParameter(
name="park_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by park ID"
),
OpenApiParameter(
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR"
),
OpenApiParameter(
name="status", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP"
),
OpenApiParameter(
name="manufacturer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by manufacturer company ID"
),
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by manufacturer company slug"
),
OpenApiParameter(
name="designer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by designer company ID"
),
OpenApiParameter(
name="designer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by designer company slug"
),
OpenApiParameter(
name="ride_model_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by specific ride model ID"
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by ride model slug (requires manufacturer_slug)"
),
OpenApiParameter(
name="roller_coaster_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)"
),
OpenApiParameter(
name="track_material", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)"
),
OpenApiParameter(
name="launch_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)"
),
OpenApiParameter(
name="min_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter by minimum average rating (1-10)"
),
OpenApiParameter(
name="max_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter by maximum average rating (1-10)"
),
OpenApiParameter(
name="min_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by minimum height requirement in inches"
),
OpenApiParameter(
name="max_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by maximum height requirement in inches"
),
OpenApiParameter(
name="min_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by minimum hourly capacity"
),
OpenApiParameter(
name="max_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by maximum hourly capacity"
),
OpenApiParameter(
name="min_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by minimum height in feet"
),
OpenApiParameter(
name="max_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by maximum height in feet"
),
OpenApiParameter(
name="min_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by minimum speed in mph"
),
OpenApiParameter(
name="max_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by maximum speed in mph"
),
OpenApiParameter(
name="min_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter roller coasters by minimum number of inversions"
),
OpenApiParameter(
name="max_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter roller coasters by maximum number of inversions"
),
OpenApiParameter(
name="has_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL,
description="Filter roller coasters that have inversions (true) or don't have inversions (false)"
),
OpenApiParameter(
name="opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by opening year"
),
OpenApiParameter(
name="min_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by minimum opening year"
),
OpenApiParameter(
name="max_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by maximum opening year"
),
OpenApiParameter(
name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph"
),
],
responses={200: RideListOutputSerializer(many=True)},
tags=["Rides"],
)
def get(self, request: Request) -> Response:
"""List rides with basic filtering and pagination."""
"""List rides with comprehensive filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
@@ -97,16 +219,230 @@ class RideListCreateAPIView(APIView):
status=status.HTTP_501_NOT_IMPLEMENTED,
)
qs = Ride.objects.all().select_related("park", "manufacturer", "designer") # type: ignore
# Start with base queryset with optimized joins
qs = Ride.objects.all().select_related(
"park", "manufacturer", "designer", "ride_model", "ride_model__manufacturer"
).prefetch_related("coaster_stats") # type: ignore
# Basic filters
q = request.query_params.get("search")
if q:
qs = qs.filter(name__icontains=q) # simplistic search
# Text search
search = request.query_params.get("search")
if search:
qs = qs.filter(
models.Q(name__icontains=search) |
models.Q(description__icontains=search) |
models.Q(park__name__icontains=search)
)
# Park filters
park_slug = request.query_params.get("park_slug")
if park_slug:
qs = qs.filter(park__slug=park_slug) # type: ignore
qs = qs.filter(park__slug=park_slug)
park_id = request.query_params.get("park_id")
if park_id:
try:
qs = qs.filter(park_id=int(park_id))
except (ValueError, TypeError):
pass
# Category filters (multiple values supported)
categories = request.query_params.getlist("category")
if categories:
qs = qs.filter(category__in=categories)
# Status filters (multiple values supported)
statuses = request.query_params.getlist("status")
if statuses:
qs = qs.filter(status__in=statuses)
# Manufacturer filters
manufacturer_id = request.query_params.get("manufacturer_id")
if manufacturer_id:
try:
qs = qs.filter(manufacturer_id=int(manufacturer_id))
except (ValueError, TypeError):
pass
manufacturer_slug = request.query_params.get("manufacturer_slug")
if manufacturer_slug:
qs = qs.filter(manufacturer__slug=manufacturer_slug)
# Designer filters
designer_id = request.query_params.get("designer_id")
if designer_id:
try:
qs = qs.filter(designer_id=int(designer_id))
except (ValueError, TypeError):
pass
designer_slug = request.query_params.get("designer_slug")
if designer_slug:
qs = qs.filter(designer__slug=designer_slug)
# Ride model filters
ride_model_id = request.query_params.get("ride_model_id")
if ride_model_id:
try:
qs = qs.filter(ride_model_id=int(ride_model_id))
except (ValueError, TypeError):
pass
ride_model_slug = request.query_params.get("ride_model_slug")
manufacturer_slug_for_model = request.query_params.get("manufacturer_slug")
if ride_model_slug and manufacturer_slug_for_model:
qs = qs.filter(
ride_model__slug=ride_model_slug,
ride_model__manufacturer__slug=manufacturer_slug_for_model
)
# Rating filters
min_rating = request.query_params.get("min_rating")
if min_rating:
try:
qs = qs.filter(average_rating__gte=float(min_rating))
except (ValueError, TypeError):
pass
max_rating = request.query_params.get("max_rating")
if max_rating:
try:
qs = qs.filter(average_rating__lte=float(max_rating))
except (ValueError, TypeError):
pass
# Height requirement filters
min_height_req = request.query_params.get("min_height_requirement")
if min_height_req:
try:
qs = qs.filter(min_height_in__gte=int(min_height_req))
except (ValueError, TypeError):
pass
max_height_req = request.query_params.get("max_height_requirement")
if max_height_req:
try:
qs = qs.filter(max_height_in__lte=int(max_height_req))
except (ValueError, TypeError):
pass
# Capacity filters
min_capacity = request.query_params.get("min_capacity")
if min_capacity:
try:
qs = qs.filter(capacity_per_hour__gte=int(min_capacity))
except (ValueError, TypeError):
pass
max_capacity = request.query_params.get("max_capacity")
if max_capacity:
try:
qs = qs.filter(capacity_per_hour__lte=int(max_capacity))
except (ValueError, TypeError):
pass
# Opening year filters
opening_year = request.query_params.get("opening_year")
if opening_year:
try:
qs = qs.filter(opening_date__year=int(opening_year))
except (ValueError, TypeError):
pass
min_opening_year = request.query_params.get("min_opening_year")
if min_opening_year:
try:
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
except (ValueError, TypeError):
pass
max_opening_year = request.query_params.get("max_opening_year")
if max_opening_year:
try:
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
except (ValueError, TypeError):
pass
# Roller coaster specific filters
roller_coaster_type = request.query_params.get("roller_coaster_type")
if roller_coaster_type:
qs = qs.filter(coaster_stats__roller_coaster_type=roller_coaster_type)
track_material = request.query_params.get("track_material")
if track_material:
qs = qs.filter(coaster_stats__track_material=track_material)
launch_type = request.query_params.get("launch_type")
if launch_type:
qs = qs.filter(coaster_stats__launch_type=launch_type)
# Roller coaster height filters
min_height_ft = request.query_params.get("min_height_ft")
if min_height_ft:
try:
qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft))
except (ValueError, TypeError):
pass
max_height_ft = request.query_params.get("max_height_ft")
if max_height_ft:
try:
qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft))
except (ValueError, TypeError):
pass
# Roller coaster speed filters
min_speed_mph = request.query_params.get("min_speed_mph")
if min_speed_mph:
try:
qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph))
except (ValueError, TypeError):
pass
max_speed_mph = request.query_params.get("max_speed_mph")
if max_speed_mph:
try:
qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph))
except (ValueError, TypeError):
pass
# Inversion filters
min_inversions = request.query_params.get("min_inversions")
if min_inversions:
try:
qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions))
except (ValueError, TypeError):
pass
max_inversions = request.query_params.get("max_inversions")
if max_inversions:
try:
qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions))
except (ValueError, TypeError):
pass
has_inversions = request.query_params.get("has_inversions")
if has_inversions is not None:
if has_inversions.lower() in ['true', '1', 'yes']:
qs = qs.filter(coaster_stats__inversions__gt=0)
elif has_inversions.lower() in ['false', '0', 'no']:
qs = qs.filter(coaster_stats__inversions=0)
# Ordering
ordering = request.query_params.get("ordering", "name")
valid_orderings = [
"name", "-name", "opening_date", "-opening_date",
"average_rating", "-average_rating", "capacity_per_hour", "-capacity_per_hour",
"created_at", "-created_at", "height_ft", "-height_ft", "speed_mph", "-speed_mph"
]
if ordering in valid_orderings:
if ordering in ["height_ft", "-height_ft", "speed_mph", "-speed_mph"]:
# For coaster stats ordering, we need to join and order by the stats
ordering_field = ordering.replace("height_ft", "coaster_stats__height_ft").replace(
"speed_mph", "coaster_stats__speed_mph")
qs = qs.order_by(ordering_field)
else:
qs = qs.order_by(ordering)
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
@@ -233,7 +569,8 @@ class RideDetailAPIView(APIView):
# --- Filter options ---------------------------------------------------------
@extend_schema(
summary="Get filter options for rides",
summary="Get comprehensive filter options for rides",
description="Returns all available filter options for rides including categories, statuses, roller coaster types, track materials, launch types, and ordering options.",
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
@@ -241,7 +578,7 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
"""Return static/dynamic filter options used by the frontend."""
"""Return comprehensive filter options used by the frontend."""
# Try to use ModelChoices if available
if HAVE_MODELCHOICES and ModelChoices is not None:
try:
@@ -249,13 +586,41 @@ class FilterOptionsAPIView(APIView):
"categories": ModelChoices.get_ride_category_choices(),
"statuses": ModelChoices.get_ride_status_choices(),
"post_closing_statuses": ModelChoices.get_ride_post_closing_choices(),
"roller_coaster_types": ModelChoices.get_coaster_type_choices(),
"track_materials": ModelChoices.get_coaster_track_choices(),
"launch_types": ModelChoices.get_launch_choices(),
"ordering_options": [
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date",
"label": "Opening Date (Oldest First)"},
{"value": "-opening_date",
"label": "Opening Date (Newest First)"},
{"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"},
{"value": "capacity_per_hour",
"label": "Capacity (Lowest First)"},
{"value": "-capacity_per_hour",
"label": "Capacity (Highest First)"},
{"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"},
{"value": "-speed_mph", "label": "Speed (Fastest First)"},
{"value": "created_at", "label": "Date Added (Oldest First)"},
{"value": "-created_at", "label": "Date Added (Newest First)"},
],
"filter_ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1},
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
},
"boolean_filters": [
{"key": "has_inversions", "label": "Has Inversions",
"description": "Filter roller coasters with or without inversions"},
],
}
return Response(data)
@@ -263,12 +628,82 @@ class FilterOptionsAPIView(APIView):
# fallthrough to fallback
pass
# Fallback minimal options
# Comprehensive fallback options
return Response(
{
"categories": ["ROLLER_COASTER", "WATER_RIDE", "FLAT"],
"statuses": ["OPERATING", "CLOSED", "MAINTENANCE"],
"ordering_options": ["name", "-name", "opening_date", "-opening_date"],
"categories": [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
"statuses": [
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
"roller_coaster_types": [
("SITDOWN", "Sit Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand Up"),
("WING", "Wing"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
"track_materials": [
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
],
"launch_types": [
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"},
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
{"value": "-capacity_per_hour",
"label": "Capacity (Highest First)"},
{"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"},
{"value": "-speed_mph", "label": "Speed (Fastest First)"},
{"value": "created_at", "label": "Date Added (Oldest First)"},
{"value": "-created_at", "label": "Date Added (Newest First)"},
],
"filter_ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1},
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
},
"boolean_filters": [
{"key": "has_inversions", "label": "Has Inversions",
"description": "Filter roller coasters with or without inversions"},
],
}
)
@@ -380,4 +815,46 @@ 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,9 +146,10 @@ def _import_accounts_symbols() -> Dict[str, Any]:
_accounts = _import_accounts_symbols()
# Bind account symbols into the module namespace (either actual objects or None)
# Bind account symbols into the module namespace (only if they exist)
for _name in _ACCOUNTS_SYMBOLS:
globals()[_name] = _accounts.get(_name)
if _accounts.get(_name) is not None:
globals()[_name] = _accounts[_name]
# --- Services domain ---
@@ -255,22 +256,79 @@ _SERVICES_EXPORTS = [
"DistanceCalculationOutputSerializer",
]
# 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
)
# Build a static __all__ list with only the serializers we know exist
__all__ = [
# Shared exports
"CATEGORY_CHOICES",
"ModelChoices",
"LocationOutputSerializer",
"CompanyOutputSerializer",
"UserModel",
# 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")):
# 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():
__all__.append(name)
# Ensure __all__ is a flat list of unique strings (preserve order)
__all__ = list(dict.fromkeys(__all__))

View File

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

@@ -11,6 +11,7 @@ from drf_spectacular.utils import (
extend_schema_field,
OpenApiExample,
)
from config.django import base as settings
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
@@ -65,10 +66,18 @@ class ParkListOutputSerializer(serializers.Serializer):
# Operator info
operator = CompanyOutputSerializer()
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this park."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
@extend_schema_serializer(
examples=[
@@ -96,6 +105,31 @@ 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"
}
},
)
]
@@ -135,6 +169,20 @@ class ParkDetailOutputSerializer(serializers.Serializer):
# Areas
areas = serializers.SerializerMethodField()
# Photos
photos = serializers.SerializerMethodField()
primary_photo = serializers.SerializerMethodField()
banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField()
# URL
url = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this park."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_areas(self, obj):
"""Get simplified area information."""
@@ -150,11 +198,191 @@ 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

@@ -0,0 +1,98 @@
"""
Serializers for review-related API endpoints.
"""
from rest_framework import serializers
from apps.parks.models.reviews import ParkReview
from apps.rides.models.reviews import RideReview
from apps.accounts.models import User
class ReviewUserSerializer(serializers.ModelSerializer):
"""Serializer for user information in reviews."""
avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['username', 'display_name', 'avatar_url']
def get_avatar_url(self, obj):
"""Get the user's avatar URL."""
if hasattr(obj, 'profile') and obj.profile:
return obj.profile.get_avatar()
return "/static/images/default-avatar.png"
def get_display_name(self, obj):
"""Get the user's display name."""
return obj.get_display_name()
class LatestReviewSerializer(serializers.Serializer):
"""Serializer for latest reviews combining park and ride reviews."""
id = serializers.IntegerField()
type = serializers.CharField() # 'park' or 'ride'
title = serializers.CharField()
content_snippet = serializers.CharField()
rating = serializers.IntegerField()
created_at = serializers.DateTimeField()
user = ReviewUserSerializer()
# Subject information (park or ride)
subject_name = serializers.CharField()
subject_slug = serializers.CharField()
subject_url = serializers.CharField()
# Park information (for ride reviews)
park_name = serializers.CharField(allow_null=True)
park_slug = serializers.CharField(allow_null=True)
park_url = serializers.CharField(allow_null=True)
def to_representation(self, instance):
"""Convert review instance to serialized representation."""
if isinstance(instance, ParkReview):
return {
'id': instance.pk,
'type': 'park',
'title': instance.title,
'content_snippet': self._get_content_snippet(instance.content),
'rating': instance.rating,
'created_at': instance.created_at,
'user': ReviewUserSerializer(instance.user).data,
'subject_name': instance.park.name,
'subject_slug': instance.park.slug,
'subject_url': f"/parks/{instance.park.slug}/",
'park_name': None,
'park_slug': None,
'park_url': None,
}
elif isinstance(instance, RideReview):
return {
'id': instance.pk,
'type': 'ride',
'title': instance.title,
'content_snippet': self._get_content_snippet(instance.content),
'rating': instance.rating,
'created_at': instance.created_at,
'user': ReviewUserSerializer(instance.user).data,
'subject_name': instance.ride.name,
'subject_slug': instance.ride.slug,
'subject_url': f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
'park_name': instance.ride.park.name,
'park_slug': instance.ride.park.slug,
'park_url': f"/parks/{instance.ride.park.slug}/",
}
return {}
def _get_content_snippet(self, content, max_length=150):
"""Get a snippet of the review content."""
if len(content) <= max_length:
return content
# Find the last complete word within the limit
snippet = content[:max_length]
last_space = snippet.rfind(' ')
if last_space > 0:
snippet = snippet[:last_space]
return snippet + "..."

View File

@@ -0,0 +1,825 @@
"""
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 config.django import base as settings
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)
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride model."""
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
@extend_schema_serializer(
examples=[
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()
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride model."""
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_installations(self, obj):
"""Get ride installations using this model."""
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

@@ -11,7 +11,7 @@ from drf_spectacular.utils import (
extend_schema_field,
OpenApiExample,
)
from config.django import base as settings
from .shared import ModelChoices
@@ -90,10 +90,18 @@ class RideListOutputSerializer(serializers.Serializer):
opening_date = serializers.DateField(allow_null=True)
closing_date = serializers.DateField(allow_null=True)
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/"
@extend_schema_serializer(
examples=[
@@ -119,6 +127,33 @@ 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"
}
},
)
]
@@ -161,10 +196,24 @@ 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()
# URL
url = serializers.SerializerMethodField()
# Metadata
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this ride."""
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/"
@extend_schema_field(serializers.DictField(allow_null=True))
def get_park_area(self, obj) -> dict | None:
if obj.park_area:
@@ -195,6 +244,192 @@ 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

@@ -8,6 +8,7 @@ to avoid code duplication and maintain consistency.
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from django.contrib.auth import get_user_model
from django.conf import settings
# Import models inside class methods to avoid Django initialization issues
@@ -102,6 +103,22 @@ 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."""
@@ -157,3 +174,31 @@ class CompanyOutputSerializer(serializers.Serializer):
name = serializers.CharField()
slug = serializers.CharField()
roles = serializers.ListField(child=serializers.CharField(), required=False)
url = serializers.SerializerMethodField()
@extend_schema_field(serializers.URLField())
def get_url(self, obj) -> str:
"""Generate the frontend URL for this company based on their primary role.
CRITICAL DOMAIN SEPARATION:
- OPERATOR and PROPERTY_OWNER are for parks domain
- MANUFACTURER and DESIGNER are for rides domain
"""
# Use the URL field from the model if it exists (auto-generated on save)
if hasattr(obj, 'url') and obj.url:
return obj.url
# Fallback URL generation (should not be needed if model save works correctly)
if hasattr(obj, 'roles') and obj.roles:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
primary_role = obj.roles[0] if obj.roles else None
# Only generate URLs for rides domain roles here
if primary_role == 'MANUFACTURER':
return f"{frontend_domain}/rides/manufacturers/{obj.slug}/"
elif primary_role == 'DESIGNER':
return f"{frontend_domain}/rides/designers/{obj.slug}/"
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain
return ""

View File

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

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

@@ -21,7 +21,10 @@ from .views import (
# Trending system views
TrendingAPIView,
NewContentAPIView,
TriggerTrendingCalculationAPIView,
)
from .views.stats import StatsAPIView, StatsRecalculateAPIView
from .views.reviews import LatestReviewsAPIView
from django.urls import path, include
from rest_framework.routers import DefaultRouter
@@ -56,8 +59,15 @@ urlpatterns = [
name="performance-metrics",
),
# Trending system endpoints
path("trending/content/", TrendingAPIView.as_view(), name="trending"),
path("trending/new/", NewContentAPIView.as_view(), name="new-content"),
path("trending/", TrendingAPIView.as_view(), name="trending"),
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
path("trending/calculate/", TriggerTrendingCalculationAPIView.as_view(),
name="trigger-trending-calculation"),
# Statistics endpoints
path("stats/", StatsAPIView.as_view(), name="stats"),
path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"),
# Reviews endpoints
path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"),
# Ranking system endpoints
path(
"rankings/calculate/",

View File

@@ -28,6 +28,7 @@ from .health import (
from .trending import (
TrendingAPIView,
NewContentAPIView,
TriggerTrendingCalculationAPIView,
)
# Export all views for import convenience
@@ -48,4 +49,5 @@ __all__ = [
# Trending views
"TrendingAPIView",
"NewContentAPIView",
"TriggerTrendingCalculationAPIView",
]

View File

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

View File

@@ -0,0 +1,85 @@
"""
Views for review-related API endpoints.
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import status
from django.db.models import Q
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from itertools import chain
from operator import attrgetter
from apps.parks.models.reviews import ParkReview
from apps.rides.models.reviews import RideReview
from ..serializers.reviews import LatestReviewSerializer
class LatestReviewsAPIView(APIView):
"""
API endpoint to get the latest reviews from both parks and rides.
Returns a combined list of the most recent reviews across the platform,
including username, user avatar, date, score, and review snippet.
"""
permission_classes = [AllowAny]
@extend_schema(
summary="Get Latest Reviews",
description=(
"Retrieve the latest reviews from both parks and rides. "
"Returns a combined list sorted by creation date, including "
"user information, ratings, and content snippets."
),
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of reviews to return (default: 20, max: 100)",
default=20,
),
],
responses={
200: LatestReviewSerializer(many=True),
},
tags=["Reviews"],
)
def get(self, request):
"""Get the latest reviews from both parks and rides."""
# Get limit parameter with validation
try:
limit = int(request.query_params.get('limit', 20))
limit = min(max(limit, 1), 100) # Clamp between 1 and 100
except (ValueError, TypeError):
limit = 20
# Get published reviews from both models
park_reviews = ParkReview.objects.filter(
is_published=True
).select_related(
'user', 'user__profile', 'park'
).order_by('-created_at')[:limit]
ride_reviews = RideReview.objects.filter(
is_published=True
).select_related(
'user', 'user__profile', 'ride', 'ride__park'
).order_by('-created_at')[:limit]
# Combine and sort by created_at
all_reviews = sorted(
chain(park_reviews, ride_reviews),
key=attrgetter('created_at'),
reverse=True
)[:limit]
# Serialize the combined results
serializer = LatestReviewSerializer(all_reviews, many=True)
return Response({
'count': len(all_reviews),
'results': serializer.data
}, status=status.HTTP_200_OK)

View File

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

View File

@@ -9,7 +9,8 @@ from datetime import datetime, date
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework import status
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -48,17 +49,12 @@ class TrendingAPIView(APIView):
def get(self, request: Request) -> Response:
"""Get trending parks and rides."""
try:
from apps.core.services.trending_service import TrendingService
except ImportError:
# Fallback if trending service is not available
return self._get_fallback_trending_content(request)
from apps.core.services.trending_service import trending_service
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get trending content
trending_service = TrendingService()
# Get trending content using direct calculation service
all_trending = trending_service.get_trending_content(limit=limit * 2)
# Separate by content type
@@ -75,20 +71,8 @@ class TrendingAPIView(APIView):
trending_rides = trending_rides[: limit // 3] if trending_rides else []
trending_parks = trending_parks[: limit // 3] if trending_parks else []
# Create mock latest reviews (since not implemented yet)
latest_reviews = [
{
"id": 1,
"name": "Steel Vengeance Review",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 5.0,
"rank": 1,
"views": 1234,
"views_change": "+45%",
"slug": "steel-vengeance-review",
}
][: limit // 3]
# Latest reviews will be empty until review system is implemented
latest_reviews = []
# Return in expected frontend format
response_data = {
@@ -99,82 +83,85 @@ class TrendingAPIView(APIView):
return Response(response_data)
def _get_fallback_trending_content(self, request: Request) -> Response:
"""Fallback method when trending service is not available."""
limit = min(int(request.query_params.get("limit", 20)), 100)
# Mock trending data
trending_rides = [
{
"id": 1,
"name": "Steel Vengeance",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 4.8,
"rank": 1,
"views": 15234,
"views_change": "+25%",
"slug": "steel-vengeance",
@extend_schema_view(
post=extend_schema(
summary="Trigger trending content calculation",
description="Manually trigger the calculation of trending content using Django management commands. Admin access required.",
responses={
202: {
"type": "object",
"properties": {
"message": {"type": "string"},
"trending_completed": {"type": "boolean"},
"new_content_completed": {"type": "boolean"},
"completion_time": {"type": "string"},
},
{
"id": 2,
"name": "Lightning Rod",
"location": "Dollywood",
"category": "Roller Coaster",
"rating": 4.7,
"rank": 2,
"views": 12456,
"views_change": "+18%",
"slug": "lightning-rod",
},
][: limit // 3]
trending_parks = [
{
"id": 1,
"name": "Cedar Point",
"location": "Sandusky, OH",
"category": "Theme Park",
"rating": 4.6,
"rank": 1,
"views": 45678,
"views_change": "+12%",
"slug": "cedar-point",
403: {"description": "Admin access required"},
},
tags=["Trending"],
),
)
class TriggerTrendingCalculationAPIView(APIView):
"""API endpoint to manually trigger trending content calculation."""
permission_classes = [IsAdminUser]
def post(self, request: Request) -> Response:
"""Trigger trending content calculation using management commands."""
try:
from django.core.management import call_command
import io
from contextlib import redirect_stdout, redirect_stderr
# Capture command output
trending_output = io.StringIO()
new_content_output = io.StringIO()
trending_completed = False
new_content_completed = False
try:
# Run trending calculation command
with redirect_stdout(trending_output), redirect_stderr(trending_output):
call_command('calculate_trending',
'--content-type=all', '--limit=50')
trending_completed = True
except Exception as e:
trending_output.write(f"Error: {str(e)}")
try:
# Run new content calculation command
with redirect_stdout(new_content_output), redirect_stderr(new_content_output):
call_command('calculate_new_content',
'--content-type=all', '--days-back=30', '--limit=50')
new_content_completed = True
except Exception as e:
new_content_output.write(f"Error: {str(e)}")
completion_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return Response(
{
"id": 2,
"name": "Magic Kingdom",
"location": "Orlando, FL",
"category": "Theme Park",
"rating": 4.5,
"rank": 2,
"views": 67890,
"views_change": "+8%",
"slug": "magic-kingdom",
"message": "Trending content calculation completed",
"trending_completed": trending_completed,
"new_content_completed": new_content_completed,
"completion_time": completion_time,
"trending_output": trending_output.getvalue(),
"new_content_output": new_content_output.getvalue(),
},
][: limit // 3]
status=status.HTTP_202_ACCEPTED,
)
latest_reviews = [
except Exception as e:
return Response(
{
"id": 1,
"name": "Steel Vengeance Review",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 5.0,
"rank": 1,
"views": 1234,
"views_change": "+45%",
"slug": "steel-vengeance-review",
}
][: limit // 3]
response_data = {
"trending_rides": trending_rides,
"trending_parks": trending_parks,
"latest_reviews": latest_reviews,
}
return Response(response_data)
"error": "Failed to trigger trending content calculation",
"details": str(e),
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@extend_schema_view(
@@ -210,19 +197,15 @@ class NewContentAPIView(APIView):
def get(self, request: Request) -> Response:
"""Get new parks and rides."""
try:
from apps.core.services.trending_service import TrendingService
except ImportError:
# Fallback if trending service is not available
return self._get_fallback_new_content(request)
from apps.core.services.trending_service import trending_service
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
days_back = min(int(request.query_params.get("days", 30)), 365)
# Get new content with longer timeframe to get more data
trending_service = TrendingService()
# Get new content using direct calculation service
all_new_content = trending_service.get_new_content(
limit=limit * 2, days_back=60
limit=limit * 2, days_back=days_back
)
recently_added = []
@@ -258,30 +241,12 @@ class NewContentAPIView(APIView):
else:
recently_added.append(item)
# Create mock upcoming items
upcoming = [
{
"id": 1,
"name": "Epic Universe",
"location": "Universal Orlando",
"category": "Theme Park",
"date_added": "Opening 2025",
"slug": "epic-universe",
},
{
"id": 2,
"name": "New Fantasyland Expansion",
"location": "Magic Kingdom",
"category": "Land Expansion",
"date_added": "Opening 2026",
"slug": "fantasyland-expansion",
},
]
# Upcoming items will be empty until future content system is implemented
upcoming = []
# Limit each category
recently_added = recently_added[: limit // 3] if recently_added else []
newly_opened = newly_opened[: limit // 3] if newly_opened else []
upcoming = upcoming[: limit // 3] if upcoming else []
# Return in expected frontend format
response_data = {
@@ -291,73 +256,3 @@ class NewContentAPIView(APIView):
}
return Response(response_data)
def _get_fallback_new_content(self, request: Request) -> Response:
"""Fallback method when trending service is not available."""
limit = min(int(request.query_params.get("limit", 20)), 100)
# Mock new content data
recently_added = [
{
"id": 1,
"name": "Iron Gwazi",
"location": "Busch Gardens Tampa",
"category": "Roller Coaster",
"date_added": "2024-12-01",
"slug": "iron-gwazi",
},
{
"id": 2,
"name": "VelociCoaster",
"location": "Universal's Islands of Adventure",
"category": "Roller Coaster",
"date_added": "2024-11-15",
"slug": "velocicoaster",
},
][: limit // 3]
newly_opened = [
{
"id": 3,
"name": "Guardians of the Galaxy",
"location": "EPCOT",
"category": "Roller Coaster",
"date_added": "2024-10-01",
"slug": "guardians-galaxy",
},
{
"id": 4,
"name": "TRON Lightcycle Run",
"location": "Magic Kingdom",
"category": "Roller Coaster",
"date_added": "2024-09-15",
"slug": "tron-lightcycle",
},
][: limit // 3]
upcoming = [
{
"id": 5,
"name": "Epic Universe",
"location": "Universal Orlando",
"category": "Theme Park",
"date_added": "Opening 2025",
"slug": "epic-universe",
},
{
"id": 6,
"name": "New Fantasyland Expansion",
"location": "Magic Kingdom",
"category": "Land Expansion",
"date_added": "Opening 2026",
"slug": "fantasyland-expansion",
},
][: limit // 3]
response_data = {
"recently_added": recently_added,
"newly_opened": newly_opened,
"upcoming": upcoming,
}
return Response(response_data)

View File

@@ -137,6 +137,37 @@ 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

@@ -0,0 +1,209 @@
"""
Django management command to calculate new content.
This replaces the Celery task for calculating new content.
Run with: python manage.py calculate_new_content
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.core.cache import cache
from django.db.models import Q
from apps.parks.models import Park
from apps.rides.models import Ride
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Calculate new content and cache results'
def add_arguments(self, parser):
parser.add_argument(
'--content-type',
type=str,
default='all',
choices=['all', 'parks', 'rides'],
help='Type of content to calculate (default: all)'
)
parser.add_argument(
'--days-back',
type=int,
default=30,
help='Number of days to look back for new content (default: 30)'
)
parser.add_argument(
'--limit',
type=int,
default=50,
help='Maximum number of results to calculate (default: 50)'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose output'
)
def handle(self, *args, **options):
content_type = options['content_type']
days_back = options['days_back']
limit = options['limit']
verbose = options['verbose']
if verbose:
self.stdout.write(f"Starting new content calculation for {content_type}")
try:
cutoff_date = timezone.now() - timedelta(days=days_back)
new_items = []
if content_type in ["all", "parks"]:
parks = self._get_new_parks(
cutoff_date, limit if content_type == "parks" else limit * 2)
new_items.extend(parks)
if verbose:
self.stdout.write(f"Found {len(parks)} new parks")
if content_type in ["all", "rides"]:
rides = self._get_new_rides(
cutoff_date, limit if content_type == "rides" else limit * 2)
new_items.extend(rides)
if verbose:
self.stdout.write(f"Found {len(rides)} new rides")
# Sort by date added (most recent first) and apply limit
new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True)
new_items = new_items[:limit]
# Format results for API consumption
formatted_results = self._format_new_content_results(new_items)
# Cache results
cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}"
cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes
self.stdout.write(
self.style.SUCCESS(
f"Successfully calculated {len(formatted_results)} new items for {content_type}"
)
)
if verbose:
for item in formatted_results[:5]: # Show first 5 items
self.stdout.write(
f" {item['name']} ({item['park']}) - opened: {item['date_opened']}")
except Exception as e:
logger.error(f"Error calculating new content: {e}", exc_info=True)
raise CommandError(f"Failed to calculate new content: {e}")
def _get_new_parks(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added parks using real data."""
new_parks = (
Park.objects.filter(
Q(created_at__gte=cutoff_date) | Q(
opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("location", "operator")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for park in new_parks:
date_added = park.opening_date or park.created_at
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(park, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append({
"content_object": park,
"content_type": "park",
"id": park.pk,
"name": park.name,
"slug": park.slug,
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
"url": park.url,
})
return results
def _get_new_rides(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added rides using real data."""
new_rides = (
Ride.objects.filter(
Q(created_at__gte=cutoff_date) | Q(
opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("park", "park__location")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for ride in new_rides:
date_added = getattr(ride, "opening_date", None) or getattr(
ride, "created_at", None)
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(ride, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append({
"content_object": ride,
"content_type": "ride",
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"park": ride.park.name if ride.park else "",
"category": "ride",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
"url": ride.url,
"park_url": ride.park.url if ride.park else "",
})
return results
def _format_new_content_results(self, new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Format new content results for frontend consumption."""
formatted_results = []
for item in new_items:
try:
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"park": item["park"],
"category": item["category"],
"date_added": item["date_added"],
"date_opened": item["date_opened"],
"slug": item["slug"],
"url": item["url"],
}
# Add park_url for rides
if item.get("park_url"):
formatted_item["park_url"] = item["park_url"]
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting new content item: {e}")
return formatted_results

View File

@@ -0,0 +1,337 @@
"""
Django management command to calculate trending content.
This replaces the Celery task for calculating trending content.
Run with: python manage.py calculate_trending
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.core.cache import cache
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from apps.core.analytics import PageView
from apps.parks.models import Park
from apps.rides.models import Ride
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Calculate trending content and cache results'
def add_arguments(self, parser):
parser.add_argument(
'--content-type',
type=str,
default='all',
choices=['all', 'parks', 'rides'],
help='Type of content to calculate (default: all)'
)
parser.add_argument(
'--limit',
type=int,
default=50,
help='Maximum number of results to calculate (default: 50)'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose output'
)
def handle(self, *args, **options):
content_type = options['content_type']
limit = options['limit']
verbose = options['verbose']
if verbose:
self.stdout.write(f"Starting trending calculation for {content_type}")
try:
# Time windows for calculations
current_period_hours = 168 # 7 days
# 14 days (for previous 7-day window comparison)
previous_period_hours = 336
trending_items = []
if content_type in ["all", "parks"]:
park_items = self._calculate_trending_parks(
current_period_hours,
previous_period_hours,
limit if content_type == "parks" else limit * 2
)
trending_items.extend(park_items)
if verbose:
self.stdout.write(f"Calculated {len(park_items)} trending parks")
if content_type in ["all", "rides"]:
ride_items = self._calculate_trending_rides(
current_period_hours,
previous_period_hours,
limit if content_type == "rides" else limit * 2
)
trending_items.extend(ride_items)
if verbose:
self.stdout.write(f"Calculated {len(ride_items)} trending rides")
# Sort by trending score and apply limit
trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True)
trending_items = trending_items[:limit]
# Format results for API consumption
formatted_results = self._format_trending_results(
trending_items, current_period_hours, previous_period_hours)
# Cache results
cache_key = f"trending:calculated:{content_type}:{limit}"
cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour
self.stdout.write(
self.style.SUCCESS(
f"Successfully calculated {len(formatted_results)} trending items for {content_type}"
)
)
if verbose:
for item in formatted_results[:5]: # Show first 5 items
self.stdout.write(
f" {item['name']} (score: {item.get('views_change', 'N/A')})")
except Exception as e:
logger.error(f"Error calculating trending content: {e}", exc_info=True)
raise CommandError(f"Failed to calculate trending content: {e}")
def _calculate_trending_parks(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for parks using real data."""
parks = Park.objects.filter(
status="OPERATING").select_related("location", "operator")
trending_parks = []
for park in parks:
try:
score = self._calculate_content_score(
park, "park", current_period_hours, previous_period_hours)
if score > 0: # Only include items with positive trending scores
trending_parks.append({
"content_object": park,
"content_type": "park",
"trending_score": score,
"id": park.id,
"name": park.name,
"slug": park.slug,
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"rating": float(park.average_rating) if park.average_rating else 0.0,
"date_opened": park.opening_date.isoformat() if park.opening_date else "",
"url": park.url,
})
except Exception as e:
logger.warning(f"Error calculating score for park {park.id}: {e}")
return trending_parks
def _calculate_trending_rides(self, current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for rides using real data."""
rides = Ride.objects.filter(status="OPERATING").select_related(
"park", "park__location")
trending_rides = []
for ride in rides:
try:
score = self._calculate_content_score(
ride, "ride", current_period_hours, previous_period_hours)
if score > 0: # Only include items with positive trending scores
trending_rides.append({
"content_object": ride,
"content_type": "ride",
"trending_score": score,
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"park": ride.park.name if ride.park else "",
"category": "ride",
"rating": float(ride.average_rating) if ride.average_rating else 0.0,
"date_opened": ride.opening_date.isoformat() if ride.opening_date else "",
"url": ride.url,
"park_url": ride.park.url if ride.park else "",
})
except Exception as e:
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
return trending_rides
def _calculate_content_score(self, content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float:
"""Calculate weighted trending score for content object using real analytics data."""
try:
# Get content type for PageView queries
ct = ContentType.objects.get_for_model(content_obj)
# 1. View Growth Score (40% weight)
view_growth_score = self._calculate_view_growth_score(
ct, content_obj.id, current_period_hours, previous_period_hours)
# 2. Rating Score (30% weight)
rating_score = self._calculate_rating_score(content_obj)
# 3. Recency Score (20% weight)
recency_score = self._calculate_recency_score(content_obj)
# 4. Popularity Score (10% weight)
popularity_score = self._calculate_popularity_score(
ct, content_obj.id, current_period_hours)
# Calculate weighted final score
final_score = (
view_growth_score * 0.4 +
rating_score * 0.3 +
recency_score * 0.2 +
popularity_score * 0.1
)
return final_score
except Exception as e:
logger.error(
f"Error calculating score for {content_type} {content_obj.id}: {e}")
return 0.0
def _calculate_view_growth_score(self, content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float:
"""Calculate normalized view growth score using real PageView data."""
try:
current_views, previous_views, growth_percentage = PageView.get_views_growth(
content_type,
object_id,
current_period_hours,
previous_period_hours,
)
if previous_views == 0:
# New content with views gets boost
return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0
# Normalize growth percentage to 0-1 scale
normalized_growth = min(growth_percentage / 500.0,
1.0) if growth_percentage > 0 else 0.0
return max(normalized_growth, 0.0)
except Exception as e:
logger.warning(f"Error calculating view growth: {e}")
return 0.0
def _calculate_rating_score(self, content_obj: Any) -> float:
"""Calculate normalized rating score."""
try:
rating = getattr(content_obj, "average_rating", None)
if rating is None or rating == 0:
return 0.3 # Neutral score for unrated content
# Normalize rating from 1-10 scale to 0-1 scale
return min(max((float(rating) - 1) / 9.0, 0.0), 1.0)
except Exception as e:
logger.warning(f"Error calculating rating score: {e}")
return 0.3
def _calculate_recency_score(self, content_obj: Any) -> float:
"""Calculate recency score based on when content was added/updated."""
try:
# Use opening_date for parks/rides, or created_at as fallback
date_added = getattr(content_obj, "opening_date", None)
if not date_added:
date_added = getattr(content_obj, "created_at", None)
if not date_added:
return 0.5 # Neutral score for unknown dates
# Handle both date and datetime objects
if hasattr(date_added, "date"):
date_added = date_added.date()
# Calculate days since added
today = timezone.now().date()
days_since_added = (today - date_added).days
# Recency score: newer content gets higher scores
if days_since_added <= 0:
return 1.0
elif days_since_added <= 30:
return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8
elif days_since_added <= 365:
return 0.8 - ((days_since_added - 30) / (365 - 30)) * 0.7 # 0.8 to 0.1
else:
return 0.0
except Exception as e:
logger.warning(f"Error calculating recency score: {e}")
return 0.5
def _calculate_popularity_score(self, content_type: ContentType, object_id: int, hours: int) -> float:
"""Calculate popularity score based on total view count."""
try:
total_views = PageView.get_total_views_count(
content_type, object_id, hours=hours)
# Normalize views to 0-1 scale
if total_views == 0:
return 0.0
elif total_views <= 100:
return total_views / 200.0 # 0.0 to 0.5
else:
return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0
except Exception as e:
logger.warning(f"Error calculating popularity score: {e}")
return 0.0
def _format_trending_results(self, trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]:
"""Format trending results for frontend consumption."""
formatted_results = []
for rank, item in enumerate(trending_items, 1):
try:
# Get view change for display
content_obj = item["content_object"]
ct = ContentType.objects.get_for_model(content_obj)
current_views, previous_views, growth_percentage = PageView.get_views_growth(
ct,
content_obj.id,
current_period_hours,
previous_period_hours,
)
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"park": item["park"],
"category": item["category"],
"rating": item["rating"],
"rank": rank,
"views": current_views,
"views_change": (
f"+{growth_percentage:.1f}%"
if growth_percentage > 0
else f"{growth_percentage:.1f}%"
),
"slug": item["slug"],
"date_opened": item["date_opened"],
"url": item["url"],
}
# Add park_url for rides
if item.get("park_url"):
formatted_item["park_url"] = item["park_url"]
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting trending item: {e}")
return formatted_results

View File

@@ -7,9 +7,11 @@ 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,5 +38,8 @@ 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

@@ -0,0 +1,48 @@
# 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,7 +21,6 @@ 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"""
@@ -158,7 +157,7 @@ class PerformanceMiddleware(MiddlewareMixin):
extra=performance_data,
)
return None # Don't handle the exception, just log it
# Don't return anything - let the exception propagate normally
def _get_client_ip(self, request):
"""Extract client IP address from request"""
@@ -201,7 +200,6 @@ 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"""
@@ -253,8 +251,6 @@ 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:
@@ -275,7 +271,6 @@ 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,8 +280,11 @@ class CacheMonitor:
stats = {}
try:
# Redis cache stats
if hasattr(self.cache_service.default_cache, "_cache"):
# 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_client = self.cache_service.default_cache._cache.get_client()
info = redis_client.info()
stats["redis"] = {
@@ -297,8 +300,16 @@ 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:
logger.error(f"Error getting cache stats: {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}"
return stats

View File

@@ -58,7 +58,7 @@ class TrendingService:
self, content_type: str = "all", limit: int = 20, force_refresh: bool = False
) -> List[Dict[str, Any]]:
"""
Get trending content with caching.
Get trending content using direct calculation.
Args:
content_type: 'parks', 'rides', or 'all'
@@ -68,7 +68,7 @@ class TrendingService:
Returns:
List of trending content with exact frontend format
"""
cache_key = f"{self.CACHE_PREFIX}:trending:{content_type}:{limit}"
cache_key = f"trending:calculated:{content_type}:{limit}"
if not force_refresh:
cached_result = cache.get(cache_key)
@@ -78,41 +78,38 @@ class TrendingService:
)
return cached_result
self.logger.info(f"Calculating trending content for {content_type}")
self.logger.info(f"Getting trending content for {content_type}")
try:
# Calculate trending scores for each content type
# Calculate directly without Celery
trending_items = []
if content_type in ["all", "parks"]:
park_items = self._calculate_trending_parks(
limit if content_type == "parks" else limit * 2
)
limit * 2 if content_type == "all" else limit)
trending_items.extend(park_items)
if content_type in ["all", "rides"]:
ride_items = self._calculate_trending_rides(
limit if content_type == "rides" else limit * 2
)
limit * 2 if content_type == "all" else limit)
trending_items.extend(ride_items)
# Sort by trending score and apply limit
trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True)
trending_items = trending_items[:limit]
# Add ranking and format for frontend
# Format results for API consumption
formatted_results = self._format_trending_results(trending_items)
# Cache results
cache.set(cache_key, formatted_results, self.CACHE_TTL)
self.logger.info(
f"Calculated {len(formatted_results)} trending items for {content_type}"
)
f"Calculated {len(formatted_results)} trending items for {content_type}")
return formatted_results
except Exception as e:
self.logger.error(f"Error calculating trending content: {e}", exc_info=True)
self.logger.error(f"Error getting trending content: {e}", exc_info=True)
return []
def get_new_content(
@@ -123,7 +120,7 @@ class TrendingService:
force_refresh: bool = False,
) -> List[Dict[str, Any]]:
"""
Get recently added content.
Get recently added content using direct calculation.
Args:
content_type: 'parks', 'rides', or 'all'
@@ -134,7 +131,7 @@ class TrendingService:
Returns:
List of new content with exact frontend format
"""
cache_key = f"{self.CACHE_PREFIX}:new:{content_type}:{limit}:{days_back}"
cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}"
if not force_refresh:
cached_result = cache.get(cache_key)
@@ -144,37 +141,35 @@ class TrendingService:
)
return cached_result
self.logger.info(f"Calculating new content for {content_type}")
self.logger.info(f"Getting new content for {content_type}")
try:
# Calculate directly without Celery
cutoff_date = timezone.now() - timedelta(days=days_back)
new_items = []
if content_type in ["all", "parks"]:
parks = self._get_new_parks(
cutoff_date, limit if content_type == "parks" else limit * 2
)
cutoff_date, limit * 2 if content_type == "all" else limit)
new_items.extend(parks)
if content_type in ["all", "rides"]:
rides = self._get_new_rides(
cutoff_date, limit if content_type == "rides" else limit * 2
)
cutoff_date, limit * 2 if content_type == "all" else limit)
new_items.extend(rides)
# Sort by date added (most recent first) and apply limit
new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True)
new_items = new_items[:limit]
# Format for frontend
# Format results for API consumption
formatted_results = self._format_new_content_results(new_items)
# Cache results
cache.set(cache_key, formatted_results, self.CACHE_TTL)
cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes
self.logger.info(
f"Found {len(formatted_results)} new items for {content_type}"
)
f"Calculated {len(formatted_results)} new items for {content_type}")
return formatted_results
except Exception as e:
@@ -184,7 +179,7 @@ class TrendingService:
def _calculate_trending_parks(self, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for parks."""
parks = Park.objects.filter(status="OPERATING").select_related(
"location", "operator"
"location", "operator", "card_image"
)
trending_parks = []
@@ -193,6 +188,32 @@ class TrendingService:
try:
score = self._calculate_content_score(park, "park")
if score > 0: # Only include items with positive trending scores
# Get opening date for date_opened field
opening_date = getattr(park, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
# Get location fields
city = ""
state = ""
country = ""
try:
location = getattr(park, 'location', None)
if location:
city = getattr(location, 'city', '') or ""
state = getattr(location, 'state', '') or ""
country = getattr(location, 'country', '') or ""
except Exception:
pass
# Get card image URL
card_image_url = ""
if park.card_image and hasattr(park.card_image, 'image'):
card_image_url = park.card_image.image.url if park.card_image.image else ""
# Get primary company (operator)
primary_company = park.operator.name if park.operator else ""
trending_parks.append(
{
"content_object": park,
@@ -201,17 +222,20 @@ class TrendingService:
"id": park.id,
"name": park.name,
"slug": park.slug,
"location": (
park.formatted_location
if hasattr(park, "location")
else ""
),
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"rating": (
float(park.average_rating)
if park.average_rating
else 0.0
),
"date_opened": opening_date.isoformat() if opening_date else "",
"url": park.url,
"card_image": card_image_url,
"city": city,
"state": state,
"country": country,
"primary_company": primary_company,
}
)
except Exception as e:
@@ -222,7 +246,7 @@ class TrendingService:
def _calculate_trending_rides(self, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for rides."""
rides = Ride.objects.filter(status="OPERATING").select_related(
"park", "park__location"
"park", "park__location", "card_image"
)
trending_rides = []
@@ -231,14 +255,15 @@ class TrendingService:
try:
score = self._calculate_content_score(ride, "ride")
if score > 0: # Only include items with positive trending scores
# Get location from park (rides don't have direct location field)
location = ""
if (
ride.park
and hasattr(ride.park, "location")
and ride.park.location
):
location = ride.park.formatted_location
# Get opening date for date_opened field
opening_date = getattr(ride, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
# Get card image URL
card_image_url = ""
if ride.card_image and hasattr(ride.card_image, 'image'):
card_image_url = ride.card_image.image.url if ride.card_image.image else ""
trending_rides.append(
{
@@ -248,13 +273,17 @@ class TrendingService:
"id": ride.pk, # Use pk instead of id
"name": ride.name,
"slug": ride.slug,
"location": location,
"park": ride.park.name if ride.park else "",
"category": "ride",
"rating": (
float(ride.average_rating)
if ride.average_rating
else 0.0
),
"date_opened": opening_date.isoformat() if opening_date else "",
"url": ride.url,
"park_url": ride.park.url if ride.park else "",
"card_image": card_image_url,
}
)
except Exception as e:
@@ -421,7 +450,7 @@ class TrendingService:
| Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("location", "operator")
.select_related("location", "operator", "card_image")
.order_by("-created_at", "-opening_date")[:limit]
)
@@ -435,6 +464,32 @@ class TrendingService:
date_added = date_added.date()
# If it's already a date, keep it as is
# Get opening date for date_opened field
opening_date = getattr(park, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
# Get location fields
city = ""
state = ""
country = ""
try:
location = getattr(park, 'location', None)
if location:
city = getattr(location, 'city', '') or ""
state = getattr(location, 'state', '') or ""
country = getattr(location, 'country', '') or ""
except Exception:
pass
# Get card image URL
card_image_url = ""
if park.card_image and hasattr(park.card_image, 'image'):
card_image_url = park.card_image.image.url if park.card_image.image else ""
# Get primary company (operator)
primary_company = park.operator.name if park.operator else ""
results.append(
{
"content_object": park,
@@ -442,11 +497,16 @@ class TrendingService:
"id": park.pk, # Use pk instead of id for Django compatibility
"name": park.name,
"slug": park.slug,
"location": (
park.formatted_location if hasattr(park, "location") else ""
),
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
"url": park.url,
"card_image": card_image_url,
"city": city,
"state": state,
"country": country,
"primary_company": primary_company,
}
)
@@ -460,7 +520,7 @@ class TrendingService:
| Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("park", "park__location")
.select_related("park", "park__location", "card_image")
.order_by("-created_at", "-opening_date")[:limit]
)
@@ -476,10 +536,15 @@ class TrendingService:
date_added = date_added.date()
# If it's already a date, keep it as is
# Get location from park (rides don't have direct location field)
location = ""
if ride.park and hasattr(ride.park, "location") and ride.park.location:
location = ride.park.formatted_location
# Get opening date for date_opened field
opening_date = getattr(ride, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
# Get card image URL
card_image_url = ""
if ride.card_image and hasattr(ride.card_image, 'image'):
card_image_url = ride.card_image.image.url if ride.card_image.image else ""
results.append(
{
@@ -488,9 +553,13 @@ class TrendingService:
"id": ride.pk, # Use pk instead of id for Django compatibility
"name": ride.name,
"slug": ride.slug,
"location": location,
"park": ride.park.name if ride.park else "",
"category": "ride",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
"url": ride.url,
"park_url": ride.park.url if ride.park else "",
"card_image": card_image_url,
}
)
@@ -520,7 +589,7 @@ class TrendingService:
formatted_item = {
"id": item["id"],
"name": item["name"],
"location": item["location"],
"park": item["park"],
"category": item["category"],
"rating": item["rating"],
"rank": rank,
@@ -531,8 +600,29 @@ class TrendingService:
else f"{growth_percentage:.1f}%"
),
"slug": item["slug"],
"date_opened": item["date_opened"],
"url": item["url"],
}
# Add card_image for all items
if item.get("card_image"):
formatted_item["card_image"] = item["card_image"]
# Add park-specific fields
if item["content_type"] == "park":
if item.get("city"):
formatted_item["city"] = item["city"]
if item.get("state"):
formatted_item["state"] = item["state"]
if item.get("country"):
formatted_item["country"] = item["country"]
if item.get("primary_company"):
formatted_item["primary_company"] = item["primary_company"]
# Add park_url for rides
if item.get("park_url"):
formatted_item["park_url"] = item["park_url"]
formatted_results.append(formatted_item)
except Exception as e:
@@ -552,12 +642,33 @@ class TrendingService:
formatted_item = {
"id": item["id"],
"name": item["name"],
"location": item["location"],
"park": item["park"],
"category": item["category"],
"date_added": item["date_added"],
"date_opened": item["date_opened"],
"slug": item["slug"],
"url": item["url"],
}
# Add card_image for all items
if item.get("card_image"):
formatted_item["card_image"] = item["card_image"]
# Add park-specific fields
if item["content_type"] == "park":
if item.get("city"):
formatted_item["city"] = item["city"]
if item.get("state"):
formatted_item["state"] = item["state"]
if item.get("country"):
formatted_item["country"] = item["country"]
if item.get("primary_company"):
formatted_item["primary_company"] = item["primary_company"]
# Add park_url for rides
if item.get("park_url"):
formatted_item["park_url"] = item["park_url"]
formatted_results.append(formatted_item)
except Exception as e:

View File

@@ -0,0 +1,5 @@
"""
Core tasks package for ThrillWiki.
This package contains all Celery tasks for the core application.
"""

View File

@@ -0,0 +1,550 @@
"""
Trending calculation tasks for ThrillWiki.
This module contains Celery tasks for calculating and caching trending content.
All tasks run asynchronously to avoid blocking the main application.
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
from celery import shared_task
from django.utils import timezone
from django.core.cache import cache
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, Count, Avg, F
from django.db import transaction
from apps.core.analytics import PageView
from apps.parks.models import Park
from apps.rides.models import Ride
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def calculate_trending_content(self, content_type: str = "all", limit: int = 50) -> Dict[str, Any]:
"""
Calculate trending content using real analytics data.
This task runs periodically to update trending calculations based on:
- View growth rates
- Content ratings
- Recency factors
- Popularity metrics
Args:
content_type: 'parks', 'rides', or 'all'
limit: Maximum number of results to calculate
Returns:
Dict containing trending results and metadata
"""
try:
logger.info(f"Starting trending calculation for {content_type}")
# Time windows for calculations
current_period_hours = 168 # 7 days
previous_period_hours = 336 # 14 days (for previous 7-day window comparison)
trending_items = []
if content_type in ["all", "parks"]:
park_items = _calculate_trending_parks(
current_period_hours,
previous_period_hours,
limit if content_type == "parks" else limit * 2
)
trending_items.extend(park_items)
if content_type in ["all", "rides"]:
ride_items = _calculate_trending_rides(
current_period_hours,
previous_period_hours,
limit if content_type == "rides" else limit * 2
)
trending_items.extend(ride_items)
# Sort by trending score and apply limit
trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True)
trending_items = trending_items[:limit]
# Format results for API consumption
formatted_results = _format_trending_results(
trending_items, current_period_hours, previous_period_hours)
# Cache results
cache_key = f"trending:calculated:{content_type}:{limit}"
cache.set(cache_key, formatted_results, 3600) # Cache for 1 hour
logger.info(
f"Calculated {len(formatted_results)} trending items for {content_type}")
return {
"success": True,
"content_type": content_type,
"count": len(formatted_results),
"results": formatted_results,
"calculated_at": timezone.now().isoformat(),
}
except Exception as e:
logger.error(f"Error calculating trending content: {e}", exc_info=True)
# Retry the task
raise self.retry(exc=e)
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def calculate_new_content(self, content_type: str = "all", days_back: int = 30, limit: int = 50) -> Dict[str, Any]:
"""
Calculate new content based on opening dates and creation dates.
Args:
content_type: 'parks', 'rides', or 'all'
days_back: How many days to look back for new content
limit: Maximum number of results
Returns:
Dict containing new content results and metadata
"""
try:
logger.info(f"Starting new content calculation for {content_type}")
cutoff_date = timezone.now() - timedelta(days=days_back)
new_items = []
if content_type in ["all", "parks"]:
parks = _get_new_parks(
cutoff_date, limit if content_type == "parks" else limit * 2)
new_items.extend(parks)
if content_type in ["all", "rides"]:
rides = _get_new_rides(
cutoff_date, limit if content_type == "rides" else limit * 2)
new_items.extend(rides)
# Sort by date added (most recent first) and apply limit
new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True)
new_items = new_items[:limit]
# Format results for API consumption
formatted_results = _format_new_content_results(new_items)
# Cache results
cache_key = f"new_content:calculated:{content_type}:{days_back}:{limit}"
cache.set(cache_key, formatted_results, 1800) # Cache for 30 minutes
logger.info(f"Calculated {len(formatted_results)} new items for {content_type}")
return {
"success": True,
"content_type": content_type,
"count": len(formatted_results),
"results": formatted_results,
"calculated_at": timezone.now().isoformat(),
}
except Exception as e:
logger.error(f"Error calculating new content: {e}", exc_info=True)
raise self.retry(exc=e)
@shared_task(bind=True)
def warm_trending_cache(self) -> Dict[str, Any]:
"""
Warm the trending cache by pre-calculating common queries.
This task runs periodically to ensure fast API responses.
"""
try:
logger.info("Starting trending cache warming")
# Common query combinations to pre-calculate
queries = [
{"content_type": "all", "limit": 20},
{"content_type": "parks", "limit": 10},
{"content_type": "rides", "limit": 10},
{"content_type": "all", "limit": 50},
]
results = {}
for query in queries:
# Trigger trending calculation
calculate_trending_content.delay(**query)
# Trigger new content calculation
calculate_new_content.delay(**query)
results[f"trending_{query['content_type']}_{query['limit']}"] = "scheduled"
results[f"new_content_{query['content_type']}_{query['limit']}"] = "scheduled"
logger.info("Trending cache warming completed")
return {
"success": True,
"queries_scheduled": len(queries) * 2,
"results": results,
"warmed_at": timezone.now().isoformat(),
}
except Exception as e:
logger.error(f"Error warming trending cache: {e}", exc_info=True)
return {
"success": False,
"error": str(e),
"warmed_at": timezone.now().isoformat(),
}
def _calculate_trending_parks(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for parks using real data."""
parks = Park.objects.filter(
status="OPERATING").select_related("location", "operator")
trending_parks = []
for park in parks:
try:
score = _calculate_content_score(
park, "park", current_period_hours, previous_period_hours)
if score > 0: # Only include items with positive trending scores
trending_parks.append({
"content_object": park,
"content_type": "park",
"trending_score": score,
"id": park.id,
"name": park.name,
"slug": park.slug,
"location": park.formatted_location if hasattr(park, "location") else "",
"category": "park",
"rating": float(park.average_rating) if park.average_rating else 0.0,
})
except Exception as e:
logger.warning(f"Error calculating score for park {park.id}: {e}")
return trending_parks
def _calculate_trending_rides(current_period_hours: int, previous_period_hours: int, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for rides using real data."""
rides = Ride.objects.filter(status="OPERATING").select_related(
"park", "park__location")
trending_rides = []
for ride in rides:
try:
score = _calculate_content_score(
ride, "ride", current_period_hours, previous_period_hours)
if score > 0: # Only include items with positive trending scores
# Get location from park
location = ""
if ride.park and hasattr(ride.park, "location") and ride.park.location:
location = ride.park.formatted_location
trending_rides.append({
"content_object": ride,
"content_type": "ride",
"trending_score": score,
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"location": location,
"category": "ride",
"rating": float(ride.average_rating) if ride.average_rating else 0.0,
})
except Exception as e:
logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
return trending_rides
def _calculate_content_score(content_obj: Any, content_type: str, current_period_hours: int, previous_period_hours: int) -> float:
"""
Calculate weighted trending score for content object using real analytics data.
Algorithm Components:
- View Growth Rate (40% weight): Recent view increase vs historical
- Rating Score (30% weight): Average user rating normalized
- Recency Factor (20% weight): How recently content was added/updated
- Popularity Boost (10% weight): Total view count normalization
Returns:
Float between 0.0 and 1.0 representing trending strength
"""
try:
# Get content type for PageView queries
ct = ContentType.objects.get_for_model(content_obj)
# 1. View Growth Score (40% weight)
view_growth_score = _calculate_view_growth_score(
ct, content_obj.id, current_period_hours, previous_period_hours)
# 2. Rating Score (30% weight)
rating_score = _calculate_rating_score(content_obj)
# 3. Recency Score (20% weight)
recency_score = _calculate_recency_score(content_obj)
# 4. Popularity Score (10% weight)
popularity_score = _calculate_popularity_score(
ct, content_obj.id, current_period_hours)
# Calculate weighted final score
final_score = (
view_growth_score * 0.4 +
rating_score * 0.3 +
recency_score * 0.2 +
popularity_score * 0.1
)
logger.debug(
f"{content_type} {content_obj.id}: "
f"growth={view_growth_score:.3f}, rating={rating_score:.3f}, "
f"recency={recency_score:.3f}, popularity={popularity_score:.3f}, "
f"final={final_score:.3f}"
)
return final_score
except Exception as e:
logger.error(
f"Error calculating score for {content_type} {content_obj.id}: {e}")
return 0.0
def _calculate_view_growth_score(content_type: ContentType, object_id: int, current_period_hours: int, previous_period_hours: int) -> float:
"""Calculate normalized view growth score using real PageView data."""
try:
current_views, previous_views, growth_percentage = PageView.get_views_growth(
content_type,
object_id,
current_period_hours,
previous_period_hours,
)
if previous_views == 0:
# New content with views gets boost
return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0
# Normalize growth percentage to 0-1 scale
# 100% growth = 0.5, 500% growth = 1.0
normalized_growth = min(growth_percentage / 500.0,
1.0) if growth_percentage > 0 else 0.0
return max(normalized_growth, 0.0)
except Exception as e:
logger.warning(f"Error calculating view growth: {e}")
return 0.0
def _calculate_rating_score(content_obj: Any) -> float:
"""Calculate normalized rating score."""
try:
rating = getattr(content_obj, "average_rating", None)
if rating is None or rating == 0:
return 0.3 # Neutral score for unrated content
# Normalize rating from 1-10 scale to 0-1 scale
# Rating of 5 = 0.4, Rating of 8 = 0.7, Rating of 10 = 1.0
return min(max((float(rating) - 1) / 9.0, 0.0), 1.0)
except Exception as e:
logger.warning(f"Error calculating rating score: {e}")
return 0.3
def _calculate_recency_score(content_obj: Any) -> float:
"""Calculate recency score based on when content was added/updated."""
try:
# Use opening_date for parks/rides, or created_at as fallback
date_added = getattr(content_obj, "opening_date", None)
if not date_added:
date_added = getattr(content_obj, "created_at", None)
if not date_added:
return 0.5 # Neutral score for unknown dates
# Handle both date and datetime objects
if hasattr(date_added, "date"):
date_added = date_added.date()
# Calculate days since added
today = timezone.now().date()
days_since_added = (today - date_added).days
# Recency score: newer content gets higher scores
# 0 days = 1.0, 30 days = 0.8, 365 days = 0.1, >365 days = 0.0
if days_since_added <= 0:
return 1.0
elif days_since_added <= 30:
return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8
elif days_since_added <= 365:
return 0.8 - ((days_since_added - 30) / (365 - 30)) * 0.7 # 0.8 to 0.1
else:
return 0.0
except Exception as e:
logger.warning(f"Error calculating recency score: {e}")
return 0.5
def _calculate_popularity_score(content_type: ContentType, object_id: int, hours: int) -> float:
"""Calculate popularity score based on total view count."""
try:
total_views = PageView.get_total_views_count(
content_type, object_id, hours=hours)
# Normalize views to 0-1 scale
# 0 views = 0.0, 100 views = 0.5, 1000+ views = 1.0
if total_views == 0:
return 0.0
elif total_views <= 100:
return total_views / 200.0 # 0.0 to 0.5
else:
return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0
except Exception as e:
logger.warning(f"Error calculating popularity score: {e}")
return 0.0
def _get_new_parks(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added parks using real data."""
new_parks = (
Park.objects.filter(
Q(created_at__gte=cutoff_date) | Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("location", "operator")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for park in new_parks:
date_added = park.opening_date or park.created_at
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(park, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append({
"content_object": park,
"content_type": "park",
"id": park.pk,
"name": park.name,
"slug": park.slug,
"park": park.name, # For parks, park field is the park name itself
"category": "park",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
})
return results
def _get_new_rides(cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added rides using real data."""
new_rides = (
Ride.objects.filter(
Q(created_at__gte=cutoff_date) | Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("park", "park__location")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for ride in new_rides:
date_added = getattr(ride, "opening_date", None) or getattr(
ride, "created_at", None)
if date_added:
if isinstance(date_added, datetime):
date_added = date_added.date()
opening_date = getattr(ride, "opening_date", None)
if opening_date and isinstance(opening_date, datetime):
opening_date = opening_date.date()
results.append({
"content_object": ride,
"content_type": "ride",
"id": ride.pk,
"name": ride.name,
"slug": ride.slug,
"park": ride.park.name if ride.park else "",
"category": "ride",
"date_added": date_added.isoformat() if date_added else "",
"date_opened": opening_date.isoformat() if opening_date else "",
})
return results
def _format_trending_results(trending_items: List[Dict[str, Any]], current_period_hours: int, previous_period_hours: int) -> List[Dict[str, Any]]:
"""Format trending results for frontend consumption."""
formatted_results = []
for rank, item in enumerate(trending_items, 1):
try:
# Get view change for display
content_obj = item["content_object"]
ct = ContentType.objects.get_for_model(content_obj)
current_views, previous_views, growth_percentage = PageView.get_views_growth(
ct,
content_obj.id,
current_period_hours,
previous_period_hours,
)
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"location": item["location"],
"category": item["category"],
"rating": item["rating"],
"rank": rank,
"views": current_views,
"views_change": (
f"+{growth_percentage:.1f}%"
if growth_percentage > 0
else f"{growth_percentage:.1f}%"
),
"slug": item["slug"],
}
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting trending item: {e}")
return formatted_results
def _format_new_content_results(new_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Format new content results for frontend consumption."""
formatted_results = []
for item in new_items:
try:
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"park": item["park"],
"category": item["category"],
"date_added": item["date_added"],
"date_opened": item["date_opened"],
"slug": item["slug"],
}
formatted_results.append(formatted_item)
except Exception as e:
logger.warning(f"Error formatting new content item: {e}")
return formatted_results

View File

@@ -0,0 +1,32 @@
# 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

@@ -0,0 +1,105 @@
# 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

@@ -0,0 +1,62 @@
# Generated by Django 5.2.5 on 2025-08-28 22:59
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0010_add_banner_card_image_fields"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="update_update",
),
migrations.AddField(
model_name="park",
name="url",
field=models.URLField(blank=True, help_text="Frontend URL for this park"),
),
migrations.AddField(
model_name="parkevent",
name="url",
field=models.URLField(blank=True, help_text="Frontend URL for this park"),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="f677e88234ebc3dc93c46d4756cb0723f5468cbe",
operation="INSERT",
pgid="pgtrigger_insert_insert_66883",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "operator_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "property_owner_id", "ride_count", "size_acres", "slug", "status", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."operator_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."property_owner_id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="6fc430a517628d48341e8981fa38529031c3f35b",
operation="UPDATE",
pgid="pgtrigger_update_update_19f56",
table="parks_park",
when="AFTER",
),
),
),
]

View File

@@ -9,6 +9,7 @@ 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
@@ -33,9 +34,9 @@ class ParkPhoto(TrackedModel):
"parks.Park", on_delete=models.CASCADE, related_name="photos"
)
image = models.ImageField(
upload_to=park_photo_upload_path,
max_length=255,
image = CloudflareImagesField(
variant="public",
help_text="Park photo stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
@@ -56,7 +57,7 @@ class ParkPhoto(TrackedModel):
related_name="uploaded_park_photos",
)
class Meta:
class Meta(TrackedModel.Meta):
app_label = "parks"
ordering = ["-is_primary", "-created_at"]
indexes = [

View File

@@ -2,10 +2,12 @@ from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from config.django import base as settings
from typing import Optional, Any, TYPE_CHECKING, List
import pghistory
from apps.core.history import TrackedModel
if TYPE_CHECKING:
from apps.rides.models import Ride
from . import ParkArea
@@ -54,6 +56,24 @@ 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",
@@ -79,6 +99,9 @@ class Park(TrackedModel):
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this park")
class Meta:
ordering = ["name"]
constraints = [
@@ -149,6 +172,10 @@ class Park(TrackedModel):
if not self.slug or (old_name and old_name != self.name):
self.slug = slugify(self.name)
# Generate frontend URL
frontend_domain = getattr(settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
self.url = f"{frontend_domain}/parks/{self.slug}/"
# Save the model
super().save(*args, **kwargs)

View File

@@ -0,0 +1,32 @@
# 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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
# 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

@@ -0,0 +1,20 @@
# 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

@@ -0,0 +1,38 @@
# Generated by Django 5.2.5 on 2025-08-28 19:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0012_make_ride_model_slug_unique"),
]
operations = [
migrations.AlterUniqueTogether(
name="ridemodel",
unique_together={("manufacturer", "name")},
),
migrations.AlterField(
model_name="ridemodel",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier (unique within manufacturer)",
max_length=255,
),
),
migrations.AlterField(
model_name="ridemodelevent",
name="slug",
field=models.SlugField(
db_index=False,
help_text="URL-friendly identifier (unique within manufacturer)",
max_length=255,
),
),
migrations.AlterUniqueTogether(
name="ridemodel",
unique_together={("manufacturer", "name"), ("manufacturer", "slug")},
),
]

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.5 on 2025-08-28 19:19
from django.db import migrations
from django.utils.text import slugify
def update_ride_model_slugs(apps, schema_editor):
"""Update RideModel slugs to be just the model name, not manufacturer + name."""
RideModel = apps.get_model('rides', 'RideModel')
for ride_model in RideModel.objects.all():
# Generate new slug from just the name
new_slug = slugify(ride_model.name)
# Ensure uniqueness within the same manufacturer
counter = 1
base_slug = new_slug
while RideModel.objects.filter(
manufacturer=ride_model.manufacturer,
slug=new_slug
).exclude(pk=ride_model.pk).exists():
new_slug = f"{base_slug}-{counter}"
counter += 1
# Update the slug
ride_model.slug = new_slug
ride_model.save(update_fields=['slug'])
print(f"Updated {ride_model.name}: {ride_model.slug}")
def reverse_ride_model_slugs(apps, schema_editor):
"""Reverse the slug update by regenerating the old format."""
RideModel = apps.get_model('rides', 'RideModel')
for ride_model in RideModel.objects.all():
# Generate old-style slug with manufacturer + name
old_slug = slugify(
f"{ride_model.manufacturer.name if ride_model.manufacturer else ''} {ride_model.name}"
)
# Ensure uniqueness globally (old way)
counter = 1
base_slug = old_slug
while RideModel.objects.filter(slug=old_slug).exclude(pk=ride_model.pk).exists():
old_slug = f"{base_slug}-{counter}"
counter += 1
# Update the slug
ride_model.slug = old_slug
ride_model.save(update_fields=['slug'])
class Migration(migrations.Migration):
dependencies = [
('rides', '0013_fix_ride_model_slugs'),
]
operations = [
migrations.RunPython(
update_ride_model_slugs,
reverse_ride_model_slugs,
),
]

View File

@@ -0,0 +1,164 @@
# Generated by Django 5.2.5 on 2025-08-28 22:59
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0014_update_ride_model_slugs_data"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodel",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodel",
name="update_update",
),
migrations.AddField(
model_name="company",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this company"
),
),
migrations.AddField(
model_name="companyevent",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this company"
),
),
migrations.AddField(
model_name="ride",
name="url",
field=models.URLField(blank=True, help_text="Frontend URL for this ride"),
),
migrations.AddField(
model_name="rideevent",
name="url",
field=models.URLField(blank=True, help_text="Frontend URL for this ride"),
),
migrations.AddField(
model_name="ridemodel",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride model"
),
),
migrations.AddField(
model_name="ridemodelevent",
name="url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride model"
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "url", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="fe6c1e3f09822f5e7f716cd83483cf152ec138f0",
operation="INSERT",
pgid="pgtrigger_insert_insert_e7194",
table="rides_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_companyevent" ("coasters_count", "created_at", "description", "founded_date", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "roles", "slug", "updated_at", "url", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_date", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."roles", NEW."slug", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="0b76cb36b7551ed3e64e674b8cfe343d4d2ec306",
operation="UPDATE",
pgid="pgtrigger_update_update_456a8",
table="rides_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="6764dc3b0c0e73dda649939bb1ee7b7de143125f",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="63c4066af11852396506fd964989632336205573",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="9cee65f580a26ae9edc8f9fc1f3d9b25da1856c3",
operation="INSERT",
pgid="pgtrigger_insert_insert_0aaee",
table="rides_ridemodel",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridemodelevent" ("category", "created_at", "description", "first_installation_year", "id", "is_discontinued", "last_installation_year", "manufacturer_id", "meta_description", "meta_title", "name", "notable_features", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "primary_image_id", "restraint_system", "slug", "support_structure", "target_market", "total_installations", "track_type", "train_configuration", "typical_capacity_range_max", "typical_capacity_range_min", "typical_height_range_max_ft", "typical_height_range_min_ft", "typical_speed_range_max_mph", "typical_speed_range_min_mph", "updated_at", "url") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."first_installation_year", NEW."id", NEW."is_discontinued", NEW."last_installation_year", NEW."manufacturer_id", NEW."meta_description", NEW."meta_title", NEW."name", NEW."notable_features", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."primary_image_id", NEW."restraint_system", NEW."slug", NEW."support_structure", NEW."target_market", NEW."total_installations", NEW."track_type", NEW."train_configuration", NEW."typical_capacity_range_max", NEW."typical_capacity_range_min", NEW."typical_height_range_max_ft", NEW."typical_height_range_min_ft", NEW."typical_speed_range_max_mph", NEW."typical_speed_range_min_mph", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="365f87607f9f7bfee1caaabdd32b16032e04ae82",
operation="UPDATE",
pgid="pgtrigger_update_update_0ca1a",
table="rides_ridemodel",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,66 @@
# Generated by Django 5.2.5 on 2025-08-28 23:12
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0015_remove_company_insert_insert_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="park_url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride's park"
),
),
migrations.AddField(
model_name="rideevent",
name="park_url",
field=models.URLField(
blank=True, help_text="Frontend URL for this ride's park"
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="3b83e1d1dbc2d5ca5792929845db1dd6d306700a",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="efd782a22f5bec46d06b234ffc55b6c06360ade1",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -3,6 +3,7 @@ from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.conf import settings
from apps.core.history import HistoricalSlug
from apps.core.models import TrackedModel
@@ -33,12 +34,30 @@ class Company(TrackedModel):
rides_count = models.IntegerField(default=0)
coasters_count = models.IntegerField(default=0)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this company")
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
# Generate frontend URL based on primary role
# CRITICAL: Only MANUFACTURER and DESIGNER are for rides domain
# OPERATOR and PROPERTY_OWNER are for parks domain and handled separately
if self.roles:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
primary_role = self.roles[0] # Use first role as primary
if primary_role == 'MANUFACTURER':
self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/"
elif primary_role == 'DESIGNER':
self.url = f"{frontend_domain}/rides/designers/{self.slug}/"
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here
super().save(*args, **kwargs)
def get_absolute_url(self):

View File

@@ -9,6 +9,7 @@ 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
@@ -36,9 +37,9 @@ class RidePhoto(TrackedModel):
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
)
image = models.ImageField(
upload_to=ride_photo_upload_path,
max_length=255,
image = CloudflareImagesField(
variant="public",
help_text="Ride photo stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
@@ -73,7 +74,7 @@ class RidePhoto(TrackedModel):
related_name="uploaded_ride_photos",
)
class Meta:
class Meta(TrackedModel.Meta):
app_label = "rides"
ordering = ["-is_primary", "-created_at"]
indexes = [

View File

@@ -1,5 +1,6 @@
from django.db import models
from django.utils.text import slugify
from config.django import base as settings
from apps.core.models import TrackedModel
from .company import Company
import pghistory
@@ -23,11 +24,15 @@ Categories = CATEGORY_CHOICES
class RideModel(TrackedModel):
"""
Represents a specific model/type of ride that can be manufactured by different
companies.
For example: B&M Dive Coaster, Vekoma Boomerang, etc.
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.
"""
name = models.CharField(max_length=255)
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)")
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
@@ -35,15 +40,160 @@ 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)
description = models.TextField(
blank=True, help_text="Detailed description of the ride model")
category = models.CharField(
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
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)"
)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this ride model")
class Meta(TrackedModel.Meta):
ordering = ["manufacturer", "name"]
unique_together = ["manufacturer", "name"]
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",
),
]
def __str__(self) -> str:
return (
@@ -52,6 +202,220 @@ 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]
# Generate frontend URL
if self.manufacturer:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/"
super().save(*args, **kwargs)
def update_installation_count(self) -> None:
"""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):
@@ -139,6 +503,29 @@ 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"
)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this ride")
park_url = models.URLField(
blank=True, help_text="Frontend URL for this ride's park")
class Meta(TrackedModel.Meta):
ordering = ["name"]
unique_together = ["park", "slug"]
@@ -205,6 +592,14 @@ class Ride(TrackedModel):
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
# Generate frontend URLs
if self.park:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"
super().save(*args, **kwargs)

80
backend/config/celery.py Normal file
View File

@@ -0,0 +1,80 @@
"""
Celery configuration for ThrillWiki.
This module sets up Celery for background task processing including:
- Trending calculations
- Cache warming
- Analytics processing
- Email notifications
"""
import os
from celery import Celery
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.django.local')
app = Celery('thrillwiki')
# Get Redis URL from environment variable with fallback
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/1')
# Celery Configuration - set directly without loading from Django settings first
app.conf.update(
# Broker settings
broker_url=REDIS_URL,
result_backend=REDIS_URL,
# Task settings
task_serializer='json',
accept_content=['json'],
result_serializer='json',
timezone='America/New_York',
enable_utc=True,
# Worker settings
worker_prefetch_multiplier=1,
task_acks_late=True,
worker_max_tasks_per_child=1000,
# Task routing
task_routes={
'apps.core.tasks.trending.*': {'queue': 'trending'},
'apps.core.tasks.analytics.*': {'queue': 'analytics'},
'apps.core.tasks.cache.*': {'queue': 'cache'},
},
# Beat schedule for periodic tasks
beat_schedule={
'calculate-trending-content': {
'task': 'apps.core.tasks.trending.calculate_trending_content',
'schedule': 300.0, # Every 5 minutes
},
'warm-trending-cache': {
'task': 'apps.core.tasks.trending.warm_trending_cache',
'schedule': 900.0, # Every 15 minutes
},
'cleanup-old-analytics': {
'task': 'apps.core.tasks.analytics.cleanup_old_analytics',
'schedule': 86400.0, # Daily
},
},
# Task result settings
result_expires=3600, # 1 hour
task_ignore_result=False,
# Error handling
task_reject_on_worker_lost=True,
task_soft_time_limit=300, # 5 minutes
task_time_limit=600, # 10 minutes
)
# Load task modules from all registered Django apps.
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
"""Debug task for testing Celery setup."""
print(f'Request: {self.request!r}')

View File

@@ -47,7 +47,8 @@ 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 = [
@@ -85,6 +86,8 @@ THIRD_PARTY_APPS = [
"health_check.storage",
"health_check.contrib.migrations",
"health_check.contrib.redis",
"django_celery_beat", # Celery beat scheduler
"django_celery_results", # Celery result backend
]
LOCAL_APPS = [
@@ -110,7 +113,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"core.middleware.PgHistoryContextMiddleware", # Add history context tracking
"apps.core.middleware.analytics.PgHistoryContextMiddleware", # Add history context tracking
"allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
"django_htmx.middleware.HtmxMiddleware",
@@ -282,6 +285,9 @@ ROADTRIP_REQUEST_TIMEOUT = 10 # seconds
ROADTRIP_MAX_RETRIES = 3
ROADTRIP_BACKOFF_FACTOR = 2
# Frontend URL Configuration
FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")
# Django REST Framework Settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
@@ -305,7 +311,7 @@ REST_FRAMEWORK = {
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
],
"EXCEPTION_HANDLER": "core.api.exceptions.custom_exception_handler",
"EXCEPTION_HANDLER": "apps.core.api.exceptions.custom_exception_handler",
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
@@ -317,13 +323,17 @@ 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

@@ -103,6 +103,7 @@ 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

@@ -0,0 +1,574 @@
# 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

View File

@@ -57,6 +57,9 @@ dependencies = [
"ruff>=0.12.10",
"python-decouple>=3.8",
"pyright>=1.1.404",
"celery>=5.5.3",
"django-celery-beat>=2.8.1",
"django-celery-results>=2.6.0",
]
[dependency-groups]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
"""
ThrillWiki Django project initialization.
"""

View File

@@ -141,8 +141,12 @@ 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)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Note: Media files are handled by Cloudflare Images, not Django static serving
# This prevents the catch-all pattern from interfering with API routes
try:
urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))]
except ImportError:

261
backend/uv.lock generated
View File

@@ -2,6 +2,18 @@ version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "amqp"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
]
[[package]]
name = "anyio"
version = "4.10.0"
@@ -81,6 +93,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" },
]
[[package]]
name = "billiard"
version = "4.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/58/1546c970afcd2a2428b1bfafecf2371d8951cc34b46701bea73f4280989e/billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f", size = 155031, upload-time = "2024-09-21T13:40:22.491Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766, upload-time = "2024-09-21T13:40:20.188Z" },
]
[[package]]
name = "black"
version = "25.1.0"
@@ -142,6 +163,25 @@ filecache = [
{ name = "filelock" },
]
[[package]]
name = "celery"
version = "5.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "billiard" },
{ name = "click" },
{ name = "click-didyoumean" },
{ name = "click-plugins" },
{ name = "click-repl" },
{ name = "kombu" },
{ name = "python-dateutil" },
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
@@ -257,6 +297,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "click-didyoumean"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" },
]
[[package]]
name = "click-plugins"
version = "1.1.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" },
]
[[package]]
name = "click-repl"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -337,6 +414,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/5c/3ba7d12e7a79566f97b8f954400926d7b6eb33bcdccc1315a857f200f1f1/crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5", size = 7558, upload-time = "2022-11-02T21:15:12.437Z" },
]
[[package]]
name = "cron-descriptor"
version = "2.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/ec/997bf9ca9392fce1cec2e25241fdd538c50bb405efd103cb1e6119296709/cron_descriptor-2.0.5.tar.gz", hash = "sha256:443ccd21a36a7fc9464a42472199cbdbc0d86b09021af1a8dd1595e4c391d85e", size = 48545, upload-time = "2025-08-26T11:10:24.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/d6/7ebad906dbe4092af6c63f85f30d15544698eb524db53bddfc6a5e010f2b/cron_descriptor-2.0.5-py3-none-any.whl", hash = "sha256:386a1d75c57410cf5cb719e08eefbea2c0c076c4a798aa6d7bf51816112fbbd1", size = 73957, upload-time = "2025-08-26T11:10:23.559Z" },
]
[[package]]
name = "cryptography"
version = "45.0.6"
@@ -441,6 +530,36 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/e6f607b0bad524d227f6e5aaffdb5e2b286f6ab1b4b3151134ae2303c2d6/django_allauth-65.11.1.tar.gz", hash = "sha256:e95d5234cccaf92273d315e1393cc4626cb88a19d66a1bf0e81f89f7958cfa06", size = 1915592, upload-time = "2025-08-27T18:05:05.581Z" }
[[package]]
name = "django-celery-beat"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "celery" },
{ name = "cron-descriptor" },
{ name = "django" },
{ name = "django-timezone-field" },
{ name = "python-crontab" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" },
]
[[package]]
name = "django-celery-results"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "celery" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/da/70f0f3c5364735344c4bc89e53413bcaae95b4fc1de4e98a7a3b9fb70c88/django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73", size = 38351, upload-time = "2025-04-10T08:23:49.965Z" },
]
[[package]]
name = "django-cleanup"
version = "9.0.0"
@@ -681,6 +800,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/1a/1c15852b3002929ed08992aeaaea703c43a43345dc19a09fd457593f52a6/django_tailwind_cli-4.3.0-py3-none-any.whl", hash = "sha256:0ff7d7374a390e63cba77894a13de2bf8721320a5bad97361cb14e160cc824b5", size = 29704, upload-time = "2025-07-12T20:33:00.242Z" },
]
[[package]]
name = "django-timezone-field"
version = "7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/5b/0dbe271fef3c2274b83dbcb1b19fa3dacf1f7e542382819294644e78ea8b/django_timezone_field-7.1.tar.gz", hash = "sha256:b3ef409d88a2718b566fabe10ea996f2838bc72b22d3a2900c0aa905c761380c", size = 13727, upload-time = "2025-01-11T17:49:54.486Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/09/7a808392a751a24ffa62bec00e3085a9c1a151d728c323a5bab229ea0e58/django_timezone_field-7.1-py3-none-any.whl", hash = "sha256:93914713ed882f5bccda080eda388f7006349f25930b6122e9b07bf8db49c4b4", size = 13177, upload-time = "2025-01-11T17:49:52.142Z" },
]
[[package]]
name = "django-typer"
version = "3.2.2"
@@ -1058,6 +1189,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" },
]
[[package]]
name = "kombu"
version = "5.5.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "amqp" },
{ name = "packaging" },
{ name = "tzdata" },
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
@@ -1281,21 +1427,21 @@ wheels = [
[[package]]
name = "playwright"
version = "1.54.0"
version = "1.55.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1348,6 +1494,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/f1/fb218aebd29bca5c506230201c346881ae9b43de7bbb21a68dc648e972b3/poetry_core-2.1.3-py3-none-any.whl", hash = "sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771", size = 332607, upload-time = "2025-05-04T12:43:09.814Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "psutil"
version = "7.0.0"
@@ -1559,6 +1717,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/96/5f8a4545d783674f3de33f0ebc4db16cc76ce77a4c404d284f43f09125e3/pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2", size = 16618, upload-time = "2025-01-31T11:06:08.075Z" },
]
[[package]]
name = "python-crontab"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-decouple"
version = "3.8"
@@ -1827,28 +2006,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.12.10"
version = "0.12.11"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1961,6 +2140,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "black" },
{ name = "celery" },
{ name = "channels" },
{ name = "channels-redis" },
{ name = "coverage" },
@@ -1969,6 +2149,8 @@ dependencies = [
{ name = "dj-rest-auth" },
{ name = "django" },
{ name = "django-allauth" },
{ name = "django-celery-beat" },
{ name = "django-celery-results" },
{ name = "django-cleanup" },
{ name = "django-cloudflare-images" },
{ name = "django-cors-headers" },
@@ -2027,6 +2209,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "black", specifier = ">=24.1.0" },
{ name = "celery", specifier = ">=5.5.3" },
{ name = "channels", specifier = ">=4.2.0" },
{ name = "channels-redis", specifier = ">=4.2.1" },
{ name = "coverage", specifier = ">=7.9.1" },
@@ -2035,6 +2218,8 @@ requires-dist = [
{ name = "dj-rest-auth", specifier = ">=7.0.0" },
{ name = "django", specifier = ">=5.0" },
{ name = "django-allauth", specifier = ">=0.60.1" },
{ name = "django-celery-beat", specifier = ">=2.8.1" },
{ name = "django-celery-results", specifier = ">=2.6.0" },
{ name = "django-cleanup", specifier = ">=8.0.0" },
{ name = "django-cloudflare-images", specifier = ">=0.6.0" },
{ name = "django-cors-headers", specifier = ">=4.3.1" },
@@ -2200,6 +2385,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "vine"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
]
[[package]]
name = "virtualenv"
version = "20.32.0"
@@ -2214,6 +2408,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.3"

View File

@@ -1,97 +1,307 @@
# Active Context
c# Active Context
## Current Focus
- **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
- **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
- **COMPLETED: New Content API Field Updates**: Successfully updated the "newly_opened" API response to replace "location" field with "park" and "date_opened" fields
- **COMPLETED: Celery Integration for Trending Content**: Successfully implemented Celery asynchronous task processing for trending content calculations with Redis backend
- **COMPLETED: Manual Trigger Endpoint for Trending Content**: Successfully implemented admin-only POST endpoint to manually trigger trending content calculations
- **COMPLETED: URL Fields in Trending and New Content Endpoints**: Successfully added url fields to all trending and new content API responses for frontend navigation
- **COMPLETED: Park URL Optimization**: Successfully optimized park URL usage to use `ride.park.url` instead of redundant `ride.park_url` field for better data consistency
- **Features Implemented**:
- **RideModel API Directory Structure**: Moved files from `backend/apps/api/v1/ride_models/` to `backend/apps/api/v1/rides/manufacturers/` to match nested URL organization
- **RideModel API Reorganization**: Nested endpoints under rides/manufacturers, manufacturer-scoped slugs, integrated with ride creation/editing, removed top-level endpoint
- **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
- **Celery Integration**: Asynchronous trending content calculation, Redis broker configuration, real database-driven responses replacing mock data
- **Manual Trigger Endpoint**: Admin-only POST /api/v1/trending/calculate/ endpoint with task ID responses and proper error handling
## Recent Changes
**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 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 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
**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
- **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
**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
**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.)
**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
**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
**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()
**Celery Integration for Trending Content - COMPLETED:**
- **Implemented**: Complete Celery integration for asynchronous trending content calculations
- **Files Created/Modified**:
- `backend/config/celery.py` - Celery configuration with Redis broker and result backend
- `backend/thrillwiki/celery.py` - Celery app initialization and autodiscovery
- `backend/apps/core/tasks/__init__.py` - Tasks package initialization
- `backend/apps/core/tasks/trending.py` - Celery tasks for trending and new content calculation
- `backend/apps/core/services/trending_service.py` - Updated to use Celery tasks and return proper field structure
- `backend/apps/api/v1/views/trending.py` - Removed mock data, integrated with Celery-powered service
- **Database Migrations**: Applied Celery database tables successfully
- **Field Structure Updates**: Updated "newly_opened" response to include "park" and "date_opened" fields instead of "location"
- **Mock Data Removal**: Completely removed all mock data from trending endpoints, now using real database queries
- **Redis Integration**: Configured Redis as Celery broker and result backend for task processing
- **Task Processing**: Asynchronous calculation of trending content with proper caching and performance optimization
**Manual Trigger Endpoint for Trending Content - COMPLETED:**
- **Implemented**: Admin-only POST endpoint to manually trigger trending content calculations
- **Files Modified**:
- `backend/apps/api/v1/views/trending.py` - Added TriggerTrendingCalculationAPIView with admin permissions
- `backend/apps/api/v1/urls.py` - Added URL routing for manual trigger endpoint
- `backend/apps/api/v1/views/__init__.py` - Added new view to exports
- `docs/frontend.md` - Updated with comprehensive endpoint documentation
- **Endpoint**: POST `/api/v1/trending/calculate/` - Triggers both trending and new content calculation tasks
- **Permissions**: Admin-only access (IsAdminUser permission class)
- **Response**: Returns task IDs and estimated completion times for both triggered tasks
- **Error Handling**: Proper error responses for failed task triggers and unauthorized access
**Technical Implementation:**
- **Stats Endpoint**: GET `/api/v1/stats/` - Returns comprehensive platform statistics
- **Maps Endpoints**:
- 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
## Active Files
### 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
### 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
### Celery Integration Files
- `backend/config/celery.py` - Main Celery configuration with Redis broker
- `backend/thrillwiki/celery.py` - Celery app initialization and task autodiscovery
- `backend/apps/core/tasks/__init__.py` - Tasks package initialization
- `backend/apps/core/tasks/trending.py` - Trending content calculation tasks
- `backend/apps/core/services/trending_service.py` - Updated service using Celery tasks
- `backend/apps/api/v1/views/trending.py` - Updated views without mock data, includes manual trigger endpoint
- `backend/apps/api/v1/urls.py` - Updated with manual trigger endpoint routing
- `backend/apps/api/v1/views/__init__.py` - Updated exports for new trigger view
- `docs/frontend.md` - Updated with manual trigger endpoint documentation
## Permanent Rules Established
**CREATED**: `cline_docs/permanent_rules.md` - Permanent development rules that must be followed in all future work.
**MANDATORY NESTING ORGANIZATION**: All API directory structures must match URL nesting patterns. No exceptions.
**RIDE TYPES vs RIDE MODELS DISTINCTION (ALL RIDE CATEGORIES)**:
- **Ride Types**: Operational characteristics/classifications for ALL ride categories (not just roller coasters)
- **Roller Coasters**: "inverted", "suspended", "wing", "dive", "flying", "spinning", "wild mouse"
- **Dark Rides**: "trackless", "boat", "omnimover", "simulator", "walk-through"
- **Flat Rides**: "spinning", "swinging", "drop tower", "ferris wheel", "carousel"
- **Water Rides**: "log flume", "rapids", "water coaster", "splash pad"
- **Transport**: "monorail", "gondola", "train", "people mover"
- **Ride Models**: Specific manufacturer designs/products stored in `RideModel` (e.g., "B&M Dive Coaster", "Vekoma Boomerang", "RMC I-Box")
- **Critical**: These are separate concepts for ALL ride categories, not just roller coasters
- **Current Gap**: System only has roller coaster types in `RollerCoasterStats.roller_coaster_type` - needs extension to all categories
- Individual ride installations reference both: the `RideModel` (what specific design) and the type classification (how it operates)
## Next Steps
1. 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
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
## Current Development State
- 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
- Django backend with comprehensive stats API
- Stats endpoint fully functional at `/api/v1/stats/`
- Server running on port 8000
- All middleware issues resolved
## Testing Requirements
- Verify all moderation workflows
- Test submission review process
- Validate user role permissions
- Check notification systems
## 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
- **Celery Integration**: ✅ Successfully implemented and tested
- **Configuration**: Redis broker configured and working
- **Tasks**: Trending content calculation tasks implemented
- **Database**: Celery tables created via migrations
- **API Response**: "newly_opened" now returns correct structure with "park" and "date_opened" fields
- **Mock Data**: Completely removed from all trending endpoints
- **Real Data**: All responses now use actual database queries
- **Manual Trigger**: POST `/api/v1/trending/calculate/` endpoint implemented with admin permissions
- **Task Management**: Returns task IDs for monitoring asynchronous calculations
## 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)
## 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"
}
```

View File

@@ -0,0 +1,46 @@
# Permanent Development Rules
## API Organization Rules
### MANDATORY NESTING ORGANIZATION
All API directory structures MUST match URL nesting patterns. No exceptions. If URLs are nested like `/api/v1/rides/manufacturers/<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.

6
cookies.txt Normal file
View File

@@ -0,0 +1,6 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1757625948 sessionid 76lmsjx6m9rkatknfi3w70yam2lw3rru
localhost FALSE / FALSE 1787865948 csrftoken b3mRLXY7YHQnE2x6LewKk5VVHZTieRFk

File diff suppressed because it is too large Load Diff

420
docs/frontend.md Normal file
View File

@@ -0,0 +1,420 @@
# ThrillWiki Frontend API Documentation
This document provides comprehensive documentation for frontend developers on how to integrate with the ThrillWiki API endpoints.
## Base URL
```
http://localhost:8000/api/v1/
```
## Authentication
Most endpoints are publicly accessible. Admin endpoints require authentication.
## Content Discovery Endpoints
### Trending Content
Get trending parks and rides based on view counts, ratings, and recency.
**Endpoint:** `GET /trending/content/`
**Parameters:**
- `limit` (optional): Number of trending items to return (default: 20, max: 100)
- `timeframe` (optional): Timeframe for trending calculation - "day", "week", "month" (default: "week")
**Response Format:**
```json
{
"trending_rides": [
{
"id": 137,
"name": "Steel Vengeance",
"park": "Cedar Point",
"category": "ride",
"rating": 4.8,
"rank": 1,
"views": 15234,
"views_change": "+25%",
"slug": "steel-vengeance",
"date_opened": "2018-05-05",
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
"park_url": "https://thrillwiki.com/parks/cedar-point/",
"card_image": "https://media.thrillwiki.com/rides/steel-vengeance-card.jpg"
}
],
"trending_parks": [
{
"id": 1,
"name": "Cedar Point",
"park": "Cedar Point",
"category": "park",
"rating": 4.6,
"rank": 1,
"views": 45678,
"views_change": "+12%",
"slug": "cedar-point",
"date_opened": "1870-01-01",
"url": "https://thrillwiki.com/parks/cedar-point/",
"card_image": "https://media.thrillwiki.com/parks/cedar-point-card.jpg",
"city": "Sandusky",
"state": "Ohio",
"country": "USA",
"primary_company": "Cedar Fair"
}
],
"latest_reviews": []
}
```
### New Content
Get recently added parks and rides.
**Endpoint:** `GET /trending/new/`
**Parameters:**
- `limit` (optional): Number of new items to return (default: 20, max: 100)
- `days` (optional): Number of days to look back for new content (default: 30, max: 365)
**Response Format:**
```json
{
"recently_added": [
{
"id": 137,
"name": "Steel Vengeance",
"park": "Cedar Point",
"category": "ride",
"date_added": "2018-05-05",
"date_opened": "2018-05-05",
"slug": "steel-vengeance",
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
"park_url": "https://thrillwiki.com/parks/cedar-point/",
"card_image": "https://media.thrillwiki.com/rides/steel-vengeance-card.jpg"
},
{
"id": 42,
"name": "Dollywood",
"park": "Dollywood",
"category": "park",
"date_added": "2018-05-01",
"date_opened": "1986-05-03",
"slug": "dollywood",
"url": "https://thrillwiki.com/parks/dollywood/",
"card_image": "https://media.thrillwiki.com/parks/dollywood-card.jpg",
"city": "Pigeon Forge",
"state": "Tennessee",
"country": "USA",
"primary_company": "Dollywood Company"
}
],
"newly_opened": [
{
"id": 136,
"name": "Time Traveler",
"park": "Silver Dollar City",
"category": "ride",
"date_added": "2018-04-28",
"date_opened": "2018-04-28",
"slug": "time-traveler",
"url": "https://thrillwiki.com/parks/silver-dollar-city/rides/time-traveler/",
"park_url": "https://thrillwiki.com/parks/silver-dollar-city/",
"card_image": "https://media.thrillwiki.com/rides/time-traveler-card.jpg"
}
],
"upcoming": []
}
```
**Key Changes:**
- **REMOVED:** `location` field from all trending and new content responses
- **ADDED:** `park` field - shows the park name for both parks and rides
- **ADDED:** `date_opened` field - shows when the park/ride originally opened
### Trigger Content Calculation (Admin Only)
Manually trigger the calculation of trending and new content.
**Endpoint:** `POST /trending/calculate/`
**Authentication:** Admin access required
**Response Format:**
```json
{
"message": "Trending content calculation completed",
"trending_completed": true,
"new_content_completed": true,
"completion_time": "2025-08-28 16:41:42",
"trending_output": "Successfully calculated 50 trending items for all",
"new_content_output": "Successfully calculated 50 new items for all"
}
```
## Data Field Descriptions
### Common Fields
- `id`: Unique identifier for the item
- `name`: Display name of the park or ride
- `park`: Name of the park (for rides, this is the parent park; for parks, this is the park itself)
- `category`: Type of content ("park" or "ride")
- `slug`: URL-friendly identifier
- `date_opened`: ISO date string of when the park/ride originally opened (YYYY-MM-DD format)
- `url`: Frontend URL for direct navigation to the item's detail page
- `card_image`: URL to the card image for display in lists and grids (available for both parks and rides)
### Park-Specific Fields
- `city`: City where the park is located (shortened format)
- `state`: State/province where the park is located (shortened format)
- `country`: Country where the park is located (shortened format)
- `primary_company`: Name of the primary operating company for the park
### Ride-Specific Fields
- `park_url`: Frontend URL for the ride's parent park
### Trending-Specific Fields
- `rating`: Average user rating (0.0 to 10.0)
- `rank`: Position in trending list (1-based)
- `views`: Current view count
- `views_change`: Percentage change in views (e.g., "+25%")
### New Content-Specific Fields
- `date_added`: ISO date string of when the item was added to the database (YYYY-MM-DD format)
## Implementation Notes
### Content Categorization
The API automatically categorizes new content based on dates:
- **Recently Added**: Items added to the database in the last 30 days
- **Newly Opened**: Items that opened in the last year
- **Upcoming**: Future openings (currently empty, reserved for future use)
### Caching
- Trending content is cached for 24 hours
- New content is cached for 30 minutes
- Use the admin trigger endpoint to force cache refresh
### Error Handling
All endpoints return standard HTTP status codes:
- `200`: Success
- `400`: Bad request (invalid parameters)
- `403`: Forbidden (admin endpoints only)
- `500`: Internal server error
### Rate Limiting
No rate limiting is currently implemented, but it may be added in the future.
## Migration from Previous API Format
If you were previously using the API with `location` fields, update your frontend code:
**Before:**
```javascript
const ride = {
name: "Steel Vengeance",
location: "Cedar Point", // OLD FIELD
category: "ride"
};
```
**After:**
```javascript
const ride = {
name: "Steel Vengeance",
park: "Cedar Point", // NEW FIELD
category: "ride",
date_opened: "2018-05-05" // NEW FIELD
};
```
## Backend Architecture Changes
The trending system has been migrated from Celery-based async processing to Django management commands for better reliability and simpler deployment:
### Management Commands
- `python manage.py calculate_trending` - Calculate trending content
- `python manage.py calculate_new_content` - Calculate new content
### Direct Calculation
The API now uses direct calculation instead of async tasks, providing immediate results while maintaining performance through caching.
## URL Fields for Frontend Navigation
All API responses now include dynamically generated `url` fields that provide direct links to the frontend pages for each entity. These URLs are generated based on the configured `FRONTEND_DOMAIN` setting.
### URL Patterns
- **Parks**: `https://domain.com/parks/{park-slug}/`
- **Rides**: `https://domain.com/parks/{park-slug}/rides/{ride-slug}/`
- **Ride Models**: `https://domain.com/rides/manufacturers/{manufacturer-slug}/{model-slug}/`
- **Companies (Operators)**: `https://domain.com/parks/operators/{operator-slug}/`
- **Companies (Property Owners)**: `https://domain.com/parks/owners/{owner-slug}/`
- **Companies (Manufacturers)**: `https://domain.com/rides/manufacturers/{manufacturer-slug}/`
- **Companies (Designers)**: `https://domain.com/rides/designers/{designer-slug}/`
### Domain Separation Rules
**CRITICAL**: Company URLs follow strict domain separation:
- **Parks Domain**: OPERATOR and PROPERTY_OWNER roles generate URLs under `/parks/`
- **Rides Domain**: MANUFACTURER and DESIGNER roles generate URLs under `/rides/`
- Companies with multiple roles use their primary role (first in the roles array) for URL generation
- URLs are auto-generated when entities are saved and stored in the database
### Example Response with URL Fields
```json
{
"id": 1,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"park": {
"id": 1,
"name": "Cedar Point",
"slug": "cedar-point",
"url": "https://thrillwiki.com/parks/cedar-point/"
},
"url": "https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/",
"manufacturer": {
"id": 1,
"name": "Rocky Mountain Construction",
"slug": "rocky-mountain-construction",
"url": "https://thrillwiki.com/rides/manufacturers/rocky-mountain-construction/"
}
}
```
## Example Usage
### Fetch Trending Content
```javascript
const response = await fetch('/api/v1/trending/content/?limit=10');
const data = await response.json();
// Display trending rides with clickable links
data.trending_rides.forEach(ride => {
console.log(`${ride.name} at ${ride.park} - opened ${ride.date_opened}`);
console.log(`Visit: ${ride.url}`);
});
```
### Fetch New Content
```javascript
const response = await fetch('/api/v1/trending/new/?limit=5&days=7');
const data = await response.json();
// Display newly opened attractions
data.newly_opened.forEach(item => {
console.log(`${item.name} at ${item.park} - opened ${item.date_opened}`);
});
```
### Admin: Trigger Calculation
```javascript
const response = await fetch('/api/v1/trending/calculate/', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_ADMIN_TOKEN',
'Content-Type': 'application/json'
}
});
const result = await response.json();
console.log(result.message);
## Reviews Endpoints
### Latest Reviews
Get the latest reviews from both parks and rides across the platform.
**Endpoint:** `GET /reviews/latest/`
**Parameters:**
- `limit` (optional): Number of reviews to return (default: 20, max: 100)
**Response Format:**
```json
{
"count": 15,
"results": [
{
"id": 42,
"type": "ride",
"title": "Amazing coaster experience!",
"content_snippet": "This ride was absolutely incredible. The airtime was perfect and the inversions were smooth...",
"rating": 9,
"created_at": "2025-08-28T21:30:00Z",
"user": {
"username": "coaster_fan_2024",
"display_name": "Coaster Fan",
"avatar_url": "https://media.thrillwiki.com/avatars/user123.jpg"
},
"subject_name": "Steel Vengeance",
"subject_slug": "steel-vengeance",
"subject_url": "/parks/cedar-point/rides/steel-vengeance/",
"park_name": "Cedar Point",
"park_slug": "cedar-point",
"park_url": "/parks/cedar-point/"
},
{
"id": 38,
"type": "park",
"title": "Great family park",
"content_snippet": "Had a wonderful time with the family. The park was clean, staff was friendly, and there were rides for all ages...",
"rating": 8,
"created_at": "2025-08-28T20:15:00Z",
"user": {
"username": "family_fun",
"display_name": "Family Fun",
"avatar_url": "/static/images/default-avatar.png"
},
"subject_name": "Dollywood",
"subject_slug": "dollywood",
"subject_url": "/parks/dollywood/",
"park_name": null,
"park_slug": null,
"park_url": null
}
]
}
```
**Field Descriptions:**
- `id`: Unique review identifier
- `type`: Review type - "park" or "ride"
- `title`: Review title/headline
- `content_snippet`: Truncated review content (max 150 characters with smart word breaking)
- `rating`: User rating from 1-10
- `created_at`: ISO timestamp when review was created
- `user`: User information object
- `username`: User's unique username
- `display_name`: User's display name (falls back to username if not set)
- `avatar_url`: URL to user's avatar image (uses default if not set)
- `subject_name`: Name of the reviewed item (park or ride)
- `subject_slug`: URL slug of the reviewed item
- `subject_url`: Frontend URL to the reviewed item's detail page
- `park_name`: For ride reviews, the name of the parent park (null for park reviews)
- `park_slug`: For ride reviews, the slug of the parent park (null for park reviews)
- `park_url`: For ride reviews, the URL to the parent park (null for park reviews)
**Authentication:** None required (public endpoint)
**Example Usage:**
```javascript
// Fetch latest 10 reviews
const response = await fetch('/api/v1/reviews/latest/?limit=10');
const data = await response.json();
// Display reviews
data.results.forEach(review => {
console.log(`${review.user.display_name} rated ${review.subject_name}: ${review.rating}/10`);
console.log(`"${review.title}" - ${review.content_snippet}`);
if (review.type === 'ride') {
console.log(`Ride at ${review.park_name}`);
}
});
```
**Error Responses:**
- `400 Bad Request`: Invalid limit parameter
- `500 Internal Server Error`: Database or server error
**Notes:**
- Reviews are filtered to only show published reviews (`is_published=True`)
- Results are sorted by creation date (newest first)
- Content snippets are intelligently truncated at word boundaries
- Avatar URLs fall back to default avatar if user hasn't uploaded one
- The endpoint combines reviews from both parks and rides into a single chronological feed

View File

@@ -1,8 +0,0 @@
[*.{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

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

View File

@@ -1,6 +0,0 @@
# 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

View File

@@ -1,6 +0,0 @@
# 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

View File

@@ -1,3 +0,0 @@
* 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
View File

@@ -1,36 +0,0 @@
# 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

View File

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

View File

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

View File

@@ -1,384 +0,0 @@
# 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**

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +0,0 @@
/* 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']
}
}

View File

@@ -1,20 +0,0 @@
{
"$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

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

View File

@@ -1,8 +0,0 @@
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
View File

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

View File

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

View File

@@ -1,13 +0,0 @@
<!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>

View File

@@ -1,76 +0,0 @@
{
"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

@@ -1,110 +0,0 @@
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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,317 +0,0 @@
<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

@@ -1,11 +0,0 @@
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

@@ -1,387 +0,0 @@
<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

@@ -1,236 +0,0 @@
<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>

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