Files
thrillwiki_django_no_react/backend/apps/api/v1/rides/views.py
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

427 lines
15 KiB
Python

"""
Full-featured Rides API views for ThrillWiki API v1.
This module implements a "full fat" set of endpoints:
- List / Create: GET /rides/ POST /rides/
- Retrieve / Update / Delete: GET /rides/{pk}/ PATCH/PUT/DELETE
- Filter options: GET /rides/filter-options/
- Company search: GET /rides/search/companies/?q=...
- Ride model search: GET /rides/search-ride-models/?q=...
- Search suggestions: GET /rides/search-suggestions/?q=...
Notes:
- These views try to use real Django models if available. If the domain models/services
are not present, they return a clear 501 response explaining what to wire up.
"""
from typing import Any
from rest_framework import status, permissions
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Reuse existing serializers where possible
from apps.api.v1.serializers.rides import (
RideListOutputSerializer,
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
RideImageSettingsInputSerializer,
)
# Attempt to import model-level helpers; fall back gracefully if not present.
try:
from apps.rides.models import Ride, RideModel, Company as RideCompany # type: ignore
from apps.parks.models import Park, Company as ParkCompany # type: ignore
MODELS_AVAILABLE = True
except Exception:
Ride = None # type: ignore
RideModel = None # type: ignore
RideCompany = None # type: ignore
Park = None # type: ignore
ParkCompany = None # type: ignore
MODELS_AVAILABLE = False
# Attempt to import ModelChoices to return filter options
try:
from apps.api.v1.serializers.shared import ModelChoices # type: ignore
HAVE_MODELCHOICES = True
except Exception:
ModelChoices = None # type: ignore
HAVE_MODELCHOICES = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 1000
# --- Ride list & create -----------------------------------------------------
class RideListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List rides with filtering and pagination",
description="List rides with basic filtering and pagination.",
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
],
responses={200: RideListOutputSerializer(many=True)},
tags=["Rides"],
)
def get(self, request: Request) -> Response:
"""List rides with basic filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride listing is not available because domain models are not imported. "
"Implement apps.rides.models.Ride (and related managers) to enable listing."
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
qs = Ride.objects.all().select_related("park", "manufacturer", "designer") # type: ignore
# Basic filters
q = request.query_params.get("search")
if q:
qs = qs.filter(name__icontains=q) # simplistic search
park_slug = request.query_params.get("park_slug")
if park_slug:
qs = qs.filter(park__slug=park_slug) # type: ignore
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = RideListOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
@extend_schema(
summary="Create a new ride",
description="Create a new ride.",
responses={201: RideDetailOutputSerializer()},
tags=["Rides"],
)
def post(self, request: Request) -> Response:
"""Create a new ride."""
serializer_in = RideCreateInputSerializer(data=request.data)
serializer_in.is_valid(raise_exception=True)
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride creation is not available because domain models are not imported. "
"Implement apps.rides.models.Ride and necessary create logic."
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
validated = serializer_in.validated_data
# Minimal create logic using model fields if available.
try:
park = Park.objects.get(id=validated["park_id"]) # type: ignore
except Park.DoesNotExist: # type: ignore
raise NotFound("Park not found")
ride = Ride.objects.create( # type: ignore
name=validated["name"],
description=validated.get("description", ""),
category=validated.get("category"),
status=validated.get("status"),
park=park,
park_area_id=validated.get("park_area_id"),
opening_date=validated.get("opening_date"),
closing_date=validated.get("closing_date"),
status_since=validated.get("status_since"),
min_height_in=validated.get("min_height_in"),
max_height_in=validated.get("max_height_in"),
capacity_per_hour=validated.get("capacity_per_hour"),
ride_duration_seconds=validated.get("ride_duration_seconds"),
)
# Optional foreign keys
if validated.get("manufacturer_id"):
try:
ride.manufacturer_id = validated["manufacturer_id"]
ride.save()
except Exception:
# ignore if foreign key constraints or models not present
pass
out_serializer = RideDetailOutputSerializer(ride, context={"request": request})
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
# --- Ride retrieve / update / delete ---------------------------------------
@extend_schema(
summary="Retrieve, update or delete a ride",
responses={200: RideDetailOutputSerializer()},
tags=["Rides"],
)
class RideDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_ride_or_404(self, pk: int) -> Any:
if not MODELS_AVAILABLE:
raise NotFound(
"Ride detail is not available because domain models are not imported. "
"Implement apps.rides.models.Ride to enable detail endpoints."
)
try:
return Ride.objects.select_related("park").get(pk=pk) # type: ignore
except Ride.DoesNotExist: # type: ignore
raise NotFound("Ride not found")
def get(self, request: Request, pk: int) -> Response:
ride = self._get_ride_or_404(pk)
serializer = RideDetailOutputSerializer(ride, context={"request": request})
return Response(serializer.data)
def patch(self, request: Request, pk: int) -> Response:
ride = self._get_ride_or_404(pk)
serializer_in = RideUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride update is not available because domain models are not imported."
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
for key, value in serializer_in.validated_data.items():
setattr(ride, key, value)
ride.save()
serializer = RideDetailOutputSerializer(ride, context={"request": request})
return Response(serializer.data)
def put(self, request: Request, pk: int) -> Response:
# Full replace - reuse patch behavior for simplicity
return self.patch(request, pk)
def delete(self, request: Request, pk: int) -> Response:
if not MODELS_AVAILABLE:
return Response(
{
"detail": "Ride delete is not available because domain models are not imported."
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
ride = self._get_ride_or_404(pk)
ride.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
# --- Filter options ---------------------------------------------------------
@extend_schema(
summary="Get filter options for rides",
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
"""Return static/dynamic filter options used by the frontend."""
# Try to use ModelChoices if available
if HAVE_MODELCHOICES and ModelChoices is not None:
try:
data = {
"categories": ModelChoices.get_ride_category_choices(),
"statuses": ModelChoices.get_ride_status_choices(),
"post_closing_statuses": ModelChoices.get_ride_post_closing_choices(),
"ordering_options": [
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
],
}
return Response(data)
except Exception:
# fallthrough to fallback
pass
# Fallback minimal options
return Response(
{
"categories": ["ROLLER_COASTER", "WATER_RIDE", "FLAT"],
"statuses": ["OPERATING", "CLOSED", "MAINTENANCE"],
"ordering_options": ["name", "-name", "opening_date", "-opening_date"],
}
)
# --- Company search (autocomplete) -----------------------------------------
@extend_schema(
summary="Search companies (manufacturers/designers) for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
class CompanySearchAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
q = request.query_params.get("q", "")
if not q:
return Response([], status=status.HTTP_200_OK)
if RideCompany is None:
# Provide helpful placeholder structure
return Response(
[
{"id": 1, "name": "Rocky Mountain Construction", "slug": "rmc"},
{"id": 2, "name": "Bolliger & Mabillard", "slug": "b&m"},
]
)
qs = RideCompany.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
]
return Response(results)
# --- Ride model search (autocomplete) --------------------------------------
@extend_schema(
summary="Search ride models for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
tags=["Rides"],
)
class RideModelSearchAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
q = request.query_params.get("q", "")
if not q:
return Response([], status=status.HTTP_200_OK)
if RideModel is None:
return Response(
[
{"id": 1, "name": "I-Box (RMC)", "category": "ROLLER_COASTER"},
{
"id": 2,
"name": "Hyper Coaster Model X",
"category": "ROLLER_COASTER",
},
]
)
qs = RideModel.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": m.id, "name": m.name, "category": getattr(m, "category", "")}
for m in qs
]
return Response(results)
# --- Search suggestions -----------------------------------------------------
@extend_schema(
summary="Search suggestions for ride search box",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
)
],
tags=["Rides"],
)
class RideSearchSuggestionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
q = request.query_params.get("q", "")
if not q:
return Response([], status=status.HTTP_200_OK)
# Very small suggestion implementation: look in ride names if available
if MODELS_AVAILABLE and Ride is not None:
qs = Ride.objects.filter(name__icontains=q).values_list("name", flat=True)[
:10
] # type: ignore
return Response([{"suggestion": name} for name in qs])
# Fallback suggestions
fallback = [
{"suggestion": f"{q} coaster"},
{"suggestion": f"{q} ride"},
{"suggestion": f"{q} park"},
]
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 --------------------------------------------------