mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 15:35:17 -05:00
Compare commits
3 Commits
fbbfea50a3
...
96df23242e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96df23242e | ||
|
|
692c0bbbbf | ||
|
|
22ff0d1c49 |
143
.gitignore
vendored
143
.gitignore
vendored
@@ -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
|
||||
@@ -6,6 +6,7 @@ from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from . import views, views_credits, views_magic_link
|
||||
from .views import list_profiles
|
||||
|
||||
# Register ViewSets
|
||||
router = DefaultRouter()
|
||||
@@ -119,7 +120,8 @@ urlpatterns = [
|
||||
# Magic Link (Login by Code) endpoints
|
||||
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"),
|
||||
# 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"),
|
||||
# Bulk lookup endpoints
|
||||
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 ===
|
||||
|
||||
|
||||
|
||||
@@ -96,10 +96,10 @@ def get_registration_options(request):
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# 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
|
||||
webauthn_auth.set_state(request, state)
|
||||
# State is stored internally by begin_registration via set_state()
|
||||
|
||||
return Response({
|
||||
"options": creation_options,
|
||||
@@ -154,8 +154,8 @@ def register_passkey(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get stored state from session
|
||||
state = webauthn_auth.get_state(request)
|
||||
# Get stored state from session (no request needed, uses context)
|
||||
state = webauthn_auth.get_state()
|
||||
if not state:
|
||||
return Response(
|
||||
{"detail": "No pending registration. Please start registration again."},
|
||||
@@ -164,19 +164,24 @@ def register_passkey(request):
|
||||
|
||||
# Use the correct allauth API: complete_registration
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
# Parse the credential response
|
||||
credential_data = webauthn_auth.parse_registration_response(credential)
|
||||
|
||||
# Complete registration - this creates the Authenticator
|
||||
authenticator = webauthn_auth.complete_registration(
|
||||
request,
|
||||
credential_data,
|
||||
state,
|
||||
name=name,
|
||||
)
|
||||
# Complete registration - returns AuthenticatorData (binding)
|
||||
authenticator_data = webauthn_auth.complete_registration(credential_data)
|
||||
|
||||
# Clear session state
|
||||
webauthn_auth.clear_state(request)
|
||||
# Create the Authenticator record ourselves
|
||||
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({
|
||||
"detail": "Passkey registered successfully",
|
||||
@@ -225,10 +230,8 @@ def get_authentication_options(request):
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Use the correct allauth API: begin_authentication
|
||||
request_options, state = webauthn_auth.begin_authentication(request)
|
||||
|
||||
# Store state in session for verification
|
||||
webauthn_auth.set_state(request, state)
|
||||
# Takes optional user, returns just options (state is stored internally)
|
||||
request_options = webauthn_auth.begin_authentication(request.user)
|
||||
|
||||
return Response({
|
||||
"options": request_options,
|
||||
@@ -281,8 +284,8 @@ def authenticate_passkey(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get stored state from session
|
||||
state = webauthn_auth.get_state(request)
|
||||
# Get stored state from session (no request needed, uses context)
|
||||
state = webauthn_auth.get_state()
|
||||
if not state:
|
||||
return Response(
|
||||
{"detail": "No pending authentication. Please start authentication again."},
|
||||
@@ -291,14 +294,9 @@ def authenticate_passkey(request):
|
||||
|
||||
# Use the correct allauth API: complete_authentication
|
||||
try:
|
||||
# Parse the credential response
|
||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
||||
|
||||
# Complete authentication
|
||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
||||
|
||||
# Clear session state
|
||||
webauthn_auth.clear_state(request)
|
||||
# Complete authentication - takes user and credential response
|
||||
# State is handled internally
|
||||
webauthn_auth.complete_authentication(request.user, credential)
|
||||
|
||||
return Response({"success": True})
|
||||
except Exception as e:
|
||||
@@ -514,9 +512,13 @@ def get_login_passkey_options(request):
|
||||
request.user = user
|
||||
|
||||
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}"
|
||||
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})
|
||||
finally:
|
||||
if original_user is not None:
|
||||
|
||||
@@ -417,23 +417,23 @@ class MFALoginVerifyAPIView(APIView):
|
||||
return {"success": False, "error": "No passkey registered for this user"}
|
||||
|
||||
try:
|
||||
# Parse the authentication response
|
||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
||||
|
||||
# Get or create authentication state
|
||||
# For login flow, we need to set up the state first
|
||||
state = webauthn_auth.get_state(request)
|
||||
# For MFA login flow, we need to set up state first if not present
|
||||
# Note: allauth's begin_authentication stores state internally
|
||||
state = webauthn_auth.get_state()
|
||||
|
||||
if not state:
|
||||
# If no state, generate one for this user
|
||||
_, state = webauthn_auth.begin_authentication(request)
|
||||
webauthn_auth.set_state(request, state)
|
||||
# Need to temporarily set request.user for allauth context
|
||||
original_user = getattr(request, "user", None)
|
||||
request.user = user
|
||||
try:
|
||||
webauthn_auth.begin_authentication(user)
|
||||
finally:
|
||||
if original_user is not None:
|
||||
request.user = original_user
|
||||
|
||||
# Complete authentication
|
||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
||||
|
||||
# Clear the state
|
||||
webauthn_auth.clear_state(request)
|
||||
# Complete authentication - takes user and credential dict
|
||||
# State is managed internally by allauth
|
||||
webauthn_auth.complete_authentication(user, credential)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ router.register(r"milestones", MilestoneViewSet, basename="milestone")
|
||||
|
||||
# Entity search endpoints - migrated from apps.core.urls
|
||||
urlpatterns = [
|
||||
# View counts endpoint for tracking page views
|
||||
path(
|
||||
"views/",
|
||||
views.ViewCountView.as_view(),
|
||||
name="view_counts",
|
||||
),
|
||||
path(
|
||||
"entities/search/",
|
||||
views.EntityFuzzySearchView.as_view(),
|
||||
|
||||
@@ -27,6 +27,106 @@ import logging
|
||||
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):
|
||||
"""
|
||||
Handle frontend telemetry and request metadata logging.
|
||||
|
||||
254
backend/apps/api/v1/rides/ride_model_views.py
Normal file
254
backend/apps/api/v1/rides/ride_model_views.py
Normal 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)
|
||||
@@ -306,6 +306,12 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
banner_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
|
||||
opening_year = serializers.IntegerField(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."""
|
||||
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_wetness_level = 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 .photo_views import RidePhotoViewSet
|
||||
from .ride_model_views import GlobalRideModelDetailAPIView, GlobalRideModelListAPIView
|
||||
from .views import (
|
||||
CompanySearchAPIView,
|
||||
DesignerListAPIView,
|
||||
@@ -40,6 +41,9 @@ urlpatterns = [
|
||||
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
|
||||
# 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
|
||||
path(
|
||||
"search/companies/",
|
||||
|
||||
@@ -211,6 +211,18 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
# Former names (name history)
|
||||
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 = serializers.SerializerMethodField()
|
||||
|
||||
@@ -427,6 +439,99 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
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):
|
||||
"""Input serializer for setting ride banner and card images."""
|
||||
|
||||
635
docs/allauth_integration_guide.md
Normal file
635
docs/allauth_integration_guide.md
Normal 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
|
||||
Reference in New Issue
Block a user