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:
pacnpal
2026-01-10 13:00:02 -05:00
parent 22ff0d1c49
commit 692c0bbbbf
9 changed files with 424 additions and 45 deletions

View File

@@ -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"),

View File

@@ -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 ===

View File

@@ -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:

View File

@@ -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}

View File

@@ -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(),

View File

@@ -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.

View File

@@ -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()

View File

@@ -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/",

View File

@@ -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."""