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