feat(accounts): add public profiles list endpoint with search and pagination

- Add new `/profiles/` endpoint for listing user profiles with search, filtering, and pagination support
- Implement list_profiles view with OpenAPI documentation for user discovery and leaderboards
- Refactor WebAuthn authentication state management to simplify begin_authentication flow
- Update MFA passkey login to store user reference instead of full state in cache

This enables public profile browsing and improves the passkey authentication implementation by leveraging allauth's internal session management.
This commit is contained in:
pacnpal
2026-01-10 12:59:39 -05:00
parent fbbfea50a3
commit 22ff0d1c49
2 changed files with 889 additions and 0 deletions

View File

@@ -0,0 +1,254 @@
"""
Global Ride Model views for ThrillWiki API v1.
This module provides top-level ride model endpoints that don't require
manufacturer context, matching the frontend's expectation of /rides/models/.
"""
from django.db.models import Q
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import permissions, status
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
# Import serializers
from apps.api.v1.serializers.ride_models import (
RideModelDetailOutputSerializer,
RideModelListOutputSerializer,
)
# Attempt to import models
try:
from apps.rides.models import RideModel
from apps.rides.models.company import Company
MODELS_AVAILABLE = True
except ImportError:
try:
from apps.rides.models.rides import Company, RideModel
MODELS_AVAILABLE = True
except ImportError:
RideModel = None
Company = None
MODELS_AVAILABLE = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class GlobalRideModelListAPIView(APIView):
"""
Global ride model list endpoint.
This endpoint provides a top-level list of all ride models without
requiring a manufacturer slug, matching the frontend's expectation
of calling /rides/models/ directly.
"""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List all ride models with filtering and pagination",
description=(
"List all ride models across all manufacturers with comprehensive "
"filtering and pagination support. This is a global endpoint that "
"doesn't require manufacturer context."
),
parameters=[
OpenApiParameter(
name="page",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Page number for pagination",
),
OpenApiParameter(
name="page_size",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Number of results per page (max 100)",
),
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Search term for name, description, or manufacturer",
),
OpenApiParameter(
name="category",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by category (e.g., RC, DR, FR, WR)",
),
OpenApiParameter(
name="manufacturer",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by manufacturer slug",
),
OpenApiParameter(
name="target_market",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by target market (e.g., FAMILY, THRILL)",
),
OpenApiParameter(
name="is_discontinued",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
description="Filter by discontinued status",
),
OpenApiParameter(
name="ordering",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Order by field: name, -name, manufacturer__name, etc.",
),
],
responses={200: RideModelListOutputSerializer(many=True)},
tags=["Ride Models"],
)
def get(self, request: Request) -> Response:
"""List all ride models with filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
"count": 0,
"next": None,
"previous": None,
"results": [],
"detail": "Ride model listing is not available.",
},
status=status.HTTP_200_OK,
)
# Base queryset with eager loading
qs = RideModel.objects.select_related("manufacturer").prefetch_related(
"photos"
).order_by("manufacturer__name", "name")
# Search filter
search = request.query_params.get("search", "").strip()
if search:
qs = qs.filter(
Q(name__icontains=search)
| Q(description__icontains=search)
| Q(manufacturer__name__icontains=search)
)
# Category filter
category = request.query_params.get("category", "").strip()
if category:
# Support comma-separated categories
categories = [c.strip() for c in category.split(",") if c.strip()]
if categories:
qs = qs.filter(category__in=categories)
# Manufacturer filter
manufacturer = request.query_params.get("manufacturer", "").strip()
if manufacturer:
qs = qs.filter(manufacturer__slug=manufacturer)
# Target market filter
target_market = request.query_params.get("target_market", "").strip()
if target_market:
markets = [m.strip() for m in target_market.split(",") if m.strip()]
if markets:
qs = qs.filter(target_market__in=markets)
# Discontinued filter
is_discontinued = request.query_params.get("is_discontinued")
if is_discontinued is not None:
qs = qs.filter(is_discontinued=is_discontinued.lower() == "true")
# Ordering
ordering = request.query_params.get("ordering", "manufacturer__name,name")
valid_orderings = [
"name", "-name",
"manufacturer__name", "-manufacturer__name",
"first_installation_year", "-first_installation_year",
"total_installations", "-total_installations",
"created_at", "-created_at",
]
if ordering:
order_fields = [
f.strip() for f in ordering.split(",")
if f.strip() in valid_orderings or f.strip().lstrip("-") in [
o.lstrip("-") for o in valid_orderings
]
]
if order_fields:
qs = qs.order_by(*order_fields)
# Paginate
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
if page is not None:
serializer = RideModelListOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
# Fallback without pagination
serializer = RideModelListOutputSerializer(
qs[:100], many=True, context={"request": request}
)
return Response(serializer.data)
class GlobalRideModelDetailAPIView(APIView):
"""
Global ride model detail endpoint by ID or slug.
This endpoint provides detail for a single ride model without
requiring manufacturer context.
"""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="Retrieve a ride model by ID",
description="Get detailed information about a specific ride model by its ID.",
parameters=[
OpenApiParameter(
name="pk",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
required=True,
description="Ride model ID",
),
],
responses={200: RideModelDetailOutputSerializer()},
tags=["Ride Models"],
)
def get(self, request: Request, pk: int) -> Response:
"""Get ride model detail by ID."""
if not MODELS_AVAILABLE:
return Response(
{"detail": "Ride model not found"},
status=status.HTTP_404_NOT_FOUND,
)
try:
ride_model = (
RideModel.objects.select_related("manufacturer")
.prefetch_related("photos", "variants", "technical_specs")
.get(pk=pk)
)
except RideModel.DoesNotExist:
return Response(
{"detail": "Ride model not found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = RideModelDetailOutputSerializer(
ride_model, context={"request": request}
)
return Response(serializer.data)