Compare commits

...

3 Commits

Author SHA1 Message Date
pacnpal
96df23242e ok 2026-01-10 13:06:19 -05:00
pacnpal
692c0bbbbf 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.
2026-01-10 13:00:02 -05:00
pacnpal
22ff0d1c49 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.
2026-01-10 12:59:39 -05:00
13 changed files with 1313 additions and 188 deletions

BIN
.coverage

Binary file not shown.

143
.gitignore vendored
View File

@@ -1,143 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Django
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
/backend/staticfiles/
/backend/media/
# Celery Beat schedule database (runtime state, regenerated automatically)
celerybeat-schedule*
celerybeat.pid
# UV
.uv/
backend/.uv/
# Generated requirements files (auto-generated from pyproject.toml)
# Uncomment if you want to track these files
# backend/requirements.txt
# backend/requirements-dev.txt
# backend/requirements-test.txt
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-store/
# Vue.js / Vite
/frontend/dist/
/frontend/dist-ssr/
*.local
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
backend/.env
frontend/.env
# IDEs
.vscode/
.idea/
*.swp
*.swo
*.sublime-project
*.sublime-workspace
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Logs
logs/
*.log
# Coverage
coverage/
*.lcov
.nyc_output
htmlcov/
.coverage
.coverage.*
# Testing
.pytest_cache/
.cache
# Temporary files
tmp/
temp/
*.tmp
*.temp
# Build outputs
/dist/
/build/
# Backup files
*.bak
*.backup
*.orig
*.swp
*_backup.*
*_OLD_*
# Archive files
*.tar.gz
*.zip
*.rar
# Security
*.pem
*.key
*.cert
# Local development
/uploads/
/backups/
.django_tailwind_cli/
backend/.env
frontend/.env
# Extracted packages
django-forwardemail/
frontend/
frontend
.snapshots
web/next-env.d.ts
web/.next/types/cache-life.d.ts
.gitignore
web/.next/types/routes.d.ts
web/.next/types/validator.ts

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

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

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

View File

