Refactor API structure and add comprehensive user management features

- Restructure API v1 with improved serializers organization
- Add user deletion requests and moderation queue system
- Implement bulk moderation operations and permissions
- Add user profile enhancements with display names and avatars
- Expand ride and park API endpoints with better filtering
- Add manufacturer API with detailed ride relationships
- Improve authentication flows and error handling
- Update frontend documentation and API specifications
This commit is contained in:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

View File

@@ -1,18 +1,108 @@
"""
Accounts API URL Configuration
URL configuration for user account management API endpoints.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from django.urls import path
from . import views
# Create router and register ViewSets
router = DefaultRouter()
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
router.register(r"toplists", views.TopListViewSet, basename="top-list")
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
urlpatterns = [
# Include router URLs for ViewSets
path("", include(router.urls)),
# Admin endpoints for user management
path(
"users/<str:user_id>/delete/",
views.delete_user_preserve_submissions,
name="delete_user_preserve_submissions",
),
path(
"users/<str:user_id>/deletion-check/",
views.check_user_deletion_eligibility,
name="check_user_deletion_eligibility",
),
# Self-service account deletion endpoints
path(
"delete-account/request/",
views.request_account_deletion,
name="request_account_deletion",
),
path(
"delete-account/verify/",
views.verify_account_deletion,
name="verify_account_deletion",
),
path(
"delete-account/cancel/",
views.cancel_account_deletion,
name="cancel_account_deletion",
),
# User profile endpoints
path("profile/", views.get_user_profile, name="get_user_profile"),
path("profile/account/", views.update_user_account, name="update_user_account"),
path("profile/update/", views.update_user_profile, name="update_user_profile"),
# User preferences endpoints
path("preferences/", views.get_user_preferences, name="get_user_preferences"),
path(
"preferences/update/",
views.update_user_preferences,
name="update_user_preferences",
),
path(
"preferences/theme/",
views.update_theme_preference,
name="update_theme_preference",
),
# Notification settings endpoints
path(
"settings/notifications/",
views.get_notification_settings,
name="get_notification_settings",
),
path(
"settings/notifications/update/",
views.update_notification_settings,
name="update_notification_settings",
),
# Privacy settings endpoints
path("settings/privacy/", views.get_privacy_settings, name="get_privacy_settings"),
path(
"settings/privacy/update/",
views.update_privacy_settings,
name="update_privacy_settings",
),
# Security settings endpoints
path(
"settings/security/", views.get_security_settings, name="get_security_settings"
),
path(
"settings/security/update/",
views.update_security_settings,
name="update_security_settings",
),
# User statistics endpoints
path("statistics/", views.get_user_statistics, name="get_user_statistics"),
# Top lists endpoints
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
path("top-lists/create/", views.create_top_list, name="create_top_list"),
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
path(
"top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"
),
# Notification endpoints
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
path(
"notifications/mark-read/",
views.mark_notifications_read,
name="mark_notifications_read",
),
path(
"notification-preferences/",
views.get_notification_preferences,
name="get_notification_preferences",
),
path(
"notification-preferences/update/",
views.update_notification_preferences,
name="update_notification_preferences",
),
# Avatar endpoints
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
]

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,7 @@ class UserOutputSerializer(serializers.ModelSerializer):
"""User serializer for API responses."""
avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
class Meta:
model = UserModel
@@ -87,9 +88,14 @@ class UserOutputSerializer(serializers.ModelSerializer):
"date_joined",
"is_active",
"avatar_url",
"display_name",
]
read_only_fields = ["id", "date_joined", "is_active"]
def get_display_name(self, obj):
"""Get the user's display name."""
return obj.get_display_name()
@extend_schema_field(serializers.URLField(allow_null=True))
def get_avatar_url(self, obj) -> str | None:
"""Get user avatar URL."""

View File

