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.
This commit is contained in:
pacnpal
2025-08-28 15:12:39 -04:00
parent 715e284b3e
commit 67db0aa46e
34 changed files with 6002 additions and 894 deletions

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"
@@ -50,6 +51,9 @@ urlpatterns = [
),
# 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

@@ -20,7 +20,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
from rest_framework.exceptions import NotFound, ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -30,6 +30,7 @@ from apps.api.v1.serializers.rides import (
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
RideImageSettingsInputSerializer,
)
# Attempt to import model-level helpers; fall back gracefully if not present.
@@ -380,4 +381,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 --------------------------------------------------