Files
thrillwiki_django_no_react/backend/apps/api/v1/viewsets.py
pacnpal bf7e0c0f40 feat: Implement comprehensive ride filtering system with API integration
- Added `useRideFiltering` composable for managing ride filters and fetching rides from the API.
- Created `useParkRideFiltering` for park-specific ride filtering.
- Developed `useTheme` composable for theme management with localStorage support.
- Established `rideFiltering` Pinia store for centralized state management of ride filters and UI state.
- Defined enhanced filter types in `filters.ts` for better type safety and clarity.
- Built `RideFilteringPage.vue` to provide a user interface for filtering rides with responsive design.
- Integrated filter sidebar and ride list display components for a cohesive user experience.
- Added support for filter presets and search suggestions.
- Implemented computed properties for active filters, average ratings, and operating counts.
2025-08-25 12:03:22 -04:00

3144 lines
106 KiB
Python

"""
Consolidated ViewSets for ThrillWiki API v1.
This module consolidates all API ViewSets from different apps into a unified structure
following Django REST Framework and drf-spectacular best practices.
"""
import time
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.permissions import (
IsAuthenticated,
IsAuthenticatedOrReadOnly,
AllowAny,
)
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rest_framework.views import APIView
from rest_framework.authtoken.models import Token
from django.contrib.auth import authenticate, login, logout, get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.http import Http404
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount import providers
from health_check.views import MainView
import pghistory.models
# Import models from different apps
from apps.parks.models import Park, ParkArea, ParkLocation, ParkReview, Company
from apps.rides.models import (
Ride,
RideModel,
RollerCoasterStats,
RideLocation,
RideReview,
)
from apps.accounts.models import UserProfile, TopList, TopListItem
# Import selectors from different apps
from apps.parks.selectors import (
park_list_with_stats,
park_detail_optimized,
park_reviews_for_park,
park_statistics,
)
from apps.rides.selectors import (
ride_list_for_display,
ride_detail_optimized,
ride_statistics_by_category,
)
# Import services from different apps
from apps.parks.services import ParkService
# Import consolidated serializers
from .serializers import (
# Park serializers
ParkListOutputSerializer,
ParkDetailOutputSerializer,
ParkCreateInputSerializer,
ParkUpdateInputSerializer,
ParkFilterInputSerializer,
ParkStatsOutputSerializer,
ParkReviewOutputSerializer,
# Ride serializers
RideListOutputSerializer,
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
RideFilterInputSerializer,
RideStatsOutputSerializer,
# Accounts serializers
UserOutputSerializer,
LoginInputSerializer,
LoginOutputSerializer,
SignupInputSerializer,
SignupOutputSerializer,
PasswordResetInputSerializer,
PasswordResetOutputSerializer,
PasswordChangeInputSerializer,
PasswordChangeOutputSerializer,
LogoutOutputSerializer,
SocialProviderOutputSerializer,
AuthStatusOutputSerializer,
# Health check serializers
HealthCheckOutputSerializer,
PerformanceMetricsOutputSerializer,
SimpleHealthOutputSerializer,
# History serializers
ParkHistoryEventSerializer,
RideHistoryEventSerializer,
ParkHistoryOutputSerializer,
RideHistoryOutputSerializer,
UnifiedHistoryTimelineSerializer,
# New comprehensive serializers
ParkAreaDetailOutputSerializer,
ParkAreaCreateInputSerializer,
ParkAreaUpdateInputSerializer,
ParkLocationOutputSerializer,
ParkLocationCreateInputSerializer,
ParkLocationUpdateInputSerializer,
CompanyDetailOutputSerializer,
CompanyCreateInputSerializer,
CompanyUpdateInputSerializer,
RideModelDetailOutputSerializer,
RideModelCreateInputSerializer,
RideModelUpdateInputSerializer,
RollerCoasterStatsOutputSerializer,
RollerCoasterStatsCreateInputSerializer,
RollerCoasterStatsUpdateInputSerializer,
RideLocationOutputSerializer,
RideLocationCreateInputSerializer,
RideLocationUpdateInputSerializer,
RideReviewOutputSerializer,
RideReviewCreateInputSerializer,
RideReviewUpdateInputSerializer,
UserProfileOutputSerializer,
UserProfileCreateInputSerializer,
UserProfileUpdateInputSerializer,
TopListOutputSerializer,
TopListCreateInputSerializer,
TopListUpdateInputSerializer,
TopListItemOutputSerializer,
TopListItemCreateInputSerializer,
TopListItemUpdateInputSerializer,
)
# Handle optional dependencies with fallback classes
class FallbackTurnstileMixin:
"""Fallback mixin if TurnstileMixin is not available."""
def validate_turnstile(self, request):
pass
class FallbackCacheMonitor:
"""Fallback class if CacheMonitor is not available."""
def get_cache_stats(self):
return {"error": "Cache monitoring not available"}
class FallbackIndexAnalyzer:
"""Fallback class if IndexAnalyzer is not available."""
@staticmethod
def analyze_slow_queries(threshold):
return {"error": "Query analysis not available"}
# Try to import the real classes, use fallbacks if not available
try:
from apps.accounts.mixins import TurnstileMixin
except ImportError:
TurnstileMixin = FallbackTurnstileMixin
try:
from apps.core.services.enhanced_cache_service import CacheMonitor
except ImportError:
CacheMonitor = FallbackCacheMonitor
try:
from apps.core.utils.query_optimization import IndexAnalyzer
except ImportError:
IndexAnalyzer = FallbackIndexAnalyzer
UserModel = get_user_model()
# === PARK VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List parks",
description="Retrieve a paginated list of theme parks with filtering and search capabilities.",
parameters=[
OpenApiParameter(
name="search",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Search parks by name or description",
),
OpenApiParameter(
name="status",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by park status (OPERATING, CLOSED_PERM, etc.)",
),
OpenApiParameter(
name="country",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by country",
),
OpenApiParameter(
name="state",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by state/province",
),
OpenApiParameter(
name="ordering",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Order results by field (name, opening_date, average_rating, etc.)",
),
],
responses={200: ParkListOutputSerializer(many=True)},
tags=["Parks"],
),
create=extend_schema(
summary="Create park",
description="Create a new theme park. Requires authentication.",
request=ParkCreateInputSerializer,
responses={
201: ParkDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
retrieve=extend_schema(
summary="Get park details",
description="Retrieve detailed information about a specific park.",
responses={
200: ParkDetailOutputSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
update=extend_schema(
summary="Update park",
description="Update a park's information. Requires authentication.",
request=ParkUpdateInputSerializer,
responses={
200: ParkDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
partial_update=extend_schema(
summary="Partially update park",
description="Partially update a park's information. Requires authentication.",
request=ParkUpdateInputSerializer,
responses={
200: ParkDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
destroy=extend_schema(
summary="Delete park",
description="Delete a park. Requires authentication and appropriate permissions.",
responses={
204: None,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Parks"],
),
stats=extend_schema(
summary="Get park statistics",
description="Retrieve global statistics about all parks in the system.",
responses={200: ParkStatsOutputSerializer},
tags=["Parks", "Statistics"],
),
reviews=extend_schema(
summary="Get park reviews",
description="Retrieve reviews for a specific park.",
responses={200: ParkReviewOutputSerializer(many=True)},
tags=["Parks", "Reviews"],
),
)
class ParkViewSet(ModelViewSet):
"""
ViewSet for managing theme parks.
Provides CRUD operations for parks plus additional endpoints for
statistics and reviews.
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "slug"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = [
"name",
"opening_date",
"average_rating",
"coaster_count",
"created_at",
]
ordering = ["name"]
def get_queryset(self): # type: ignore[override]
"""Get optimized queryset based on action."""
if self.action == "list":
# Parse filter parameters for list view
filter_serializer = ParkFilterInputSerializer(
data=self.request.query_params # type: ignore[attr-defined]
)
filter_serializer.is_valid(raise_exception=True)
filters = filter_serializer.validated_data
return park_list_with_stats(filters=filters) # type: ignore[arg-type]
# For other actions, return base queryset
return Park.objects.select_related("operator", "property_owner").all()
def get_object(self): # type: ignore[override]
"""Get optimized object for detail operations."""
if self.action in ["retrieve", "update", "partial_update", "destroy"]:
slug = self.kwargs.get("slug")
return park_detail_optimized(slug=slug)
return super().get_object()
def get_serializer_class(self): # type: ignore[override]
"""Return appropriate serializer class based on action."""
if self.action == "list":
return ParkListOutputSerializer
elif self.action == "create":
return ParkCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return ParkUpdateInputSerializer
else:
return ParkDetailOutputSerializer
def perform_create(self, serializer):
"""Create park using service layer."""
park = ParkService.create_park(**serializer.validated_data)
serializer.instance = park
def perform_update(self, serializer):
"""Update park using service layer."""
park = ParkService.update_park(
park_id=self.get_object().id, **serializer.validated_data
)
serializer.instance = park
def perform_destroy(self, instance):
"""Delete park using service layer."""
ParkService.delete_park(park_id=instance.id)
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""
Get park statistics.
Returns global statistics about all parks including totals,
averages, and top countries.
"""
stats = park_statistics()
serializer = ParkStatsOutputSerializer(stats)
return Response(
data=serializer.data,
headers={"Cache-Control": "max-age=3600"}, # 1 hour cache hint
)
@action(detail=True, methods=["get"])
def reviews(self, request: Request, slug: str | None = None) -> Response:
"""
Get reviews for a specific park.
Returns a list of user reviews for the park.
"""
park = self.get_object()
reviews = park_reviews_for_park(park_id=park.id, limit=50)
serializer = ParkReviewOutputSerializer(reviews, many=True)
return Response(
data=serializer.data,
headers={
"X-Total-Reviews": str(len(reviews)),
"X-Park-Name": park.name,
},
)
@action(detail=False, methods=["get"])
def recent_changes(self, request: Request) -> Response:
"""
Get recently changed parks.
Returns parks that have been modified recently with change details.
"""
days = request.query_params.get("days", "7")
try:
days = int(days)
except (ValueError, TypeError):
days = 7
# Get parks changed in the last N days
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
recent_events = (
pghistory.models.Events.objects.filter(
pgh_model="parks.park", pgh_created_at__gte=cutoff_date
)
.values("pgh_obj_id")
.distinct()
)
park_ids = [event["pgh_obj_id"] for event in recent_events]
changed_parks = Park.objects.filter(id__in=park_ids).select_related(
"operator", "property_owner"
)
serializer = ParkListOutputSerializer(changed_parks, many=True)
return Response(
{"count": len(changed_parks), "days": days, "parks": serializer.data}
)
@action(detail=False, methods=["get"])
def recent_openings(self, request: Request) -> Response:
"""
Get recently opened parks.
Returns parks that have opened in the specified time period.
"""
days = request.query_params.get("days", "30")
try:
days = int(days)
except (ValueError, TypeError):
days = 30
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
recent_openings = Park.objects.filter(
opening_date__gte=cutoff_date, status="OPERATING"
).select_related("operator", "property_owner")
serializer = ParkListOutputSerializer(recent_openings, many=True)
return Response(
{"count": len(recent_openings), "days": days, "parks": serializer.data}
)
@action(detail=False, methods=["get"])
def recent_closures(self, request: Request) -> Response:
"""
Get recently closed parks.
Returns parks that have closed or changed to non-operating status recently.
"""
days = request.query_params.get("days", "30")
try:
days = int(days)
except (ValueError, TypeError):
days = 30
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
# Get parks that have closure events in recent history
closure_events = (
pghistory.models.Events.objects.filter(
pgh_model="parks.park",
pgh_created_at__gte=cutoff_date,
pgh_data__contains={"status": "CLOSED_PERM"},
)
.values("pgh_obj_id")
.distinct()
)
park_ids = [event["pgh_obj_id"] for event in closure_events]
closed_parks = Park.objects.filter(id__in=park_ids).select_related(
"operator", "property_owner"
)
serializer = ParkListOutputSerializer(closed_parks, many=True)
return Response(
{"count": len(closed_parks), "days": days, "parks": serializer.data}
)
@action(detail=False, methods=["get"])
def recent_name_changes(self, request: Request) -> Response:
"""
Get parks with recent name changes.
Returns parks that have had their names changed recently.
"""
days = request.query_params.get("days", "90")
try:
days = int(days)
except (ValueError, TypeError):
days = 90
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
# Get parks with name change events
name_change_events = (
pghistory.models.Events.objects.filter(
pgh_model="parks.park",
pgh_created_at__gte=cutoff_date,
pgh_label="updated",
)
.values("pgh_obj_id")
.distinct()
)
park_ids = [event["pgh_obj_id"] for event in name_change_events]
changed_parks = Park.objects.filter(id__in=park_ids).select_related(
"operator", "property_owner"
)
serializer = ParkListOutputSerializer(changed_parks, many=True)
return Response(
{"count": len(changed_parks), "days": days, "parks": serializer.data}
)
# === RIDE VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List rides",
description="Retrieve a paginated list of rides with filtering and search capabilities.",
parameters=[
OpenApiParameter(
name="search",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Search rides by name or description",
),
OpenApiParameter(
name="category",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by ride category (RC, DR, FR, WR, TR, OT)",
),
OpenApiParameter(
name="status",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by ride status",
),
OpenApiParameter(
name="park_id",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter by park ID",
),
OpenApiParameter(
name="park_slug",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by park slug",
),
OpenApiParameter(
name="ordering",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Order results by field (name, opening_date, average_rating, etc.)",
),
],
responses={200: RideListOutputSerializer(many=True)},
tags=["Rides"],
),
create=extend_schema(
summary="Create ride",
description="Create a new ride. Requires authentication.",
request=RideCreateInputSerializer,
responses={
201: RideDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
retrieve=extend_schema(
summary="Get ride details",
description="Retrieve detailed information about a specific ride.",
responses={
200: RideDetailOutputSerializer,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
update=extend_schema(
summary="Update ride",
description="Update a ride's information. Requires authentication.",
request=RideUpdateInputSerializer,
responses={
200: RideDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
partial_update=extend_schema(
summary="Partially update ride",
description="Partially update a ride's information. Requires authentication.",
request=RideUpdateInputSerializer,
responses={
200: RideDetailOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
destroy=extend_schema(
summary="Delete ride",
description="Delete a ride. Requires authentication and appropriate permissions.",
responses={
204: None,
401: OpenApiTypes.OBJECT,
403: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Rides"],
),
stats=extend_schema(
summary="Get ride statistics",
description="Retrieve global statistics about all rides in the system.",
responses={200: RideStatsOutputSerializer},
tags=["Rides", "Statistics"],
),
)
class RideViewSet(ModelViewSet):
"""
ViewSet for managing rides.
Provides CRUD operations for rides plus additional endpoints for
statistics.
"""
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "slug"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = [
"name",
"opening_date",
"average_rating",
"capacity_per_hour",
"created_at",
]
ordering = ["name"]
def get_queryset(self): # type: ignore[override]
"""Get optimized queryset based on action."""
if self.action == "list":
# CRITICAL FIX: Check if this is a nested endpoint first
park_slug = self.kwargs.get("park_slug")
if park_slug:
# For nested endpoints, use the dedicated park selector
from apps.rides.selectors import rides_in_park
return rides_in_park(park_slug=park_slug)
# For global endpoints, parse filter parameters and use general selector
filter_serializer = RideFilterInputSerializer(
data=self.request.query_params # type: ignore[attr-defined]
)
filter_serializer.is_valid(raise_exception=True)
filters = filter_serializer.validated_data
return ride_list_for_display(filters=filters) # type: ignore[arg-type]
# For other actions, return base queryset
return Ride.objects.select_related(
"park", "park_area", "manufacturer", "designer", "ride_model"
).all()
def get_object(self): # type: ignore[override]
"""Get optimized object for detail operations."""
if self.action in ["retrieve", "update", "partial_update", "destroy"]:
# For rides, we need to get by park slug and ride slug
park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("slug") or self.kwargs.get("ride_slug")
if park_slug and ride_slug:
try:
return ride_detail_optimized(slug=ride_slug, park_slug=park_slug)
except Ride.DoesNotExist:
raise Http404("Ride not found")
elif ride_slug:
# For rides accessed directly by slug, we'll use the first approach
# and let the 404 handling work naturally
return super().get_object()
return super().get_object()
def get_serializer_class(self): # type: ignore[override]
"""Return appropriate serializer class based on action."""
if self.action == "list":
return RideListOutputSerializer
elif self.action == "create":
return RideCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return RideUpdateInputSerializer
else:
return RideDetailOutputSerializer
def perform_create(self, serializer):
"""Create ride using validated data."""
# For now, use standard Django creation
# TODO: Implement RideService for business logic
serializer.save()
def perform_update(self, serializer):
"""Update ride using validated data."""
# For now, use standard Django update
# TODO: Implement RideService for business logic
serializer.save()
def perform_destroy(self, instance):
"""Delete ride instance."""
# For now, use standard Django deletion
# TODO: Implement RideService for business logic
instance.delete()
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""
Get ride statistics.
Returns global statistics about all rides including totals,
averages by category, and top manufacturers.
"""
# Import here to avoid circular imports
# Use the existing statistics function
stats = ride_statistics_by_category()
serializer = RideStatsOutputSerializer(stats)
return Response(
data=serializer.data,
headers={"Cache-Control": "max-age=3600"}, # 1 hour cache hint
)
@action(detail=False, methods=["get"])
def recent_changes(self, request: Request) -> Response:
"""
Get recently changed rides.
Returns rides that have been modified recently with change details.
"""
days = request.query_params.get("days", "7")
try:
days = int(days)
except (ValueError, TypeError):
days = 7
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
recent_events = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
],
pgh_created_at__gte=cutoff_date,
)
.values("pgh_obj_id")
.distinct()
)
ride_ids = [event["pgh_obj_id"] for event in recent_events]
changed_rides = Ride.objects.filter(id__in=ride_ids).select_related(
"park", "park_area", "manufacturer", "designer", "ride_model"
)
serializer = RideListOutputSerializer(changed_rides, many=True)
return Response(
{"count": len(changed_rides), "days": days, "rides": serializer.data}
)
@action(detail=False, methods=["get"])
def recent_openings(self, request: Request) -> Response:
"""
Get recently opened rides.
Returns rides that have opened in the specified time period.
"""
days = request.query_params.get("days", "30")
try:
days = int(days)
except (ValueError, TypeError):
days = 30
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
recent_openings = Ride.objects.filter(
opening_date__gte=cutoff_date, status="OPERATING"
).select_related("park", "park_area", "manufacturer", "designer", "ride_model")
serializer = RideListOutputSerializer(recent_openings, many=True)
return Response(
{"count": len(recent_openings), "days": days, "rides": serializer.data}
)
@action(detail=False, methods=["get"])
def recent_closures(self, request: Request) -> Response:
"""
Get recently closed rides.
Returns rides that have closed or changed to non-operating status recently.
"""
days = request.query_params.get("days", "30")
try:
days = int(days)
except (ValueError, TypeError):
days = 30
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
# Get rides that have closure events in recent history
closure_events = (
pghistory.models.Events.objects.filter(
pgh_model="rides.ride",
pgh_created_at__gte=cutoff_date,
pgh_data__contains={"status": "CLOSED_PERM"},
)
.values("pgh_obj_id")
.distinct()
)
ride_ids = [event["pgh_obj_id"] for event in closure_events]
closed_rides = Ride.objects.filter(id__in=ride_ids).select_related(
"park", "park_area", "manufacturer", "designer", "ride_model"
)
serializer = RideListOutputSerializer(closed_rides, many=True)
return Response(
{"count": len(closed_rides), "days": days, "rides": serializer.data}
)
@action(detail=False, methods=["get"])
def recent_name_changes(self, request: Request) -> Response:
"""
Get rides with recent name changes.
Returns rides that have had their names changed recently.
"""
days = request.query_params.get("days", "90")
try:
days = int(days)
except (ValueError, TypeError):
days = 90
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
# Get rides with name change events
name_change_events = (
pghistory.models.Events.objects.filter(
pgh_model="rides.ride",
pgh_created_at__gte=cutoff_date,
pgh_label="updated",
)
.values("pgh_obj_id")
.distinct()
)
ride_ids = [event["pgh_obj_id"] for event in name_change_events]
changed_rides = Ride.objects.filter(id__in=ride_ids).select_related(
"park", "park_area", "manufacturer", "designer", "ride_model"
)
serializer = RideListOutputSerializer(changed_rides, many=True)
return Response(
{"count": len(changed_rides), "days": days, "rides": serializer.data}
)
@action(detail=False, methods=["get"])
def recent_relocations(self, request: Request) -> Response:
"""
Get rides that have been relocated recently.
Returns rides that have moved between parks or areas recently.
"""
days = request.query_params.get(
"days", "365"
) # Default to 1 year for relocations
try:
days = int(days)
except (ValueError, TypeError):
days = 365
from datetime import timedelta
from django.utils import timezone
cutoff_date = timezone.now() - timedelta(days=days)
# Get rides with park/area change events
relocation_events = (
pghistory.models.Events.objects.filter(
pgh_model="rides.ride",
pgh_created_at__gte=cutoff_date,
pgh_label="updated",
)
.values("pgh_obj_id")
.distinct()
)
ride_ids = [event["pgh_obj_id"] for event in relocation_events]
relocated_rides = Ride.objects.filter(id__in=ride_ids).select_related(
"park", "park_area", "manufacturer", "designer", "ride_model"
)
serializer = RideListOutputSerializer(relocated_rides, many=True)
return Response(
{"count": len(relocated_rides), "days": days, "rides": serializer.data}
)
# === PARK AREA VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List park areas",
description="Retrieve a list of park areas with optional filtering",
responses={200: ParkAreaDetailOutputSerializer(many=True)},
tags=["Park Areas"],
),
create=extend_schema(
summary="Create park area",
description="Create a new park area",
request=ParkAreaCreateInputSerializer,
responses={201: ParkAreaDetailOutputSerializer},
tags=["Park Areas"],
),
retrieve=extend_schema(
summary="Get park area details",
description="Retrieve detailed information about a specific park area",
responses={200: ParkAreaDetailOutputSerializer},
tags=["Park Areas"],
),
update=extend_schema(
summary="Update park area",
description="Update park area information",
request=ParkAreaUpdateInputSerializer,
responses={200: ParkAreaDetailOutputSerializer},
tags=["Park Areas"],
),
destroy=extend_schema(
summary="Delete park area",
description="Delete a park area",
responses={204: None},
tags=["Park Areas"],
),
)
class ParkAreaViewSet(ModelViewSet):
"""ViewSet for managing park areas."""
queryset = ParkArea.objects.select_related("park").all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
def get_serializer_class(self):
if self.action == "create":
return ParkAreaCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return ParkAreaUpdateInputSerializer
return ParkAreaDetailOutputSerializer
def perform_create(self, serializer):
park_id = serializer.validated_data.pop("park_id")
park = Park.objects.get(id=park_id)
serializer.save(park=park)
# === PARK LOCATION VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List park locations",
description="Retrieve a list of park locations",
responses={200: ParkLocationOutputSerializer(many=True)},
tags=["Park Locations"],
),
create=extend_schema(
summary="Create park location",
description="Create a new park location",
request=ParkLocationCreateInputSerializer,
responses={201: ParkLocationOutputSerializer},
tags=["Park Locations"],
),
retrieve=extend_schema(
summary="Get park location details",
description="Retrieve detailed information about a specific park location",
responses={200: ParkLocationOutputSerializer},
tags=["Park Locations"],
),
update=extend_schema(
summary="Update park location",
description="Update park location information",
request=ParkLocationUpdateInputSerializer,
responses={200: ParkLocationOutputSerializer},
tags=["Park Locations"],
),
destroy=extend_schema(
summary="Delete park location",
description="Delete a park location",
responses={204: None},
tags=["Park Locations"],
),
)
class ParkLocationViewSet(ModelViewSet):
"""ViewSet for managing park locations."""
queryset = ParkLocation.objects.select_related("park").all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
def get_serializer_class(self):
if self.action == "create":
return ParkLocationCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return ParkLocationUpdateInputSerializer
return ParkLocationOutputSerializer
def perform_create(self, serializer):
park_id = serializer.validated_data.pop("park_id")
park = Park.objects.get(id=park_id)
serializer.save(park=park)
# === COMPANY VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List companies",
description="Retrieve a list of companies with optional role filtering",
parameters=[
OpenApiParameter(
name="roles",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by company roles (OPERATOR, MANUFACTURER, etc.)",
),
],
responses={200: CompanyDetailOutputSerializer(many=True)},
tags=["Companies"],
),
create=extend_schema(
summary="Create company",
description="Create a new company",
request=CompanyCreateInputSerializer,
responses={201: CompanyDetailOutputSerializer},
tags=["Companies"],
),
retrieve=extend_schema(
summary="Get company details",
description="Retrieve detailed information about a specific company",
responses={200: CompanyDetailOutputSerializer},
tags=["Companies"],
),
update=extend_schema(
summary="Update company",
description="Update company information",
request=CompanyUpdateInputSerializer,
responses={200: CompanyDetailOutputSerializer},
tags=["Companies"],
),
destroy=extend_schema(
summary="Delete company",
description="Delete a company",
responses={204: None},
tags=["Companies"],
),
)
class CompanyViewSet(ModelViewSet):
"""ViewSet for managing companies."""
queryset = Company.objects.all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "slug"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = ["name", "founded_date", "created_at"]
ordering = ["name"]
def get_queryset(self):
queryset = super().get_queryset()
roles = self.request.query_params.get("roles")
if roles:
role_list = roles.split(",")
queryset = queryset.filter(roles__overlap=role_list)
return queryset
def get_serializer_class(self):
if self.action == "create":
return CompanyCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return CompanyUpdateInputSerializer
return CompanyDetailOutputSerializer
# === RIDE MODEL VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List ride models",
description="Retrieve a list of ride models",
responses={200: RideModelDetailOutputSerializer(many=True)},
tags=["Ride Models"],
),
create=extend_schema(
summary="Create ride model",
description="Create a new ride model",
request=RideModelCreateInputSerializer,
responses={201: RideModelDetailOutputSerializer},
tags=["Ride Models"],
),
retrieve=extend_schema(
summary="Get ride model details",
description="Retrieve detailed information about a specific ride model",
responses={200: RideModelDetailOutputSerializer},
tags=["Ride Models"],
),
update=extend_schema(
summary="Update ride model",
description="Update ride model information",
request=RideModelUpdateInputSerializer,
responses={200: RideModelDetailOutputSerializer},
tags=["Ride Models"],
),
destroy=extend_schema(
summary="Delete ride model",
description="Delete a ride model",
responses={204: None},
tags=["Ride Models"],
),
)
class RideModelViewSet(ModelViewSet):
"""ViewSet for managing ride models."""
queryset = RideModel.objects.select_related("manufacturer").all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = ["name", "manufacturer__name", "created_at"]
ordering = ["manufacturer__name", "name"]
def get_serializer_class(self):
if self.action == "create":
return RideModelCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return RideModelUpdateInputSerializer
return RideModelDetailOutputSerializer
def perform_create(self, serializer):
manufacturer_id = serializer.validated_data.pop("manufacturer_id", None)
manufacturer = None
if manufacturer_id:
manufacturer = Company.objects.get(id=manufacturer_id)
serializer.save(manufacturer=manufacturer)
def perform_update(self, serializer):
manufacturer_id = serializer.validated_data.pop("manufacturer_id", None)
if manufacturer_id is not None:
manufacturer = (
Company.objects.get(id=manufacturer_id) if manufacturer_id else None
)
serializer.save(manufacturer=manufacturer)
else:
serializer.save()
# === ROLLER COASTER STATS VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List roller coaster stats",
description="Retrieve a list of roller coaster statistics",
responses={200: RollerCoasterStatsOutputSerializer(many=True)},
tags=["Roller Coaster Stats"],
),
create=extend_schema(
summary="Create roller coaster stats",
description="Create statistics for a roller coaster",
request=RollerCoasterStatsCreateInputSerializer,
responses={201: RollerCoasterStatsOutputSerializer},
tags=["Roller Coaster Stats"],
),
retrieve=extend_schema(
summary="Get roller coaster stats",
description="Retrieve statistics for a specific roller coaster",
responses={200: RollerCoasterStatsOutputSerializer},
tags=["Roller Coaster Stats"],
),
update=extend_schema(
summary="Update roller coaster stats",
description="Update roller coaster statistics",
request=RollerCoasterStatsUpdateInputSerializer,
responses={200: RollerCoasterStatsOutputSerializer},
tags=["Roller Coaster Stats"],
),
destroy=extend_schema(
summary="Delete roller coaster stats",
description="Delete roller coaster statistics",
responses={204: None},
tags=["Roller Coaster Stats"],
),
)
class RollerCoasterStatsViewSet(ModelViewSet):
"""ViewSet for managing roller coaster statistics."""
queryset = RollerCoasterStats.objects.select_related("ride", "ride__park").all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
def get_serializer_class(self):
if self.action == "create":
return RollerCoasterStatsCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return RollerCoasterStatsUpdateInputSerializer
return RollerCoasterStatsOutputSerializer
def perform_create(self, serializer):
ride_id = serializer.validated_data.pop("ride_id")
ride = Ride.objects.get(id=ride_id)
serializer.save(ride=ride)
# === RIDE LOCATION VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List ride locations",
description="Retrieve a list of ride locations",
responses={200: RideLocationOutputSerializer(many=True)},
tags=["Ride Locations"],
),
create=extend_schema(
summary="Create ride location",
description="Create a location for a ride",
request=RideLocationCreateInputSerializer,
responses={201: RideLocationOutputSerializer},
tags=["Ride Locations"],
),
retrieve=extend_schema(
summary="Get ride location",
description="Retrieve location information for a specific ride",
responses={200: RideLocationOutputSerializer},
tags=["Ride Locations"],
),
update=extend_schema(
summary="Update ride location",
description="Update ride location information",
request=RideLocationUpdateInputSerializer,
responses={200: RideLocationOutputSerializer},
tags=["Ride Locations"],
),
destroy=extend_schema(
summary="Delete ride location",
description="Delete ride location",
responses={204: None},
tags=["Ride Locations"],
),
)
class RideLocationViewSet(ModelViewSet):
"""ViewSet for managing ride locations."""
queryset = RideLocation.objects.select_related("ride", "ride__park").all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
def get_serializer_class(self):
if self.action == "create":
return RideLocationCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return RideLocationUpdateInputSerializer
return RideLocationOutputSerializer
def perform_create(self, serializer):
ride_id = serializer.validated_data.pop("ride_id")
ride = Ride.objects.get(id=ride_id)
serializer.save(ride=ride)
# === RIDE REVIEW VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List ride reviews",
description="Retrieve a list of ride reviews",
parameters=[
OpenApiParameter(
name="ride_id",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter by ride ID",
),
OpenApiParameter(
name="user",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by username",
),
],
responses={200: RideReviewOutputSerializer(many=True)},
tags=["Ride Reviews"],
),
create=extend_schema(
summary="Create ride review",
description="Create a new ride review",
request=RideReviewCreateInputSerializer,
responses={201: RideReviewOutputSerializer},
tags=["Ride Reviews"],
),
retrieve=extend_schema(
summary="Get ride review",
description="Retrieve a specific ride review",
responses={200: RideReviewOutputSerializer},
tags=["Ride Reviews"],
),
update=extend_schema(
summary="Update ride review",
description="Update a ride review (only by the author)",
request=RideReviewUpdateInputSerializer,
responses={200: RideReviewOutputSerializer},
tags=["Ride Reviews"],
),
destroy=extend_schema(
summary="Delete ride review",
description="Delete a ride review (only by the author)",
responses={204: None},
tags=["Ride Reviews"],
),
)
class RideReviewViewSet(ModelViewSet):
"""ViewSet for managing ride reviews."""
queryset = RideReview.objects.select_related("ride", "ride__park", "user").filter(
is_published=True
)
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["title", "content"]
ordering_fields = ["created_at", "rating", "visit_date"]
ordering = ["-created_at"]
def get_queryset(self):
queryset = super().get_queryset()
ride_id = self.request.query_params.get("ride_id")
user = self.request.query_params.get("user")
if ride_id:
queryset = queryset.filter(ride_id=ride_id)
if user:
queryset = queryset.filter(user__username=user)
return queryset
def get_serializer_class(self):
if self.action == "create":
return RideReviewCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return RideReviewUpdateInputSerializer
return RideReviewOutputSerializer
def perform_create(self, serializer):
ride_id = serializer.validated_data.pop("ride_id")
ride = Ride.objects.get(id=ride_id)
serializer.save(ride=ride, user=self.request.user)
def get_permissions(self):
"""
Instantiates and returns the list of permissions that this view requires.
"""
if self.action in ["create", "update", "partial_update", "destroy"]:
permission_classes = [IsAuthenticated]
else:
permission_classes = [AllowAny]
return [permission() for permission in permission_classes]
# === USER PROFILE VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List user profiles",
description="Retrieve a list of user profiles",
responses={200: UserProfileOutputSerializer(many=True)},
tags=["User Profiles"],
),
create=extend_schema(
summary="Create user profile",
description="Create a user profile",
request=UserProfileCreateInputSerializer,
responses={201: UserProfileOutputSerializer},
tags=["User Profiles"],
),
retrieve=extend_schema(
summary="Get user profile",
description="Retrieve a specific user profile",
responses={200: UserProfileOutputSerializer},
tags=["User Profiles"],
),
update=extend_schema(
summary="Update user profile",
description="Update user profile (only own profile)",
request=UserProfileUpdateInputSerializer,
responses={200: UserProfileOutputSerializer},
tags=["User Profiles"],
),
destroy=extend_schema(
summary="Delete user profile",
description="Delete user profile (only own profile)",
responses={204: None},
tags=["User Profiles"],
),
)
class UserProfileViewSet(ModelViewSet):
"""ViewSet for managing user profiles."""
queryset = UserProfile.objects.select_related("user").all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "profile_id"
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ["display_name", "bio"]
ordering_fields = ["display_name", "coaster_credits"]
ordering = ["display_name"]
def get_serializer_class(self):
if self.action == "create":
return UserProfileCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return UserProfileUpdateInputSerializer
return UserProfileOutputSerializer
def perform_create(self, serializer):
serializer.save(user=self.request.user)
def get_permissions(self):
"""Only allow users to modify their own profiles."""
if self.action in ["create", "update", "partial_update", "destroy"]:
permission_classes = [IsAuthenticated]
else:
permission_classes = [AllowAny]
return [permission() for permission in permission_classes]
# === TOP LIST VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List top lists",
description="Retrieve a list of user top lists",
parameters=[
OpenApiParameter(
name="category",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by category (RC, DR, PK, etc.)",
),
OpenApiParameter(
name="user",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by username",
),
],
responses={200: TopListOutputSerializer(many=True)},
tags=["Top Lists"],
),
create=extend_schema(
summary="Create top list",
description="Create a new top list",
request=TopListCreateInputSerializer,
responses={201: TopListOutputSerializer},
tags=["Top Lists"],
),
retrieve=extend_schema(
summary="Get top list",
description="Retrieve a specific top list",
responses={200: TopListOutputSerializer},
tags=["Top Lists"],
),
update=extend_schema(
summary="Update top list",
description="Update a top list (only by the owner)",
request=TopListUpdateInputSerializer,
responses={200: TopListOutputSerializer},
tags=["Top Lists"],
),
destroy=extend_schema(
summary="Delete top list",
description="Delete a top list (only by the owner)",
responses={204: None},
tags=["Top Lists"],
),
)
class TopListViewSet(ModelViewSet):
"""ViewSet for managing user top lists."""
queryset = TopList.objects.select_related("user").all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["title", "description"]
ordering_fields = ["title", "created_at", "updated_at"]
ordering = ["-updated_at"]
def get_queryset(self):
queryset = super().get_queryset()
category = self.request.query_params.get("category")
user = self.request.query_params.get("user")
if category:
queryset = queryset.filter(category=category)
if user:
queryset = queryset.filter(user__username=user)
return queryset
def get_serializer_class(self):
if self.action == "create":
return TopListCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return TopListUpdateInputSerializer
return TopListOutputSerializer
def perform_create(self, serializer):
serializer.save(user=self.request.user)
def get_permissions(self):
"""Allow authenticated users to create, but only owners can modify."""
if self.action in ["create", "update", "partial_update", "destroy"]:
permission_classes = [IsAuthenticated]
else:
permission_classes = [AllowAny]
return [permission() for permission in permission_classes]
# === TOP LIST ITEM VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="List top list items",
description="Retrieve items in top lists",
parameters=[
OpenApiParameter(
name="top_list_id",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter by top list ID",
),
],
responses={200: TopListItemOutputSerializer(many=True)},
tags=["Top List Items"],
),
create=extend_schema(
summary="Create top list item",
description="Add an item to a top list",
request=TopListItemCreateInputSerializer,
responses={201: TopListItemOutputSerializer},
tags=["Top List Items"],
),
retrieve=extend_schema(
summary="Get top list item",
description="Retrieve a specific top list item",
responses={200: TopListItemOutputSerializer},
tags=["Top List Items"],
),
update=extend_schema(
summary="Update top list item",
description="Update a top list item",
request=TopListItemUpdateInputSerializer,
responses={200: TopListItemOutputSerializer},
tags=["Top List Items"],
),
destroy=extend_schema(
summary="Delete top list item",
description="Remove an item from a top list",
responses={204: None},
tags=["Top List Items"],
),
)
class TopListItemViewSet(ModelViewSet):
"""ViewSet for managing top list items."""
queryset = TopListItem.objects.select_related(
"top_list", "top_list__user", "content_type"
).all()
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = "id"
ordering_fields = ["rank"]
ordering = ["rank"]
def get_queryset(self):
queryset = super().get_queryset()
top_list_id = self.request.query_params.get("top_list_id")
if top_list_id:
queryset = queryset.filter(top_list_id=top_list_id)
return queryset
def get_serializer_class(self):
if self.action == "create":
return TopListItemCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return TopListItemUpdateInputSerializer
return TopListItemOutputSerializer
def perform_create(self, serializer):
top_list_id = serializer.validated_data.pop("top_list_id")
content_type_id = serializer.validated_data.pop("content_type_id")
object_id = serializer.validated_data.pop("object_id")
top_list = TopList.objects.get(id=top_list_id)
from django.contrib.contenttypes.models import ContentType
content_type = ContentType.objects.get(id=content_type_id)
serializer.save(
top_list=top_list,
content_type=content_type,
object_id=object_id,
)
def get_permissions(self):
"""Allow authenticated users to manage their own top list items."""
if self.action in ["create", "update", "partial_update", "destroy"]:
permission_classes = [IsAuthenticated]
else:
permission_classes = [AllowAny]
return [permission() for permission in permission_classes]
# === READ-ONLY VIEWSETS FOR REFERENCE DATA ===
class ParkReadOnlyViewSet(ReadOnlyModelViewSet):
"""
Read-only ViewSet for parks.
Provides list and retrieve operations for parks without
modification capabilities. Useful for reference data.
"""
queryset = Park.objects.select_related("operator", "property_owner").all()
serializer_class = ParkListOutputSerializer
lookup_field = "slug"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = ["name", "opening_date", "average_rating"]
ordering = ["name"]
def get_serializer_class(self): # type: ignore[override]
"""Return appropriate serializer class based on action."""
if self.action == "retrieve":
return ParkDetailOutputSerializer
return ParkListOutputSerializer
class RideReadOnlyViewSet(ReadOnlyModelViewSet):
"""
Read-only ViewSet for rides.
Provides list and retrieve operations for rides without
modification capabilities. Useful for reference data.
"""
queryset = Ride.objects.select_related(
"park", "park_area", "manufacturer", "designer", "ride_model"
).all()
serializer_class = RideListOutputSerializer
lookup_field = "slug"
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = ["name", "opening_date", "average_rating"]
ordering = ["name"]
def get_serializer_class(self): # type: ignore[override]
"""Return appropriate serializer class based on action."""
if self.action == "retrieve":
return RideDetailOutputSerializer
return RideListOutputSerializer
# === ACCOUNTS VIEWSETS ===
@extend_schema_view(
post=extend_schema(
summary="User login",
description="Authenticate user with username/email and password.",
request=LoginInputSerializer,
responses={
200: LoginOutputSerializer,
400: OpenApiTypes.OBJECT,
},
tags=["Authentication"],
),
)
class LoginAPIView(TurnstileMixin, APIView):
"""API endpoint for user login."""
permission_classes = [AllowAny]
authentication_classes = []
serializer_class = LoginInputSerializer
def post(self, request: Request) -> Response:
try:
# Validate Turnstile if configured
self.validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
serializer = LoginInputSerializer(data=request.data)
if serializer.is_valid():
# type: ignore[index]
email_or_username = serializer.validated_data["username"]
password = serializer.validated_data["password"] # type: ignore[index]
# Optimized user lookup: single query using Q objects
from django.db.models import Q
from django.contrib.auth import get_user_model
User = get_user_model()
user = None
# Single query to find user by email OR username
try:
if "@" in email_or_username:
# Email-like input: try email first, then username as fallback
user_obj = (
User.objects.select_related()
.filter(
Q(email=email_or_username) | Q(username=email_or_username)
)
.first()
)
else:
# Username-like input: try username first, then email as fallback
user_obj = (
User.objects.select_related()
.filter(
Q(username=email_or_username) | Q(email=email_or_username)
)
.first()
)
if user_obj:
user = authenticate(
# type: ignore[attr-defined]
request._request,
username=user_obj.username,
password=password,
)
except Exception:
# Fallback to original behavior
user = authenticate(
# type: ignore[attr-defined]
request._request,
username=email_or_username,
password=password,
)
if user:
if user.is_active:
login(request._request, user) # type: ignore[attr-defined]
# Optimized token creation - get_or_create is atomic
token, created = Token.objects.get_or_create(user=user)
response_serializer = LoginOutputSerializer(
{
"token": token.key,
"user": user,
"message": "Login successful",
}
)
return Response(response_serializer.data)
else:
return Response(
{"error": "Account is disabled"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"error": "Invalid credentials"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="User registration",
description="Register a new user account.",
request=SignupInputSerializer,
responses={
201: SignupOutputSerializer,
400: OpenApiTypes.OBJECT,
},
tags=["Authentication"],
),
)
class SignupAPIView(TurnstileMixin, APIView):
"""API endpoint for user registration."""
permission_classes = [AllowAny]
authentication_classes = []
serializer_class = SignupInputSerializer
def post(self, request: Request) -> Response:
try:
# Validate Turnstile if configured
self.validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
serializer = SignupInputSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
login(request._request, user) # type: ignore[attr-defined]
token, created = Token.objects.get_or_create(user=user)
response_serializer = SignupOutputSerializer(
{
"token": token.key,
"user": user,
"message": "Registration successful",
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="User logout",
description="Logout the current user and invalidate their token.",
responses={
200: LogoutOutputSerializer,
401: OpenApiTypes.OBJECT,
},
tags=["Authentication"],
),
)
class LogoutAPIView(APIView):
"""API endpoint for user logout."""
permission_classes = [IsAuthenticated]
serializer_class = LogoutOutputSerializer
def post(self, request: Request) -> Response:
try:
# Delete the token for token-based auth
if hasattr(request.user, "auth_token"):
request.user.auth_token.delete()
# Logout from session
logout(request._request) # type: ignore[attr-defined]
response_serializer = LogoutOutputSerializer(
{"message": "Logout successful"}
)
return Response(response_serializer.data)
except Exception as e:
return Response(
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@extend_schema_view(
get=extend_schema(
summary="Get current user",
description="Retrieve information about the currently authenticated user.",
responses={
200: UserOutputSerializer,
401: OpenApiTypes.OBJECT,
},
tags=["Authentication"],
),
)
class CurrentUserAPIView(APIView):
"""API endpoint to get current user information."""
permission_classes = [IsAuthenticated]
serializer_class = UserOutputSerializer
def get(self, request: Request) -> Response:
serializer = UserOutputSerializer(request.user)
return Response(serializer.data)
@extend_schema_view(
post=extend_schema(
summary="Request password reset",
description="Send a password reset email to the user.",
request=PasswordResetInputSerializer,
responses={
200: PasswordResetOutputSerializer,
400: OpenApiTypes.OBJECT,
},
tags=["Authentication"],
),
)
class PasswordResetAPIView(APIView):
"""API endpoint to request password reset."""
permission_classes = [AllowAny]
serializer_class = PasswordResetInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordResetInputSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save()
response_serializer = PasswordResetOutputSerializer(
{"detail": "Password reset email sent"}
)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="Change password",
description="Change the current user's password.",
request=PasswordChangeInputSerializer,
responses={
200: PasswordChangeOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
},
tags=["Authentication"],
),
)
class PasswordChangeAPIView(APIView):
"""API endpoint to change password."""
permission_classes = [IsAuthenticated]
serializer_class = PasswordChangeInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordChangeInputSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save()
response_serializer = PasswordChangeOutputSerializer(
{"detail": "Password changed successfully"}
)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
get=extend_schema(
summary="Get social providers",
description="Retrieve available social authentication providers.",
responses={200: SocialProviderOutputSerializer(many=True)},
tags=["Authentication"],
),
)
class SocialProvidersAPIView(APIView):
"""API endpoint to get available social authentication providers."""
permission_classes = [AllowAny]
serializer_class = SocialProviderOutputSerializer
def get(self, request: Request) -> Response:
from django.core.cache import cache
from django.contrib.sites.shortcuts import get_current_site
site = get_current_site(request._request) # type: ignore[attr-defined]
# Cache key based on site and request host
cache_key = (
f"social_providers:{getattr(site, 'id', site.pk)}:{request.get_host()}"
)
# Try to get from cache first (cache for 15 minutes)
cached_providers = cache.get(cache_key)
if cached_providers is not None:
return Response(cached_providers)
providers_list = []
# Optimized query: filter by site and order by provider name
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
for social_app in social_apps:
try:
# Simplified provider name resolution - avoid expensive provider class loading
provider_name = social_app.name or social_app.provider.title()
# Build auth URL efficiently
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
providers_list.append(
{
"id": social_app.provider,
"name": provider_name,
"authUrl": auth_url,
}
)
except Exception:
# Skip if provider can't be loaded
continue
# Serialize and cache the result
serializer = SocialProviderOutputSerializer(providers_list, many=True)
response_data = serializer.data
# Cache for 15 minutes (900 seconds)
cache.set(cache_key, response_data, 900)
return Response(response_data)
@extend_schema_view(
post=extend_schema(
summary="Check authentication status",
description="Check if user is authenticated and return user data.",
responses={200: AuthStatusOutputSerializer},
tags=["Authentication"],
),
)
class AuthStatusAPIView(APIView):
"""API endpoint to check authentication status."""
permission_classes = [AllowAny]
serializer_class = AuthStatusOutputSerializer
def post(self, request: Request) -> Response:
if request.user.is_authenticated:
response_data = {
"authenticated": True,
"user": request.user,
}
else:
response_data = {
"authenticated": False,
"user": None,
}
serializer = AuthStatusOutputSerializer(response_data)
return Response(serializer.data)
# === HEALTH CHECK VIEWSETS ===
@extend_schema_view(
get=extend_schema(
summary="Health check",
description="Get comprehensive health check information including system metrics.",
responses={
200: HealthCheckOutputSerializer,
503: HealthCheckOutputSerializer,
},
tags=["Health"],
),
)
class HealthCheckAPIView(APIView):
"""Enhanced API endpoint for health checks with detailed JSON response."""
permission_classes = [AllowAny]
serializer_class = HealthCheckOutputSerializer
def get(self, request: Request) -> Response:
"""Return comprehensive health check information."""
start_time = time.time()
# Get basic health check results
main_view = MainView()
main_view.request = request._request # type: ignore[attr-defined]
plugins = main_view.plugins
errors = main_view.errors
# Collect additional performance metrics
try:
cache_monitor = CacheMonitor()
cache_stats = cache_monitor.get_cache_stats()
except Exception:
cache_stats = {"error": "Cache monitoring unavailable"}
# Build comprehensive health data
health_data = {
"status": "healthy" if not errors else "unhealthy",
"timestamp": timezone.now(),
"version": getattr(settings, "VERSION", "1.0.0"),
"environment": getattr(settings, "ENVIRONMENT", "development"),
"response_time_ms": 0, # Will be calculated at the end
"checks": {},
"metrics": {
"cache": cache_stats,
"database": self._get_database_metrics(),
"system": self._get_system_metrics(),
},
}
# Process individual health checks
for plugin in plugins:
plugin_name = plugin.identifier()
plugin_errors = (
errors.get(plugin.__class__.__name__, [])
if isinstance(errors, dict)
else []
)
health_data["checks"][plugin_name] = {
"status": "healthy" if not plugin_errors else "unhealthy",
"critical": getattr(plugin, "critical_service", False),
"errors": [str(error) for error in plugin_errors],
"response_time_ms": getattr(plugin, "_response_time", None),
}
# Calculate total response time
health_data["response_time_ms"] = round((time.time() - start_time) * 1000, 2)
# Determine HTTP status code
status_code = 200
if errors:
# Check if any critical services are failing
critical_errors = any(
getattr(plugin, "critical_service", False)
for plugin in plugins
if isinstance(errors, dict) and errors.get(plugin.__class__.__name__)
)
status_code = 503 if critical_errors else 200
serializer = HealthCheckOutputSerializer(health_data)
return Response(serializer.data, status=status_code)
def _get_database_metrics(self):
"""Get database performance metrics."""
try:
from django.db import connection
# Get basic connection info
metrics = {
"vendor": connection.vendor,
"connection_status": "connected",
}
# Test query performance
start_time = time.time()
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
query_time = (time.time() - start_time) * 1000
metrics["test_query_time_ms"] = round(query_time, 2)
# PostgreSQL specific metrics
if connection.vendor == "postgresql":
try:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
numbackends as active_connections,
xact_commit as transactions_committed,
xact_rollback as transactions_rolled_back,
blks_read as blocks_read,
blks_hit as blocks_hit
FROM pg_stat_database
WHERE datname = current_database()
"""
)
row = cursor.fetchone()
if row:
metrics.update(
{ # type: ignore[arg-type]
"active_connections": row[0],
"transactions_committed": row[1],
"transactions_rolled_back": row[2],
"cache_hit_ratio": (
round((row[4] / (row[3] + row[4])) * 100, 2)
if (row[3] + row[4]) > 0
else 0
),
}
)
except Exception:
pass # Skip advanced metrics if not available
return metrics
except Exception as e:
return {"connection_status": "error", "error": str(e)}
def _get_system_metrics(self):
"""Get system performance metrics."""
metrics = {
"debug_mode": settings.DEBUG,
"allowed_hosts": (settings.ALLOWED_HOSTS if settings.DEBUG else ["hidden"]),
}
try:
import psutil
# Memory metrics
memory = psutil.virtual_memory()
metrics["memory"] = {
"total_mb": round(memory.total / 1024 / 1024, 2),
"available_mb": round(memory.available / 1024 / 1024, 2),
"percent_used": memory.percent,
}
# CPU metrics
metrics["cpu"] = {
"percent_used": psutil.cpu_percent(interval=0.1),
"core_count": psutil.cpu_count(),
}
# Disk metrics
disk = psutil.disk_usage("/")
metrics["disk"] = {
"total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
"free_gb": round(disk.free / 1024 / 1024 / 1024, 2),
"percent_used": round((disk.used / disk.total) * 100, 2),
}
except ImportError:
metrics["system_monitoring"] = "psutil not available"
except Exception as e:
metrics["system_error"] = str(e)
return metrics
@extend_schema_view(
get=extend_schema(
summary="Performance metrics",
description="Get performance metrics and database analysis (debug mode only).",
responses={
200: PerformanceMetricsOutputSerializer,
403: OpenApiTypes.OBJECT,
},
tags=["Health"],
),
)
class PerformanceMetricsAPIView(APIView):
"""API view for performance metrics and database analysis."""
permission_classes = [AllowAny] if settings.DEBUG else []
serializer_class = PerformanceMetricsOutputSerializer
def get(self, request: Request) -> Response:
"""Return performance metrics and analysis."""
if not settings.DEBUG:
return Response({"error": "Only available in debug mode"}, status=403)
metrics = {
"timestamp": timezone.now(),
"database_analysis": self._get_database_analysis(),
"cache_performance": self._get_cache_performance(),
"recent_slow_queries": self._get_slow_queries(),
}
serializer = PerformanceMetricsOutputSerializer(metrics)
return Response(serializer.data)
def _get_database_analysis(self):
"""Analyze database performance."""
try:
from django.db import connection
analysis = {
"total_queries": len(connection.queries),
"query_analysis": IndexAnalyzer.analyze_slow_queries(0.05),
}
if connection.queries:
query_times = [float(q.get("time", 0)) for q in connection.queries]
analysis.update(
{
"total_query_time": sum(query_times),
"average_query_time": sum(query_times) / len(query_times),
"slowest_query_time": max(query_times),
"fastest_query_time": min(query_times),
}
)
return analysis
except Exception as e:
return {"error": str(e)}
def _get_cache_performance(self):
"""Get cache performance metrics."""
try:
cache_monitor = CacheMonitor()
return cache_monitor.get_cache_stats()
except Exception as e:
return {"error": str(e)}
def _get_slow_queries(self):
"""Get recent slow queries."""
try:
return IndexAnalyzer.analyze_slow_queries(0.1) # 100ms threshold
except Exception as e:
return {"error": str(e)}
@extend_schema_view(
get=extend_schema(
summary="Simple health check",
description="Simple health check endpoint for load balancers.",
responses={
200: SimpleHealthOutputSerializer,
503: SimpleHealthOutputSerializer,
},
tags=["Health"],
),
)
class SimpleHealthAPIView(APIView):
"""Simple health check endpoint for load balancers."""
permission_classes = [AllowAny]
serializer_class = SimpleHealthOutputSerializer
def get(self, request: Request) -> Response:
"""Return simple OK status."""
try:
# Basic database connectivity test
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
response_data = {
"status": "ok",
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data)
except Exception as e:
response_data = {
"status": "error",
"error": str(e),
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data, status=503)
# === HISTORY VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="Get park history",
description="Retrieve history timeline for a specific park including all changes over time.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of history events to return (default: 50, max: 500)",
),
OpenApiParameter(
name="offset",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Offset for pagination",
),
OpenApiParameter(
name="event_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by event type (created, updated, deleted)",
),
OpenApiParameter(
name="start_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events after this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="end_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events before this date (YYYY-MM-DD)",
),
],
responses={200: ParkHistoryEventSerializer(many=True)},
tags=["History", "Parks"],
),
retrieve=extend_schema(
summary="Get complete park history",
description="Retrieve complete history for a park including current state and timeline.",
responses={200: ParkHistoryOutputSerializer},
tags=["History", "Parks"],
),
)
class ParkHistoryViewSet(ReadOnlyModelViewSet):
"""
ViewSet for accessing park history data.
Provides read-only access to historical changes for parks,
including version history and real-world changes.
"""
permission_classes = [AllowAny]
lookup_field = "park_slug"
filter_backends = [OrderingFilter]
ordering_fields = ["pgh_created_at"]
ordering = ["-pgh_created_at"]
def get_queryset(self):
"""Get history events for the specified park."""
park_slug = self.kwargs.get("park_slug")
if not park_slug:
return pghistory.models.Events.objects.none()
# Get the park to ensure it exists
park = get_object_or_404(Park, slug=park_slug)
# Get all history events for this park
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=["parks.park"], pgh_obj_id=park.id
)
.select_related()
.order_by("-pgh_created_at")
)
# Apply filters
if self.action == "list":
# Filter by event type
event_type = self.request.query_params.get("event_type")
if event_type:
if event_type == "created":
queryset = queryset.filter(pgh_label="created")
elif event_type == "updated":
queryset = queryset.filter(pgh_label="updated")
elif event_type == "deleted":
queryset = queryset.filter(pgh_label="deleted")
# Filter by date range
start_date = self.request.query_params.get("start_date")
if start_date:
try:
from datetime import datetime
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__gte=start_datetime)
except ValueError:
pass
end_date = self.request.query_params.get("end_date")
if end_date:
try:
from datetime import datetime
end_datetime = datetime.strptime(end_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__lte=end_datetime)
except ValueError:
pass
# Apply limit
limit = self.request.query_params.get("limit", "50")
try:
limit = min(int(limit), 500) # Max 500 events
queryset = queryset[:limit]
except (ValueError, TypeError):
queryset = queryset[:50]
return queryset
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "retrieve":
return ParkHistoryOutputSerializer
return ParkHistoryEventSerializer
def retrieve(self, request, park_slug=None):
"""Get complete park history including current state."""
park = get_object_or_404(Park, slug=park_slug)
# Get history events
history_events = self.get_queryset()[:100] # Latest 100 events
# Prepare data for serializer
history_data = {
"park": park,
"current_state": park,
"summary": {
"total_events": self.get_queryset().count(),
"first_recorded": (
history_events.last().pgh_created_at if history_events else None
),
"last_modified": (
history_events.first().pgh_created_at if history_events else None
),
},
"events": history_events,
}
serializer = ParkHistoryOutputSerializer(history_data)
return Response(serializer.data)
@extend_schema_view(
list=extend_schema(
summary="Get ride history",
description="Retrieve history timeline for a specific ride including all changes over time.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of history events to return (default: 50, max: 500)",
),
OpenApiParameter(
name="offset",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Offset for pagination",
),
OpenApiParameter(
name="event_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by event type (created, updated, deleted)",
),
OpenApiParameter(
name="start_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events after this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="end_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events before this date (YYYY-MM-DD)",
),
],
responses={200: RideHistoryEventSerializer(many=True)},
tags=["History", "Rides"],
),
retrieve=extend_schema(
summary="Get complete ride history",
description="Retrieve complete history for a ride including current state and timeline.",
responses={200: RideHistoryOutputSerializer},
tags=["History", "Rides"],
),
)
class RideHistoryViewSet(ReadOnlyModelViewSet):
"""
ViewSet for accessing ride history data.
Provides read-only access to historical changes for rides,
including version history and real-world changes.
"""
permission_classes = [AllowAny]
lookup_field = "ride_slug"
filter_backends = [OrderingFilter]
ordering_fields = ["pgh_created_at"]
ordering = ["-pgh_created_at"]
def get_queryset(self):
"""Get history events for the specified ride."""
park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug")
if not park_slug or not ride_slug:
return pghistory.models.Events.objects.none()
# Get the ride to ensure it exists
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
# Get all history events for this ride
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
],
pgh_obj_id=ride.id,
)
.select_related()
.order_by("-pgh_created_at")
)
# Apply the same filtering logic as ParkHistoryViewSet
if self.action == "list":
# Filter by event type
event_type = self.request.query_params.get("event_type")
if event_type:
if event_type == "created":
queryset = queryset.filter(pgh_label="created")
elif event_type == "updated":
queryset = queryset.filter(pgh_label="updated")
elif event_type == "deleted":
queryset = queryset.filter(pgh_label="deleted")
# Filter by date range
start_date = self.request.query_params.get("start_date")
if start_date:
try:
from datetime import datetime
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__gte=start_datetime)
except ValueError:
pass
end_date = self.request.query_params.get("end_date")
if end_date:
try:
from datetime import datetime
end_datetime = datetime.strptime(end_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__lte=end_datetime)
except ValueError:
pass
# Apply limit
limit = self.request.query_params.get("limit", "50")
try:
limit = min(int(limit), 500) # Max 500 events
queryset = queryset[:limit]
except (ValueError, TypeError):
queryset = queryset[:50]
return queryset
def get_serializer_class(self):
"""Return appropriate serializer based on action."""
if self.action == "retrieve":
return RideHistoryOutputSerializer
return RideHistoryEventSerializer
def retrieve(self, request, park_slug=None, ride_slug=None):
"""Get complete ride history including current state."""
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
# Get history events
history_events = self.get_queryset()[:100] # Latest 100 events
# Prepare data for serializer
history_data = {
"ride": ride,
"current_state": ride,
"summary": {
"total_events": self.get_queryset().count(),
"first_recorded": (
history_events.last().pgh_created_at if history_events else None
),
"last_modified": (
history_events.first().pgh_created_at if history_events else None
),
},
"events": history_events,
}
serializer = RideHistoryOutputSerializer(history_data)
return Response(serializer.data)
@extend_schema_view(
list=extend_schema(
summary="Unified history timeline",
description="Retrieve a unified timeline of all changes across parks, rides, and companies.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of history events to return (default: 100, max: 1000)",
),
OpenApiParameter(
name="offset",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Offset for pagination",
),
OpenApiParameter(
name="model_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by model type (park, ride, company)",
),
OpenApiParameter(
name="event_type",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by event type (created, updated, deleted)",
),
OpenApiParameter(
name="start_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events after this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="end_date",
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Filter events before this date (YYYY-MM-DD)",
),
OpenApiParameter(
name="significance",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by change significance (major, minor, routine)",
),
],
responses={200: UnifiedHistoryTimelineSerializer},
tags=["History"],
),
)
class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
"""
ViewSet for unified history timeline across all models.
Provides a comprehensive view of all changes across
parks, rides, and companies in chronological order.
"""
permission_classes = [AllowAny]
filter_backends = [OrderingFilter]
ordering_fields = ["pgh_created_at"]
ordering = ["-pgh_created_at"]
def get_queryset(self):
"""Get unified history events across all tracked models."""
queryset = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
)
.select_related()
.order_by("-pgh_created_at")
)
# Apply filters
model_type = self.request.query_params.get("model_type")
if model_type:
if model_type == "park":
queryset = queryset.filter(pgh_model="parks.park")
elif model_type == "ride":
queryset = queryset.filter(
pgh_model__in=[
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
]
)
elif model_type == "company":
queryset = queryset.filter(
pgh_model__in=[
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
]
)
elif model_type == "user":
queryset = queryset.filter(pgh_model="accounts.user")
# Filter by event type
event_type = self.request.query_params.get("event_type")
if event_type:
if event_type == "created":
queryset = queryset.filter(pgh_label="created")
elif event_type == "updated":
queryset = queryset.filter(pgh_label="updated")
elif event_type == "deleted":
queryset = queryset.filter(pgh_label="deleted")
# Filter by date range
start_date = self.request.query_params.get("start_date")
if start_date:
try:
from datetime import datetime
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__gte=start_datetime)
except ValueError:
pass
end_date = self.request.query_params.get("end_date")
if end_date:
try:
from datetime import datetime
end_datetime = datetime.strptime(end_date, "%Y-%m-%d")
queryset = queryset.filter(pgh_created_at__lte=end_datetime)
except ValueError:
pass
# Apply limit
limit = self.request.query_params.get("limit", "100")
try:
limit = min(int(limit), 1000) # Max 1000 events
queryset = queryset[:limit]
except (ValueError, TypeError):
queryset = queryset[:100]
return queryset
def get_serializer_class(self):
"""Return unified history timeline serializer."""
return UnifiedHistoryTimelineSerializer
def list(self, request):
"""Get unified history timeline with summary statistics."""
events = self.get_queryset()
# Calculate summary statistics
total_events = pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
).count()
# Get event type counts
from django.db.models import Count
event_type_counts = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
)
.values("pgh_label")
.annotate(count=Count("id"))
)
# Get model type counts
model_type_counts = (
pghistory.models.Events.objects.filter(
pgh_model__in=[
"parks.park",
"rides.ride",
"rides.ridemodel",
"rides.rollercoasterstats",
"companies.operator",
"companies.propertyowner",
"companies.manufacturer",
"companies.designer",
"accounts.user",
]
)
.values("pgh_model")
.annotate(count=Count("id"))
)
timeline_data = {
"summary": {
"total_events": total_events,
"events_returned": len(events),
"event_type_breakdown": {
item["pgh_label"]: item["count"] for item in event_type_counts
},
"model_type_breakdown": {
item["pgh_model"]: item["count"] for item in model_type_counts
},
"time_range": {
"earliest": events.last().pgh_created_at if events else None,
"latest": events.first().pgh_created_at if events else None,
},
},
"events": events,
}
serializer = UnifiedHistoryTimelineSerializer(timeline_data)
return Response(serializer.data)
# === TRENDING VIEWSETS ===
@extend_schema_view(
list=extend_schema(
summary="Get trending content",
description="Retrieve trending parks and rides based on view counts, ratings, and recency.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of trending items to return (default: 20, max: 100)",
),
OpenApiParameter(
name="timeframe",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Timeframe for trending calculation (day, week, month) - default: week",
),
],
responses={200: OpenApiTypes.OBJECT},
tags=["Trending"],
),
)
class TrendingAPIView(APIView):
"""API endpoint for trending content."""
permission_classes = [AllowAny]
def get(self, request: Request) -> Response:
"""Get trending parks and rides."""
from apps.core.services.trending_service import TrendingService
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get trending content
trending_service = TrendingService()
all_trending = trending_service.get_trending_content(limit=limit * 2)
# Separate by content type
trending_rides = []
trending_parks = []
for item in all_trending:
if item.get("category") == "ride":
trending_rides.append(item)
elif item.get("category") == "park":
trending_parks.append(item)
# Limit each category
trending_rides = trending_rides[: limit // 3] if trending_rides else []
trending_parks = trending_parks[: limit // 3] if trending_parks else []
# Create mock latest reviews (since not implemented yet)
latest_reviews = [
{
"id": 1,
"name": "Steel Vengeance Review",
"location": "Cedar Point",
"category": "Roller Coaster",
"rating": 5.0,
"rank": 1,
"views": 1234,
"views_change": "+45%",
"slug": "steel-vengeance-review",
}
][: limit // 3]
# Return in expected frontend format
response_data = {
"trending_rides": trending_rides,
"trending_parks": trending_parks,
"latest_reviews": latest_reviews,
}
return Response(response_data)
@extend_schema_view(
list=extend_schema(
summary="Get new content",
description="Retrieve recently added parks and rides.",
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of new items to return (default: 20, max: 100)",
),
OpenApiParameter(
name="days",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of days to look back for new content (default: 30, max: 365)",
),
],
responses={200: OpenApiTypes.OBJECT},
tags=["Trending"],
),
)
class NewContentAPIView(APIView):
"""API endpoint for new content."""
permission_classes = [AllowAny]
def get(self, request: Request) -> Response:
"""Get new parks and rides."""
from apps.core.services.trending_service import TrendingService
from datetime import datetime, date
# Parse parameters
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get new content with longer timeframe to get more data
trending_service = TrendingService()
all_new_content = trending_service.get_new_content(
limit=limit * 2, days_back=60
)
recently_added = []
newly_opened = []
upcoming = []
# Categorize items based on date
today = date.today()
for item in all_new_content:
date_added = item.get("date_added", "")
if date_added:
try:
# Parse the date string
if isinstance(date_added, str):
item_date = datetime.fromisoformat(date_added).date()
else:
item_date = date_added
# Calculate days difference
days_diff = (today - item_date).days
if days_diff <= 30: # Recently added (last 30 days)
recently_added.append(item)
elif days_diff <= 365: # Newly opened (last year)
newly_opened.append(item)
else: # Older items
newly_opened.append(item)
except (ValueError, TypeError):
# If date parsing fails, add to recently added
recently_added.append(item)
else:
recently_added.append(item)
# Create mock upcoming items
upcoming = [
{
"id": 1,
"name": "Epic Universe",
"location": "Universal Orlando",
"category": "Theme Park",
"date_added": "Opening 2025",
"slug": "epic-universe",
},
{
"id": 2,
"name": "New Fantasyland Expansion",
"location": "Magic Kingdom",
"category": "Land Expansion",
"date_added": "Opening 2026",
"slug": "fantasyland-expansion",
},
]
# Limit each category
recently_added = recently_added[: limit // 3] if recently_added else []
newly_opened = newly_opened[: limit // 3] if newly_opened else []
upcoming = upcoming[: limit // 3] if upcoming else []
# Return in expected frontend format
response_data = {
"recently_added": recently_added,
"newly_opened": newly_opened,
"upcoming": upcoming,
}
return Response(response_data)