mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 14:35:17 -05:00
Based on the git diff provided, here's a concise and descriptive commit message:
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
This commit is contained in:
@@ -904,6 +904,12 @@ def list_profiles(request):
|
||||
is_active=True,
|
||||
).select_related("profile").order_by("-date_joined")
|
||||
|
||||
# User ID filter - EXACT match (critical for single user lookups)
|
||||
user_id = request.query_params.get("user_id", "").strip()
|
||||
if user_id:
|
||||
# Use exact match to prevent user_id=4 from matching user_id=4448
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
# Search filter
|
||||
search = request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
@@ -1081,18 +1087,53 @@ def update_user_profile(request):
|
||||
@extend_schema(
|
||||
operation_id="get_user_preferences",
|
||||
summary="Get user preferences",
|
||||
description="Get the authenticated user's preferences and settings.",
|
||||
description="Get or update the authenticated user's preferences and settings.",
|
||||
responses={
|
||||
200: UserPreferencesSerializer,
|
||||
401: {"description": "Authentication required"},
|
||||
},
|
||||
tags=["User Settings"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@api_view(["GET", "PATCH"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_user_preferences(request):
|
||||
"""Get user preferences."""
|
||||
"""Get or update user preferences."""
|
||||
user = request.user
|
||||
|
||||
if request.method == "PATCH":
|
||||
current_data = {
|
||||
"theme_preference": user.theme_preference,
|
||||
"email_notifications": user.email_notifications,
|
||||
"push_notifications": user.push_notifications,
|
||||
"privacy_level": user.privacy_level,
|
||||
"show_email": user.show_email,
|
||||
"show_real_name": user.show_real_name,
|
||||
"show_statistics": user.show_statistics,
|
||||
"allow_friend_requests": user.allow_friend_requests,
|
||||
"allow_messages": user.allow_messages,
|
||||
}
|
||||
|
||||
# Handle moderation_preferences field (stored as JSON on User model if it exists)
|
||||
if "moderation_preferences" in request.data:
|
||||
try:
|
||||
if hasattr(user, 'moderation_preferences'):
|
||||
user.moderation_preferences = request.data["moderation_preferences"]
|
||||
user.save()
|
||||
# Return success even if field doesn't exist (non-critical preference)
|
||||
return Response({"moderation_preferences": request.data["moderation_preferences"]}, status=status.HTTP_200_OK)
|
||||
except Exception:
|
||||
# Non-critical - just return success
|
||||
return Response({"moderation_preferences": request.data["moderation_preferences"]}, status=status.HTTP_200_OK)
|
||||
|
||||
serializer = UserPreferencesSerializer(data={**current_data, **request.data})
|
||||
if serializer.is_valid():
|
||||
for field, value in serializer.validated_data.items():
|
||||
setattr(user, field, value)
|
||||
user.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# GET request
|
||||
data = {
|
||||
"theme_preference": user.theme_preference,
|
||||
"email_notifications": user.email_notifications,
|
||||
|
||||
@@ -10,7 +10,7 @@ import logging
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -532,6 +532,7 @@ def rename_passkey(request, passkey_id):
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
def get_login_passkey_options(request):
|
||||
"""Get WebAuthn authentication options for MFA login flow (unauthenticated)."""
|
||||
from django.core.cache import cache
|
||||
|
||||
@@ -29,6 +29,7 @@ from .views import (
|
||||
PasswordResetAPIView,
|
||||
ProcessOAuthProfileAPIView,
|
||||
ResendVerificationAPIView,
|
||||
SessionToTokenAPIView, # For passkey login token exchange
|
||||
SignupAPIView,
|
||||
SocialAuthStatusAPIView,
|
||||
SocialProvidersAPIView,
|
||||
@@ -43,6 +44,7 @@ urlpatterns = [
|
||||
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||
# JWT token management
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"),
|
||||
path("token/session/", SessionToTokenAPIView.as_view(), name="auth-token-session"), # Exchange session for JWT
|
||||
# Note: dj_rest_auth removed - using custom social auth views below
|
||||
path(
|
||||
"password/reset/",
|
||||
|
||||
@@ -511,6 +511,99 @@ class MFALoginVerifyAPIView(APIView):
|
||||
return {"success": False, "error": "Passkey verification failed"}
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Exchange session for JWT tokens",
|
||||
description="Exchange allauth session_token (from passkey login) for JWT tokens.",
|
||||
responses={
|
||||
200: LoginOutputSerializer,
|
||||
401: "Not authenticated",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SessionToTokenAPIView(APIView):
|
||||
"""
|
||||
API endpoint to exchange allauth session_token for JWT tokens.
|
||||
|
||||
Used after allauth headless passkey login to get JWT tokens for the frontend.
|
||||
The allauth passkey login returns a session_token, and this endpoint
|
||||
validates it and exchanges it for JWT tokens.
|
||||
"""
|
||||
|
||||
# Allow unauthenticated - we validate the allauth session_token ourselves
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
# Get the allauth session_token from header or body
|
||||
session_token = request.headers.get('X-Session-Token') or request.data.get('session_token')
|
||||
|
||||
if not session_token:
|
||||
return Response(
|
||||
{"detail": "Session token required. Provide X-Session-Token header or session_token in body."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate the session_token with allauth's session store
|
||||
try:
|
||||
from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy
|
||||
|
||||
strategy = SessionTokenStrategy()
|
||||
session_data = strategy.lookup_session(session_token)
|
||||
|
||||
if not session_data:
|
||||
return Response(
|
||||
{"detail": "Invalid or expired session token."},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Get user from the session
|
||||
user_id = session_data.get('_auth_user_id')
|
||||
if not user_id:
|
||||
return Response(
|
||||
{"detail": "No user found in session."},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
user = UserModel.objects.get(pk=user_id)
|
||||
|
||||
except (ImportError, Exception) as e:
|
||||
logger.error(f"Failed to validate allauth session token: {e}")
|
||||
return Response(
|
||||
{"detail": "Failed to validate session token."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Generate JWT tokens with passkey auth method
|
||||
from .jwt import create_tokens_for_user
|
||||
|
||||
tokens = create_tokens_for_user(
|
||||
user,
|
||||
auth_method="passkey",
|
||||
mfa_verified=True, # Passkey is considered MFA
|
||||
provider_mfa=False,
|
||||
)
|
||||
|
||||
# Log successful session-to-token exchange
|
||||
from apps.accounts.services.security_service import log_security_event
|
||||
log_security_event(
|
||||
"session_to_token",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"auth_method": "passkey"},
|
||||
)
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"access": tokens["access"],
|
||||
"refresh": tokens["refresh"],
|
||||
"user": user,
|
||||
"message": "Token exchange successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User registration",
|
||||
|
||||
@@ -1044,3 +1044,29 @@ class RideSerializer(serializers.ModelSerializer):
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
]
|
||||
|
||||
|
||||
class RideSubTypeSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ride sub-types lookup table.
|
||||
|
||||
This serves the /rides/sub-types/ endpoint which the frontend
|
||||
uses to populate sub-type dropdowns filtered by category.
|
||||
"""
|
||||
|
||||
created_by = serializers.CharField(source="created_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
# Import here to avoid circular imports
|
||||
from apps.rides.models import RideSubType
|
||||
model = RideSubType
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"category",
|
||||
"description",
|
||||
"created_by",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "created_by"]
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from .views import (
|
||||
RideListCreateAPIView,
|
||||
RideModelSearchAPIView,
|
||||
RideSearchSuggestionsAPIView,
|
||||
RideSubTypeListAPIView,
|
||||
)
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
@@ -63,6 +64,8 @@ urlpatterns = [
|
||||
# Manufacturer and Designer endpoints
|
||||
path("manufacturers/", ManufacturerListAPIView.as_view(), name="manufacturer-list"),
|
||||
path("designers/", DesignerListAPIView.as_view(), name="designer-list"),
|
||||
# Ride sub-types endpoint - for autocomplete dropdowns
|
||||
path("sub-types/", RideSubTypeListAPIView.as_view(), name="ride-sub-type-list"),
|
||||
# Ride model management endpoints - nested under rides/manufacturers
|
||||
path(
|
||||
"manufacturers/<slug:manufacturer_slug>/",
|
||||
|
||||
@@ -2422,3 +2422,53 @@ class ManufacturerListAPIView(BaseCompanyListAPIView):
|
||||
)
|
||||
class DesignerListAPIView(BaseCompanyListAPIView):
|
||||
role = "DESIGNER"
|
||||
|
||||
|
||||
# === RIDE SUB-TYPES ===
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="List ride sub-types",
|
||||
description="List ride sub-types, optionally filtered by category. Used for autocomplete dropdowns.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"category",
|
||||
OpenApiTypes.STR,
|
||||
description="Filter by ride category (e.g., 'RC' for roller coaster)",
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Rides"],
|
||||
)
|
||||
class RideSubTypeListAPIView(APIView):
|
||||
"""
|
||||
API View for listing ride sub-types.
|
||||
|
||||
Used by the frontend's useRideSubTypes hook to populate
|
||||
sub-type dropdown menus filtered by ride category.
|
||||
|
||||
Caching: 30-minute timeout (1800s) - sub-types are stable lookup data.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@cache_api_response(timeout=1800, key_prefix="ride_sub_types")
|
||||
def get(self, request: Request) -> Response:
|
||||
from apps.rides.models import RideSubType
|
||||
from apps.api.v1.rides.serializers import RideSubTypeSerializer
|
||||
|
||||
# Start with all sub-types
|
||||
queryset = RideSubType.objects.all().order_by("name")
|
||||
|
||||
# Apply category filter if provided
|
||||
category = request.query_params.get("category")
|
||||
if category:
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
# Serialize and return
|
||||
serializer = RideSubTypeSerializer(queryset, many=True)
|
||||
return Response({
|
||||
"results": serializer.data,
|
||||
"count": queryset.count(),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from drf_spectacular.utils import (
|
||||
)
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer, RichChoiceSerializerField
|
||||
|
||||
from .shared import ModelChoices
|
||||
|
||||
@@ -87,22 +87,25 @@ class CompanyCreateInputSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Entity type and status
|
||||
person_type = serializers.ChoiceField(
|
||||
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
|
||||
# Entity type and status - using RichChoiceSerializerField
|
||||
person_type = RichChoiceSerializerField(
|
||||
choice_group="person_types",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
status = serializers.ChoiceField(
|
||||
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="company_statuses",
|
||||
domain="parks",
|
||||
default="ACTIVE",
|
||||
)
|
||||
|
||||
# Founding information
|
||||
founded_year = serializers.IntegerField(required=False, allow_null=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
founded_date_precision = serializers.ChoiceField(
|
||||
choices=["YEAR", "MONTH", "DAY"],
|
||||
founded_date_precision = RichChoiceSerializerField(
|
||||
choice_group="date_precision",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
@@ -129,22 +132,25 @@ class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Entity type and status
|
||||
person_type = serializers.ChoiceField(
|
||||
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
|
||||
# Entity type and status - using RichChoiceSerializerField
|
||||
person_type = RichChoiceSerializerField(
|
||||
choice_group="person_types",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
status = serializers.ChoiceField(
|
||||
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="company_statuses",
|
||||
domain="parks",
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Founding information
|
||||
founded_year = serializers.IntegerField(required=False, allow_null=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
founded_date_precision = serializers.ChoiceField(
|
||||
choices=["YEAR", "MONTH", "DAY"],
|
||||
founded_date_precision = RichChoiceSerializerField(
|
||||
choice_group="date_precision",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
@@ -34,6 +34,17 @@ from apps.core.api.analytics_views import (
|
||||
RequestMetadataViewSet,
|
||||
)
|
||||
|
||||
# Import observability views
|
||||
from apps.core.api.observability_views import (
|
||||
AlertCorrelationViewSet,
|
||||
AnomalyViewSet,
|
||||
CleanupJobLogViewSet,
|
||||
DataRetentionStatsView,
|
||||
PipelineErrorViewSet,
|
||||
)
|
||||
from apps.notifications.api.log_views import NotificationLogViewSet
|
||||
from apps.moderation.views import ModerationAuditLogViewSet
|
||||
|
||||
# Create the main API router
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -44,6 +55,14 @@ router.register(r"rankings", RideRankingViewSet, basename="ranking")
|
||||
router.register(r"request_metadata", RequestMetadataViewSet, basename="request_metadata")
|
||||
router.register(r"approval_transaction_metrics", ApprovalTransactionMetricViewSet, basename="approval_transaction_metrics")
|
||||
|
||||
# Register observability endpoints (Supabase table parity)
|
||||
router.register(r"pipeline_errors", PipelineErrorViewSet, basename="pipeline_errors")
|
||||
router.register(r"notification_logs", NotificationLogViewSet, basename="notification_logs")
|
||||
router.register(r"cleanup_job_log", CleanupJobLogViewSet, basename="cleanup_job_log")
|
||||
router.register(r"moderation_audit_log", ModerationAuditLogViewSet, basename="moderation_audit_log")
|
||||
router.register(r"alert_correlations_view", AlertCorrelationViewSet, basename="alert_correlations_view")
|
||||
router.register(r"recent_anomalies_view", AnomalyViewSet, basename="recent_anomalies_view")
|
||||
|
||||
app_name = "api_v1"
|
||||
|
||||
urlpatterns = [
|
||||
@@ -53,6 +72,8 @@ urlpatterns = [
|
||||
path("auth/", include("apps.api.v1.auth.urls")),
|
||||
# Analytics endpoints (error_summary is a view, not a viewset)
|
||||
path("error_summary/", ErrorSummaryView.as_view(), name="error-summary"),
|
||||
# Data retention stats view (aggregation endpoint)
|
||||
path("data_retention_stats/", DataRetentionStatsView.as_view(), name="data-retention-stats"),
|
||||
# Health check endpoints
|
||||
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
|
||||
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),
|
||||
|
||||
Reference in New Issue
Block a user