@@ -4,27 +4,27 @@ Migrated from apps.core.views.map_views
"""
import logging
from typing import Dict, List, Any, Optional
from django.http import HttpRequest
from django.db.models import Q
from django.core.cache import cache
from django.contrib.gis.geos import Polygon
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
OpenApiParameter,
OpenApiExample,
)
from drf_spectacular.types import OpenApiTypes
from apps.parks.models import Park, ParkLocation
from apps.parks.models import Park
from apps.rides.models import Ride
from ..serializers.maps import (
MapLocationSerializer,
MapLocationsResponseSerializer,
MapSearchResultSerializer,
MapSearchResponseSerializer,
MapLocationDetailSerializer,
)
@@ -86,7 +86,7 @@ logger = logging.getLogger(__name__)
examples=[
OpenApiExample("All types", value="park,ride"),
OpenApiExample("Parks only", value="park"),
OpenApiExample("Rides only", value="ride")
OpenApiExample("Rides only", value="ride"),
],
),
OpenApiParameter(
@@ -97,7 +97,7 @@ logger = logging.getLogger(__name__)
description="Enable location clustering for high-density areas. Default: false",
examples=[
OpenApiExample("Enable clustering", value=True),
OpenApiExample("Disable clustering", value=False)
OpenApiExample("Disable clustering", value=False),
],
),
OpenApiParameter(
@@ -109,7 +109,7 @@ logger = logging.getLogger(__name__)
examples=[
OpenApiExample("Park name", value="Cedar Point"),
OpenApiExample("Ride type", value="roller coaster"),
OpenApiExample("Location", value="Ohio")
OpenApiExample("Location", value="Ohio"),
],
),
],
@@ -150,27 +150,28 @@ class MapLocationsAPIView(APIView):
# Get parks if requested
if "park" in types:
parks_query = Park.objects.select_related("location", "operator").filter(
location__point__isnull=False
)
parks_query = Park.objects.select_related(
"location", "operator"
).filter(location__point__isnull=False)
# Apply bounds filtering
if all([north, south, east, west]):
try:
bounds_polygon = Polygon.from_bbox((
float(west), float(south), float(east), float(north)
))
bounds_polygon = Polygon.from_bbox(
(float(west), float(south), float(east), float(north))
)
parks_query = parks_query.filter(
location__point__within=bounds_polygon)
location__point__within=bounds_polygon
)
except (ValueError, TypeError):
pass
# Apply text search
if query:
parks_query = parks_query.filter(
Q(name__icontains=query) |
Q(location__city__icontains=query) |
Q(location__state__icontains=query)
Q(name__icontains=query)
| Q(location__city__icontains=query)
| Q(location__state__icontains=query)
)
# Serialize parks
@@ -180,46 +181,75 @@ class MapLocationsAPIView(APIView):
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"latitude": (
park.location.latitude
if hasattr(park, "location") and park.location
else None
),
"longitude": (
park.location.longitude
if hasattr(park, "location") and park.location
else None
),
"status": park.status,
"location": {
"city": park.location.city if hasattr(park, 'location') and park.location else "",
"state": park.location.state if hasattr(park, 'location') and park.location else "",
"country": park.location.country if hasattr(park, 'location') and park.location else "",
"formatted_address": park.location.formatted_address if hasattr(park, 'location') and park.location else "",
"city": (
park.location.city
if hasattr(park, "location") and park.location
else ""
),
"state": (
park.location.state
if hasattr(park, "location") and park.location
else ""
),
"country": (
park.location.country
if hasattr(park, "location") and park.location
else ""
),
"formatted_address": (
park.location.formatted_address
if hasattr(park, "location") and park.location
else ""
),
},
"stats": {
"coaster_count": park.coaster_count or 0,
"ride_count": park.ride_count or 0,
"average_rating": float(park.average_rating) if park.average_rating else None,
"average_rating": (
float(park.average_rating)
if park.average_rating
else None
),
},
}
locations.append(park_data)
# Get rides if requested
if "ride" in types:
rides_query = Ride.objects.select_related("park__location", "manufacturer").filter(
park__location__point__isnull=False
)
rides_query = Ride.objects.select_related(
"park__location", "manufacturer"
).filter(park__location__point__isnull=False)
# Apply bounds filtering
if all([north, south, east, west]):
try:
bounds_polygon = Polygon.from_bbox((
float(west), float(south), float(east), float(north)
))
bounds_polygon = Polygon.from_bbox(
(float(west), float(south), float(east), float(north))
)
rides_query = rides_query.filter(
park__location__point__within=bounds_polygon)
park__location__point__within=bounds_polygon
)
except (ValueError, TypeError):
pass
# Apply text search
if query:
rides_query = rides_query.filter(
Q(name__icontains=query) |
Q(park__name__icontains=query) |
Q(park__location__city__icontains=query)
Q(name__icontains=query)
| Q(park__name__icontains=query)
| Q(park__location__city__icontains=query)
)
# Serialize rides
@@ -229,18 +259,48 @@ class MapLocationsAPIView(APIView):
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"latitude": (
ride.park.location.latitude
if hasattr(ride.park, "location") and ride.park.location
else None
),
"longitude": (
ride.park.location.longitude
if hasattr(ride.park, "location") and ride.park.location
else None
),
"status": ride.status,
"location": {
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
"formatted_address": ride.park.location.formatted_address if hasattr(ride.park, 'location') and ride.park.location else "",
"city": (
ride.park.location.city
if hasattr(ride.park, "location") and ride.park.location
else ""
),
"state": (
ride.park.location.state
if hasattr(ride.park, "location") and ride.park.location
else ""
),
"country": (
ride.park.location.country
if hasattr(ride.park, "location") and ride.park.location
else ""
),
"formatted_address": (
ride.park.location.formatted_address
if hasattr(ride.park, "location") and ride.park.location
else ""
),
},
"stats": {
"category": ride.get_category_display() if ride.category else None,
"average_rating": float(ride.average_rating) if ride.average_rating else None,
"category": (
ride.get_category_display() if ride.category else None
),
"average_rating": (
float(ride.average_rating)
if ride.average_rating
else None
),
"park_name": ride.park.name,
},
}
@@ -324,8 +384,9 @@ class MapLocationDetailAPIView(APIView):
try:
if location_type == "park":
try:
obj = Park.objects.select_related(
"location", "operator").get(id=location_id)
obj = Park.objects.select_related("location", "operator").get(
id=location_id
)
except Park.DoesNotExist:
return Response(
{"status": "error", "message": "Park not found"},
@@ -334,7 +395,8 @@ class MapLocationDetailAPIView(APIView):
elif location_type == "ride":
try:
obj = Ride.objects.select_related(
"park__location", "manufacturer").get(id=location_id)
"park__location", "manufacturer"
).get(id=location_id)
except Ride.DoesNotExist:
return Response(
{"status": "error", "message": "Ride not found"},
@@ -354,23 +416,59 @@ class MapLocationDetailAPIView(APIView):
"name": obj.name,
"slug": obj.slug,
"description": obj.description,
"latitude": obj.location.latitude if hasattr(obj, 'location') and obj.location else None,
"longitude": obj.location.longitude if hasattr(obj, 'location') and obj.location else None,
"latitude": (
obj.location.latitude
if hasattr(obj, "location") and obj.location
else None
),
"longitude": (
obj.location.longitude
if hasattr(obj, "location") and obj.location
else None
),
"status": obj.status,
"location": {
"street_address": obj.location.street_address if hasattr(obj, 'location') and obj.location else "",
"city": obj.location.city if hasattr(obj, 'location') and obj.location else "",
"state": obj.location.state if hasattr(obj, 'location') and obj.location else "",
"country": obj.location.country if hasattr(obj, 'location') and obj.location else "",
"postal_code": obj.location.postal_code if hasattr(obj, 'location') and obj.location else "",
"formatted_address": obj.location.formatted_address if hasattr(obj, 'location') and obj.location else "",
"street_address": (
obj.location.street_address
if hasattr(obj, "location") and obj.location
else ""
),
"city": (
obj.location.city
if hasattr(obj, "location") and obj.location
else ""
),
"state": (
obj.location.state
if hasattr(obj, "location") and obj.location
else ""
),
"country": (
obj.location.country
if hasattr(obj, "location") and obj.location
else ""
),
"postal_code": (
obj.location.postal_code
if hasattr(obj, "location") and obj.location
else ""
),
"formatted_address": (
obj.location.formatted_address
if hasattr(obj, "location") and obj.location
else ""
),
},
"stats": {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
},
"nearby_locations": [], # TODO: Implement nearby locations
}
@@ -381,31 +479,73 @@ class MapLocationDetailAPIView(APIView):
"name": obj.name,
"slug": obj.slug,
"description": obj.description,
"latitude": obj.park.location.latitude if hasattr(obj.park, 'location') and obj.park.location else None,
"longitude": obj.park.location.longitude if hasattr(obj.park, 'location') and obj.park.location else None,
"latitude": (
obj.park.location.latitude
if hasattr(obj.park, "location") and obj.park.location
else None
),
"longitude": (
obj.park.location.longitude
if hasattr(obj.park, "location") and obj.park.location
else None
),
"status": obj.status,
"location": {
"street_address": obj.park.location.street_address if hasattr(obj.park, 'location') and obj.park.location else "",
"city": obj.park.location.city if hasattr(obj.park, 'location') and obj.park.location else "",
"state": obj.park.location.state if hasattr(obj.park, 'location') and obj.park.location else "",
"country": obj.park.location.country if hasattr(obj.park, 'location') and obj.park.location else "",
"postal_code": obj.park.location.postal_code if hasattr(obj.park, 'location') and obj.park.location else "",
"formatted_address": obj.park.location.formatted_address if hasattr(obj.park, 'location') and obj.park.location else "",
"street_address": (
obj.park.location.street_address
if hasattr(obj.park, "location") and obj.park.location
else ""
),
"city": (
obj.park.location.city
if hasattr(obj.park, "location") and obj.park.location
else ""
),
"state": (
obj.park.location.state
if hasattr(obj.park, "location") and obj.park.location
else ""
),
"country": (
obj.park.location.country
if hasattr(obj.park, "location") and obj.park.location
else ""
),
"postal_code": (
obj.park.location.postal_code
if hasattr(obj.park, "location") and obj.park.location
else ""
),
"formatted_address": (
obj.park.location.formatted_address
if hasattr(obj.park, "location") and obj.park.location
else ""
),
},
"stats": {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"category": (
obj.get_category_display() if obj.category else None
),
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"park_name": obj.park.name,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
"manufacturer": (
obj.manufacturer.name if obj.manufacturer else None
),
},
"nearby_locations": [], # TODO: Implement nearby locations
}
return Response({
"status": "success",
"data": data,
})
return Response(
{
"status": "success",
"data": data,
}
)
except Exception as e:
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
@@ -484,51 +624,106 @@ class MapSearchAPIView(APIView):
# Search parks
if "park" in types:
parks_query = Park.objects.select_related("location").filter(
Q(name__icontains=query) |
Q(location__city__icontains=query) |
Q(location__state__icontains=query)
).filter(location__point__isnull=False)
parks_query = (
Park.objects.select_related("location")
.filter(
Q(name__icontains=query)
| Q(location__city__icontains=query)
| Q(location__state__icontains=query)
)
.filter(location__point__isnull=False)
)
for park in parks_query[:50]: # Limit results
results.append({
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"location": {
"city": park.location.city if hasattr(park, 'location') and park.location else "",
"state": park.location.state if hasattr(park, 'location') and park.location else "",
"country": park.location.country if hasattr(park, 'location') and park.location else "",
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
})
results.append(
{
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": (
park.location.latitude
if hasattr(park, "location") and park.location
else None
),
"longitude": (
park.location.longitude
if hasattr(park, "location") and park.location
else None
),
"location": {
"city": (
park.location.city
if hasattr(park, "location") and park.location
else ""
),
"state": (
park.location.state
if hasattr(park, "location") and park.location
else ""
),
"country": (
park.location.country
if hasattr(park, "location") and park.location
else ""
),
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
}
)
# Search rides
if "ride" in types:
rides_query = Ride.objects.select_related("park__location").filter(
Q(name__icontains=query) |
Q(park__name__icontains=query) |
Q(park__location__city__icontains=query)
).filter(park__location__point__isnull=False)
rides_query = (
Ride.objects.select_related("park__location")
.filter(
Q(name__icontains=query)
| Q(park__name__icontains=query)
| Q(park__location__city__icontains=query)
)
.filter(park__location__point__isnull=False)
)
for ride in rides_query[:50]: # Limit results
results.append({
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"location": {
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
})
results.append(
{
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": (
ride.park.location.latitude
if hasattr(ride.park, "location") and ride.park.location
else None
),
"longitude": (
ride.park.location.longitude
if hasattr(ride.park, "location") and ride.park.location
else None
),
"location": {
"city": (
ride.park.location.city
if hasattr(ride.park, "location")
and ride.park.location
else ""
),
"state": (
ride.park.location.state
if hasattr(ride.park, "location")
and ride.park.location
else ""
),
"country": (
ride.park.location.country
if hasattr(ride.park, "location")
and ride.park.location
else ""
),
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
}
)
total_count = len(results)
@@ -537,14 +732,16 @@ class MapSearchAPIView(APIView):
end_idx = start_idx + page_size
paginated_results = results[start_idx:end_idx]
return Response({
"status": "success",
"results": paginated_results,
"query": query,
"total_count": total_count,
"page": page,
"page_size": page_size,
})
return Response(
{
"status": "success",
"results": paginated_results,
"query": query,
"total_count": total_count,
"page": page,
"page_size": page_size,
}
)
except Exception as e:
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
@@ -622,13 +819,19 @@ class MapBoundsAPIView(APIView):
# Validate bounds
if north <= south:
return Response(
{"status": "error", "message": "North bound must be greater than south bound"},
{
"status": "error",
"message": "North bound must be greater than south bound",
},
status=status.HTTP_400_BAD_REQUEST,
)
if west >= east:
return Response(
{"status": "error", "message": "West bound must be less than east bound"},
{
"status": "error",
"message": "West bound must be less than east bound",
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -645,15 +848,25 @@ class MapBoundsAPIView(APIView):
)
for park in parks_query[:100]: # Limit results
locations.append({
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"status": park.status,
})
locations.append(
{
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": (
park.location.latitude
if hasattr(park, "location") and park.location
else None
),
"longitude": (
park.location.longitude
if hasattr(park, "location") and park.location
else None
),
"status": park.status,
}
)
# Get rides within bounds
if "ride" in types:
@@ -662,32 +875,47 @@ class MapBoundsAPIView(APIView):
)
for ride in rides_query[:100]: # Limit results
locations.append({
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"status": ride.status,
})
locations.append(
{
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": (
ride.park.location.latitude
if hasattr(ride.park, "location") and ride.park.location
else None
),
"longitude": (
ride.park.location.longitude
if hasattr(ride.park, "location") and ride.park.location
else None
),
"status": ride.status,
}
)
return Response({
"status": "success",
"locations": locations,
"bounds": {
"north": north,
"south": south,
"east": east,
"west": west,
},
"total_count": len(locations),
})
return Response(
{
"status": "success",
"locations": locations,
"bounds": {
"north": north,
"south": south,
"east": east,
"west": west,
},
"total_count": len(locations),
}
)
except Exception as e:
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
return Response(
{"status": "error", "message": "Failed to retrieve locations within bounds"},
{
"status": "error",
"message": "Failed to retrieve locations within bounds",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -710,21 +938,25 @@ class MapStatsAPIView(APIView):
try:
# Count locations with coordinates
parks_with_location = Park.objects.filter(
location__point__isnull=False).count()
location__point__isnull=False
).count()
rides_with_location = Ride.objects.filter(
park__location__point__isnull=False).count()
park__location__point__isnull=False
).count()
total_locations = parks_with_location + rides_with_location
return Response({
"status": "success",
"data": {
"total_locations": total_locations,
"parks_with_location": parks_with_location,
"rides_with_location": rides_with_location,
"cache_hits": 0, # TODO: Implement cache statistics
"cache_misses": 0, # TODO: Implement cache statistics
},
})
return Response(
{
"status": "success",
"data": {
"total_locations": total_locations,
"parks_with_location": parks_with_location,
"rides_with_location": rides_with_location,
"cache_hits": 0, # TODO: Implement cache statistics
"cache_misses": 0, # TODO: Implement cache statistics
},
}
)
except Exception as e:
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
@@ -764,10 +996,12 @@ class MapCacheAPIView(APIView):
else:
cleared_count = 0
return Response({
"status": "success",
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
})
return Response(
{
"status": "success",
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
}
)
except Exception as e:
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
@@ -787,10 +1021,12 @@ class MapCacheAPIView(APIView):
else:
invalidated_count = 0
return Response({
"status": "success",
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
})
return Response(
{
"status": "success",
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
}
)
except Exception as e:
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)

View File

@@ -16,8 +16,7 @@ from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -422,9 +421,17 @@ class ParkSearchSuggestionsAPIView(APIView):
@extend_schema(
summary="Set park banner and card images",
description="Set banner_image and card_image for a park from existing park photos",
request=("ParkImageSettingsInputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
request=(
"ParkImageSettingsInputSerializer"
if SERIALIZERS_AVAILABLE
else OpenApiTypes.OBJECT
),
responses={
200: ("ParkDetailOutputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
200: (
"ParkDetailOutputSerializer"
if SERIALIZERS_AVAILABLE
else OpenApiTypes.OBJECT
),
400: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
@@ -462,5 +469,6 @@ class ParkImageSettingsAPIView(APIView):
# Return updated park data
output_serializer = ParkDetailOutputSerializer(
park, context={"request": request})
park, context={"request": request}
)
return Response(output_serializer.data)

View File

@@ -6,39 +6,43 @@ Enhanced from rogue implementation to maintain full feature parity.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
from drf_spectacular.utils import (
extend_schema_field,
extend_schema_serializer,
OpenApiExample,
)
from apps.parks.models import Park, ParkPhoto
@extend_schema_serializer(
examples=[
OpenApiExample(
name='Park Photo with Cloudflare Images',
summary='Complete park photo response',
description='Example response showing all fields including Cloudflare Images URLs and variants',
name="Park Photo with Cloudflare Images",
summary="Complete park photo response",
description="Example response showing all fields including Cloudflare Images URLs and variants",
value={
'id': 456,
'image': 'https://imagedelivery.net/account-hash/def456ghi789/public',
'image_url': 'https://imagedelivery.net/account-hash/def456ghi789/public',
'image_variants': {
'thumbnail': 'https://imagedelivery.net/account-hash/def456ghi789/thumbnail',
'medium': 'https://imagedelivery.net/account-hash/def456ghi789/medium',
'large': 'https://imagedelivery.net/account-hash/def456ghi789/large',
'public': 'https://imagedelivery.net/account-hash/def456ghi789/public'
"id": 456,
"image": "https://imagedelivery.net/account-hash/def456ghi789/public",
"image_url": "https://imagedelivery.net/account-hash/def456ghi789/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/def456ghi789/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def456ghi789/medium",
"large": "https://imagedelivery.net/account-hash/def456ghi789/large",
"public": "https://imagedelivery.net/account-hash/def456ghi789/public",
},
'caption': 'Beautiful park entrance',
'alt_text': 'Main entrance gate with decorative archway',
'is_primary': True,
'is_approved': True,
'created_at': '2023-01-01T12:00:00Z',
'updated_at': '2023-01-01T12:00:00Z',
'date_taken': '2023-01-01T11:00:00Z',
'uploaded_by_username': 'parkfan456',
'file_size': 1536000,
'dimensions': [1600, 900],
'park_slug': 'cedar-point',
'park_name': 'Cedar Point'
}
"caption": "Beautiful park entrance",
"alt_text": "Main entrance gate with decorative archway",
"is_primary": True,
"is_approved": True,
"created_at": "2023-01-01T12:00:00Z",
"updated_at": "2023-01-01T12:00:00Z",
"date_taken": "2023-01-01T11:00:00Z",
"uploaded_by_username": "parkfan456",
"file_size": 1536000,
"dimensions": [1600, 900],
"park_slug": "cedar-point",
"park_name": "Cedar Point",
},
)
]
)
@@ -76,8 +80,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset",
allow_null=True
help_text="Full URL to the Cloudflare Images asset", allow_null=True
)
)
def get_image_url(self, obj):
@@ -89,7 +92,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
@extend_schema_field(
serializers.DictField(
child=serializers.URLField(),
help_text="Available Cloudflare Images variants with their URLs"
help_text="Available Cloudflare Images variants with their URLs",
)
)
def get_image_variants(self, obj):
@@ -99,10 +102,10 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
# Common variants for park photos
variants = {
'thumbnail': f"{obj.image.url}/thumbnail",
'medium': f"{obj.image.url}/medium",
'large': f"{obj.image.url}/large",
'public': f"{obj.image.url}/public"
"thumbnail": f"{obj.image.url}/thumbnail",
"medium": f"{obj.image.url}/medium",
"large": f"{obj.image.url}/large",
"public": f"{obj.image.url}/public",
}
return variants

View File

@@ -44,7 +44,11 @@ urlpatterns = [
# Detail and action endpoints
path("<int:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
# Park image settings endpoint
path("<int:pk>/image-settings/", ParkImageSettingsAPIView.as_view(), name="park-image-settings"),
path(
"<int:pk>/image-settings/",
ParkImageSettingsAPIView.as_view(),
name="park-image-settings",
),
# Park photo endpoints - domain-specific photo management
path("<int:park_pk>/photos/", include(router.urls)),
]

View File

@@ -29,37 +29,51 @@ app_name = "api_v1_ride_models"
urlpatterns = [
# Core ride model endpoints - nested under manufacturer
path("", RideModelListCreateAPIView.as_view(), name="ride-model-list-create"),
path("<slug:ride_model_slug>/", RideModelDetailAPIView.as_view(), name="ride-model-detail"),
path(
"<slug:ride_model_slug>/",
RideModelDetailAPIView.as_view(),
name="ride-model-detail",
),
# Search and filtering (global, not manufacturer-specific)
path("search/", RideModelSearchAPIView.as_view(), name="ride-model-search"),
path("filter-options/", RideModelFilterOptionsAPIView.as_view(),
name="ride-model-filter-options"),
path(
"filter-options/",
RideModelFilterOptionsAPIView.as_view(),
name="ride-model-filter-options",
),
# Statistics (global, not manufacturer-specific)
path("stats/", RideModelStatsAPIView.as_view(), name="ride-model-stats"),
# Ride model variants - using slug-based lookup
path("<slug:ride_model_slug>/variants/",
RideModelVariantListCreateAPIView.as_view(),
name="ride-model-variant-list-create"),
path("<slug:ride_model_slug>/variants/<int:pk>/",
RideModelVariantDetailAPIView.as_view(),
name="ride-model-variant-detail"),
path(
"<slug:ride_model_slug>/variants/",
RideModelVariantListCreateAPIView.as_view(),
name="ride-model-variant-list-create",
),
path(
"<slug:ride_model_slug>/variants/<int:pk>/",
RideModelVariantDetailAPIView.as_view(),
name="ride-model-variant-detail",
),
# Technical specifications - using slug-based lookup
path("<slug:ride_model_slug>/technical-specs/",
RideModelTechnicalSpecListCreateAPIView.as_view(),
name="ride-model-technical-spec-list-create"),
path("<slug:ride_model_slug>/technical-specs/<int:pk>/",
RideModelTechnicalSpecDetailAPIView.as_view(),
name="ride-model-technical-spec-detail"),
path(
"<slug:ride_model_slug>/technical-specs/",
RideModelTechnicalSpecListCreateAPIView.as_view(),
name="ride-model-technical-spec-list-create",
),
path(
"<slug:ride_model_slug>/technical-specs/<int:pk>/",
RideModelTechnicalSpecDetailAPIView.as_view(),
name="ride-model-technical-spec-detail",
),
# Photos - using slug-based lookup
path("<slug:ride_model_slug>/photos/",
RideModelPhotoListCreateAPIView.as_view(),
name="ride-model-photo-list-create"),
path("<slug:ride_model_slug>/photos/<int:pk>/",
RideModelPhotoDetailAPIView.as_view(),
name="ride-model-photo-detail"),
path(
"<slug:ride_model_slug>/photos/",
RideModelPhotoListCreateAPIView.as_view(),
name="ride-model-photo-list-create",
),
path(
"<slug:ride_model_slug>/photos/<int:pk>/",
RideModelPhotoDetailAPIView.as_view(),
name="ride-model-photo-detail",
),
]

View File

@@ -13,7 +13,7 @@ This module implements comprehensive endpoints for ride model management:
"""
from typing import Any
from datetime import datetime, timedelta
from datetime import timedelta
from rest_framework import status, permissions
from rest_framework.views import APIView
@@ -36,25 +36,31 @@ from apps.api.v1.serializers.ride_models import (
RideModelVariantOutputSerializer,
RideModelVariantCreateInputSerializer,
RideModelVariantUpdateInputSerializer,
RideModelTechnicalSpecOutputSerializer,
RideModelTechnicalSpecCreateInputSerializer,
RideModelTechnicalSpecUpdateInputSerializer,
RideModelPhotoOutputSerializer,
RideModelPhotoCreateInputSerializer,
RideModelPhotoUpdateInputSerializer,
RideModelStatsOutputSerializer,
)
# Attempt to import models; fall back gracefully if not present
try:
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
from apps.rides.models import (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
)
from apps.rides.models.company import Company
MODELS_AVAILABLE = True
except ImportError:
try:
# Try alternative import path
from apps.rides.models.rides import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
from apps.rides.models.rides import (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
)
from apps.rides.models.rides import Company
MODELS_AVAILABLE = True
except ImportError:
RideModel = None
@@ -82,7 +88,10 @@ class RideModelListCreateAPIView(APIView):
description="List ride models with comprehensive filtering and pagination.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
@@ -97,10 +106,14 @@ class RideModelListCreateAPIView(APIView):
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="target_market", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
name="target_market",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
),
OpenApiParameter(
name="is_discontinued", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL
name="is_discontinued",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
),
],
responses={200: RideModelListOutputSerializer(many=True)},
@@ -123,7 +136,11 @@ class RideModelListCreateAPIView(APIView):
except Company.DoesNotExist:
raise NotFound("Manufacturer not found")
qs = RideModel.objects.filter(manufacturer=manufacturer).select_related("manufacturer").prefetch_related("photos")
qs = (
RideModel.objects.filter(manufacturer=manufacturer)
.select_related("manufacturer")
.prefetch_related("photos")
)
# Apply filters
filter_serializer = RideModelFilterInputSerializer(data=request.query_params)
@@ -134,9 +151,9 @@ class RideModelListCreateAPIView(APIView):
if filters.get("search"):
search_term = filters["search"]
qs = qs.filter(
Q(name__icontains=search_term) |
Q(description__icontains=search_term) |
Q(manufacturer__name__icontains=search_term)
Q(name__icontains=search_term)
| Q(description__icontains=search_term)
| Q(manufacturer__name__icontains=search_term)
)
# Category filter
@@ -160,10 +177,12 @@ class RideModelListCreateAPIView(APIView):
# Year filters
if filters.get("first_installation_year_min"):
qs = qs.filter(
first_installation_year__gte=filters["first_installation_year_min"])
first_installation_year__gte=filters["first_installation_year_min"]
)
if filters.get("first_installation_year_max"):
qs = qs.filter(
first_installation_year__lte=filters["first_installation_year_max"])
first_installation_year__lte=filters["first_installation_year_max"]
)
# Installation count filter
if filters.get("min_installations"):
@@ -172,18 +191,22 @@ class RideModelListCreateAPIView(APIView):
# Height filters
if filters.get("min_height_ft"):
qs = qs.filter(
typical_height_range_max_ft__gte=filters["min_height_ft"])
typical_height_range_max_ft__gte=filters["min_height_ft"]
)
if filters.get("max_height_ft"):
qs = qs.filter(
typical_height_range_min_ft__lte=filters["max_height_ft"])
typical_height_range_min_ft__lte=filters["max_height_ft"]
)
# Speed filters
if filters.get("min_speed_mph"):
qs = qs.filter(
typical_speed_range_max_mph__gte=filters["min_speed_mph"])
typical_speed_range_max_mph__gte=filters["min_speed_mph"]
)
if filters.get("max_speed_mph"):
qs = qs.filter(
typical_speed_range_min_mph__lte=filters["max_speed_mph"])
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
)
# Ordering
ordering = filters.get("ordering", "manufacturer__name,name")
@@ -203,7 +226,10 @@ class RideModelListCreateAPIView(APIView):
description="Create a new ride model for a specific manufacturer.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
request=RideModelCreateInputSerializer,
@@ -262,13 +288,17 @@ class RideModelListCreateAPIView(APIView):
class RideModelDetailAPIView(APIView):
permission_classes = [permissions.AllowAny]
def _get_ride_model_or_404(self, manufacturer_slug: str, ride_model_slug: str) -> Any:
def _get_ride_model_or_404(
self, manufacturer_slug: str, ride_model_slug: str
) -> Any:
if not MODELS_AVAILABLE:
raise NotFound("Ride model models not available")
try:
return RideModel.objects.select_related("manufacturer").prefetch_related(
"photos", "variants", "technical_specs"
).get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
return (
RideModel.objects.select_related("manufacturer")
.prefetch_related("photos", "variants", "technical_specs")
.get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
)
except RideModel.DoesNotExist:
raise NotFound("Ride model not found")
@@ -277,16 +307,24 @@ class RideModelDetailAPIView(APIView):
description="Get detailed information about a specific ride model.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="ride_model_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def get(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def get(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
@@ -298,17 +336,25 @@ class RideModelDetailAPIView(APIView):
description="Update a ride model (partial update supported).",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="ride_model_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
request=RideModelUpdateInputSerializer,
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def patch(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def patch(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
@@ -331,7 +377,9 @@ class RideModelDetailAPIView(APIView):
)
return Response(serializer.data)
def put(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def put(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
# Full replace - reuse patch behavior for simplicity
return self.patch(request, manufacturer_slug, ride_model_slug)
@@ -340,16 +388,24 @@ class RideModelDetailAPIView(APIView):
description="Delete a ride model.",
parameters=[
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="manufacturer_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.PATH, type=OpenApiTypes.STR, required=True
name="ride_model_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
),
],
responses={204: None},
tags=["Ride Models"],
)
def delete(self, request: Request, manufacturer_slug: str, ride_model_slug: str) -> Response:
def delete(
self, request: Request, manufacturer_slug: str, ride_model_slug: str
) -> Response:
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
ride_model.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -366,7 +422,10 @@ class RideModelSearchAPIView(APIView):
description="Search ride models by name, description, or manufacturer.",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True
name="q",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=True,
)
],
responses={200: RideModelListOutputSerializer(many=True)},
@@ -384,15 +443,15 @@ class RideModelSearchAPIView(APIView):
"id": 1,
"name": "Hyper Coaster",
"manufacturer": {"name": "Bolliger & Mabillard"},
"category": "RC"
"category": "RC",
}
]
)
qs = RideModel.objects.filter(
Q(name__icontains=q) |
Q(description__icontains=q) |
Q(manufacturer__name__icontains=q)
Q(name__icontains=q)
| Q(description__icontains=q)
| Q(manufacturer__name__icontains=q)
).select_related("manufacturer")[:20]
results = [
@@ -426,54 +485,65 @@ class RideModelFilterOptionsAPIView(APIView):
def get(self, request: Request) -> Response:
"""Return filter options for ride models."""
if not MODELS_AVAILABLE:
return Response({
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
})
return Response(
{
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
}
)
# Get actual data from database
manufacturers = Company.objects.filter(
roles__contains=["MANUFACTURER"],
ride_models__isnull=False
).distinct().values("id", "name", "slug")
manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.values("id", "name", "slug")
)
categories = RideModel.objects.exclude(category="").values_list(
"category", flat=True
).distinct()
categories = (
RideModel.objects.exclude(category="")
.values_list("category", flat=True)
.distinct()
)
target_markets = RideModel.objects.exclude(target_market="").values_list(
"target_market", flat=True
).distinct()
target_markets = (
RideModel.objects.exclude(target_market="")
.values_list("target_market", flat=True)
.distinct()
)
return Response({
"categories": [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
"target_markets": [
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
"manufacturers": list(manufacturers),
"ordering_options": [
("name", "Name A-Z"),
("-name", "Name Z-A"),
("manufacturer__name", "Manufacturer A-Z"),
("-manufacturer__name", "Manufacturer Z-A"),
("first_installation_year", "Oldest First"),
("-first_installation_year", "Newest First"),
("total_installations", "Fewest Installations"),
("-total_installations", "Most Installations"),
],
})
return Response(
{
"categories": [
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
"target_markets": [
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
"manufacturers": list(manufacturers),
"ordering_options": [
("name", "Name A-Z"),
("-name", "Name Z-A"),
("manufacturer__name", "Manufacturer A-Z"),
("-manufacturer__name", "Manufacturer Z-A"),
("first_installation_year", "Oldest First"),
("-first_installation_year", "Newest First"),
("total_installations", "Fewest Installations"),
("-total_installations", "Most Installations"),
],
}
)
# === RIDE MODEL STATISTICS ===
@@ -491,69 +561,84 @@ class RideModelStatsAPIView(APIView):
def get(self, request: Request) -> Response:
"""Get ride model statistics."""
if not MODELS_AVAILABLE:
return Response({
"total_models": 50,
"total_installations": 500,
"active_manufacturers": 15,
"discontinued_models": 10,
"by_category": {"RC": 30, "FR": 15, "WR": 5},
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
"recent_models": 3,
})
return Response(
{
"total_models": 50,
"total_installations": 500,
"active_manufacturers": 15,
"discontinued_models": 10,
"by_category": {"RC": 30, "FR": 15, "WR": 5},
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
"recent_models": 3,
}
)
# Calculate statistics
total_models = RideModel.objects.count()
total_installations = RideModel.objects.aggregate(
total=Count('rides')
)['total'] or 0
total_installations = (
RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
)
active_manufacturers = Company.objects.filter(
roles__contains=["MANUFACTURER"],
ride_models__isnull=False
).distinct().count()
active_manufacturers = (
Company.objects.filter(
roles__contains=["MANUFACTURER"], ride_models__isnull=False
)
.distinct()
.count()
)
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
# Category breakdown
by_category = {}
category_counts = RideModel.objects.exclude(category="").values(
"category"
).annotate(count=Count("id"))
category_counts = (
RideModel.objects.exclude(category="")
.values("category")
.annotate(count=Count("id"))
)
for item in category_counts:
by_category[item["category"]] = item["count"]
# Target market breakdown
by_target_market = {}
market_counts = RideModel.objects.exclude(target_market="").values(
"target_market"
).annotate(count=Count("id"))
market_counts = (
RideModel.objects.exclude(target_market="")
.values("target_market")
.annotate(count=Count("id"))
)
for item in market_counts:
by_target_market[item["target_market"]] = item["count"]
# Manufacturer breakdown (top 10)
by_manufacturer = {}
manufacturer_counts = RideModel.objects.filter(
manufacturer__isnull=False
).values("manufacturer__name").annotate(count=Count("id")).order_by("-count")[:10]
manufacturer_counts = (
RideModel.objects.filter(manufacturer__isnull=False)
.values("manufacturer__name")
.annotate(count=Count("id"))
.order_by("-count")[:10]
)
for item in manufacturer_counts:
by_manufacturer[item["manufacturer__name"]] = item["count"]
# Recent models (last 30 days)
thirty_days_ago = timezone.now() - timedelta(days=30)
recent_models = RideModel.objects.filter(
created_at__gte=thirty_days_ago).count()
created_at__gte=thirty_days_ago
).count()
return Response({
"total_models": total_models,
"total_installations": total_installations,
"active_manufacturers": active_manufacturers,
"discontinued_models": discontinued_models,
"by_category": by_category,
"by_target_market": by_target_market,
"by_manufacturer": by_manufacturer,
"recent_models": recent_models,
})
return Response(
{
"total_models": total_models,
"total_installations": total_installations,
"active_manufacturers": active_manufacturers,
"discontinued_models": discontinued_models,
"by_category": by_category,
"by_target_market": by_target_market,
"by_manufacturer": by_manufacturer,
"recent_models": recent_models,
}
)
# === RIDE MODEL VARIANTS ===
@@ -592,7 +677,7 @@ class RideModelVariantListCreateAPIView(APIView):
if not MODELS_AVAILABLE:
return Response(
{"detail": "Variants not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
status=status.HTTP_501_NOT_IMPLEMENTED,
)
try:
@@ -653,7 +738,8 @@ class RideModelVariantDetailAPIView(APIView):
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
variant = self._get_variant_or_404(ride_model_pk, pk)
serializer_in = RideModelVariantUpdateInputSerializer(
data=request.data, partial=True)
data=request.data, partial=True
)
serializer_in.is_valid(raise_exception=True)
for field, value in serializer_in.validated_data.items():
@@ -677,25 +763,30 @@ class RideModelVariantDetailAPIView(APIView):
# Note: Similar patterns would be implemented for RideModelTechnicalSpec and RideModelPhoto
# For brevity, I'm including the class definitions but not the full implementations
class RideModelTechnicalSpecListCreateAPIView(APIView):
"""CRUD operations for ride model technical specifications."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variants...
class RideModelTechnicalSpecDetailAPIView(APIView):
"""CRUD operations for individual technical specifications."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variant detail...
class RideModelPhotoListCreateAPIView(APIView):
"""CRUD operations for ride model photos."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variants...
class RideModelPhotoDetailAPIView(APIView):
"""CRUD operations for individual ride model photos."""
permission_classes = [permissions.AllowAny]
# Implementation similar to variant detail...

View File

@@ -5,42 +5,46 @@ This module contains serializers for ride-specific media functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
from drf_spectacular.utils import (
extend_schema_field,
extend_schema_serializer,
OpenApiExample,
)
from apps.rides.models import Ride, RidePhoto
@extend_schema_serializer(
examples=[
OpenApiExample(
name='Ride Photo with Cloudflare Images',
summary='Complete ride photo response',
description='Example response showing all fields including Cloudflare Images URLs and variants',
name="Ride Photo with Cloudflare Images",
summary="Complete ride photo response",
description="Example response showing all fields including Cloudflare Images URLs and variants",
value={
'id': 123,
'image': 'https://imagedelivery.net/account-hash/abc123def456/public',
'image_url': 'https://imagedelivery.net/account-hash/abc123def456/public',
'image_variants': {
'thumbnail': 'https://imagedelivery.net/account-hash/abc123def456/thumbnail',
'medium': 'https://imagedelivery.net/account-hash/abc123def456/medium',
'large': 'https://imagedelivery.net/account-hash/abc123def456/large',
'public': 'https://imagedelivery.net/account-hash/abc123def456/public'
"id": 123,
"image": "https://imagedelivery.net/account-hash/abc123def456/public",
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
"image_variants": {
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
},
'caption': 'Amazing roller coaster photo',
'alt_text': 'Steel roller coaster with multiple inversions',
'is_primary': True,
'is_approved': True,
'photo_type': 'exterior',
'created_at': '2023-01-01T12:00:00Z',
'updated_at': '2023-01-01T12:00:00Z',
'date_taken': '2023-01-01T10:00:00Z',
'uploaded_by_username': 'photographer123',
'file_size': 2048576,
'dimensions': [1920, 1080],
'ride_slug': 'steel-vengeance',
'ride_name': 'Steel Vengeance',
'park_slug': 'cedar-point',
'park_name': 'Cedar Point'
}
"caption": "Amazing roller coaster photo",
"alt_text": "Steel roller coaster with multiple inversions",
"is_primary": True,
"is_approved": True,
"photo_type": "exterior",
"created_at": "2023-01-01T12:00:00Z",
"updated_at": "2023-01-01T12:00:00Z",
"date_taken": "2023-01-01T10:00:00Z",
"uploaded_by_username": "photographer123",
"file_size": 2048576,
"dimensions": [1920, 1080],
"ride_slug": "steel-vengeance",
"ride_name": "Steel Vengeance",
"park_slug": "cedar-point",
"park_name": "Cedar Point",
},
)
]
)
@@ -78,8 +82,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
@extend_schema_field(
serializers.URLField(
help_text="Full URL to the Cloudflare Images asset",
allow_null=True
help_text="Full URL to the Cloudflare Images asset", allow_null=True
)
)
def get_image_url(self, obj):
@@ -91,7 +94,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
@extend_schema_field(
serializers.DictField(
child=serializers.URLField(),
help_text="Available Cloudflare Images variants with their URLs"
help_text="Available Cloudflare Images variants with their URLs",
)
)
def get_image_variants(self, obj):
@@ -101,10 +104,10 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
# Common variants for ride photos
variants = {
'thumbnail': f"{obj.image.url}/thumbnail",
'medium': f"{obj.image.url}/medium",
'large': f"{obj.image.url}/large",
'public': f"{obj.image.url}/public"
"thumbnail": f"{obj.image.url}/thumbnail",
"medium": f"{obj.image.url}/medium",
"large": f"{obj.image.url}/large",
"public": f"{obj.image.url}/public",
}
return variants

View File

@@ -50,13 +50,18 @@ urlpatterns = [
name="ride-search-suggestions",
),
# Ride model management endpoints - nested under rides/manufacturers
path("manufacturers/<slug:manufacturer_slug>/",
include("apps.api.v1.rides.manufacturers.urls")),
path(
"manufacturers/<slug:manufacturer_slug>/",
include("apps.api.v1.rides.manufacturers.urls"),
),
# Detail and action endpoints
path("<int:pk>/", RideDetailAPIView.as_view(), name="ride-detail"),
# Ride image settings endpoint
path("<int:pk>/image-settings/", RideImageSettingsAPIView.as_view(),
name="ride-image-settings"),
path(
"<int:pk>/image-settings/",
RideImageSettingsAPIView.as_view(),
name="ride-image-settings",
),
# Ride photo endpoints - domain-specific photo management
path("<int:ride_pk>/photos/", include(router.urls)),
]

View File

@@ -21,7 +21,7 @@ from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
@@ -73,136 +73,202 @@ class RideListCreateAPIView(APIView):
description="List rides with comprehensive filtering options including category, status, manufacturer, designer, ride model, and more.",
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Page number for pagination"
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)"
name="page_size",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Number of results per page (max 1000)",
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Search in ride names and descriptions"
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Search in ride names and descriptions",
),
OpenApiParameter(
name="park_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by park slug"
name="park_slug",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by park slug",
),
OpenApiParameter(
name="park_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by park ID"
name="park_id",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by park ID",
),
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"
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"
name="status",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by ride status. Multiple values supported: ?status=OPERATING&status=CLOSED_TEMP",
),
OpenApiParameter(
name="manufacturer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by manufacturer company ID"
name="manufacturer_id",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by manufacturer company ID",
),
OpenApiParameter(
name="manufacturer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by manufacturer company slug"
name="manufacturer_slug",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by manufacturer company slug",
),
OpenApiParameter(
name="designer_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by designer company ID"
name="designer_id",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by designer company ID",
),
OpenApiParameter(
name="designer_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by designer company slug"
name="designer_slug",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by designer company slug",
),
OpenApiParameter(
name="ride_model_id", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by specific ride model ID"
name="ride_model_id",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by specific ride model ID",
),
OpenApiParameter(
name="ride_model_slug", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter by ride model slug (requires manufacturer_slug)"
name="ride_model_slug",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by ride model slug (requires manufacturer_slug)",
),
OpenApiParameter(
name="roller_coaster_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)"
name="roller_coaster_type",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter roller coasters by type (SITDOWN, INVERTED, FLYING, etc.)",
),
OpenApiParameter(
name="track_material", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)"
name="track_material",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)",
),
OpenApiParameter(
name="launch_type", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)"
name="launch_type",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)",
),
OpenApiParameter(
name="min_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter by minimum average rating (1-10)"
name="min_rating",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER,
description="Filter by minimum average rating (1-10)",
),
OpenApiParameter(
name="max_rating", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter by maximum average rating (1-10)"
name="max_rating",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER,
description="Filter by maximum average rating (1-10)",
),
OpenApiParameter(
name="min_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by minimum height requirement in inches"
name="min_height_requirement",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by minimum height requirement in inches",
),
OpenApiParameter(
name="max_height_requirement", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by maximum height requirement in inches"
name="max_height_requirement",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by maximum height requirement in inches",
),
OpenApiParameter(
name="min_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by minimum hourly capacity"
name="min_capacity",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by minimum hourly capacity",
),
OpenApiParameter(
name="max_capacity", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by maximum hourly capacity"
name="max_capacity",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by maximum hourly capacity",
),
OpenApiParameter(
name="min_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by minimum height in feet"
name="min_height_ft",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER,
description="Filter roller coasters by minimum height in feet",
),
OpenApiParameter(
name="max_height_ft", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by maximum height in feet"
name="max_height_ft",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER,
description="Filter roller coasters by maximum height in feet",
),
OpenApiParameter(
name="min_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by minimum speed in mph"
name="min_speed_mph",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER,
description="Filter roller coasters by minimum speed in mph",
),
OpenApiParameter(
name="max_speed_mph", location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER,
description="Filter roller coasters by maximum speed in mph"
name="max_speed_mph",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER,
description="Filter roller coasters by maximum speed in mph",
),
OpenApiParameter(
name="min_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter roller coasters by minimum number of inversions"
name="min_inversions",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter roller coasters by minimum number of inversions",
),
OpenApiParameter(
name="max_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter roller coasters by maximum number of inversions"
name="max_inversions",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter roller coasters by maximum number of inversions",
),
OpenApiParameter(
name="has_inversions", location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL,
description="Filter roller coasters that have inversions (true) or don't have inversions (false)"
name="has_inversions",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
description="Filter roller coasters that have inversions (true) or don't have inversions (false)",
),
OpenApiParameter(
name="opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by opening year"
name="opening_year",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by opening year",
),
OpenApiParameter(
name="min_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by minimum opening year"
name="min_opening_year",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by minimum opening year",
),
OpenApiParameter(
name="max_opening_year", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description="Filter by maximum opening year"
name="max_opening_year",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Filter by maximum opening year",
),
OpenApiParameter(
name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph"
name="ordering",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Order results by field. Options: name, -name, opening_date, -opening_date, average_rating, -average_rating, capacity_per_hour, -capacity_per_hour, created_at, -created_at, height_ft, -height_ft, speed_mph, -speed_mph",
),
],
responses={200: RideListOutputSerializer(many=True)},
@@ -220,17 +286,25 @@ class RideListCreateAPIView(APIView):
)
# Start with base queryset with optimized joins
qs = Ride.objects.all().select_related(
"park", "manufacturer", "designer", "ride_model", "ride_model__manufacturer"
).prefetch_related("coaster_stats") # type: ignore
qs = (
Ride.objects.all()
.select_related(
"park",
"manufacturer",
"designer",
"ride_model",
"ride_model__manufacturer",
)
.prefetch_related("coaster_stats")
) # type: ignore
# Text search
search = request.query_params.get("search")
if search:
qs = qs.filter(
models.Q(name__icontains=search) |
models.Q(description__icontains=search) |
models.Q(park__name__icontains=search)
models.Q(name__icontains=search)
| models.Q(description__icontains=search)
| models.Q(park__name__icontains=search)
)
# Park filters
@@ -292,7 +366,7 @@ class RideListCreateAPIView(APIView):
if ride_model_slug and manufacturer_slug_for_model:
qs = qs.filter(
ride_model__slug=ride_model_slug,
ride_model__manufacturer__slug=manufacturer_slug_for_model
ride_model__manufacturer__slug=manufacturer_slug_for_model,
)
# Rating filters
@@ -422,24 +496,36 @@ class RideListCreateAPIView(APIView):
has_inversions = request.query_params.get("has_inversions")
if has_inversions is not None:
if has_inversions.lower() in ['true', '1', 'yes']:
if has_inversions.lower() in ["true", "1", "yes"]:
qs = qs.filter(coaster_stats__inversions__gt=0)
elif has_inversions.lower() in ['false', '0', 'no']:
elif has_inversions.lower() in ["false", "0", "no"]:
qs = qs.filter(coaster_stats__inversions=0)
# Ordering
ordering = request.query_params.get("ordering", "name")
valid_orderings = [
"name", "-name", "opening_date", "-opening_date",
"average_rating", "-average_rating", "capacity_per_hour", "-capacity_per_hour",
"created_at", "-created_at", "height_ft", "-height_ft", "speed_mph", "-speed_mph"
"name",
"-name",
"opening_date",
"-opening_date",
"average_rating",
"-average_rating",
"capacity_per_hour",
"-capacity_per_hour",
"created_at",
"-created_at",
"height_ft",
"-height_ft",
"speed_mph",
"-speed_mph",
]
if ordering in valid_orderings:
if ordering in ["height_ft", "-height_ft", "speed_mph", "-speed_mph"]:
# For coaster stats ordering, we need to join and order by the stats
ordering_field = ordering.replace("height_ft", "coaster_stats__height_ft").replace(
"speed_mph", "coaster_stats__speed_mph")
ordering_field = ordering.replace(
"height_ft", "coaster_stats__height_ft"
).replace("speed_mph", "coaster_stats__speed_mph")
qs = qs.order_by(ordering_field)
else:
qs = qs.order_by(ordering)
@@ -592,16 +678,24 @@ class FilterOptionsAPIView(APIView):
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date",
"label": "Opening Date (Oldest First)"},
{"value": "-opening_date",
"label": "Opening Date (Newest First)"},
{
"value": "opening_date",
"label": "Opening Date (Oldest First)",
},
{
"value": "-opening_date",
"label": "Opening Date (Newest First)",
},
{"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"},
{"value": "capacity_per_hour",
"label": "Capacity (Lowest First)"},
{"value": "-capacity_per_hour",
"label": "Capacity (Highest First)"},
{
"value": "capacity_per_hour",
"label": "Capacity (Lowest First)",
},
{
"value": "-capacity_per_hour",
"label": "Capacity (Highest First)",
},
{"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"},
@@ -611,16 +705,39 @@ class FilterOptionsAPIView(APIView):
],
"filter_ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1},
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
"height_requirement": {
"min": 30,
"max": 90,
"step": 1,
"unit": "inches",
},
"capacity": {
"min": 0,
"max": 5000,
"step": 50,
"unit": "riders/hour",
},
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
"inversions": {
"min": 0,
"max": 20,
"step": 1,
"unit": "inversions",
},
"opening_year": {
"min": 1800,
"max": 2030,
"step": 1,
"unit": "year",
},
},
"boolean_filters": [
{"key": "has_inversions", "label": "Has Inversions",
"description": "Filter roller coasters with or without inversions"},
{
"key": "has_inversions",
"label": "Has Inversions",
"description": "Filter roller coasters with or without inversions",
},
],
}
return Response(data)
@@ -682,8 +799,10 @@ class FilterOptionsAPIView(APIView):
{"value": "average_rating", "label": "Rating (Lowest First)"},
{"value": "-average_rating", "label": "Rating (Highest First)"},
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
{"value": "-capacity_per_hour",
"label": "Capacity (Highest First)"},
{
"value": "-capacity_per_hour",
"label": "Capacity (Highest First)",
},
{"value": "height_ft", "label": "Height (Shortest First)"},
{"value": "-height_ft", "label": "Height (Tallest First)"},
{"value": "speed_mph", "label": "Speed (Slowest First)"},
@@ -693,16 +812,39 @@ class FilterOptionsAPIView(APIView):
],
"filter_ranges": {
"rating": {"min": 1, "max": 10, "step": 0.1},
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
"height_requirement": {
"min": 30,
"max": 90,
"step": 1,
"unit": "inches",
},
"capacity": {
"min": 0,
"max": 5000,
"step": 50,
"unit": "riders/hour",
},
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
"inversions": {
"min": 0,
"max": 20,
"step": 1,
"unit": "inversions",
},
"opening_year": {
"min": 1800,
"max": 2030,
"step": 1,
"unit": "year",
},
},
"boolean_filters": [
{"key": "has_inversions", "label": "Has Inversions",
"description": "Filter roller coasters with or without inversions"},
{
"key": "has_inversions",
"label": "Has Inversions",
"description": "Filter roller coasters with or without inversions",
},
],
}
)
@@ -853,7 +995,8 @@ class RideImageSettingsAPIView(APIView):
# Return updated ride data
output_serializer = RideDetailOutputSerializer(
ride, context={"request": request})
ride, context={"request": request}
)
return Response(output_serializer.data)

View File

@@ -264,7 +264,6 @@ __all__ = [
"LocationOutputSerializer",
"CompanyOutputSerializer",
"UserModel",
# Parks exports
"ParkListOutputSerializer",
"ParkDetailOutputSerializer",
@@ -279,7 +278,6 @@ __all__ = [
"ParkLocationUpdateInputSerializer",
"ParkSuggestionSerializer",
"ParkSuggestionOutputSerializer",
# Companies exports
"CompanyDetailOutputSerializer",
"CompanyCreateInputSerializer",
@@ -287,7 +285,6 @@ __all__ = [
"RideModelDetailOutputSerializer",
"RideModelCreateInputSerializer",
"RideModelUpdateInputSerializer",
# Rides exports
"RideParkOutputSerializer",
"RideModelOutputSerializer",
@@ -305,7 +302,6 @@ __all__ = [
"RideReviewOutputSerializer",
"RideReviewCreateInputSerializer",
"RideReviewUpdateInputSerializer",
# Services exports
"HealthCheckOutputSerializer",
"PerformanceMetricsOutputSerializer",

View File

@@ -0,0 +1,873 @@
"""
User accounts and settings serializers for ThrillWiki API v1.
This module contains all serializers related to user account management,
profile settings, preferences, privacy, notifications, and security.
"""
from rest_framework import serializers
from django.contrib.auth import get_user_model
from drf_spectacular.utils import (
extend_schema_serializer,
OpenApiExample,
)
from apps.accounts.models import (
User,
UserProfile,
TopList,
UserNotification,
NotificationPreference,
)
UserModel = get_user_model()
# === USER PROFILE SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"User Profile Example",
summary="Complete user profile",
description="Full user profile with all fields",
value={
"user_id": "1234",
"username": "thrillseeker",
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"is_active": True,
"date_joined": "2024-01-01T00:00:00Z",
"role": "USER",
"theme_preference": "dark",
"profile": {
"profile_id": "5678",
"display_name": "Thrill Seeker",
"avatar": "https://example.com/avatars/user.jpg",
"pronouns": "they/them",
"bio": "Love roller coasters and theme parks!",
"twitter": "https://twitter.com/thrillseeker",
"instagram": "https://instagram.com/thrillseeker",
"youtube": "https://youtube.com/thrillseeker",
"discord": "thrillseeker#1234",
"coaster_credits": 150,
"dark_ride_credits": 45,
"flat_ride_credits": 89,
"water_ride_credits": 23,
},
},
)
]
)
class UserProfileSerializer(serializers.ModelSerializer):
"""Serializer for user profile data."""
avatar_url = serializers.SerializerMethodField()
avatar_variants = serializers.SerializerMethodField()
class Meta:
model = UserProfile
fields = [
"profile_id",
"display_name",
"avatar",
"avatar_url",
"avatar_variants",
"pronouns",
"bio",
"twitter",
"instagram",
"youtube",
"discord",
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
]
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
def get_avatar_url(self, obj):
"""Get the avatar URL with fallback to default letter-based avatar."""
return obj.get_avatar_url()
def get_avatar_variants(self, obj):
"""Get avatar variants for different use cases."""
return obj.get_avatar_variants()
def validate_display_name(self, value):
"""Validate display name uniqueness - now checks User model first."""
user = self.context["request"].user
# Check User model for display_name uniqueness (primary location)
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
raise serializers.ValidationError("Display name already taken")
# Also check UserProfile for backward compatibility during transition
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
raise serializers.ValidationError("Display name already taken")
return value
@extend_schema_serializer(
examples=[
OpenApiExample(
"Complete User Example",
summary="Complete user with profile",
description="Full user object with embedded profile",
value={
"user_id": "1234",
"username": "thrillseeker",
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"is_active": True,
"date_joined": "2024-01-01T00:00:00Z",
"role": "USER",
"theme_preference": "dark",
"profile": {
"profile_id": "5678",
"display_name": "Thrill Seeker",
"avatar": "https://example.com/avatars/user.jpg",
"pronouns": "they/them",
"bio": "Love roller coasters and theme parks!",
"twitter": "https://twitter.com/thrillseeker",
"instagram": "https://instagram.com/thrillseeker",
"youtube": "https://youtube.com/thrillseeker",
"discord": "thrillseeker#1234",
"coaster_credits": 150,
"dark_ride_credits": 45,
"flat_ride_credits": 89,
"water_ride_credits": 23,
},
},
)
]
)
class CompleteUserSerializer(serializers.ModelSerializer):
"""Complete user serializer with profile data."""
profile = UserProfileSerializer(read_only=True)
class Meta:
model = User
fields = [
"user_id",
"username",
"email",
"first_name",
"last_name",
"is_active",
"date_joined",
"role",
"theme_preference",
"profile",
]
read_only_fields = ["user_id", "date_joined", "role"]
# === USER SETTINGS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"User Preferences Example",
summary="User preferences and settings",
description="User's preference settings",
value={
"theme_preference": "dark",
"email_notifications": True,
"push_notifications": False,
"privacy_level": "public",
"show_email": False,
"show_real_name": True,
"show_statistics": True,
"allow_friend_requests": True,
"allow_messages": True,
},
)
]
)
class UserPreferencesSerializer(serializers.Serializer):
"""Serializer for user preferences and settings."""
theme_preference = serializers.ChoiceField(
choices=User.ThemePreference.choices, help_text="User's theme preference"
)
email_notifications = serializers.BooleanField(
default=True, help_text="Whether to receive email notifications"
)
push_notifications = serializers.BooleanField(
default=False, help_text="Whether to receive push notifications"
)
privacy_level = serializers.ChoiceField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
help_text="Profile visibility level",
)
show_email = serializers.BooleanField(
default=False, help_text="Whether to show email on profile"
)
show_real_name = serializers.BooleanField(
default=True, help_text="Whether to show real name on profile"
)
show_statistics = serializers.BooleanField(
default=True, help_text="Whether to show ride statistics on profile"
)
allow_friend_requests = serializers.BooleanField(
default=True, help_text="Whether to allow friend requests"
)
allow_messages = serializers.BooleanField(
default=True, help_text="Whether to allow direct messages"
)
# === NOTIFICATION SETTINGS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Notification Settings Example",
summary="User notification preferences",
description="Detailed notification settings",
value={
"email_notifications": {
"new_reviews": True,
"review_replies": True,
"friend_requests": True,
"messages": True,
"weekly_digest": False,
"new_features": True,
"security_alerts": True,
},
"push_notifications": {
"new_reviews": False,
"review_replies": True,
"friend_requests": True,
"messages": True,
},
"in_app_notifications": {
"new_reviews": True,
"review_replies": True,
"friend_requests": True,
"messages": True,
"system_announcements": True,
},
},
)
]
)
class NotificationSettingsSerializer(serializers.Serializer):
"""Serializer for detailed notification settings."""
class EmailNotificationsSerializer(serializers.Serializer):
new_reviews = serializers.BooleanField(default=True)
review_replies = serializers.BooleanField(default=True)
friend_requests = serializers.BooleanField(default=True)
messages = serializers.BooleanField(default=True)
weekly_digest = serializers.BooleanField(default=False)
new_features = serializers.BooleanField(default=True)
security_alerts = serializers.BooleanField(default=True)
class PushNotificationsSerializer(serializers.Serializer):
new_reviews = serializers.BooleanField(default=False)
review_replies = serializers.BooleanField(default=True)
friend_requests = serializers.BooleanField(default=True)
messages = serializers.BooleanField(default=True)
class InAppNotificationsSerializer(serializers.Serializer):
new_reviews = serializers.BooleanField(default=True)
review_replies = serializers.BooleanField(default=True)
friend_requests = serializers.BooleanField(default=True)
messages = serializers.BooleanField(default=True)
system_announcements = serializers.BooleanField(default=True)
email_notifications = EmailNotificationsSerializer()
push_notifications = PushNotificationsSerializer()
in_app_notifications = InAppNotificationsSerializer()
# === PRIVACY SETTINGS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Privacy Settings Example",
summary="User privacy settings",
description="Detailed privacy and visibility settings",
value={
"profile_visibility": "public",
"show_email": False,
"show_real_name": True,
"show_join_date": True,
"show_statistics": True,
"show_reviews": True,
"show_photos": True,
"show_top_lists": True,
"allow_friend_requests": True,
"allow_messages": True,
"allow_profile_comments": False,
"search_visibility": True,
"activity_visibility": "friends",
},
)
]
)
class PrivacySettingsSerializer(serializers.Serializer):
"""Serializer for privacy and visibility settings."""
profile_visibility = serializers.ChoiceField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
help_text="Overall profile visibility",
)
show_email = serializers.BooleanField(
default=False, help_text="Show email address on profile"
)
show_real_name = serializers.BooleanField(
default=True, help_text="Show real name on profile"
)
show_join_date = serializers.BooleanField(
default=True, help_text="Show join date on profile"
)
show_statistics = serializers.BooleanField(
default=True, help_text="Show ride statistics on profile"
)
show_reviews = serializers.BooleanField(
default=True, help_text="Show reviews on profile"
)
show_photos = serializers.BooleanField(
default=True, help_text="Show uploaded photos on profile"
)
show_top_lists = serializers.BooleanField(
default=True, help_text="Show top lists on profile"
)
allow_friend_requests = serializers.BooleanField(
default=True, help_text="Allow others to send friend requests"
)
allow_messages = serializers.BooleanField(
default=True, help_text="Allow others to send direct messages"
)
allow_profile_comments = serializers.BooleanField(
default=False, help_text="Allow others to comment on profile"
)
search_visibility = serializers.BooleanField(
default=True, help_text="Allow profile to appear in search results"
)
activity_visibility = serializers.ChoiceField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
help_text="Who can see your activity feed",
)
# === SECURITY SETTINGS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Security Settings Example",
summary="User security settings",
description="Account security and authentication settings",
value={
"two_factor_enabled": False,
"login_notifications": True,
"session_timeout": 30,
"require_password_change": False,
"last_password_change": "2024-01-01T00:00:00Z",
"active_sessions": 2,
"login_history_retention": 90,
},
)
]
)
class SecuritySettingsSerializer(serializers.Serializer):
"""Serializer for security settings."""
two_factor_enabled = serializers.BooleanField(
default=False, help_text="Whether two-factor authentication is enabled"
)
login_notifications = serializers.BooleanField(
default=True, help_text="Send notifications for new logins"
)
session_timeout = serializers.IntegerField(
default=30, min_value=5, max_value=180, help_text="Session timeout in days"
)
require_password_change = serializers.BooleanField(
default=False, help_text="Whether password change is required"
)
last_password_change = serializers.DateTimeField(
read_only=True, help_text="When password was last changed"
)
active_sessions = serializers.IntegerField(
read_only=True, help_text="Number of active sessions"
)
login_history_retention = serializers.IntegerField(
default=90,
min_value=30,
max_value=365,
help_text="How long to keep login history (days)",
)
# === USER STATISTICS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"User Statistics Example",
summary="User activity statistics",
description="Comprehensive user activity and contribution statistics",
value={
"ride_credits": {
"coaster_credits": 150,
"dark_ride_credits": 45,
"flat_ride_credits": 89,
"water_ride_credits": 23,
"total_credits": 307,
},
"contributions": {
"park_reviews": 25,
"ride_reviews": 87,
"photos_uploaded": 156,
"top_lists_created": 8,
"helpful_votes_received": 342,
},
"activity": {
"days_active": 45,
"last_active": "2024-01-15T10:30:00Z",
"average_review_rating": 4.2,
"most_reviewed_park": "Cedar Point",
"favorite_ride_type": "Roller Coaster",
},
"achievements": {
"first_review": True,
"photo_contributor": True,
"top_reviewer": False,
"park_explorer": True,
"coaster_enthusiast": True,
},
},
)
]
)
class UserStatisticsSerializer(serializers.Serializer):
"""Serializer for user statistics and achievements."""
class RideCreditsSerializer(serializers.Serializer):
coaster_credits = serializers.IntegerField()
dark_ride_credits = serializers.IntegerField()
flat_ride_credits = serializers.IntegerField()
water_ride_credits = serializers.IntegerField()
total_credits = serializers.IntegerField()
class ContributionsSerializer(serializers.Serializer):
park_reviews = serializers.IntegerField()
ride_reviews = serializers.IntegerField()
photos_uploaded = serializers.IntegerField()
top_lists_created = serializers.IntegerField()
helpful_votes_received = serializers.IntegerField()
class ActivitySerializer(serializers.Serializer):
days_active = serializers.IntegerField()
last_active = serializers.DateTimeField()
average_review_rating = serializers.FloatField()
most_reviewed_park = serializers.CharField()
favorite_ride_type = serializers.CharField()
class AchievementsSerializer(serializers.Serializer):
first_review = serializers.BooleanField()
photo_contributor = serializers.BooleanField()
top_reviewer = serializers.BooleanField()
park_explorer = serializers.BooleanField()
coaster_enthusiast = serializers.BooleanField()
ride_credits = RideCreditsSerializer()
contributions = ContributionsSerializer()
activity = ActivitySerializer()
achievements = AchievementsSerializer()
# === TOP LISTS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Top List Example",
summary="User's top list",
description="A user's ranked list of rides or parks",
value={
"id": 1,
"title": "My Top 10 Roller Coasters",
"category": "RC",
"description": "My favorite roller coasters from around the world",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"items_count": 10,
},
)
]
)
class TopListSerializer(serializers.ModelSerializer):
"""Serializer for user's top lists."""
items_count = serializers.SerializerMethodField()
class Meta:
model = TopList
fields = [
"id",
"title",
"category",
"description",
"created_at",
"updated_at",
"items_count",
]
read_only_fields = ["id", "created_at", "updated_at"]
def get_items_count(self, obj):
"""Get the number of items in the list."""
return obj.items.count()
# === ACCOUNT UPDATE SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Account Update Example",
summary="Update account information",
description="Update basic account information",
value={
"first_name": "John",
"last_name": "Doe",
"email": "newemail@example.com",
},
)
]
)
class AccountUpdateSerializer(serializers.ModelSerializer):
"""Serializer for updating account information."""
class Meta:
model = User
fields = [
"first_name",
"last_name",
"email",
]
def validate_email(self, value):
"""Validate email uniqueness."""
user = self.context["request"].user
if User.objects.filter(email=value).exclude(id=user.id).exists():
raise serializers.ValidationError("Email already in use")
return value
# === PROFILE UPDATE SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Profile Update Example",
summary="Update profile information",
description="Update profile information and social links",
value={
"display_name": "New Display Name",
"pronouns": "they/them",
"bio": "Updated bio text",
"twitter": "https://twitter.com/newhandle",
"instagram": "",
"youtube": "https://youtube.com/newchannel",
"discord": "newhandle#5678",
},
)
]
)
class ProfileUpdateSerializer(serializers.ModelSerializer):
"""Serializer for updating profile information."""
class Meta:
model = UserProfile
fields = [
"display_name",
"pronouns",
"bio",
"twitter",
"instagram",
"youtube",
"discord",
]
def validate_display_name(self, value):
"""Validate display name uniqueness - now checks User model first."""
user = self.context["request"].user
# Check User model for display_name uniqueness (primary location)
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
raise serializers.ValidationError("Display name already taken")
# Also check UserProfile for backward compatibility during transition
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
raise serializers.ValidationError("Display name already taken")
return value
# === THEME PREFERENCE SERIALIZER ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Theme Update Example",
summary="Update theme preference",
description="Update user's theme preference",
value={
"theme_preference": "dark",
},
)
]
)
class ThemePreferenceSerializer(serializers.ModelSerializer):
"""Serializer for updating theme preference."""
class Meta:
model = User
fields = ["theme_preference"]
# === NOTIFICATION SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"User Notification Example",
summary="User notification",
description="A notification sent to a user",
value={
"id": 1,
"notification_type": "submission_approved",
"title": "Your submission has been approved!",
"message": "Your photo submission for Cedar Point has been approved and is now live on the site.",
"priority": "normal",
"is_read": False,
"read_at": None,
"created_at": "2024-01-15T10:30:00Z",
"expires_at": None,
"extra_data": {"submission_id": 123, "park_name": "Cedar Point"},
},
)
]
)
class UserNotificationSerializer(serializers.ModelSerializer):
"""Serializer for user notifications."""
class Meta:
model = UserNotification
fields = [
"id",
"notification_type",
"title",
"message",
"priority",
"is_read",
"read_at",
"created_at",
"expires_at",
"extra_data",
]
read_only_fields = [
"id",
"notification_type",
"title",
"message",
"priority",
"created_at",
"expires_at",
"extra_data",
]
@extend_schema_serializer(
examples=[
OpenApiExample(
"Notification Preferences Example",
summary="User notification preferences",
description="Comprehensive notification preferences for all channels",
value={
"submission_approved_email": True,
"submission_approved_push": True,
"submission_approved_inapp": True,
"submission_rejected_email": True,
"submission_rejected_push": True,
"submission_rejected_inapp": True,
"submission_pending_email": False,
"submission_pending_push": False,
"submission_pending_inapp": True,
"review_reply_email": True,
"review_reply_push": True,
"review_reply_inapp": True,
"review_helpful_email": False,
"review_helpful_push": True,
"review_helpful_inapp": True,
"friend_request_email": True,
"friend_request_push": True,
"friend_request_inapp": True,
"friend_accepted_email": False,
"friend_accepted_push": True,
"friend_accepted_inapp": True,
"message_received_email": True,
"message_received_push": True,
"message_received_inapp": True,
"system_announcement_email": True,
"system_announcement_push": False,
"system_announcement_inapp": True,
"account_security_email": True,
"account_security_push": True,
"account_security_inapp": True,
"feature_update_email": True,
"feature_update_push": False,
"feature_update_inapp": True,
"achievement_unlocked_email": False,
"achievement_unlocked_push": True,
"achievement_unlocked_inapp": True,
"milestone_reached_email": False,
"milestone_reached_push": True,
"milestone_reached_inapp": True,
},
)
]
)
class NotificationPreferenceSerializer(serializers.ModelSerializer):
"""Serializer for notification preferences."""
class Meta:
model = NotificationPreference
fields = [
# Submission notifications
"submission_approved_email",
"submission_approved_push",
"submission_approved_inapp",
"submission_rejected_email",
"submission_rejected_push",
"submission_rejected_inapp",
"submission_pending_email",
"submission_pending_push",
"submission_pending_inapp",
# Review notifications
"review_reply_email",
"review_reply_push",
"review_reply_inapp",
"review_helpful_email",
"review_helpful_push",
"review_helpful_inapp",
# Social notifications
"friend_request_email",
"friend_request_push",
"friend_request_inapp",
"friend_accepted_email",
"friend_accepted_push",
"friend_accepted_inapp",
"message_received_email",
"message_received_push",
"message_received_inapp",
# System notifications
"system_announcement_email",
"system_announcement_push",
"system_announcement_inapp",
"account_security_email",
"account_security_push",
"account_security_inapp",
"feature_update_email",
"feature_update_push",
"feature_update_inapp",
# Achievement notifications
"achievement_unlocked_email",
"achievement_unlocked_push",
"achievement_unlocked_inapp",
"milestone_reached_email",
"milestone_reached_push",
"milestone_reached_inapp",
]
# === NOTIFICATION ACTIONS SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Mark Notifications Read Example",
summary="Mark notifications as read",
description="Mark specific notifications as read",
value={"notification_ids": [1, 2, 3, 4, 5]},
)
]
)
class MarkNotificationsReadSerializer(serializers.Serializer):
"""Serializer for marking notifications as read."""
notification_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="List of notification IDs to mark as read",
)
def validate_notification_ids(self, value):
"""Validate that all notification IDs belong to the requesting user."""
user = self.context["request"].user
valid_ids = UserNotification.objects.filter(
id__in=value, user=user
).values_list("id", flat=True)
invalid_ids = set(value) - set(valid_ids)
if invalid_ids:
raise serializers.ValidationError(
f"Invalid notification IDs: {list(invalid_ids)}"
)
return value
@extend_schema_serializer(
examples=[
OpenApiExample(
"Avatar Upload Example",
summary="Upload user avatar",
description="Upload a new avatar image",
value={"avatar": "base64_encoded_image_data_or_file_upload"},
)
]
)
class AvatarUploadSerializer(serializers.ModelSerializer):
"""Serializer for uploading user avatar."""
class Meta:
model = UserProfile
fields = ["avatar"]
def validate_avatar(self, value):
"""Validate avatar file."""
if value:
# Add any avatar-specific validation here
# The CloudflareImagesField will handle the upload
pass
return value