@@ -0,0 +1,635 @@
# Django-Allauth Integration Guide for ThrillWiki
This guide documents how to properly integrate django-allauth for authentication in ThrillWiki, covering JWT tokens, password authentication, MFA (TOTP/WebAuthn), and social OAuth (Google/Discord).
---
## Table of Contents
1. [Installation & Setup](#installation--setup)
2. [JWT Token Authentication](#jwt-token-authentication)
3. [Password Authentication](#password-authentication)
4. [MFA: TOTP (Authenticator App)](#mfa-totp-authenticator-app)
5. [MFA: WebAuthn/Passkeys](#mfa-webauthnpasskeys)
6. [Social OAuth: Google](#social-oauth-google)
7. [Social OAuth: Discord](#social-oauth-discord)
8. [API Patterns & DRF Integration](#api-patterns--drf-integration)
9. [Internal API Reference](#internal-api-reference)
---
## Installation & Setup
### Required Packages
```bash
# Add packages to pyproject.toml
uv add "django-allauth[headless,mfa,socialaccount]"
uv add fido2 # For WebAuthn support
# Install all dependencies
uv sync
```
### Running Django Commands
```bash
# Run migrations
uv run manage.py migrate
# Create superuser
uv run manage.py createsuperuser
# Run development server
uv run manage.py runserver
# Collect static files
uv run manage.py collectstatic
```
### INSTALLED_APPS Configuration
```python
# config/django/base.py
INSTALLED_APPS = [
# Django built-in
"django.contrib.auth",
"django.contrib.sites",
# Allauth core (required)
"allauth",
"allauth.account",
# Optional modules
"allauth.headless", # For headless/API mode
"allauth.mfa", # MFA support (TOTP, recovery codes)
"allauth.mfa.webauthn", # WebAuthn/Passkey support
"allauth.socialaccount", # Social auth base
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.discord",
]
```
### Middleware
```python
MIDDLEWARE = [
# ... other middleware
"allauth.account.middleware.AccountMiddleware",
]
```
### URL Configuration
```python
# urls.py
urlpatterns = [
# Allauth browser views (needed for OAuth callbacks)
path("accounts/", include("allauth.urls")),
# Allauth headless API endpoints
path("_allauth/", include("allauth.headless.urls")),
]
```
---
## JWT Token Authentication
### Configuration
```python
# settings.py
# Token strategy - use JWT
HEADLESS_TOKEN_STRATEGY = "allauth.headless.tokens.JWTTokenStrategy"
# Generate private key: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
HEADLESS_JWT_PRIVATE_KEY = """
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...
-----END PRIVATE KEY-----
"""
# Token lifetimes
HEADLESS_JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes
HEADLESS_JWT_REFRESH_TOKEN_EXPIRES_IN = 86400 # 24 hours
# Authorization header scheme
HEADLESS_JWT_AUTHORIZATION_HEADER_SCHEME = "Bearer"
# Stateful validation (invalidates tokens on logout)
HEADLESS_JWT_STATEFUL_VALIDATION_ENABLED = True
# Rotate refresh tokens on use
HEADLESS_JWT_ROTATE_REFRESH_TOKEN = True
```
### DRF Integration
```python
from allauth.headless.contrib.rest_framework.authentication import JWTTokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
class ProtectedAPIView(APIView):
authentication_classes = [JWTTokenAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request):
return Response({"user": request.user.email})
```
### JWT Flow
1. User authenticates (password/social/passkey)
2. During auth, pass `X-Session-Token` header to allauth API
3. Upon successful authentication, response `meta` contains:
```json
{
"meta": {
"access_token": "eyJ...",
"refresh_token": "abc123..."
}
}
```
4. Use `Authorization: Bearer <access_token>` for subsequent requests
5. Refresh tokens via `POST /_allauth/browser/v1/auth/token/refresh`
---
## Password Authentication
### Configuration
```python
# config/settings/third_party.py
# Signup fields (* = required)
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
# Login methods
ACCOUNT_LOGIN_METHODS = {"email", "username"} # Allow both
# Email verification
ACCOUNT_EMAIL_VERIFICATION = "mandatory" # Options: "mandatory", "optional", "none"
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True
# Security
ACCOUNT_REAUTHENTICATION_REQUIRED = True # Require re-auth for sensitive operations
ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False # Don't reveal if email exists
# Redirects
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
# Custom adapters
ACCOUNT_ADAPTER = "apps.accounts.adapters.CustomAccountAdapter"
```
### Headless API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/_allauth/browser/v1/auth/login` | POST | Login with email/username + password |
| `/_allauth/browser/v1/auth/signup` | POST | Register new account |
| `/_allauth/browser/v1/auth/logout` | POST | Logout (invalidate tokens) |
| `/_allauth/browser/v1/auth/password/reset` | POST | Request password reset |
| `/_allauth/browser/v1/auth/password/reset/key` | POST | Complete password reset |
| `/_allauth/browser/v1/auth/password/change` | POST | Change password (authenticated) |
### Magic Link (Login by Code)
```python
# Enable magic link authentication
ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 3
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 300 # 5 minutes
```
---
## MFA: TOTP (Authenticator App)
### Configuration
```python
# config/settings/third_party.py
# Enable TOTP in supported types
MFA_SUPPORTED_TYPES = ["totp", "webauthn"]
# TOTP settings
MFA_TOTP_ISSUER = "ThrillWiki" # Shows in authenticator app
MFA_TOTP_DIGITS = 6 # Code length
MFA_TOTP_PERIOD = 30 # Seconds per code
```
### TOTP API Flow
1. **Get TOTP Secret** (for QR code):
```
GET /_allauth/browser/v1/account/authenticators/totp
```
Response contains `totp_url` for QR code generation.
2. **Activate TOTP**:
```
POST /_allauth/browser/v1/account/authenticators/totp
{
"code": "123456"
}
```
3. **Deactivate TOTP**:
```
DELETE /_allauth/browser/v1/account/authenticators/totp
```
4. **MFA Login Flow**:
- After password auth, if MFA enabled, receive `401` with `mfa_required`
- Submit TOTP code:
```
POST /_allauth/browser/v1/auth/2fa/authenticate
{
"code": "123456"
}
```
---
## MFA: WebAuthn/Passkeys
### Configuration
```python
# config/settings/third_party.py
# Include webauthn in supported types
MFA_SUPPORTED_TYPES = ["totp", "webauthn"]
# Enable passkey-only login
MFA_PASSKEY_LOGIN_ENABLED = True
# Allow insecure origin for localhost development
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True # Only for DEBUG=True
```
### Internal WebAuthn API Functions
The `allauth.mfa.webauthn.internal.auth` module provides these functions:
```python
from allauth.mfa.webauthn.internal import auth as webauthn_auth
# Registration Flow
def begin_registration(user, passwordless: bool) -> Dict:
"""
Start passkey registration.
Args:
user: The Django user object
passwordless: True for passkey login, False for MFA-only
Returns:
Dict with WebAuthn creation options (challenge, rp, user, etc.)
Note: State is stored internally via set_state()
"""
def complete_registration(credential: Dict) -> AuthenticatorData:
"""
Complete passkey registration.
Args:
credential: The parsed credential response from browser
Returns:
AuthenticatorData (binding) - NOT an Authenticator model
Note: You must create the Authenticator record yourself!
"""
# Authentication Flow
def begin_authentication(user=None) -> Dict:
"""
Start passkey authentication.
Args:
user: Optional user (for MFA). None for passwordless login.
Returns:
Dict with WebAuthn request options
Note: State is stored internally via set_state()
"""
def complete_authentication(user, response: Dict) -> Authenticator:
"""
Complete passkey authentication.
Args:
user: The Django user object
response: The credential response from browser
Returns:
The matching Authenticator model instance
"""
# State Management (internal, use context)
def get_state() -> Optional[Dict]:
"""Get stored WebAuthn state from session."""
def set_state(state: Dict) -> None:
"""Store WebAuthn state in session."""
def clear_state() -> None:
"""Clear WebAuthn state from session."""
# Helper functions
def parse_registration_response(response: Any) -> RegistrationResponse:
"""Parse browser registration response."""
def parse_authentication_response(response: Any) -> AuthenticationResponse:
"""Parse browser authentication response."""
```
### Custom Passkey API Implementation
```python
# apps/api/v1/auth/passkey.py
from allauth.mfa.webauthn.internal import auth as webauthn_auth
from allauth.mfa.models import Authenticator
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_registration_options(request):
"""Get WebAuthn registration options."""
# passwordless=False for MFA passkeys, True for passwordless login
creation_options = webauthn_auth.begin_registration(
request.user,
passwordless=False
)
# State is stored internally
return Response({"options": creation_options})
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def register_passkey(request):
"""Complete passkey registration."""
credential = request.data.get("credential")
name = request.data.get("name", "Passkey")
# Check for pending registration
state = webauthn_auth.get_state()
if not state:
return Response({"error": "No pending registration"}, status=400)
# Parse and complete registration
credential_data = webauthn_auth.parse_registration_response(credential)
authenticator_data = webauthn_auth.complete_registration(credential_data)
# Create Authenticator record manually
authenticator = Authenticator.objects.create(
user=request.user,
type=Authenticator.Type.WEBAUTHN,
data={"name": name},
)
return Response({"id": str(authenticator.id), "name": name})
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_authentication_options(request):
"""Get WebAuthn authentication options."""
request_options = webauthn_auth.begin_authentication(request.user)
return Response({"options": request_options})
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def authenticate_passkey(request):
"""Verify passkey authentication."""
credential = request.data.get("credential")
state = webauthn_auth.get_state()
if not state:
return Response({"error": "No pending authentication"}, status=400)
# Complete authentication (handles state internally)
webauthn_auth.complete_authentication(request.user, credential)
return Response({"success": True})
```
---
## Social OAuth: Google
### Google Cloud Console Setup
1. Go to [Google Cloud Console](https://console.developers.google.com/)
2. Create/select project → APIs & Services → Credentials
3. Create OAuth 2.0 Client ID (Web application)
4. Set Authorized JavaScript origins:
- `http://localhost:3000` (development)
- `https://thrillwiki.com` (production)
5. Set Authorized redirect URIs:
- `http://localhost:8000/accounts/google/login/callback/`
- `https://api.thrillwiki.com/accounts/google/login/callback/`
6. Note the Client ID and Client Secret
### Django Configuration
```python
# config/settings/third_party.py
SOCIALACCOUNT_PROVIDERS = {
"google": {
"SCOPE": ["profile", "email"],
"AUTH_PARAMS": {"access_type": "online"}, # Use "offline" for refresh tokens
"OAUTH_PKCE_ENABLED": True,
# "FETCH_USERINFO": True, # If you need avatar_url for private profiles
},
}
```
### Admin Setup
1. Go to `/admin/socialaccount/socialapp/`
2. Add new Social Application:
- Provider: Google
- Name: Google
- Client ID: (from Google Console)
- Secret key: (from Google Console)
- Sites: Select your site
---
## Social OAuth: Discord
### Discord Developer Portal Setup
1. Go to [Discord Developer Portal](https://discordapp.com/developers/applications/me)
2. Create New Application
3. Go to OAuth2 → General
4. Add Redirect URIs:
- `http://127.0.0.1:8000/accounts/discord/login/callback/`
- `https://api.thrillwiki.com/accounts/discord/login/callback/`
5. Note Client ID and Client Secret
### Django Configuration
```python
# config/settings/third_party.py
SOCIALACCOUNT_PROVIDERS = {
"discord": {
"SCOPE": ["identify", "email"], # "identify" is required
"OAUTH_PKCE_ENABLED": True,
},
}
```
### Admin Setup
1. Go to `/admin/socialaccount/socialapp/`
2. Add new Social Application:
- Provider: Discord
- Name: Discord
- Client ID: (from Discord Portal)
- Secret key: (from Discord Portal)
- Sites: Select your site
---
## API Patterns & DRF Integration
### Authentication Classes
```python
from allauth.headless.contrib.rest_framework.authentication import (
JWTTokenAuthentication,
SessionTokenAuthentication,
)
# For JWT-based authentication
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"allauth.headless.contrib.rest_framework.authentication.JWTTokenAuthentication",
],
}
```
### Headless Frontend URLs
```python
# Required for email verification, password reset links
HEADLESS_FRONTEND_URLS = {
"account_confirm_email": "https://thrillwiki.com/account/verify-email/{key}",
"account_reset_password_from_key": "https://thrillwiki.com/account/password/reset/{key}",
"account_signup": "https://thrillwiki.com/signup",
}
```
### Custom Adapters
```python
# apps/accounts/adapters.py
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
class CustomAccountAdapter(DefaultAccountAdapter):
def save_user(self, request, user, form, commit=True):
"""Customize user creation."""
user = super().save_user(request, user, form, commit=False)
# Custom logic here
if commit:
user.save()
return user
def is_open_for_signup(self, request):
"""Control signup availability."""
return True
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def pre_social_login(self, request, sociallogin):
"""Hook before social login completes."""
# Link social account to existing user by email
if sociallogin.is_existing:
return
email = sociallogin.account.extra_data.get("email")
if email:
User = get_user_model()
try:
user = User.objects.get(email=email)
sociallogin.connect(request, user)
except User.DoesNotExist:
pass
```
---
## Internal API Reference
### Authenticator Model
```python
from allauth.mfa.models import Authenticator
# Types
Authenticator.Type.TOTP # TOTP authenticator
Authenticator.Type.WEBAUTHN # WebAuthn/Passkey
Authenticator.Type.RECOVERY_CODES # Recovery codes
# Query user's authenticators
passkeys = Authenticator.objects.filter(
user=user,
type=Authenticator.Type.WEBAUTHN
)
# Check if MFA is enabled
from allauth.mfa.adapter import get_adapter
is_mfa_enabled = get_adapter().is_mfa_enabled(user)
```
### Session Token Header
For headless mode during authentication flow:
```
X-Session-Token: <session-token>
```
After authentication completes with JWT enabled:
```
Authorization: Bearer <access-token>
```
---
## Current ThrillWiki Implementation Summary
ThrillWiki already has these allauth features configured:
| Feature | Status | Notes |
|---------|--------|-------|
| Password Auth | ✅ Configured | Email + username login |
| Email Verification | ✅ Mandatory | With resend support |
| TOTP MFA | ✅ Configured | 6-digit codes, 30s period |
| WebAuthn/Passkeys | ✅ Configured | Passkey login enabled |
| Google OAuth | ✅ Configured | Needs admin SocialApp |
| Discord OAuth | ✅ Configured | Needs admin SocialApp |
| Magic Link | ✅ Configured | 5-minute timeout |
| JWT Tokens | ❌ Not configured | Using SimpleJWT instead |
### Recommendation
To use allauth's native JWT support instead of SimpleJWT:
1. Add `"allauth.headless"` to INSTALLED_APPS
2. Configure `HEADLESS_TOKEN_STRATEGY` and JWT settings
3. Replace `rest_framework_simplejwt` authentication with `JWTTokenAuthentication`
4. Add `/_allauth/` URL routes