Refactor API structure and add comprehensive user management features

- Restructure API v1 with improved serializers organization
- Add user deletion requests and moderation queue system
- Implement bulk moderation operations and permissions
- Add user profile enhancements with display names and avatars
- Expand ride and park API endpoints with better filtering
- Add manufacturer API with detailed ride relationships
- Improve authentication flows and error handling
- Update frontend documentation and API specifications
This commit is contained in:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

View File

@@ -29,37 +29,51 @@ 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"),
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"),
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"),
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"),
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"),
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

@@ -13,7 +13,7 @@ This module implements comprehensive endpoints for ride model management:
"""
from typing import Any
from datetime import datetime, timedelta
from datetime import timedelta
from rest_framework import status, permissions
from rest_framework.views import APIView
@@ -36,25 +36,31 @@ from apps.api.v1.serializers.ride_models import (
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 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 (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
)
from apps.rides.models.rides import Company
MODELS_AVAILABLE = True
except ImportError:
RideModel = None
@@ -82,7 +88,10 @@ class RideModelListCreateAPIView(APIView):
description="List ride models with comprehensive filtering and pagination.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
@@ -97,10 +106,14 @@ class RideModelListCreateAPIView(APIView):
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="target_market", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
name="target_market",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
),
OpenApiParameter(
name="is_discontinued", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL
name="is_discontinued",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
),
],
responses={200: RideModelListOutputSerializer(many=True)},
@@ -123,7 +136,11 @@ class RideModelListCreateAPIView(APIView):
except Company.DoesNotExist:
raise NotFound("Manufacturer not found")
qs = RideModel.objects.filter(manufacturer=manufacturer).select_related("manufacturer").prefetch_related("photos")
qs = (
RideModel.objects.filter(manufacturer=manufacturer)
.select_related("manufacturer")
.prefetch_related("photos")
)
# Apply filters
filter_serializer = RideModelFilterInputSerializer(data=request.query_params)
@@ -134,9 +151,9 @@ class RideModelListCreateAPIView(APIView):
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)
Q(name__icontains=search_term)
| Q(description__icontains=search_term)
| Q(manufacturer__name__icontains=search_term)
)
# Category filter
@@ -160,10 +177,12 @@ class RideModelListCreateAPIView(APIView):
# Year filters
if filters.get("first_installation_year_min"):
qs = qs.filter(
first_installation_year__gte=filters["first_installation_year_min"])
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"])
first_installation_year__lte=filters["first_installation_year_max"]
)
# Installation count filter
if filters.get("min_installations"):
@@ -172,18 +191,22 @@ class RideModelListCreateAPIView(APIView):
# Height filters
if filters.get("min_height_ft"):
qs = qs.filter(
typical_height_range_max_ft__gte=filters["min_height_ft"])
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"])
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"])
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"])
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
)
# Ordering
ordering = filters.get("ordering", "manufacturer__name,name")
@@ -203,7 +226,10 @@ class RideModelListCreateAPIView(APIView):
description="Create a new ride model for a specific manufacturer.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
request=RideModelCreateInputSerializer,
@@ -262,13 +288,17 @@ class RideModelListCreateAPIView(APIView):
class RideModelDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any:
def _get_ride_model_or_404(
self, manufacturer_slug: str, ride_model_slug: str
) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Ride model models not available")
try:
return RideModel.objects.select_related("manufacturer").prefetch_related(
"photos", "variants", "technical_specs"
).get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
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")
@@ -277,16 +307,24 @@ class RideModelDetailAPIView(APIView):
description="Get detailed information about a specific ride model.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
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:
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}
@@ -298,17 +336,25 @@ class RideModelDetailAPIView(APIView):
description="Update a ride model (partial update supported).",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
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:
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)
@@ -331,7 +377,9 @@ class RideModelDetailAPIView(APIView):
)
return Response(serializer.data)
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def put(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
# Full replace - reuse patch behavior for simplicity
return self.patch(request, manufacturer_slug, ride_model_slug)
@@ -340,16 +388,24 @@ class RideModelDetailAPIView(APIView):
description="Delete a ride model.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
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:
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)
@@ -366,7 +422,10 @@ class RideModelSearchAPIView(APIView):
description="Search ride models by name, description, or manufacturer.",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True
name="q",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=True,
)
],
responses={200: RideModelListOutputSerializer(many=True)},
@@ -384,15 +443,15 @@ class RideModelSearchAPIView(APIView):
"id": 1,
"name": "Hyper Coaster",
"manufacturer": {"name": "Bolliger & Mabillard"},
"category": "RC"
"category": "RC",
}
]
)
qs = RideModel.objects.filter(
Q(name__icontains=q) |
Q(description__icontains=q) |
Q(manufacturer__name__icontains=q)
Q(name__icontains=q)
| Q(description__icontains=q)
| Q(manufacturer__name__icontains=q)
).select_related("manufacturer")[:20]
results = [
@@ -426,54 +485,65 @@ class RideModelFilterOptionsAPIView(APIView):
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"}],
})
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")
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()
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()
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"),
],
})
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 ===
@@ -491,69 +561,84 @@ class RideModelStatsAPIView(APIView):
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,
})
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
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()
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"))
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"))
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]
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()
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,
})
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 ===
@@ -592,7 +677,7 @@ class RideModelVariantListCreateAPIView(APIView):
if not MODELS_AVAILABLE:
return Response(
{"detail": "Variants not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
status=status.HTTP_501_NOT_IMPLEMENTED,
)
try:
@@ -653,7 +738,8 @@ class RideModelVariantDetailAPIView(APIView):
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)
data=request.data, partial=True
)
serializer_in.is_valid(raise_exception=True)
for field, value in serializer_in.validated_data.items():
@@ -677,25 +763,30 @@ class RideModelVariantDetailAPIView(APIView):
# 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,42 +5,46 @@ 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 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',
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'
"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'
}
"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",
},
)
]
)
@@ -78,8 +82,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset",
allow_null=True
help_text="Full URL to the Cloudflare Images asset", allow_null=True
)
)
def get_image_url(self, obj):
@@ -91,7 +94,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
@extend_schema_field(
serializers.DictField(
child=serializers.URLField(),
help_text="Available Cloudflare Images variants with their URLs"
help_text="Available Cloudflare Images variants with their URLs",
)
)
def get_image_variants(self, obj):
@@ -101,10 +104,10 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
# 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"
"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

View File

@@ -50,13 +50,18 @@ urlpatterns = [
name="ride-search-suggestions",
),
# Ride model management endpoints - nested under rides/manufacturers
path("manufacturers/<slug:manufacturer_slug>/",
include("apps.api.v1.rides.manufacturers.urls")),
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"),
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

@@ -21,7 +21,7 @@ from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -73,136 +73,202 @@ class RideListCreateAPIView(APIView):
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,
description="Page number for pagination"
name="page",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Page number for pagination",
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Number of results per page (max 1000)"
name="page_size",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Number of results per page (max 1000)",
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Search in ride names and descriptions"
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,
description="Filter by park slug"
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"
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"
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"
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"
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"
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"
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"
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"
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)"
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.)"
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)"
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.)"
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)"
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)"
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"
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"
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"
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"
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"
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"
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"
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"
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"
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"
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)"
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"
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"
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"
name="max_opening_year",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by maximum opening year",
),
OpenApiParameter(
name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph"
name="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)},
@@ -220,17 +286,25 @@ class RideListCreateAPIView(APIView):
)
# Start with base queryset with optimized joins
qs = Ride.objects.all().select_related(
"park", "manufacturer", "designer", "ride_model", "ride_model__manufacturer"
).prefetch_related("coaster_stats") # type: ignore
qs = (
Ride.objects.all()
.select_related(
"park",
"manufacturer",
"designer",
"ride_model",
"ride_model__manufacturer",
)
.prefetch_related("coaster_stats")
) # type: ignore
# Text search
search = request.query_params.get("search")
if search:
qs = qs.filter(
models.Q(name__icontains=search) |
models.Q(description__icontains=search) |
models.Q(park__name__icontains=search)
models.Q(name__icontains=search)
| models.Q(description__icontains=search)
| models.Q(park__name__icontains=search)
)
# Park filters
@@ -292,7 +366,7 @@ class RideListCreateAPIView(APIView):
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
ride_model__manufacturer__slug=manufacturer_slug_for_model,
)
# Rating filters
@@ -422,24 +496,36 @@ class RideListCreateAPIView(APIView):
has_inversions = request.query_params.get("has_inversions")
if has_inversions is not None:
if has_inversions.lower() in ['true', '1', 'yes']:
if has_inversions.lower() in ["true", "1", "yes"]:
qs = qs.filter(coaster_stats__inversions__gt=0)
elif has_inversions.lower() in ['false', '0', 'no']:
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"
"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")
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)
@@ -592,16 +678,24 @@ class FilterOptionsAPIView(APIView):
"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": "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": "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)"},
@@ -611,16 +705,39 @@ class FilterOptionsAPIView(APIView):
],
"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_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"},
"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"},
{
"key": "has_inversions",
"label": "Has Inversions",
"description": "Filter roller coasters with or without inversions",
},
],
}
return Response(data)
@@ -682,8 +799,10 @@ class FilterOptionsAPIView(APIView):
{"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": "-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)"},
@@ -693,16 +812,39 @@ class FilterOptionsAPIView(APIView):
],
"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_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"},
"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"},
{
"key": "has_inversions",
"label": "Has Inversions",
"description": "Filter roller coasters with or without inversions",
},
],
}
)
@@ -853,7 +995,8 @@ class RideImageSettingsAPIView(APIView):
# Return updated ride data
output_serializer = RideDetailOutputSerializer(
ride, context={"request": request})
ride, context={"request": request}
)
return Response(output_serializer.data)