feat: Add PrimeProgress, PrimeSelect, and PrimeSkeleton components with customizable styles and props

- Implemented PrimeProgress component with support for labels, helper text, and various styles (size, variant, color).
- Created PrimeSelect component with dropdown functionality, custom templates, and validation states.
- Developed PrimeSkeleton component for loading placeholders with different shapes and animations.
- Updated index.ts to export new components for easy import.
- Enhanced PrimeVueTest.vue to include tests for new components and their functionalities.
- Introduced a custom ThrillWiki theme for PrimeVue with tailored color schemes and component styles.
- Added ambient type declarations for various components to improve TypeScript support.
This commit is contained in:
pacnpal
2025-08-27 21:00:02 -04:00
parent 6125c4ee44
commit 08a4a2d034
164 changed files with 73094 additions and 11001 deletions

View File

@@ -0,0 +1,409 @@
"""
Ride photo API views for ThrillWiki API v1.
This module contains ride photo ViewSet following the parks pattern for domain consistency.
Enhanced from centralized media API to provide domain-specific ride photo management.
"""
from .serializers import (
RidePhotoOutputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoUpdateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoApprovalInputSerializer,
RidePhotoStatsOutputSerializer,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
import logging
from django.core.exceptions import PermissionDenied
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.rides.models import RidePhoto, Ride
from apps.rides.services.media_service import RideMediaService
from django.contrib.auth import get_user_model
UserModel = get_user_model()
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List ride photos",
description="Retrieve a paginated list of ride photos with filtering capabilities.",
responses={200: RidePhotoListOutputSerializer(many=True)},
tags=["Ride Media"],
),
create=extend_schema(
summary="Upload ride photo",
description="Upload a new photo for a ride. Requires authentication.",
request=RidePhotoCreateInputSerializer,
responses={
201: RidePhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
retrieve=extend_schema(
summary="Get ride photo details",
description="Retrieve detailed information about a specific ride photo.",
responses={
200: RidePhotoOutputSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
update=extend_schema(
summary="Update ride photo",
description="Update ride photo information. Requires authentication and ownership or admin privileges.",
request=RidePhotoUpdateInputSerializer,
responses={
200: RidePhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
partial_update=extend_schema(
summary="Partially update ride photo",
description="Partially update ride photo information. Requires authentication and ownership or admin privileges.",
request=RidePhotoUpdateInputSerializer,
responses={
200: RidePhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
destroy=extend_schema(
summary="Delete ride photo",
description="Delete a ride photo. Requires authentication and ownership or admin privileges.",
responses={
204: None,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
),
)
class RidePhotoViewSet(ModelViewSet):
"""
Enhanced ViewSet for managing ride photos with full feature parity.
Provides CRUD operations for ride photos with proper permission checking.
Uses RideMediaService for business logic operations.
Includes advanced features like bulk approval and statistics.
"""
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_queryset(self): # type: ignore[override]
"""Get photos for the current ride with optimized queries."""
queryset = RidePhoto.objects.select_related(
"ride", "ride__park", "ride__park__operator", "uploaded_by"
)
# If ride_pk is provided in URL kwargs, filter by ride
ride_pk = self.kwargs.get("ride_pk")
if ride_pk:
queryset = queryset.filter(ride_id=ride_pk)
return queryset.order_by("-created_at")
def get_serializer_class(self): # type: ignore[override]
"""Return appropriate serializer based on action."""
if self.action == "list":
return RidePhotoListOutputSerializer
elif self.action == "create":
return RidePhotoCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return RidePhotoUpdateInputSerializer
else:
return RidePhotoOutputSerializer
def perform_create(self, serializer):
"""Create a new ride photo using RideMediaService."""
ride_id = self.kwargs.get("ride_pk")
if not ride_id:
raise ValidationError("Ride ID is required")
try:
ride = Ride.objects.get(pk=ride_id)
except Ride.DoesNotExist:
raise ValidationError("Ride not found")
try:
# Use the service to create the photo with proper business logic
photo = RideMediaService.upload_photo(
ride=ride,
image_file=serializer.validated_data["image"],
user=self.request.user, # type: ignore
caption=serializer.validated_data.get("caption", ""),
alt_text=serializer.validated_data.get("alt_text", ""),
photo_type=serializer.validated_data.get("photo_type", "exterior"),
is_primary=serializer.validated_data.get("is_primary", False),
auto_approve=False, # Default to requiring approval
)
# Set the instance for the serializer response
serializer.instance = photo
except Exception as e:
logger.error(f"Error creating ride photo: {e}")
raise ValidationError(f"Failed to create photo: {str(e)}")
def perform_update(self, serializer):
"""Update ride photo with permission checking."""
instance = self.get_object()
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or getattr(self.request.user, "is_staff", False)
):
raise PermissionDenied("You can only edit your own photos or be an admin.")
# Handle primary photo logic using service
if serializer.validated_data.get("is_primary", False):
try:
RideMediaService.set_primary_photo(ride=instance.ride, photo=instance)
# Remove is_primary from validated_data since service handles it
if "is_primary" in serializer.validated_data:
del serializer.validated_data["is_primary"]
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
raise ValidationError(f"Failed to set primary photo: {str(e)}")
def perform_destroy(self, instance):
"""Delete ride photo with permission checking."""
# Check permissions - allow owner or staff
if not (
self.request.user == instance.uploaded_by
or getattr(self.request.user, "is_staff", False)
):
raise PermissionDenied(
"You can only delete your own photos or be an admin."
)
try:
RideMediaService.delete_photo(
instance, deleted_by=self.request.user # type: ignore
)
except Exception as e:
logger.error(f"Error deleting ride photo: {e}")
raise ValidationError(f"Failed to delete photo: {str(e)}")
@extend_schema(
summary="Set photo as primary",
description="Set this photo as the primary photo for the ride",
responses={
200: OpenApiTypes.OBJECT,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
)
@action(detail=True, methods=["post"])
def set_primary(self, request, **kwargs):
"""Set this photo as the primary photo for the ride."""
photo = self.get_object()
# Check permissions - allow owner or staff
if not (
request.user == photo.uploaded_by
or getattr(request.user, "is_staff", False)
):
raise PermissionDenied(
"You can only modify your own photos or be an admin."
)
try:
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
if success:
# Refresh the photo instance
photo.refresh_from_db()
serializer = self.get_serializer(photo)
return Response(
{
"message": "Photo set as primary successfully",
"photo": serializer.data,
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": "Failed to set primary photo"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
return Response(
{"error": f"Failed to set primary photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
summary="Bulk approve/reject photos",
description="Bulk approve or reject multiple ride photos (admin only)",
request=RidePhotoApprovalInputSerializer,
responses={
200: OpenApiTypes.OBJECT,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
)
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
def bulk_approve(self, request, **kwargs):
"""Bulk approve or reject multiple photos (admin only)."""
if not getattr(request.user, "is_staff", False):
raise PermissionDenied("Only administrators can approve photos.")
serializer = RidePhotoApprovalInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = getattr(serializer, "validated_data", {})
photo_ids = validated_data.get("photo_ids")
approve = validated_data.get("approve")
ride_id = self.kwargs.get("ride_pk")
if photo_ids is None or approve is None:
return Response(
{"error": "Missing required fields: photo_ids and/or approve."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
# Filter photos to only those belonging to this ride (if ride_pk provided)
photos_queryset = RidePhoto.objects.filter(id__in=photo_ids)
if ride_id:
photos_queryset = photos_queryset.filter(ride_id=ride_id)
updated_count = photos_queryset.update(is_approved=approve)
return Response(
{
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
"updated_count": updated_count,
},
status=status.HTTP_200_OK,
)
except Exception as e:
logger.error(f"Error in bulk photo approval: {e}")
return Response(
{"error": f"Failed to update photos: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
summary="Get ride photo statistics",
description="Get photo statistics for the ride",
responses={
200: RidePhotoStatsOutputSerializer,
404: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
)
@action(detail=False, methods=["get"])
def stats(self, request, **kwargs):
"""Get photo statistics for the ride."""
ride_pk = self.kwargs.get("ride_pk")
ride = None
if ride_pk:
try:
ride = Ride.objects.get(pk=ride_pk)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found."},
status=status.HTTP_404_NOT_FOUND,
)
try:
if ride is not None:
stats = RideMediaService.get_photo_stats(ride)
else:
# Global stats across all rides
stats = {
"total_photos": RidePhoto.objects.count(),
"approved_photos": RidePhoto.objects.filter(
is_approved=True
).count(),
"pending_photos": RidePhoto.objects.filter(
is_approved=False
).count(),
"has_primary": False, # Not applicable for global stats
"recent_uploads": RidePhoto.objects.order_by("-created_at")[
:5
].count(),
"by_type": {},
}
serializer = RidePhotoStatsOutputSerializer(stats)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error getting ride photo stats: {e}")
return Response(
{"error": f"Failed to get photo statistics: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Legacy compatibility action using the legacy set_primary logic
@extend_schema(
summary="Set photo as primary (legacy)",
description="Legacy set primary action for backwards compatibility",
responses={
200: OpenApiTypes.OBJECT,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
)
@action(detail=True, methods=["post"])
def set_primary_legacy(self, request, id=None):
"""Legacy set primary action for backwards compatibility."""
photo = self.get_object()
if not (
request.user == photo.uploaded_by
or request.user.has_perm("rides.change_ridephoto")
):
return Response(
{"error": "You do not have permission to edit photos for this ride."},
status=status.HTTP_403_FORBIDDEN,
)
try:
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
if success:
return Response({"message": "Photo set as primary successfully."})
else:
return Response(
{"error": "Failed to set primary photo"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -1,18 +1,157 @@
"""
Serializers for the rides API.
Ride media serializers for ThrillWiki API v1.
This module contains serializers for ride-specific media functionality.
"""
from rest_framework import serializers
from apps.rides.models import Ride, RidePhoto
class RidePhotoSerializer(serializers.ModelSerializer):
"""Serializer for the RidePhoto model."""
class RidePhotoOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride photos."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
file_size = serializers.ReadOnlyField()
dimensions = serializers.ReadOnlyField()
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
ride_name = serializers.CharField(source="ride.name", read_only=True)
park_slug = serializers.CharField(source="ride.park.slug", read_only=True)
park_name = serializers.CharField(source="ride.park.name", read_only=True)
class Meta:
model = RidePhoto
fields = (
fields = [
"id",
"image",
"caption",
"alt_text",
"is_primary",
"is_approved",
"photo_type",
"created_at",
"updated_at",
"date_taken",
"uploaded_by_username",
"file_size",
"dimensions",
"ride_slug",
"ride_name",
"park_slug",
"park_name",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"uploaded_by_username",
"file_size",
"dimensions",
"ride_slug",
"ride_name",
"park_slug",
"park_name",
]
class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
"""Input serializer for creating ride photos."""
class Meta:
model = RidePhoto
fields = [
"image",
"caption",
"alt_text",
"photo_type",
"is_primary",
]
class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
"""Input serializer for updating ride photos."""
class Meta:
model = RidePhoto
fields = [
"caption",
"alt_text",
"photo_type",
"is_primary",
]
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for ride photo lists."""
uploaded_by_username = serializers.CharField(
source="uploaded_by.username", read_only=True
)
class Meta:
model = RidePhoto
fields = [
"id",
"image",
"caption",
"photo_type",
"is_primary",
"is_approved",
"created_at",
"uploaded_by_username",
]
read_only_fields = fields
class RidePhotoApprovalInputSerializer(serializers.Serializer):
"""Input serializer for photo approval operations."""
photo_ids = serializers.ListField(
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
)
approve = serializers.BooleanField(
default=True, help_text="Whether to approve (True) or reject (False) the photos"
)
class RidePhotoStatsOutputSerializer(serializers.Serializer):
"""Output serializer for ride photo statistics."""
total_photos = serializers.IntegerField()
approved_photos = serializers.IntegerField()
pending_photos = serializers.IntegerField()
has_primary = serializers.BooleanField()
recent_uploads = serializers.IntegerField()
by_type = serializers.DictField(
child=serializers.IntegerField(), help_text="Photo counts by type"
)
class RidePhotoTypeFilterSerializer(serializers.Serializer):
"""Serializer for filtering photos by type."""
photo_type = serializers.ChoiceField(
choices=[
("exterior", "Exterior View"),
("queue", "Queue Area"),
("station", "Station"),
("onride", "On-Ride"),
("construction", "Construction"),
("other", "Other"),
],
required=False,
help_text="Filter photos by type",
)
class RidePhotoSerializer(serializers.ModelSerializer):
"""Legacy serializer for backward compatibility."""
class Meta:
model = RidePhoto
fields = [
"id",
"image",
"caption",
@@ -21,7 +160,7 @@ class RidePhotoSerializer(serializers.ModelSerializer):
"photo_type",
"uploaded_at",
"uploaded_by",
)
]
class RideSerializer(serializers.ModelSerializer):
@@ -29,7 +168,7 @@ class RideSerializer(serializers.ModelSerializer):
class Meta:
model = Ride
fields = (
fields = [
"id",
"name",
"slug",
@@ -40,4 +179,4 @@ class RideSerializer(serializers.ModelSerializer):
"status",
"opening_date",
"closing_date",
)
]

View File

@@ -1,15 +1,55 @@
"""
Ride API URLs for ThrillWiki API v1.
"""Comprehensive URL routes for Rides domain (API v1).
This file exposes a maximal set of "full-fat" endpoints implemented in
`apps.api.v1.rides.views`. Endpoints are intentionally expansive (aliases,
bulk operations, action endpoints, analytics, import/export) so the backend
surface matches the frontend's expectations. Implementations for specific
actions (bulk, publish, export, import, recommendations) should be added
to the views module when business logic is available.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import RidePhotoViewSet
from .views import (
RideListCreateAPIView,
RideDetailAPIView,
FilterOptionsAPIView,
CompanySearchAPIView,
RideModelSearchAPIView,
RideSearchSuggestionsAPIView,
)
from .photo_views import RidePhotoViewSet
# Create router for nested photo endpoints
router = DefaultRouter()
router.register(r"photos", RidePhotoViewSet, basename="ride-photo")
router.register(r"photos", RidePhotoViewSet, basename="ridephoto")
app_name = "api_v1_rides"
urlpatterns = [
path("", include(router.urls)),
# Core list/create endpoints
path("", RideListCreateAPIView.as_view(), name="ride-list-create"),
# Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
# Autocomplete / suggestion endpoints
path(
"search/companies/",
CompanySearchAPIView.as_view(),
name="ride-search-companies",
),
path(
"search/ride-models/",
RideModelSearchAPIView.as_view(),
name="ride-search-ride-models",
),
path(
"search-suggestions/",
RideSearchSuggestionsAPIView.as_view(),
name="ride-search-suggestions",
),
# Detail and action endpoints
path("<int:pk>/", RideDetailAPIView.as_view(), name="ride-detail"),
# Ride photo endpoints - domain-specific photo management
path("<int:ride_pk>/photos/", include(router.urls)),
]

View File

@@ -1,117 +1,383 @@
"""
Ride API views for ThrillWiki API v1.
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.
"""
import logging
from typing import Any
from django.core.exceptions import PermissionDenied
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
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.viewsets import ModelViewSet
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from apps.rides.models import RidePhoto
from apps.rides.services import RideMediaService
from ..media.serializers import (
PhotoUpdateInputSerializer,
PhotoListOutputSerializer,
# Reuse existing serializers where possible
from apps.api.v1.serializers.rides import (
RideListOutputSerializer,
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
)
from .serializers import RidePhotoSerializer
logger = logging.getLogger(__name__)
# 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
@extend_schema_view(
list=extend_schema(
summary="List ride photos",
description="Retrieve a list of photos for a specific ride.",
responses={200: PhotoListOutputSerializer(many=True)},
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"],
),
retrieve=extend_schema(
summary="Get ride photo details",
description="Retrieve detailed information about a specific ride photo.",
responses={
200: RidePhotoSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
update=extend_schema(
summary="Update ride photo",
description="Update ride photo information (caption, alt text, etc.)",
request=PhotoUpdateInputSerializer,
responses={
200: RidePhotoSerializer,
400: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
destroy=extend_schema(
summary="Delete ride photo",
description="Delete a ride photo (only by owner or admin)",
responses={
204: None,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
)
class RidePhotoViewSet(ModelViewSet):
"""ViewSet for managing ride photos."""
queryset = RidePhoto.objects.select_related("ride", "uploaded_by").all()
permission_classes = [IsAuthenticated]
lookup_field = "id"
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "list":
return PhotoListOutputSerializer
elif self.action in ["update", "partial_update"]:
return PhotoUpdateInputSerializer
return RidePhotoSerializer
def perform_update(self, serializer):
"""Update photo with permission check."""
photo = self.get_object()
if not (
self.request.user == photo.uploaded_by
or self.request.user.has_perm("rides.change_ridephoto")
):
raise PermissionDenied("You do not have permission to edit this photo.")
serializer.save()
def perform_destroy(self, instance):
"""Delete photo with permission check."""
if not (
self.request.user == instance.uploaded_by
or self.request.user.has_perm("rides.delete_ridephoto")
):
raise PermissionDenied("You do not have permission to delete this photo.")
instance.delete()
@action(detail=True, methods=["post"])
def set_primary(self, request, id=None):
"""Set this photo as the primary photo for its ride."""
photo = self.get_object()
if not (
request.user == photo.uploaded_by
or request.user.has_perm("rides.change_ridephoto")
):
)
def get(self, request: Request) -> Response:
"""List rides with basic filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{"error": "You do not have permission to edit photos for this ride."},
status=status.HTTP_403_FORBIDDEN,
{
"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:
RideMediaService.set_primary_photo(photo.ride, photo)
return Response({"message": "Photo set as primary successfully."})
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
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 duplicate action --------------------------------------------------