View File

@@ -64,7 +64,7 @@ class MapLocationSerializer(serializers.Serializer):
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get location information."""
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return {
"city": obj.location.city,
"state": obj.location.state,
@@ -76,16 +76,20 @@ class MapLocationSerializer(serializers.Serializer):
@extend_schema_field(serializers.DictField())
def get_stats(self, obj) -> dict:
"""Get relevant statistics based on object type."""
if obj._meta.model_name == 'park':
if obj._meta.model_name == "park":
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
}
elif obj._meta.model_name == 'ride':
elif obj._meta.model_name == "ride":
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"park_name": obj.park.name if obj.park else None,
}
return {}
@@ -210,7 +214,7 @@ class MapSearchResultSerializer(serializers.Serializer):
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get location information."""
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return {
"city": obj.location.city,
"state": obj.location.state,
@@ -318,7 +322,7 @@ class MapLocationDetailSerializer(serializers.Serializer):
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get detailed location information."""
if hasattr(obj, 'location') and obj.location:
if hasattr(obj, "location") and obj.location:
return {
"street_address": obj.location.street_address,
"city": obj.location.city,
@@ -332,20 +336,28 @@ class MapLocationDetailSerializer(serializers.Serializer):
@extend_schema_field(serializers.DictField())
def get_stats(self, obj) -> dict:
"""Get detailed statistics based on object type."""
if obj._meta.model_name == 'park':
if obj._meta.model_name == "park":
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
}
elif obj._meta.model_name == 'ride':
elif obj._meta.model_name == "ride":
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"average_rating": (
float(obj.average_rating) if obj.average_rating else None
),
"park_name": obj.park.name if obj.park else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"opening_date": (
obj.opening_date.isoformat() if obj.opening_date else None
),
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
}
return {}
@@ -370,13 +382,14 @@ class MapBoundsInputSerializer(serializers.Serializer):
def validate(self, attrs):
"""Validate that bounds make geographic sense."""
if attrs['north'] <= attrs['south']:
if attrs["north"] <= attrs["south"]:
raise serializers.ValidationError(
"North bound must be greater than south bound")
"North bound must be greater than south bound"
)
# Handle longitude wraparound (e.g., crossing the international date line)
# For now, we'll require west < east for simplicity
if attrs['west'] >= attrs['east']:
if attrs["west"] >= attrs["east"]:
raise serializers.ValidationError("West bound must be less than east bound")
return attrs
@@ -396,8 +409,8 @@ class MapSearchInputSerializer(serializers.Serializer):
if not value:
return []
valid_types = ['park', 'ride']
types = [t.strip().lower() for t in value.split(',')]
valid_types = ["park", "ride"]
types = [t.strip().lower() for t in value.split(",")]
for location_type in types:
if location_type not in valid_types:

View File

@@ -113,10 +113,10 @@ class ParkListOutputSerializer(serializers.Serializer):
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
},
"caption": "Beautiful park entrance",
"is_primary": True
"is_primary": True,
}
],
"primary_photo": {
@@ -126,10 +126,10 @@ class ParkListOutputSerializer(serializers.Serializer):
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
"public": "https://imagedelivery.net/account-hash/def789ghi012/public"
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
},
"caption": "Beautiful park entrance"
}
"caption": "Beautiful park entrance",
},
},
)
]
@@ -203,21 +203,28 @@ class ParkDetailOutputSerializer(serializers.Serializer):
"""Get all approved photos for this park."""
from apps.parks.models import ParkPhoto
photos = ParkPhoto.objects.filter(
park=obj,
is_approved=True
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
photos = ParkPhoto.objects.filter(park=obj, is_approved=True).order_by(
"-is_primary", "-created_at"
)[
:10
] # Limit to 10 photos
return [
{
"id": photo.id,
"image_url": photo.image.url if photo.image else None,
"image_variants": {
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
"medium": f"{photo.image.url}/medium" if photo.image else None,
"large": f"{photo.image.url}/large" if photo.image else None,
"public": f"{photo.image.url}/public" if photo.image else None,
} if photo.image else {},
"image_variants": (
{
"thumbnail": (
f"{photo.image.url}/thumbnail" if photo.image else None
),
"medium": f"{photo.image.url}/medium" if photo.image else None,
"large": f"{photo.image.url}/large" if photo.image else None,
"public": f"{photo.image.url}/public" if photo.image else None,
}
if photo.image
else {}
),
"caption": photo.caption,
"alt_text": photo.alt_text,
"is_primary": photo.is_primary,
@@ -232,9 +239,7 @@ class ParkDetailOutputSerializer(serializers.Serializer):
try:
photo = ParkPhoto.objects.filter(
park=obj,
is_primary=True,
is_approved=True
park=obj, is_primary=True, is_approved=True
).first()
if photo and photo.image:
@@ -275,12 +280,15 @@ class ParkDetailOutputSerializer(serializers.Serializer):
# Fallback to latest approved photo
from apps.parks.models import ParkPhoto
try:
latest_photo = ParkPhoto.objects.filter(
park=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
latest_photo = (
ParkPhoto.objects.filter(
park=obj, is_approved=True, image__isnull=False
)
.order_by("-created_at")
.first()
)
if latest_photo and latest_photo.image:
return {
@@ -321,12 +329,15 @@ class ParkDetailOutputSerializer(serializers.Serializer):
# Fallback to latest approved photo
from apps.parks.models import ParkPhoto
try:
latest_photo = ParkPhoto.objects.filter(
park=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
latest_photo = (
ParkPhoto.objects.filter(
park=obj, is_approved=True, image__isnull=False
)
.order_by("-created_at")
.first()
)
if latest_photo and latest_photo.image:
return {
@@ -362,6 +373,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
"""Validate that the banner image belongs to the same park."""
if value is not None:
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
# The park will be validated in the view
@@ -374,6 +386,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
"""Validate that the card image belongs to the same park."""
if value is not None:
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
# The park will be validated in the view

View File

@@ -10,16 +10,17 @@ from apps.accounts.models import User
class ReviewUserSerializer(serializers.ModelSerializer):
"""Serializer for user information in reviews."""
avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['username', 'display_name', 'avatar_url']
fields = ["username", "display_name", "avatar_url"]
def get_avatar_url(self, obj):
"""Get the user's avatar URL."""
if hasattr(obj, 'profile') and obj.profile:
if hasattr(obj, "profile") and obj.profile:
return obj.profile.get_avatar()
return "/static/images/default-avatar.png"
@@ -30,6 +31,7 @@ class ReviewUserSerializer(serializers.ModelSerializer):
class LatestReviewSerializer(serializers.Serializer):
"""Serializer for latest reviews combining park and ride reviews."""
id = serializers.IntegerField()
type = serializers.CharField() # 'park' or 'ride'
title = serializers.CharField()
@@ -52,35 +54,35 @@ class LatestReviewSerializer(serializers.Serializer):
"""Convert review instance to serialized representation."""
if isinstance(instance, ParkReview):
return {
'id': instance.pk,
'type': 'park',
'title': instance.title,
'content_snippet': self._get_content_snippet(instance.content),
'rating': instance.rating,
'created_at': instance.created_at,
'user': ReviewUserSerializer(instance.user).data,
'subject_name': instance.park.name,
'subject_slug': instance.park.slug,
'subject_url': f"/parks/{instance.park.slug}/",
'park_name': None,
'park_slug': None,
'park_url': None,
"id": instance.pk,
"type": "park",
"title": instance.title,
"content_snippet": self._get_content_snippet(instance.content),
"rating": instance.rating,
"created_at": instance.created_at,
"user": ReviewUserSerializer(instance.user).data,
"subject_name": instance.park.name,
"subject_slug": instance.park.slug,
"subject_url": f"/parks/{instance.park.slug}/",
"park_name": None,
"park_slug": None,
"park_url": None,
}
elif isinstance(instance, RideReview):
return {
'id': instance.pk,
'type': 'ride',
'title': instance.title,
'content_snippet': self._get_content_snippet(instance.content),
'rating': instance.rating,
'created_at': instance.created_at,
'user': ReviewUserSerializer(instance.user).data,
'subject_name': instance.ride.name,
'subject_slug': instance.ride.slug,
'subject_url': f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
'park_name': instance.ride.park.name,
'park_slug': instance.ride.park.slug,
'park_url': f"/parks/{instance.ride.park.slug}/",
"id": instance.pk,
"type": "ride",
"title": instance.title,
"content_snippet": self._get_content_snippet(instance.content),
"rating": instance.rating,
"created_at": instance.created_at,
"user": ReviewUserSerializer(instance.user).data,
"subject_name": instance.ride.name,
"subject_slug": instance.ride.slug,
"subject_url": f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
"park_name": instance.ride.park.name,
"park_slug": instance.ride.park.slug,
"park_url": f"/parks/{instance.ride.park.slug}/",
}
return {}
@@ -91,7 +93,7 @@ class LatestReviewSerializer(serializers.Serializer):
# Find the last complete word within the limit
snippet = content[:max_length]
last_space = snippet.rfind(' ')
last_space = snippet.rfind(" ")
if last_space > 0:
snippet = snippet[:last_space]

View File

@@ -20,7 +20,13 @@ from .shared import ModelChoices
def get_ride_model_classes():
"""Get ride model classes dynamically to avoid import issues."""
from apps.rides.models import RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
from apps.rides.models import (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
)
return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
@@ -73,13 +79,17 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
name = serializers.CharField()
description = serializers.CharField()
min_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True)
max_digits=6, decimal_places=2, allow_null=True
)
max_height_ft = serializers.DecimalField(
max_digits=6, decimal_places=2, allow_null=True)
max_digits=6, decimal_places=2, allow_null=True
)
min_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True)
max_digits=5, decimal_places=2, allow_null=True
)
max_speed_mph = serializers.DecimalField(
max_digits=5, decimal_places=2, allow_null=True)
max_digits=5, decimal_places=2, allow_null=True
)
distinguishing_features = serializers.CharField()
@@ -98,7 +108,7 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
"manufacturer": {
"id": 1,
"name": "Bolliger & Mabillard",
"slug": "bolliger-mabillard"
"slug": "bolliger-mabillard",
},
"target_market": "THRILL",
"is_discontinued": False,
@@ -110,8 +120,8 @@ class RideModelVariantOutputSerializer(serializers.Serializer):
"id": 123,
"image_url": "https://example.com/image.jpg",
"caption": "B&M Hyper Coaster",
"photo_type": "PROMOTIONAL"
}
"photo_type": "PROMOTIONAL",
},
},
)
]
@@ -171,7 +181,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
"manufacturer": {
"id": 1,
"name": "Bolliger & Mabillard",
"slug": "bolliger-mabillard"
"slug": "bolliger-mabillard",
},
"typical_height_range_min_ft": 200.0,
"typical_height_range_max_ft": 325.0,
@@ -194,7 +204,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
"image_url": "https://example.com/image.jpg",
"caption": "B&M Hyper Coaster",
"photo_type": "PROMOTIONAL",
"is_primary": True
"is_primary": True,
}
],
"variants": [
@@ -203,7 +213,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
"name": "Mega Coaster",
"description": "200-299 ft height variant",
"min_height_ft": 200.0,
"max_height_ft": 299.0
"max_height_ft": 299.0,
}
],
"technical_specs": [
@@ -212,7 +222,7 @@ class RideModelListOutputSerializer(serializers.Serializer):
"spec_category": "DIMENSIONS",
"spec_name": "Track Width",
"spec_value": "1435",
"spec_unit": "mm"
"spec_unit": "mm",
}
],
"installations": [
@@ -220,9 +230,9 @@ class RideModelListOutputSerializer(serializers.Serializer):
"id": 1,
"name": "Nitro",
"park_name": "Six Flags Great Adventure",
"opening_date": "2001-04-07"
"opening_date": "2001-04-07",
}
]
],
},
)
]
@@ -302,9 +312,10 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
def get_installations(self, obj):
"""Get ride installations using this model."""
from django.apps import apps
Ride = apps.get_model('rides', 'Ride')
installations = Ride.objects.filter(ride_model=obj).select_related('park')[:10]
Ride = apps.get_model("rides", "Ride")
installations = Ride.objects.filter(ride_model=obj).select_related("park")[:10]
return [
{
"id": ride.id,
@@ -325,9 +336,7 @@ class RideModelCreateInputSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(allow_blank=True, default="")
category = serializers.ChoiceField(
choices=ModelChoices.get_ride_category_choices(),
allow_blank=True,
default=""
choices=ModelChoices.get_ride_category_choices(), allow_blank=True, default=""
)
# Required manufacturer
@@ -356,11 +365,14 @@ class RideModelCreateInputSerializer(serializers.Serializer):
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, default="")
support_structure = serializers.CharField(
max_length=100, allow_blank=True, default="")
max_length=100, allow_blank=True, default=""
)
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, default="")
max_length=200, allow_blank=True, default=""
)
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, default="")
max_length=100, allow_blank=True, default=""
)
# Market information
first_installation_year = serializers.IntegerField(
@@ -375,14 +387,14 @@ class RideModelCreateInputSerializer(serializers.Serializer):
notable_features = serializers.CharField(allow_blank=True, default="")
target_market = serializers.ChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
allow_blank=True,
default=""
default="",
)
def validate(self, attrs):
@@ -434,7 +446,7 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
category = serializers.ChoiceField(
choices=ModelChoices.get_ride_category_choices(),
allow_blank=True,
required=False
required=False,
)
# Manufacturer
@@ -463,11 +475,14 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
# Design characteristics
track_type = serializers.CharField(max_length=100, allow_blank=True, required=False)
support_structure = serializers.CharField(
max_length=100, allow_blank=True, required=False)
max_length=100, allow_blank=True, required=False
)
train_configuration = serializers.CharField(
max_length=200, allow_blank=True, required=False)
max_length=200, allow_blank=True, required=False
)
restraint_system = serializers.CharField(
max_length=100, allow_blank=True, required=False)
max_length=100, allow_blank=True, required=False
)
# Market information
first_installation_year = serializers.IntegerField(
@@ -482,14 +497,14 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
notable_features = serializers.CharField(allow_blank=True, required=False)
target_market = serializers.ChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
allow_blank=True,
required=False
required=False,
)
def validate(self, attrs):
@@ -541,8 +556,7 @@ class RideModelFilterInputSerializer(serializers.Serializer):
# Category filter
category = serializers.MultipleChoiceField(
choices=ModelChoices.get_ride_category_choices(),
required=False
choices=ModelChoices.get_ride_category_choices(), required=False
)
# Manufacturer filter
@@ -552,13 +566,13 @@ class RideModelFilterInputSerializer(serializers.Serializer):
# Market filter
target_market = serializers.MultipleChoiceField(
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
required=False
required=False,
)
# Status filter
@@ -711,14 +725,14 @@ class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
ride_model_id = serializers.IntegerField()
spec_category = serializers.ChoiceField(
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
]
)
spec_name = serializers.CharField(max_length=100)
@@ -732,16 +746,16 @@ class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
spec_category = serializers.ChoiceField(
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
],
required=False
required=False,
)
spec_name = serializers.CharField(max_length=100, required=False)
spec_value = serializers.CharField(max_length=255, required=False)
@@ -761,13 +775,13 @@ class RideModelPhotoCreateInputSerializer(serializers.Serializer):
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
photo_type = serializers.ChoiceField(
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
default='PROMOTIONAL'
default="PROMOTIONAL",
)
is_primary = serializers.BooleanField(default=False)
photographer = serializers.CharField(max_length=255, allow_blank=True, default="")
@@ -782,20 +796,22 @@ class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
photo_type = serializers.ChoiceField(
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
required=False
required=False,
)
is_primary = serializers.BooleanField(required=False)
photographer = serializers.CharField(
max_length=255, allow_blank=True, required=False)
max_length=255, allow_blank=True, required=False
)
source = serializers.CharField(max_length=255, allow_blank=True, required=False)
copyright_info = serializers.CharField(
max_length=255, allow_blank=True, required=False)
max_length=255, allow_blank=True, required=False
)
# === RIDE MODEL STATS SERIALIZERS ===
@@ -809,16 +825,13 @@ class RideModelStatsOutputSerializer(serializers.Serializer):
active_manufacturers = serializers.IntegerField()
discontinued_models = serializers.IntegerField()
by_category = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by category"
child=serializers.IntegerField(), help_text="Model counts by category"
)
by_target_market = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by target market"
child=serializers.IntegerField(), help_text="Model counts by target market"
)
by_manufacturer = serializers.DictField(
child=serializers.IntegerField(),
help_text="Model counts by manufacturer"
child=serializers.IntegerField(), help_text="Model counts by manufacturer"
)
recent_models = serializers.IntegerField(
help_text="Models created in the last 30 days"

View File

@@ -135,11 +135,11 @@ class RideListOutputSerializer(serializers.Serializer):
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
},
"caption": "Amazing roller coaster photo",
"is_primary": True,
"photo_type": "exterior"
"photo_type": "exterior",
}
],
"primary_photo": {
@@ -149,11 +149,11 @@ class RideListOutputSerializer(serializers.Serializer):
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
"public": "https://imagedelivery.net/account-hash/abc123def456/public"
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
},
"caption": "Amazing roller coaster photo",
"photo_type": "exterior"
}
"photo_type": "exterior",
},
},
)
]
@@ -249,21 +249,28 @@ class RideDetailOutputSerializer(serializers.Serializer):
"""Get all approved photos for this ride."""
from apps.rides.models import RidePhoto
photos = RidePhoto.objects.filter(
ride=obj,
is_approved=True
).order_by('-is_primary', '-created_at')[:10] # Limit to 10 photos
photos = RidePhoto.objects.filter(ride=obj, is_approved=True).order_by(
"-is_primary", "-created_at"
)[
:10
] # Limit to 10 photos
return [
{
"id": photo.id,
"image_url": photo.image.url if photo.image else None,
"image_variants": {
"thumbnail": f"{photo.image.url}/thumbnail" if photo.image else None,
"medium": f"{photo.image.url}/medium" if photo.image else None,
"large": f"{photo.image.url}/large" if photo.image else None,
"public": f"{photo.image.url}/public" if photo.image else None,
} if photo.image else {},
"image_variants": (
{
"thumbnail": (
f"{photo.image.url}/thumbnail" if photo.image else None
),
"medium": f"{photo.image.url}/medium" if photo.image else None,
"large": f"{photo.image.url}/large" if photo.image else None,
"public": f"{photo.image.url}/public" if photo.image else None,
}
if photo.image
else {}
),
"caption": photo.caption,
"alt_text": photo.alt_text,
"is_primary": photo.is_primary,
@@ -279,9 +286,7 @@ class RideDetailOutputSerializer(serializers.Serializer):
try:
photo = RidePhoto.objects.filter(
ride=obj,
is_primary=True,
is_approved=True
ride=obj, is_primary=True, is_approved=True
).first()
if photo and photo.image:
@@ -324,12 +329,15 @@ class RideDetailOutputSerializer(serializers.Serializer):
# Fallback to latest approved photo
from apps.rides.models import RidePhoto
try:
latest_photo = RidePhoto.objects.filter(
ride=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
latest_photo = (
RidePhoto.objects.filter(
ride=obj, is_approved=True, image__isnull=False
)
.order_by("-created_at")
.first()
)
if latest_photo and latest_photo.image:
return {
@@ -372,12 +380,15 @@ class RideDetailOutputSerializer(serializers.Serializer):
# Fallback to latest approved photo
from apps.rides.models import RidePhoto
try:
latest_photo = RidePhoto.objects.filter(
ride=obj,
is_approved=True,
image__isnull=False
).order_by('-created_at').first()
latest_photo = (
RidePhoto.objects.filter(
ride=obj, is_approved=True, image__isnull=False
)
.order_by("-created_at")
.first()
)
if latest_photo and latest_photo.image:
return {
@@ -410,6 +421,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
"""Validate that the banner image belongs to the same ride."""
if value is not None:
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.get(id=value)
# The ride will be validated in the view
@@ -422,6 +434,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
"""Validate that the card image belongs to the same ride."""
if value is not None:
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.get(id=value)
# The ride will be validated in the view

View File

@@ -185,19 +185,20 @@ class CompanyOutputSerializer(serializers.Serializer):
- MANUFACTURER and DESIGNER are for rides domain
"""
# Use the URL field from the model if it exists (auto-generated on save)
if hasattr(obj, 'url') and obj.url:
if hasattr(obj, "url") and obj.url:
return obj.url
# Fallback URL generation (should not be needed if model save works correctly)
if hasattr(obj, 'roles') and obj.roles:
if hasattr(obj, "roles") and obj.roles:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
primary_role = obj.roles[0] if obj.roles else None
# Only generate URLs for rides domain roles here
if primary_role == 'MANUFACTURER':
if primary_role == "MANUFACTURER":
return f"{frontend_domain}/rides/manufacturers/{obj.slug}/"
elif primary_role == 'DESIGNER':
elif primary_role == "DESIGNER":
return f"{frontend_domain}/rides/designers/{obj.slug}/"
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain

View File

@@ -62,88 +62,68 @@ class StatsSerializer(serializers.Serializer):
# Ride category counts (optional fields since they depend on data)
roller_coasters = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as roller coasters"
required=False, help_text="Number of rides categorized as roller coasters"
)
dark_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as dark rides"
required=False, help_text="Number of rides categorized as dark rides"
)
flat_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as flat rides"
required=False, help_text="Number of rides categorized as flat rides"
)
water_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as water rides"
required=False, help_text="Number of rides categorized as water rides"
)
transport_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as transport rides"
required=False, help_text="Number of rides categorized as transport rides"
)
other_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as other"
required=False, help_text="Number of rides categorized as other"
)
# Park status counts (optional fields since they depend on data)
operating_parks = serializers.IntegerField(
required=False,
help_text="Number of currently operating parks"
required=False, help_text="Number of currently operating parks"
)
temporarily_closed_parks = serializers.IntegerField(
required=False,
help_text="Number of temporarily closed parks"
required=False, help_text="Number of temporarily closed parks"
)
permanently_closed_parks = serializers.IntegerField(
required=False,
help_text="Number of permanently closed parks"
required=False, help_text="Number of permanently closed parks"
)
under_construction_parks = serializers.IntegerField(
required=False,
help_text="Number of parks under construction"
required=False, help_text="Number of parks under construction"
)
demolished_parks = serializers.IntegerField(
required=False,
help_text="Number of demolished parks"
required=False, help_text="Number of demolished parks"
)
relocated_parks = serializers.IntegerField(
required=False,
help_text="Number of relocated parks"
required=False, help_text="Number of relocated parks"
)
# Ride status counts (optional fields since they depend on data)
operating_rides = serializers.IntegerField(
required=False,
help_text="Number of currently operating rides"
required=False, help_text="Number of currently operating rides"
)
temporarily_closed_rides = serializers.IntegerField(
required=False,
help_text="Number of temporarily closed rides"
required=False, help_text="Number of temporarily closed rides"
)
sbno_rides = serializers.IntegerField(
required=False,
help_text="Number of rides standing but not operating"
required=False, help_text="Number of rides standing but not operating"
)
closing_rides = serializers.IntegerField(
required=False,
help_text="Number of rides in the process of closing"
required=False, help_text="Number of rides in the process of closing"
)
permanently_closed_rides = serializers.IntegerField(
required=False,
help_text="Number of permanently closed rides"
required=False, help_text="Number of permanently closed rides"
)
under_construction_rides = serializers.IntegerField(
required=False,
help_text="Number of rides under construction"
required=False, help_text="Number of rides under construction"
)
demolished_rides = serializers.IntegerField(
required=False,
help_text="Number of demolished rides"
required=False, help_text="Number of demolished rides"
)
relocated_rides = serializers.IntegerField(
required=False,
help_text="Number of relocated rides"
required=False, help_text="Number of relocated rides"
)
# Metadata

View File

@@ -10,7 +10,13 @@ from django.dispatch import receiver
from django.core.cache import cache
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
from apps.rides.models import (
Ride,
RollerCoasterStats,
RideReview,
RidePhoto,
Company as RideCompany,
)
def invalidate_stats_cache():
@@ -23,6 +29,7 @@ def invalidate_stats_cache():
cache.delete("platform_stats")
# Also update the timestamp for when stats were last invalidated
from datetime import datetime
cache.set("platform_stats_timestamp", datetime.now().isoformat(), 300)

View File

@@ -61,11 +61,18 @@ urlpatterns = [
# Trending system endpoints
path("trending/", TrendingAPIView.as_view(), name="trending"),
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
path("trending/calculate/", TriggerTrendingCalculationAPIView.as_view(),
name="trigger-trending-calculation"),
path(
"trending/calculate/",
TriggerTrendingCalculationAPIView.as_view(),
name="trigger-trending-calculation",
),
# Statistics endpoints
path("stats/", StatsAPIView.as_view(), name="stats"),
path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"),
path(
"stats/recalculate/",
StatsRecalculateAPIView.as_view(),
name="stats-recalculate",
),
# Reviews endpoints
path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"),
# Ranking system endpoints
@@ -82,6 +89,7 @@ urlpatterns = [
path("email/", include("apps.api.v1.email.urls")),
path("core/", include("apps.api.v1.core.urls")),
path("maps/", include("apps.api.v1.maps.urls")),
path("moderation/", include("apps.moderation.urls")),
# Include router URLs (for rankings and any other router-registered endpoints)
path("", include(router.urls)),
]

View File

@@ -372,7 +372,11 @@ class SocialProvidersAPIView(APIView):
"code": "SOCIAL_PROVIDERS_ERROR",
"message": "Unable to retrieve social providers",
"details": str(e) if str(e) else None,
"request_user": str(request.user) if hasattr(request, 'user') else "AnonymousUser",
"request_user": (
str(request.user)
if hasattr(request, "user")
else "AnonymousUser"
),
},
"data": None,
},

View File

@@ -107,7 +107,7 @@ class HealthCheckAPIView(APIView):
# Process individual health checks
for plugin in plugins:
# Handle both plugin objects and strings
if hasattr(plugin, 'identifier'):
if hasattr(plugin, "identifier"):
plugin_name = plugin.identifier()
plugin_class_name = plugin.__class__.__name__
critical_service = getattr(plugin, "critical_service", False)
@@ -120,9 +120,7 @@ class HealthCheckAPIView(APIView):
response_time = None
plugin_errors = (
errors.get(plugin_class_name, [])
if isinstance(errors, dict)
else []
errors.get(plugin_class_name, []) if isinstance(errors, dict) else []
)
health_data["checks"][plugin_name] = {

View File

@@ -6,7 +6,6 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import status
from django.db.models import Q
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from itertools import chain
@@ -24,6 +23,7 @@ class LatestReviewsAPIView(APIView):
Returns a combined list of the most recent reviews across the platform,
including username, user avatar, date, score, and review snippet.
"""
permission_classes = [AllowAny]
@extend_schema(
@@ -51,35 +51,35 @@ class LatestReviewsAPIView(APIView):
"""Get the latest reviews from both parks and rides."""
# Get limit parameter with validation
try:
limit = int(request.query_params.get('limit', 20))
limit = int(request.query_params.get("limit", 20))
limit = min(max(limit, 1), 100) # Clamp between 1 and 100
except (ValueError, TypeError):
limit = 20
# Get published reviews from both models
park_reviews = ParkReview.objects.filter(
is_published=True
).select_related(
'user', 'user__profile', 'park'
).order_by('-created_at')[:limit]
park_reviews = (
ParkReview.objects.filter(is_published=True)
.select_related("user", "user__profile", "park")
.order_by("-created_at")[:limit]
)
ride_reviews = RideReview.objects.filter(
is_published=True
).select_related(
'user', 'user__profile', 'ride', 'ride__park'
).order_by('-created_at')[:limit]
ride_reviews = (
RideReview.objects.filter(is_published=True)
.select_related("user", "user__profile", "ride", "ride__park")
.order_by("-created_at")[:limit]
)
# Combine and sort by created_at
all_reviews = sorted(
chain(park_reviews, ride_reviews),
key=attrgetter('created_at'),
reverse=True
key=attrgetter("created_at"),
reverse=True,
)[:limit]
# Serialize the combined results
serializer = LatestReviewSerializer(all_reviews, many=True)
return Response({
'count': len(all_reviews),
'results': serializer.data
}, status=status.HTTP_200_OK)
return Response(
{"count": len(all_reviews), "results": serializer.data},
status=status.HTTP_200_OK,
)

View File

@@ -9,14 +9,20 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAdminUser
from django.db.models import Count, Q
from django.db.models import Count
from django.core.cache import cache
from django.utils import timezone
from drf_spectacular.utils import extend_schema, OpenApiExample
from datetime import datetime, timedelta
from datetime import datetime
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
from apps.rides.models import (
Ride,
RollerCoasterStats,
RideReview,
RidePhoto,
Company as RideCompany,
)
from ..serializers.stats import StatsSerializer
@@ -40,13 +46,13 @@ class StatsAPIView(APIView):
Returns:
str: Human-readable relative time (e.g., "2 days, 3 hours, 15 minutes ago", "just now")
"""
if not timestamp_str or timestamp_str == 'just_now':
return 'just now'
if not timestamp_str or timestamp_str == "just_now":
return "just now"
try:
# Parse the ISO timestamp
if isinstance(timestamp_str, str):
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
else:
timestamp = timestamp_str
@@ -60,7 +66,7 @@ class StatsAPIView(APIView):
# If less than a minute, return "just now"
if total_seconds < 60:
return 'just now'
return "just now"
# Calculate time components
days = diff.days
@@ -81,16 +87,16 @@ class StatsAPIView(APIView):
# Join parts with commas and add "ago"
if len(parts) == 0:
return 'just now'
return "just now"
elif len(parts) == 1:
return f'{parts[0]} ago'
return f"{parts[0]} ago"
elif len(parts) == 2:
return f'{parts[0]} and {parts[1]} ago'
return f"{parts[0]} and {parts[1]} ago"
else:
return f'{", ".join(parts[:-1])}, and {parts[-1]} ago'
except (ValueError, TypeError):
return 'unknown'
return "unknown"
@extend_schema(
operation_id="get_platform_stats",
@@ -115,9 +121,12 @@ class StatsAPIView(APIView):
500: {
"type": "object",
"properties": {
"error": {"type": "string", "description": "Error message if statistics calculation fails"}
}
}
"error": {
"type": "string",
"description": "Error message if statistics calculation fails",
}
},
},
},
tags=["Statistics"],
examples=[
@@ -142,10 +151,10 @@ class StatsAPIView(APIView):
"operating_parks": 7,
"operating_rides": 10,
"last_updated": "2025-08-28T17:34:59.677143+00:00",
"relative_last_updated": "just now"
}
"relative_last_updated": "just now",
},
)
]
],
)
def get(self, request):
"""Get platform statistics."""
@@ -197,76 +206,76 @@ class StatsAPIView(APIView):
total_roller_coasters = RollerCoasterStats.objects.count()
# Ride category counts
ride_categories = Ride.objects.values('category').annotate(
count=Count('id')
).exclude(category='')
ride_categories = (
Ride.objects.values("category")
.annotate(count=Count("id"))
.exclude(category="")
)
category_stats = {}
for category in ride_categories:
category_code = category['category']
category_count = category['count']
category_code = category["category"]
category_count = category["count"]
# Convert category codes to readable names
category_names = {
'RC': 'roller_coasters',
'DR': 'dark_rides',
'FR': 'flat_rides',
'WR': 'water_rides',
'TR': 'transport_rides',
'OT': 'other_rides'
"RC": "roller_coasters",
"DR": "dark_rides",
"FR": "flat_rides",
"WR": "water_rides",
"TR": "transport_rides",
"OT": "other_rides",
}
category_name = category_names.get(
category_code, f'category_{category_code.lower()}')
category_code, f"category_{category_code.lower()}"
)
category_stats[category_name] = category_count
# Park status counts
park_statuses = Park.objects.values('status').annotate(
count=Count('id')
)
park_statuses = Park.objects.values("status").annotate(count=Count("id"))
park_status_stats = {}
for status_item in park_statuses:
status_code = status_item['status']
status_count = status_item['count']
status_code = status_item["status"]
status_count = status_item["count"]
# Convert status codes to readable names
status_names = {
'OPERATING': 'operating_parks',
'CLOSED_TEMP': 'temporarily_closed_parks',
'CLOSED_PERM': 'permanently_closed_parks',
'UNDER_CONSTRUCTION': 'under_construction_parks',
'DEMOLISHED': 'demolished_parks',
'RELOCATED': 'relocated_parks'
"OPERATING": "operating_parks",
"CLOSED_TEMP": "temporarily_closed_parks",
"CLOSED_PERM": "permanently_closed_parks",
"UNDER_CONSTRUCTION": "under_construction_parks",
"DEMOLISHED": "demolished_parks",
"RELOCATED": "relocated_parks",
}
status_name = status_names.get(status_code, f'status_{status_code.lower()}')
status_name = status_names.get(status_code, f"status_{status_code.lower()}")
park_status_stats[status_name] = status_count
# Ride status counts
ride_statuses = Ride.objects.values('status').annotate(
count=Count('id')
)
ride_statuses = Ride.objects.values("status").annotate(count=Count("id"))
ride_status_stats = {}
for status_item in ride_statuses:
status_code = status_item['status']
status_count = status_item['count']
status_code = status_item["status"]
status_count = status_item["count"]
# Convert status codes to readable names
status_names = {
'OPERATING': 'operating_rides',
'CLOSED_TEMP': 'temporarily_closed_rides',
'SBNO': 'sbno_rides',
'CLOSING': 'closing_rides',
'CLOSED_PERM': 'permanently_closed_rides',
'UNDER_CONSTRUCTION': 'under_construction_rides',
'DEMOLISHED': 'demolished_rides',
'RELOCATED': 'relocated_rides'
"OPERATING": "operating_rides",
"CLOSED_TEMP": "temporarily_closed_rides",
"SBNO": "sbno_rides",
"CLOSING": "closing_rides",
"CLOSED_PERM": "permanently_closed_rides",
"UNDER_CONSTRUCTION": "under_construction_rides",
"DEMOLISHED": "demolished_rides",
"RELOCATED": "relocated_rides",
}
status_name = status_names.get(
status_code, f'ride_status_{status_code.lower()}')
status_code, f"ride_status_{status_code.lower()}"
)
ride_status_stats[status_name] = status_count
# Review counts
@@ -279,13 +288,13 @@ class StatsAPIView(APIView):
last_updated_iso = now.isoformat()
# Get cached timestamp or use current time
cached_timestamp = cache.get('platform_stats_timestamp')
if cached_timestamp and cached_timestamp != 'just_now':
cached_timestamp = cache.get("platform_stats_timestamp")
if cached_timestamp and cached_timestamp != "just_now":
# Use cached timestamp for consistency
last_updated_iso = cached_timestamp
else:
# Set new timestamp in cache
cache.set('platform_stats_timestamp', last_updated_iso, 300)
cache.set("platform_stats_timestamp", last_updated_iso, 300)
# Calculate relative time
relative_last_updated = self._get_relative_time(last_updated_iso)
@@ -293,34 +302,29 @@ class StatsAPIView(APIView):
# Combine all stats
stats = {
# Core entity counts
'total_parks': total_parks,
'total_rides': total_rides,
'total_manufacturers': total_manufacturers,
'total_operators': total_operators,
'total_designers': total_designers,
'total_property_owners': total_property_owners,
'total_roller_coasters': total_roller_coasters,
"total_parks": total_parks,
"total_rides": total_rides,
"total_manufacturers": total_manufacturers,
"total_operators": total_operators,
"total_designers": total_designers,
"total_property_owners": total_property_owners,
"total_roller_coasters": total_roller_coasters,
# Photo counts
'total_photos': total_photos,
'total_park_photos': total_park_photos,
'total_ride_photos': total_ride_photos,
"total_photos": total_photos,
"total_park_photos": total_park_photos,
"total_ride_photos": total_ride_photos,
# Review counts
'total_reviews': total_reviews,
'total_park_reviews': total_park_reviews,
'total_ride_reviews': total_ride_reviews,
"total_reviews": total_reviews,
"total_park_reviews": total_park_reviews,
"total_ride_reviews": total_ride_reviews,
# Category breakdowns
**category_stats,
# Status breakdowns
**park_status_stats,
**ride_status_stats,
# Metadata
'last_updated': last_updated_iso,
'relative_last_updated': relative_last_updated
"last_updated": last_updated_iso,
"relative_last_updated": relative_last_updated,
}
return stats
@@ -351,8 +355,11 @@ class StatsRecalculateAPIView(APIView):
cache.set("platform_stats", fresh_stats, 300)
# Return success response with the fresh stats
return Response({
"message": "Platform statistics have been successfully recalculated",
"stats": fresh_stats,
"recalculated_at": timezone.now().isoformat()
}, status=status.HTTP_200_OK)
return Response(
{
"message": "Platform statistics have been successfully recalculated",
"stats": fresh_stats,
"recalculated_at": timezone.now().isoformat(),
},
status=status.HTTP_200_OK,
)

View File

@@ -125,17 +125,24 @@ class TriggerTrendingCalculationAPIView(APIView):
try:
# Run trending calculation command
with redirect_stdout(trending_output), redirect_stderr(trending_output):
call_command('calculate_trending',
'--content-type=all', '--limit=50')
call_command(
"calculate_trending", "--content-type=all", "--limit=50"
)
trending_completed = True
except Exception as e:
trending_output.write(f"Error: {str(e)}")
try:
# Run new content calculation command
with redirect_stdout(new_content_output), redirect_stderr(new_content_output):
call_command('calculate_new_content',
'--content-type=all', '--days-back=30', '--limit=50')
with redirect_stdout(new_content_output), redirect_stderr(
new_content_output
):
call_command(
"calculate_new_content",
"--content-type=all",
"--days-back=30",
"--limit=50",
)
new_content_completed = True
except Exception as e:
new_content_output.write(f"Error: {str(e)}")

View File

@@ -157,7 +157,9 @@ class RideRankingViewSet(ReadOnlyModelViewSet):
ranking = self.get_object()
history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by(
"-snapshot_date"
)[:90] # Last 3 months
)[
:90
] # Last 3 months
serializer = self.get_serializer(history, many=True)
return Response(serializer.data)