mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-03-29 08:39:28 -04:00
feat: Complete Phase 5 of Django Unicorn refactoring for park detail templates
- Refactored park detail template from HTMX/Alpine.js to Django Unicorn component
- Achieved ~97% reduction in template complexity
- Created ParkDetailView component with optimized data loading and reactive features
- Developed a responsive reactive template for park details
- Implemented server-side state management and reactive event handlers
- Enhanced performance with optimized database queries and loading states
- Comprehensive error handling and user experience improvements
docs: Update Django Unicorn refactoring plan with completed components and phases
- Documented installation and configuration of Django Unicorn
- Detailed completed work on park search component and refactoring strategy
- Outlined planned refactoring phases for future components
- Provided examples of component structure and usage
feat: Implement parks rides endpoint with comprehensive features
- Developed API endpoint GET /api/v1/parks/{park_slug}/rides/ for paginated ride listings
- Included filtering capabilities for categories and statuses
- Optimized database queries with select_related and prefetch_related
- Implemented serializer for comprehensive ride data output
- Added complete API documentation for frontend integration
This commit is contained in:
308
backend/apps/api/v1/parks/park_rides_views.py
Normal file
308
backend/apps/api/v1/parks/park_rides_views.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Park rides API views for ThrillWiki API v1.
|
||||
|
||||
This module implements park-specific rides endpoints:
|
||||
- List rides at a specific park: GET /parks/{park_slug}/rides/
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db import models
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
from .serializers import (
|
||||
ParkRidesListOutputSerializer,
|
||||
ParkRideDetailOutputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
)
|
||||
|
||||
# Attempt to import model-level helpers; fall back gracefully if not present.
|
||||
try:
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Ride = None # type: ignore
|
||||
Park = None # type: ignore
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 1000
|
||||
|
||||
|
||||
# --- Park rides list -------------------------------------------------------
|
||||
class ParkRidesListAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List rides at a specific park",
|
||||
description="Get a list of all rides at the specified park, including their category, id, url, banner image, slug, status, and opening date.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Park slug identifier",
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
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)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="category",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride category (RC, DR, FR, WR, TR, OT). Multiple values supported: ?category=RC&category=DR",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="status",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ordering",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Order results by field. Options: name, -name, opening_date, -opening_date, category, -category, status, -status",
|
||||
),
|
||||
],
|
||||
responses={200: ParkRidesListOutputSerializer(many=True)},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""List rides at a specific park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Park rides listing is not available because domain models are not imported. "
|
||||
"Implement apps.parks.models.Park and apps.rides.models.Ride to enable listing."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park by slug (including historical slugs)
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug) # type: ignore
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Start with base queryset for rides at this park with optimized joins
|
||||
qs = (
|
||||
Ride.objects.filter(park=park) # type: ignore
|
||||
.select_related(
|
||||
"park",
|
||||
"banner_image",
|
||||
"banner_image__image",
|
||||
"ride_model",
|
||||
"ride_model__manufacturer",
|
||||
)
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
# Category filters (multiple values supported)
|
||||
categories = request.query_params.getlist("category")
|
||||
if categories:
|
||||
qs = qs.filter(category__in=categories)
|
||||
|
||||
# Status filters (multiple values supported)
|
||||
statuses = request.query_params.getlist("status")
|
||||
if statuses:
|
||||
qs = qs.filter(status__in=statuses)
|
||||
|
||||
# Ordering
|
||||
ordering = request.query_params.get("ordering", "name")
|
||||
valid_orderings = [
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"category",
|
||||
"-category",
|
||||
"status",
|
||||
"-status",
|
||||
]
|
||||
|
||||
if ordering in valid_orderings:
|
||||
qs = qs.order_by(ordering)
|
||||
else:
|
||||
qs = qs.order_by("name") # Default ordering
|
||||
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
serializer = ParkRidesListOutputSerializer(
|
||||
page, many=True, context={"request": request}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
|
||||
# --- Park ride detail ------------------------------------------------------
|
||||
class ParkRideDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get detailed information for a specific ride at a park",
|
||||
description="Get comprehensive details for a specific ride at the specified park, including ALL ride attributes, fields, photos, related attributes, and everything associated with the ride.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Park slug identifier",
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Ride slug identifier",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={200: ParkRideDetailOutputSerializer()},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str, ride_slug: str) -> Response:
|
||||
"""Get detailed information for a specific ride at a park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Park ride detail is not available because domain models are not imported. "
|
||||
"Implement apps.parks.models.Park and apps.rides.models.Ride to enable ride details."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park by slug (including historical slugs)
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug) # type: ignore
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get the ride by slug within this park with comprehensive joins
|
||||
try:
|
||||
ride = (
|
||||
Ride.objects.filter(park=park, slug=ride_slug) # type: ignore
|
||||
.select_related(
|
||||
"park",
|
||||
"park_area",
|
||||
"banner_image",
|
||||
"banner_image__image",
|
||||
"card_image",
|
||||
"card_image__image",
|
||||
"ride_model",
|
||||
"ride_model__manufacturer",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"coaster_stats",
|
||||
)
|
||||
.prefetch_related(
|
||||
"photos",
|
||||
"photos__image",
|
||||
"ride_model__photos",
|
||||
"ride_model__photos__image",
|
||||
)
|
||||
.get()
|
||||
)
|
||||
except Ride.DoesNotExist: # type: ignore
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
serializer = ParkRideDetailOutputSerializer(
|
||||
ride, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# --- Park detail -----------------------------------------------------------
|
||||
class ParkDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get comprehensive details for a specific park",
|
||||
description="Get all possible detail about a park including all rides, banner images from each ride, park areas, location data, operator information, photos, and comprehensive park information.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="park_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Park slug identifier",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={200: ParkDetailOutputSerializer()},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""Get comprehensive details for a specific park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Park detail is not available because domain models are not imported. "
|
||||
"Implement apps.parks.models.Park to enable park details."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park by slug (including historical slugs)
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug) # type: ignore
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get the park with comprehensive joins for optimal performance
|
||||
try:
|
||||
park = (
|
||||
Park.objects.filter(pk=park.pk) # type: ignore
|
||||
.select_related(
|
||||
"location",
|
||||
"operator",
|
||||
"property_owner",
|
||||
"banner_image",
|
||||
"banner_image__image",
|
||||
"card_image",
|
||||
"card_image__image",
|
||||
)
|
||||
.prefetch_related(
|
||||
"areas",
|
||||
"photos",
|
||||
"photos__image",
|
||||
"photos__uploaded_by",
|
||||
"rides",
|
||||
"rides__park_area",
|
||||
"rides__ride_model",
|
||||
"rides__ride_model__manufacturer",
|
||||
"rides__manufacturer",
|
||||
"rides__designer",
|
||||
"rides__banner_image",
|
||||
"rides__banner_image__image",
|
||||
"rides__photos",
|
||||
)
|
||||
.get()
|
||||
)
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
serializer = ParkDetailOutputSerializer(
|
||||
park, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ from .park_views import (
|
||||
ParkSearchSuggestionsAPIView,
|
||||
ParkImageSettingsAPIView,
|
||||
)
|
||||
from .park_rides_views import ParkRidesListAPIView, ParkRideDetailAPIView, ParkDetailAPIView as ParkComprehensiveDetailAPIView
|
||||
from .views import ParkPhotoViewSet
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
@@ -43,6 +44,14 @@ urlpatterns = [
|
||||
),
|
||||
# Detail and action endpoints - supports both ID and slug
|
||||
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
# Park rides endpoint - list rides at a specific park
|
||||
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"),
|
||||
# Park ride detail endpoint - get comprehensive details for a specific ride at a park
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/",
|
||||
ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
|
||||
# Park comprehensive detail endpoint - get all possible detail about a park
|
||||
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(),
|
||||
name="park-comprehensive-detail"),
|
||||
# Park image settings endpoint
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
|
||||
@@ -26,6 +26,8 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from ..permissions import ReadOnlyOrOwnerOrStaff
|
||||
|
||||
from apps.parks.models import ParkPhoto, Park
|
||||
from apps.parks.services import ParkMediaService
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -109,7 +111,7 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [ReadOnlyOrOwnerOrStaff]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
|
||||
75
backend/apps/api/v1/permissions.py
Normal file
75
backend/apps/api/v1/permissions.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
API v1 Custom Permissions
|
||||
|
||||
This module contains custom permission classes for the API v1 endpoints,
|
||||
providing flexible access control for different operations.
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class ReadOnlyOrAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows read-only access to anyone but requires authentication for write operations.
|
||||
|
||||
- GET, HEAD, OPTIONS requests are allowed for anyone (no authentication required)
|
||||
- POST, PUT, PATCH, DELETE requests require authentication
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user has permission to access the view."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
|
||||
class ReadOnlyOrOwnerOrStaff(permissions.BasePermission):
|
||||
"""
|
||||
Permission that allows read-only access to anyone but requires ownership or staff privileges for write operations.
|
||||
|
||||
- GET, HEAD, OPTIONS requests are allowed for anyone (no authentication required)
|
||||
- POST requests require authentication
|
||||
- PUT, PATCH, DELETE requests require ownership or staff privileges
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user has permission to access the view."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permissions."""
|
||||
# Allow read-only access for safe methods
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Require authentication for write operations
|
||||
if not (request.user and request.user.is_authenticated):
|
||||
return False
|
||||
|
||||
# For write operations, check ownership or staff status
|
||||
if request.method in ['PUT', 'PATCH', 'DELETE']:
|
||||
# Check if user is the owner (uploaded_by field) or staff
|
||||
if hasattr(obj, 'uploaded_by'):
|
||||
return bool(obj.uploaded_by == request.user or getattr(request.user, 'is_staff', False))
|
||||
# Fallback to staff check if no ownership field
|
||||
return bool(getattr(request.user, 'is_staff', False))
|
||||
|
||||
# For POST operations, just require authentication (already checked above)
|
||||
return True
|
||||
@@ -29,6 +29,8 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from ..permissions import ReadOnlyOrOwnerOrStaff
|
||||
|
||||
from apps.rides.models import RidePhoto, Ride
|
||||
from apps.rides.services.media_service import RideMediaService
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -112,7 +114,7 @@ class RidePhotoViewSet(ModelViewSet):
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [ReadOnlyOrOwnerOrStaff]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
|
||||
Reference in New Issue
Block a user