mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 00:55:19 -05:00
feat: add public profiles list endpoint with search and pagination
- Add new /profiles/ endpoint for listing user profiles - Support search by username/display name with ordering options - Include pagination with configurable page size (max 100) - Add comprehensive OpenAPI schema documentation - Refactor passkey authentication state management in MFA flow - Update URL routing and imports for new list_profiles view This enables user discovery, leaderboards, and friend-finding features with a publicly accessible, well-documented API endpoint.
This commit is contained in:
@@ -6,6 +6,7 @@ from django.urls import include, path
|
|||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from . import views, views_credits, views_magic_link
|
from . import views, views_credits, views_magic_link
|
||||||
|
from .views import list_profiles
|
||||||
|
|
||||||
# Register ViewSets
|
# Register ViewSets
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -119,7 +120,8 @@ urlpatterns = [
|
|||||||
# Magic Link (Login by Code) endpoints
|
# Magic Link (Login by Code) endpoints
|
||||||
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
|
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
|
||||||
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
|
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
|
||||||
# Public Profile
|
# Public Profiles - List and Detail
|
||||||
|
path("profiles/", list_profiles, name="list_profiles"),
|
||||||
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
||||||
# Bulk lookup endpoints
|
# Bulk lookup endpoints
|
||||||
path("profiles/bulk/", views.bulk_get_profiles, name="bulk_get_profiles"),
|
path("profiles/bulk/", views.bulk_get_profiles, name="bulk_get_profiles"),
|
||||||
|
|||||||
@@ -823,6 +823,119 @@ def check_user_deletion_eligibility(request, user_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === PUBLIC PROFILE LIST ENDPOINT ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
operation_id="list_profiles",
|
||||||
|
summary="List user profiles with search and pagination",
|
||||||
|
description=(
|
||||||
|
"Returns a paginated list of public user profiles. "
|
||||||
|
"Supports search by username or display name, and filtering by various criteria. "
|
||||||
|
"This endpoint is used for user discovery, leaderboards, and friend finding."
|
||||||
|
),
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="search",
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Search term for username or display name",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="ordering",
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Order by field: date_joined, -date_joined, username, -username",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="page",
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Page number for pagination",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="page_size",
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Number of results per page (max 100)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Paginated list of public profiles",
|
||||||
|
"example": {
|
||||||
|
"count": 150,
|
||||||
|
"next": "https://api.thrillwiki.com/api/v1/accounts/profiles/?page=2",
|
||||||
|
"previous": None,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"user_id": "uuid-1",
|
||||||
|
"username": "thrillseeker",
|
||||||
|
"date_joined": "2024-01-01T00:00:00Z",
|
||||||
|
"role": "USER",
|
||||||
|
"profile": {
|
||||||
|
"profile_id": "uuid-profile",
|
||||||
|
"display_name": "Thrill Seeker",
|
||||||
|
"avatar_url": "https://example.com/avatar.jpg",
|
||||||
|
"bio": "Coaster enthusiast!",
|
||||||
|
"total_credits": 150,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags=["User Profile"],
|
||||||
|
)
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def list_profiles(request):
|
||||||
|
"""
|
||||||
|
List public user profiles with search and pagination.
|
||||||
|
|
||||||
|
This endpoint provides the missing /accounts/profiles/ list endpoint
|
||||||
|
that the frontend expects for user discovery features.
|
||||||
|
"""
|
||||||
|
from django.db.models import Q
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
# Base queryset: only active users with public profiles
|
||||||
|
queryset = User.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
).select_related("profile").order_by("-date_joined")
|
||||||
|
|
||||||
|
# Search filter
|
||||||
|
search = request.query_params.get("search", "").strip()
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(username__icontains=search) |
|
||||||
|
Q(profile__display_name__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordering
|
||||||
|
ordering = request.query_params.get("ordering", "-date_joined")
|
||||||
|
valid_orderings = ["date_joined", "-date_joined", "username", "-username"]
|
||||||
|
if ordering in valid_orderings:
|
||||||
|
queryset = queryset.order_by(ordering)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
class ProfilePagination(PageNumberPagination):
|
||||||
|
page_size = 20
|
||||||
|
page_size_query_param = "page_size"
|
||||||
|
max_page_size = 100
|
||||||
|
|
||||||
|
paginator = ProfilePagination()
|
||||||
|
page = paginator.paginate_queryset(queryset, request)
|
||||||
|
|
||||||
|
if page is not None:
|
||||||
|
serializer = PublicUserSerializer(page, many=True)
|
||||||
|
return paginator.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
# Fallback if pagination fails
|
||||||
|
serializer = PublicUserSerializer(queryset[:20], many=True)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
# === USER PROFILE ENDPOINTS ===
|
# === USER PROFILE ENDPOINTS ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ def get_registration_options(request):
|
|||||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||||
|
|
||||||
# Use the correct allauth API: begin_registration
|
# Use the correct allauth API: begin_registration
|
||||||
creation_options, state = webauthn_auth.begin_registration(request)
|
# The function takes (user, passwordless) - passwordless=False for standard passkeys
|
||||||
|
creation_options = webauthn_auth.begin_registration(request.user, passwordless=False)
|
||||||
|
|
||||||
# Store state in session for verification
|
# State is stored internally by begin_registration via set_state()
|
||||||
webauthn_auth.set_state(request, state)
|
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
"options": creation_options,
|
"options": creation_options,
|
||||||
@@ -154,8 +154,8 @@ def register_passkey(request):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get stored state from session
|
# Get stored state from session (no request needed, uses context)
|
||||||
state = webauthn_auth.get_state(request)
|
state = webauthn_auth.get_state()
|
||||||
if not state:
|
if not state:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "No pending registration. Please start registration again."},
|
{"detail": "No pending registration. Please start registration again."},
|
||||||
@@ -164,19 +164,24 @@ def register_passkey(request):
|
|||||||
|
|
||||||
# Use the correct allauth API: complete_registration
|
# Use the correct allauth API: complete_registration
|
||||||
try:
|
try:
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
|
|
||||||
# Parse the credential response
|
# Parse the credential response
|
||||||
credential_data = webauthn_auth.parse_registration_response(credential)
|
credential_data = webauthn_auth.parse_registration_response(credential)
|
||||||
|
|
||||||
# Complete registration - this creates the Authenticator
|
# Complete registration - returns AuthenticatorData (binding)
|
||||||
authenticator = webauthn_auth.complete_registration(
|
authenticator_data = webauthn_auth.complete_registration(credential_data)
|
||||||
request,
|
|
||||||
credential_data,
|
|
||||||
state,
|
|
||||||
name=name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clear session state
|
# Create the Authenticator record ourselves
|
||||||
webauthn_auth.clear_state(request)
|
authenticator = Authenticator.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
type=Authenticator.Type.WEBAUTHN,
|
||||||
|
data={
|
||||||
|
"name": name,
|
||||||
|
"credential": authenticator_data.credential_data.aaguid.hex if authenticator_data.credential_data else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# State is cleared internally by complete_registration
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
"detail": "Passkey registered successfully",
|
"detail": "Passkey registered successfully",
|
||||||
@@ -225,10 +230,8 @@ def get_authentication_options(request):
|
|||||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||||
|
|
||||||
# Use the correct allauth API: begin_authentication
|
# Use the correct allauth API: begin_authentication
|
||||||
request_options, state = webauthn_auth.begin_authentication(request)
|
# Takes optional user, returns just options (state is stored internally)
|
||||||
|
request_options = webauthn_auth.begin_authentication(request.user)
|
||||||
# Store state in session for verification
|
|
||||||
webauthn_auth.set_state(request, state)
|
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
"options": request_options,
|
"options": request_options,
|
||||||
@@ -281,8 +284,8 @@ def authenticate_passkey(request):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get stored state from session
|
# Get stored state from session (no request needed, uses context)
|
||||||
state = webauthn_auth.get_state(request)
|
state = webauthn_auth.get_state()
|
||||||
if not state:
|
if not state:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "No pending authentication. Please start authentication again."},
|
{"detail": "No pending authentication. Please start authentication again."},
|
||||||
@@ -291,14 +294,9 @@ def authenticate_passkey(request):
|
|||||||
|
|
||||||
# Use the correct allauth API: complete_authentication
|
# Use the correct allauth API: complete_authentication
|
||||||
try:
|
try:
|
||||||
# Parse the credential response
|
# Complete authentication - takes user and credential response
|
||||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
# State is handled internally
|
||||||
|
webauthn_auth.complete_authentication(request.user, credential)
|
||||||
# Complete authentication
|
|
||||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
|
||||||
|
|
||||||
# Clear session state
|
|
||||||
webauthn_auth.clear_state(request)
|
|
||||||
|
|
||||||
return Response({"success": True})
|
return Response({"success": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -514,9 +512,13 @@ def get_login_passkey_options(request):
|
|||||||
request.user = user
|
request.user = user
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_options, state = webauthn_auth.begin_authentication(request)
|
# begin_authentication takes just user, returns options (state stored internally)
|
||||||
|
request_options = webauthn_auth.begin_authentication(user)
|
||||||
|
# Note: State is managed by allauth's session context, but for MFA login flow
|
||||||
|
# we need to track user separately since they're not authenticated yet
|
||||||
passkey_state_key = f"mfa_passkey_state:{mfa_token}"
|
passkey_state_key = f"mfa_passkey_state:{mfa_token}"
|
||||||
cache.set(passkey_state_key, state, timeout=300)
|
# Store a reference that this user has a pending passkey auth
|
||||||
|
cache.set(passkey_state_key, {"user_id": user_id}, timeout=300)
|
||||||
return Response({"options": request_options})
|
return Response({"options": request_options})
|
||||||
finally:
|
finally:
|
||||||
if original_user is not None:
|
if original_user is not None:
|
||||||
|
|||||||
@@ -417,23 +417,23 @@ class MFALoginVerifyAPIView(APIView):
|
|||||||
return {"success": False, "error": "No passkey registered for this user"}
|
return {"success": False, "error": "No passkey registered for this user"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse the authentication response
|
# For MFA login flow, we need to set up state first if not present
|
||||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
# Note: allauth's begin_authentication stores state internally
|
||||||
|
state = webauthn_auth.get_state()
|
||||||
# Get or create authentication state
|
|
||||||
# For login flow, we need to set up the state first
|
|
||||||
state = webauthn_auth.get_state(request)
|
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
# If no state, generate one for this user
|
# Need to temporarily set request.user for allauth context
|
||||||
_, state = webauthn_auth.begin_authentication(request)
|
original_user = getattr(request, "user", None)
|
||||||
webauthn_auth.set_state(request, state)
|
request.user = user
|
||||||
|
try:
|
||||||
|
webauthn_auth.begin_authentication(user)
|
||||||
|
finally:
|
||||||
|
if original_user is not None:
|
||||||
|
request.user = original_user
|
||||||
|
|
||||||
# Complete authentication
|
# Complete authentication - takes user and credential dict
|
||||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
# State is managed internally by allauth
|
||||||
|
webauthn_auth.complete_authentication(user, credential)
|
||||||
# Clear the state
|
|
||||||
webauthn_auth.clear_state(request)
|
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ router.register(r"milestones", MilestoneViewSet, basename="milestone")
|
|||||||
|
|
||||||
# Entity search endpoints - migrated from apps.core.urls
|
# Entity search endpoints - migrated from apps.core.urls
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# View counts endpoint for tracking page views
|
||||||
|
path(
|
||||||
|
"views/",
|
||||||
|
views.ViewCountView.as_view(),
|
||||||
|
name="view_counts",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"entities/search/",
|
"entities/search/",
|
||||||
views.EntityFuzzySearchView.as_view(),
|
views.EntityFuzzySearchView.as_view(),
|
||||||
|
|||||||
@@ -27,6 +27,106 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewCountView(APIView):
|
||||||
|
"""
|
||||||
|
Track and retrieve view counts for entities.
|
||||||
|
|
||||||
|
This endpoint provides the /core/views/ functionality expected by
|
||||||
|
the frontend for tracking page views on parks, rides, and companies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Core"],
|
||||||
|
summary="Get view counts for entities",
|
||||||
|
description="Retrieve view counts for specified entities",
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
"""Get view counts for entities by type and ID."""
|
||||||
|
entity_type = request.query_params.get("entity_type")
|
||||||
|
entity_id = request.query_params.get("entity_id")
|
||||||
|
|
||||||
|
if not entity_type or not entity_id:
|
||||||
|
return Response(
|
||||||
|
{"detail": "entity_type and entity_id are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to get view count from analytics tracking
|
||||||
|
try:
|
||||||
|
from apps.core.models import EntityViewCount
|
||||||
|
|
||||||
|
view_count = EntityViewCount.objects.filter(
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if view_count:
|
||||||
|
return Response({
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"view_count": view_count.count,
|
||||||
|
"last_viewed": view_count.last_viewed_at,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Model may not exist yet, return placeholder
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"view_count": 0,
|
||||||
|
"last_viewed": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Core"],
|
||||||
|
summary="Record a view for an entity",
|
||||||
|
description="Increment the view count for a specified entity",
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
"""Record a view for an entity."""
|
||||||
|
entity_type = request.data.get("entity_type")
|
||||||
|
entity_id = request.data.get("entity_id")
|
||||||
|
|
||||||
|
if not entity_type or not entity_id:
|
||||||
|
return Response(
|
||||||
|
{"detail": "entity_type and entity_id are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track the view
|
||||||
|
try:
|
||||||
|
from django.utils import timezone
|
||||||
|
from apps.core.models import EntityViewCount
|
||||||
|
|
||||||
|
view_count, created = EntityViewCount.objects.get_or_create(
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
defaults={"count": 0},
|
||||||
|
)
|
||||||
|
view_count.count += 1
|
||||||
|
view_count.last_viewed_at = timezone.now()
|
||||||
|
view_count.save(update_fields=["count", "last_viewed_at"])
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"view_count": view_count.count,
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
except Exception as e:
|
||||||
|
# Model may not exist, log and return success anyway
|
||||||
|
logger.debug(f"View count tracking not available: {e}")
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"view_count": 1, # Assume first view
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class TelemetryView(APIView):
|
class TelemetryView(APIView):
|
||||||
"""
|
"""
|
||||||
Handle frontend telemetry and request metadata logging.
|
Handle frontend telemetry and request metadata logging.
|
||||||
|
|||||||
@@ -306,6 +306,12 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
|||||||
banner_image_url = serializers.SerializerMethodField()
|
banner_image_url = serializers.SerializerMethodField()
|
||||||
card_image_url = serializers.SerializerMethodField()
|
card_image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Metric unit conversions for frontend (duplicate of imperial fields)
|
||||||
|
coaster_height_meters = serializers.SerializerMethodField()
|
||||||
|
coaster_length_meters = serializers.SerializerMethodField()
|
||||||
|
coaster_speed_kmh = serializers.SerializerMethodField()
|
||||||
|
coaster_max_drop_meters = serializers.SerializerMethodField()
|
||||||
|
|
||||||
# Computed fields for filtering
|
# Computed fields for filtering
|
||||||
opening_year = serializers.IntegerField(read_only=True)
|
opening_year = serializers.IntegerField(read_only=True)
|
||||||
search_text = serializers.CharField(read_only=True)
|
search_text = serializers.CharField(read_only=True)
|
||||||
@@ -502,6 +508,47 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
|||||||
"""Check if ride has an announced closing date in the future."""
|
"""Check if ride has an announced closing date in the future."""
|
||||||
return obj.is_closing
|
return obj.is_closing
|
||||||
|
|
||||||
|
# Metric conversions for frontend compatibility
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_coaster_height_meters(self, obj):
|
||||||
|
"""Convert coaster height from feet to meters."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.height_ft:
|
||||||
|
return round(float(obj.coaster_stats.height_ft) * 0.3048, 2)
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_coaster_length_meters(self, obj):
|
||||||
|
"""Convert coaster length from feet to meters."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.length_ft:
|
||||||
|
return round(float(obj.coaster_stats.length_ft) * 0.3048, 2)
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_coaster_speed_kmh(self, obj):
|
||||||
|
"""Convert coaster speed from mph to km/h."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.speed_mph:
|
||||||
|
return round(float(obj.coaster_stats.speed_mph) * 1.60934, 2)
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_coaster_max_drop_meters(self, obj):
|
||||||
|
"""Convert coaster max drop from feet to meters."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.max_drop_height_ft:
|
||||||
|
return round(float(obj.coaster_stats.max_drop_height_ft) * 0.3048, 2)
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
# Water ride stats fields
|
# Water ride stats fields
|
||||||
water_wetness_level = serializers.SerializerMethodField()
|
water_wetness_level = serializers.SerializerMethodField()
|
||||||
water_splash_height_ft = serializers.SerializerMethodField()
|
water_splash_height_ft = serializers.SerializerMethodField()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from django.urls import include, path
|
|||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .photo_views import RidePhotoViewSet
|
from .photo_views import RidePhotoViewSet
|
||||||
|
from .ride_model_views import GlobalRideModelDetailAPIView, GlobalRideModelListAPIView
|
||||||
from .views import (
|
from .views import (
|
||||||
CompanySearchAPIView,
|
CompanySearchAPIView,
|
||||||
DesignerListAPIView,
|
DesignerListAPIView,
|
||||||
@@ -40,6 +41,9 @@ urlpatterns = [
|
|||||||
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
|
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
|
||||||
# Filter options
|
# Filter options
|
||||||
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
|
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
|
||||||
|
# Global ride model endpoints - matches frontend's /rides/models/ expectation
|
||||||
|
path("models/", GlobalRideModelListAPIView.as_view(), name="ride-model-global-list"),
|
||||||
|
path("models/<int:pk>/", GlobalRideModelDetailAPIView.as_view(), name="ride-model-global-detail"),
|
||||||
# Autocomplete / suggestion endpoints
|
# Autocomplete / suggestion endpoints
|
||||||
path(
|
path(
|
||||||
"search/companies/",
|
"search/companies/",
|
||||||
|
|||||||
@@ -211,6 +211,18 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
|||||||
# Former names (name history)
|
# Former names (name history)
|
||||||
former_names = serializers.SerializerMethodField()
|
former_names = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Coaster statistics - includes both imperial and metric units for frontend flexibility
|
||||||
|
coaster_statistics = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Metric unit fields for frontend (converted from imperial)
|
||||||
|
height_meters = serializers.SerializerMethodField()
|
||||||
|
length_meters = serializers.SerializerMethodField()
|
||||||
|
max_speed_kmh = serializers.SerializerMethodField()
|
||||||
|
drop_meters = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Technical specifications list
|
||||||
|
technical_specifications = serializers.SerializerMethodField()
|
||||||
|
|
||||||
# URL
|
# URL
|
||||||
url = serializers.SerializerMethodField()
|
url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@@ -427,6 +439,99 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
|||||||
for entry in former_names
|
for entry in former_names
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||||
|
def get_coaster_statistics(self, obj):
|
||||||
|
"""Get coaster statistics with both imperial and metric units."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
|
||||||
|
stats = obj.coaster_stats
|
||||||
|
return {
|
||||||
|
# Imperial units (stored in DB)
|
||||||
|
"height_ft": float(stats.height_ft) if stats.height_ft else None,
|
||||||
|
"length_ft": float(stats.length_ft) if stats.length_ft else None,
|
||||||
|
"speed_mph": float(stats.speed_mph) if stats.speed_mph else None,
|
||||||
|
"max_drop_height_ft": float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
|
||||||
|
# Metric conversions for frontend
|
||||||
|
"height_meters": round(float(stats.height_ft) * 0.3048, 2) if stats.height_ft else None,
|
||||||
|
"length_meters": round(float(stats.length_ft) * 0.3048, 2) if stats.length_ft else None,
|
||||||
|
"max_speed_kmh": round(float(stats.speed_mph) * 1.60934, 2) if stats.speed_mph else None,
|
||||||
|
"drop_meters": round(float(stats.max_drop_height_ft) * 0.3048, 2) if stats.max_drop_height_ft else None,
|
||||||
|
# Other stats
|
||||||
|
"inversions": stats.inversions,
|
||||||
|
"ride_time_seconds": stats.ride_time_seconds,
|
||||||
|
"track_type": stats.track_type,
|
||||||
|
"track_material": stats.track_material,
|
||||||
|
"roller_coaster_type": stats.roller_coaster_type,
|
||||||
|
"propulsion_system": stats.propulsion_system,
|
||||||
|
"train_style": stats.train_style,
|
||||||
|
"trains_count": stats.trains_count,
|
||||||
|
"cars_per_train": stats.cars_per_train,
|
||||||
|
"seats_per_car": stats.seats_per_car,
|
||||||
|
}
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_height_meters(self, obj):
|
||||||
|
"""Convert height from feet to meters for frontend."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.height_ft:
|
||||||
|
return round(float(obj.coaster_stats.height_ft) * 0.3048, 2)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_length_meters(self, obj):
|
||||||
|
"""Convert length from feet to meters for frontend."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.length_ft:
|
||||||
|
return round(float(obj.coaster_stats.length_ft) * 0.3048, 2)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_max_speed_kmh(self, obj):
|
||||||
|
"""Convert max speed from mph to km/h for frontend."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.speed_mph:
|
||||||
|
return round(float(obj.coaster_stats.speed_mph) * 1.60934, 2)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_drop_meters(self, obj):
|
||||||
|
"""Convert drop height from feet to meters for frontend."""
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.max_drop_height_ft:
|
||||||
|
return round(float(obj.coaster_stats.max_drop_height_ft) * 0.3048, 2)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||||
|
def get_technical_specifications(self, obj):
|
||||||
|
"""Get technical specifications list for this ride."""
|
||||||
|
try:
|
||||||
|
from apps.rides.models import RideTechnicalSpec
|
||||||
|
|
||||||
|
specs = RideTechnicalSpec.objects.filter(ride=obj).order_by("category", "name")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": spec.id,
|
||||||
|
"name": spec.name,
|
||||||
|
"value": spec.value,
|
||||||
|
"unit": spec.unit,
|
||||||
|
"category": spec.category,
|
||||||
|
}
|
||||||
|
for spec in specs
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class RideImageSettingsInputSerializer(serializers.Serializer):
|
class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||||
"""Input serializer for setting ride banner and card images."""
|
"""Input serializer for setting ride banner and card images."""
|
||||||
|
|||||||
Reference in New Issue
Block a user