Based on the git diff provided, here's a concise and descriptive commit message:

feat: add security event taxonomy and optimize park queryset

- Add comprehensive security_event_types ChoiceGroup with categories for authentication, MFA, password, account, session, and API key events
- Include severity levels, icons, and CSS classes for each event type
- Fix park queryset optimization by using select_related for OneToOne location relationship
- Remove location property fields (latitude/longitude) from values() call as they are not actual DB columns
- Add proper location fields (city, state, country) to values() for map display

This change enhances security event tracking capabilities and resolves a queryset optimization issue where property decorators were incorrectly used in values() queries.
This commit is contained in:
pacnpal
2026-01-10 16:41:31 -05:00
parent 96df23242e
commit 2b66814d82
26 changed files with 2055 additions and 112 deletions

View File

@@ -370,6 +370,118 @@ def revoke_session(request, session_id):
return Response({"detail": "Session revoked"})
# ============== PASSWORD CHANGE ENDPOINT ==============
# ============== SECURITY LOG ENDPOINT ==============
@extend_schema(
operation_id="get_security_log",
summary="Get security activity log",
description="Returns paginated list of security events for the current user.",
parameters=[
{
"name": "page",
"in": "query",
"description": "Page number (1-indexed)",
"required": False,
"schema": {"type": "integer", "default": 1},
},
{
"name": "page_size",
"in": "query",
"description": "Number of items per page (max 50)",
"required": False,
"schema": {"type": "integer", "default": 20},
},
{
"name": "event_type",
"in": "query",
"description": "Filter by event type",
"required": False,
"schema": {"type": "string"},
},
],
responses={
200: {
"description": "Security log entries",
"example": {
"count": 42,
"page": 1,
"page_size": 20,
"total_pages": 3,
"results": [
{
"id": 1,
"event_type": "login_success",
"event_type_display": "Login Success",
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"created_at": "2026-01-06T12:00:00Z",
"metadata": {},
}
],
},
},
},
tags=["Account"],
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_security_log(request):
"""Get security activity log for the current user."""
from apps.accounts.models import SecurityLog
user = request.user
# Parse pagination params
try:
page = max(1, int(request.query_params.get("page", 1)))
except (ValueError, TypeError):
page = 1
try:
page_size = min(50, max(1, int(request.query_params.get("page_size", 20))))
except (ValueError, TypeError):
page_size = 20
event_type = request.query_params.get("event_type")
# Build queryset
queryset = SecurityLog.objects.filter(user=user).order_by("-created_at")
if event_type:
queryset = queryset.filter(event_type=event_type)
# Count total
total_count = queryset.count()
total_pages = (total_count + page_size - 1) // page_size
# Fetch page
offset = (page - 1) * page_size
logs = queryset[offset : offset + page_size]
# Serialize
results = []
for log in logs:
results.append({
"id": log.id,
"event_type": log.event_type,
"event_type_display": log.get_event_type_display(),
"ip_address": log.ip_address,
"user_agent": log.user_agent[:200] if log.user_agent else "", # Truncate for safety
"created_at": log.created_at.isoformat(),
"metadata": log.metadata or {},
})
return Response({
"count": total_count,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"results": results,
})
# ============== PASSWORD CHANGE ENDPOINT ==============
@extend_schema(
@@ -396,6 +508,12 @@ def revoke_session(request, session_id):
@permission_classes([IsAuthenticated])
def change_password(request):
"""Change user password."""
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
invalidate_user_sessions,
)
user = request.user
current_password = request.data.get("current_password", "")
new_password = request.data.get("new_password", "")
@@ -413,6 +531,24 @@ def change_password(request):
)
user.set_password(new_password)
user.last_password_change = timezone.now()
user.save()
return Response({"detail": "Password changed successfully"})
# Invalidate all existing sessions/tokens (except current)
invalidated_count = invalidate_user_sessions(user, exclude_current=True, request=request)
# Log security event
log_security_event(
"password_changed",
request,
user=user,
metadata={"sessions_invalidated": invalidated_count},
)
# Send security notification email
send_security_notification(user, "password_changed", metadata={})
return Response({
"detail": "Password changed successfully",
"sessions_invalidated": invalidated_count,
})

View File

@@ -0,0 +1,96 @@
"""
Custom JWT Token Generation for ThrillWiki
This module provides custom JWT token generation that includes authentication
method claims for enhanced MFA satisfaction logic.
Claims added:
- auth_method: How the user authenticated (password, passkey, totp, google, discord)
- mfa_verified: Whether MFA was verified during this login
- provider_mfa: Whether the OAuth provider (Discord) has MFA enabled
"""
from typing import Literal, TypedDict
from rest_framework_simplejwt.tokens import RefreshToken
# Type definitions for auth methods
AuthMethod = Literal["password", "passkey", "totp", "google", "discord"]
class TokenClaims(TypedDict, total=False):
"""Type definition for custom JWT claims."""
auth_method: AuthMethod
mfa_verified: bool
provider_mfa: bool
def create_tokens_for_user(
user,
auth_method: AuthMethod = "password",
mfa_verified: bool = False,
provider_mfa: bool = False,
) -> dict[str, str]:
"""
Generate JWT tokens with custom authentication claims.
Args:
user: The Django user object
auth_method: How the user authenticated
mfa_verified: True if MFA (TOTP/passkey) was verified at login
provider_mfa: True if OAuth provider (Discord) has MFA enabled
Returns:
Dictionary with 'access' and 'refresh' token strings
"""
refresh = RefreshToken.for_user(user)
# Add custom claims to both refresh and access tokens
refresh["auth_method"] = auth_method
refresh["mfa_verified"] = mfa_verified
refresh["provider_mfa"] = provider_mfa
access = refresh.access_token
return {
"access": str(access),
"refresh": str(refresh),
}
def get_auth_method_for_provider(provider: str) -> AuthMethod:
"""
Map OAuth provider name to AuthMethod type.
Args:
provider: The provider name (e.g., 'google', 'discord')
Returns:
The corresponding AuthMethod
"""
provider_map: dict[str, AuthMethod] = {
"google": "google",
"discord": "discord",
}
return provider_map.get(provider, "password")
def get_provider_mfa_status(provider: str, extra_data: dict) -> bool:
"""
Extract MFA status from OAuth provider extra_data.
Only Discord exposes mfa_enabled. Google does not share this info.
Args:
provider: The OAuth provider name
extra_data: The extra_data dict from SocialAccount
Returns:
True if provider has MFA enabled, False otherwise
"""
if provider == "discord":
return extra_data.get("mfa_enabled", False)
# Google and other providers don't expose MFA status
return False

View File

@@ -64,6 +64,23 @@ def get_mfa_status(request):
except Authenticator.DoesNotExist:
pass
# Check for Discord social account with MFA enabled
discord_mfa_enabled = False
connected_provider = None
try:
social_accounts = user.socialaccount_set.all()
for social_account in social_accounts:
if social_account.provider == "discord":
connected_provider = "discord"
discord_mfa_enabled = social_account.extra_data.get("mfa_enabled", False)
break
elif social_account.provider == "google":
connected_provider = "google"
# Google doesn't expose MFA status
except Exception:
pass
# has_second_factor is True if user has either TOTP or Passkey configured
has_second_factor = totp_enabled or passkey_enabled
@@ -76,6 +93,9 @@ def get_mfa_status(request):
"recovery_codes_enabled": recovery_enabled,
"recovery_codes_count": recovery_count,
"has_second_factor": has_second_factor,
# New fields for enhanced MFA satisfaction
"discord_mfa_enabled": discord_mfa_enabled,
"connected_provider": connected_provider,
}
)
@@ -100,6 +120,8 @@ def get_mfa_status(request):
@permission_classes([IsAuthenticated])
def setup_totp(request):
"""Generate TOTP secret and QR code for setup."""
from django.utils import timezone
from allauth.mfa.totp.internal import auth as totp_auth
user = request.user
@@ -120,14 +142,16 @@ def setup_totp(request):
qr.save(buffer, format="PNG")
qr_code_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}"
# Store secret in session for later verification
# Store secret in session for later verification with 15-minute expiry
request.session["pending_totp_secret"] = secret
request.session["pending_totp_expires"] = (timezone.now().timestamp() + 900) # 15 minutes
return Response(
{
"secret": secret,
"provisioning_uri": uri,
"qr_code_base64": qr_code_base64,
"expires_in_seconds": 900,
}
)
@@ -165,10 +189,17 @@ def setup_totp(request):
@permission_classes([IsAuthenticated])
def activate_totp(request):
"""Verify TOTP code and activate MFA."""
from django.utils import timezone
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
from allauth.mfa.totp.internal import auth as totp_auth
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
user = request.user
code = request.data.get("code", "").strip()
@@ -187,6 +218,19 @@ def activate_totp(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if setup has expired (15 minute timeout)
expires_at = request.session.get("pending_totp_expires")
if expires_at and timezone.now().timestamp() > expires_at:
# Clear expired session data
if "pending_totp_secret" in request.session:
del request.session["pending_totp_secret"]
if "pending_totp_expires" in request.session:
del request.session["pending_totp_expires"]
return Response(
{"detail": "TOTP setup session expired. Please start setup again."},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify the code
if not totp_auth.validate_totp_code(secret, code):
return Response(
@@ -215,11 +259,25 @@ def activate_totp(request):
# Clear session (only if it exists - won't exist with JWT auth + secret from body)
if "pending_totp_secret" in request.session:
del request.session["pending_totp_secret"]
if "pending_totp_expires" in request.session:
del request.session["pending_totp_expires"]
# Log security event
log_security_event(
"mfa_enrolled",
request,
user=user,
metadata={"method": "totp"},
)
# Send security notification email
send_security_notification(user, "mfa_enrolled", {"method": "TOTP Authenticator"})
return Response(
{
"detail": "Two-factor authentication enabled",
"recovery_codes": codes,
"recovery_codes_count": len(codes),
}
)
@@ -255,13 +313,59 @@ def deactivate_totp(request):
"""Disable TOTP authentication."""
from allauth.mfa.models import Authenticator
from apps.accounts.services.security_service import (
check_auth_method_availability,
log_security_event,
send_security_notification,
)
user = request.user
password = request.data.get("password", "")
recovery_code = request.data.get("recovery_code", "")
# Verify password
if not user.check_password(password):
# Check if user has other auth methods before we allow disabling MFA
auth_methods = check_auth_method_availability(user)
# If TOTP is their only way in alongside passkeys, we need to ensure they have
# at least password or social login to fall back on
if not auth_methods["has_password"] and not auth_methods["has_social"] and not auth_methods["has_passkey"]:
return Response(
{"detail": "Invalid password"},
{"detail": "Cannot disable MFA: you must have at least one authentication method. Please set a password or connect a social account first."},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify password OR recovery code
verified = False
verification_method = None
if password and user.check_password(password):
verified = True
verification_method = "password"
elif recovery_code:
# Try to verify with recovery code
try:
recovery_auth = Authenticator.objects.get(
user=user, type=Authenticator.Type.RECOVERY_CODES
)
unused_codes = recovery_auth.data.get("codes", [])
if recovery_code.upper().replace("-", "").replace(" ", "") in [
c.upper().replace("-", "").replace(" ", "") for c in unused_codes
]:
verified = True
verification_method = "recovery_code"
# Remove the used code
unused_codes = [
c for c in unused_codes
if c.upper().replace("-", "").replace(" ", "") != recovery_code.upper().replace("-", "").replace(" ", "")
]
recovery_auth.data["codes"] = unused_codes
recovery_auth.save()
except Authenticator.DoesNotExist:
pass
if not verified:
return Response(
{"detail": "Invalid password or recovery code"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -276,6 +380,17 @@ def deactivate_totp(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Log security event
log_security_event(
"mfa_disabled",
request,
user=user,
metadata={"method": "totp", "verified_via": verification_method},
)
# Send security notification email
send_security_notification(user, "mfa_disabled", {"method": "TOTP Authenticator"})
return Response(
{
"detail": "Two-factor authentication disabled",
@@ -361,6 +476,11 @@ def regenerate_recovery_codes(request):
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
user = request.user
password = request.data.get("password", "")
@@ -371,8 +491,11 @@ def regenerate_recovery_codes(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if TOTP is enabled
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
# Check if MFA is enabled (TOTP or Passkey)
has_totp = Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists()
has_passkey = Authenticator.objects.filter(user=user, type=Authenticator.Type.WEBAUTHN).exists()
if not has_totp and not has_passkey:
return Response(
{"detail": "Two-factor authentication is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
@@ -387,9 +510,21 @@ def regenerate_recovery_codes(request):
recovery_instance = RecoveryCodes.activate(user)
codes = recovery_instance.get_unused_codes()
# Log security event
log_security_event(
"recovery_codes_regenerated",
request,
user=user,
metadata={"codes_generated": len(codes)},
)
# Send security notification email
send_security_notification(user, "recovery_codes_regenerated", {"codes_generated": len(codes)})
return Response(
{
"success": True,
"recovery_codes": codes,
"recovery_codes_count": len(codes),
}
)

View File

@@ -93,6 +93,7 @@ def get_passkey_status(request):
def get_registration_options(request):
"""Get WebAuthn registration options for passkey setup."""
try:
from django.utils import timezone
from allauth.mfa.webauthn.internal import auth as webauthn_auth
# Use the correct allauth API: begin_registration
@@ -101,8 +102,17 @@ def get_registration_options(request):
# State is stored internally by begin_registration via set_state()
# Store registration timeout in session (5 minutes)
request.session["pending_passkey_expires"] = timezone.now().timestamp() + 300 # 5 minutes
# Debug log the structure
logger.debug(f"WebAuthn registration options type: {type(creation_options)}")
logger.debug(f"WebAuthn registration options keys: {creation_options.keys() if isinstance(creation_options, dict) else 'not a dict'}")
logger.info(f"WebAuthn registration options: {creation_options}")
return Response({
"options": creation_options,
"expires_in_seconds": 300,
})
except ImportError as e:
logger.error(f"WebAuthn module import error: {e}")
@@ -143,8 +153,14 @@ def get_registration_options(request):
def register_passkey(request):
"""Complete passkey registration with WebAuthn response."""
try:
from django.utils import timezone
from allauth.mfa.webauthn.internal import auth as webauthn_auth
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
credential = request.data.get("credential")
name = request.data.get("name", "Passkey")
@@ -154,6 +170,17 @@ def register_passkey(request):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if registration has expired (5 minute timeout)
expires_at = request.session.get("pending_passkey_expires")
if expires_at and timezone.now().timestamp() > expires_at:
# Clear expired session data
if "pending_passkey_expires" in request.session:
del request.session["pending_passkey_expires"]
return Response(
{"detail": "Passkey registration session expired. Please start registration again."},
status=status.HTTP_400_BAD_REQUEST,
)
# Get stored state from session (no request needed, uses context)
state = webauthn_auth.get_state()
if not state:
@@ -164,24 +191,33 @@ def register_passkey(request):
# Use the correct allauth API: complete_registration
try:
from allauth.mfa.models import Authenticator
from allauth.mfa.webauthn.internal.auth import WebAuthn
# Parse the credential response
# Parse the credential response to validate it
credential_data = webauthn_auth.parse_registration_response(credential)
# Complete registration - returns AuthenticatorData (binding)
authenticator_data = webauthn_auth.complete_registration(credential_data)
# Complete registration to validate and clear state
webauthn_auth.complete_registration(credential_data)
# 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,
},
# Use allauth's WebAuthn.add() to create the Authenticator properly
# It stores the raw credential dict and name in the data field
webauthn_wrapper = WebAuthn.add(
request.user,
name,
credential, # Pass raw credential dict, not parsed data
)
# State is cleared internally by complete_registration
authenticator = webauthn_wrapper.instance
# Log security event
log_security_event(
"passkey_registered",
request,
user=request.user,
metadata={"passkey_name": name, "passkey_id": str(authenticator.id) if authenticator else None},
)
# Send security notification email
send_security_notification(request.user, "passkey_registered", {"passkey_name": name})
return Response({
"detail": "Passkey registered successfully",
@@ -345,6 +381,12 @@ def delete_passkey(request, passkey_id):
try:
from allauth.mfa.models import Authenticator
from apps.accounts.services.security_service import (
check_auth_method_availability,
log_security_event,
send_security_notification,
)
user = request.user
password = request.data.get("password", "")
@@ -355,6 +397,17 @@ def delete_passkey(request, passkey_id):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user has other auth methods before removing passkey
auth_methods = check_auth_method_availability(user)
# If this is the last passkey and user has no other auth method, block removal
if auth_methods["passkey_count"] == 1:
if not auth_methods["has_password"] and not auth_methods["has_social"] and not auth_methods["has_totp"]:
return Response(
{"detail": "Cannot remove last passkey: you must have at least one authentication method. Please set a password or connect a social account first."},
status=status.HTTP_400_BAD_REQUEST,
)
# Find and delete the passkey
try:
authenticator = Authenticator.objects.get(
@@ -362,7 +415,20 @@ def delete_passkey(request, passkey_id):
user=user,
type=Authenticator.Type.WEBAUTHN,
)
passkey_name = authenticator.data.get("name", "Passkey") if authenticator.data else "Passkey"
authenticator.delete()
# Log security event
log_security_event(
"passkey_removed",
request,
user=user,
metadata={"passkey_name": passkey_name, "passkey_id": str(passkey_id)},
)
# Send security notification email
send_security_notification(user, "passkey_removed", {"passkey_name": passkey_name})
except Authenticator.DoesNotExist:
return Response(
{"detail": "Passkey not found"},

View File

@@ -128,6 +128,7 @@ urlpatterns = [
path("sessions/", account_views.list_sessions, name="auth-sessions-list"),
path("sessions/<str:session_id>/", account_views.revoke_session, name="auth-session-revoke"),
path("password/change/", account_views.change_password, name="auth-password-change-v2"),
path("security-log/", account_views.get_security_log, name="auth-security-log"),
]
# Note: User profiles and top lists functionality is now handled by the accounts app

View File

@@ -212,16 +212,29 @@ class LoginAPIView(APIView):
# pass a real HttpRequest to Django login with backend specified
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
# Generate JWT tokens with auth method claims
from .jwt import create_tokens_for_user
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
tokens = create_tokens_for_user(
user,
auth_method="password",
mfa_verified=False,
provider_mfa=False,
)
# Log successful login
from apps.accounts.services.security_service import log_security_event
log_security_event(
"login_success",
request,
user=user,
metadata={"auth_method": "password", "mfa_required": False},
)
response_serializer = LoginOutputSerializer(
{
"access": str(access_token),
"refresh": str(refresh),
"access": tokens["access"],
"refresh": tokens["refresh"],
"user": user,
"message": "Login successful",
}
@@ -237,6 +250,14 @@ class LoginAPIView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
else:
# Log failed login attempt
from apps.accounts.services.security_service import log_security_event
log_security_event(
"login_failed",
request,
user=None,
metadata={"username_attempted": email_or_username},
)
return Response(
{"detail": "Invalid credentials"},
status=status.HTTP_400_BAD_REQUEST,
@@ -331,8 +352,17 @@ class MFALoginVerifyAPIView(APIView):
)
# Verify MFA - either TOTP or Passkey
from apps.accounts.services.security_service import log_security_event
if totp_code:
if not self._verify_totp(user, totp_code):
# Log failed MFA attempt
log_security_event(
"mfa_challenge_failed",
request,
user=user,
metadata={"method": "totp"},
)
return Response(
{"detail": "Invalid verification code"},
status=status.HTTP_400_BAD_REQUEST,
@@ -341,6 +371,13 @@ class MFALoginVerifyAPIView(APIView):
# Verify passkey/WebAuthn credential
passkey_result = self._verify_passkey(request, user, credential)
if not passkey_result["success"]:
# Log failed MFA attempt
log_security_event(
"mfa_challenge_failed",
request,
user=user,
metadata={"method": "passkey", "error": passkey_result.get("error")},
)
return Response(
{"detail": passkey_result.get("error", "Passkey verification failed")},
status=status.HTTP_400_BAD_REQUEST,
@@ -357,16 +394,41 @@ class MFALoginVerifyAPIView(APIView):
# Complete login
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
# Determine auth method based on what was verified
from .jwt import create_tokens_for_user
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
if credential:
# Passkey verification - inherently MFA
auth_method = "passkey"
else:
# TOTP verification
auth_method = "totp"
# Log successful MFA challenge and login
log_security_event(
"mfa_challenge_success",
request,
user=user,
metadata={"method": auth_method},
)
log_security_event(
"login_success",
request,
user=user,
metadata={"auth_method": auth_method, "mfa_verified": True},
)
tokens = create_tokens_for_user(
user,
auth_method=auth_method,
mfa_verified=True,
provider_mfa=False,
)
response_serializer = LoginOutputSerializer(
{
"access": str(access_token),
"refresh": str(refresh),
"access": tokens["access"],
"refresh": tokens["refresh"],
"user": user,
"message": "Login successful",
}
@@ -516,6 +578,8 @@ class LogoutAPIView(APIView):
def post(self, request: Request) -> Response:
try:
user = request.user
# Get refresh token from request data with proper type handling
refresh_token = None
if hasattr(request, "data") and request.data is not None:
@@ -539,6 +603,15 @@ class LogoutAPIView(APIView):
if hasattr(request.user, "auth_token"):
request.user.auth_token.delete()
# Log security event
from apps.accounts.services.security_service import log_security_event
log_security_event(
"logout",
request,
user=user,
metadata={},
)
# Logout from session using the underlying HttpRequest
logout(_get_underlying_request(request))
@@ -804,6 +877,11 @@ class ConnectProviderAPIView(APIView):
serializer_class = ConnectProviderInputSerializer
def post(self, request: Request, provider: str) -> Response:
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
# Validate provider
if provider not in ["google", "discord"]:
return Response(
@@ -815,6 +893,30 @@ class ConnectProviderAPIView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user's email is verified before allowing social account linking
# This prevents attackers from linking a social account to an unverified email
user = request.user
# Check allauth email verification status
try:
from allauth.account.models import EmailAddress
primary_email = EmailAddress.objects.filter(user=user, primary=True).first()
if primary_email and not primary_email.verified:
return Response(
{
"detail": "Please verify your email address before connecting social accounts",
"code": "EMAIL_NOT_VERIFIED",
"suggestions": [
"Check your email for a verification link",
"Request a new verification email from your account settings",
],
},
status=status.HTTP_400_BAD_REQUEST,
)
except ImportError:
# If allauth.account is not available, skip check
pass
serializer = ConnectProviderInputSerializer(data=request.data)
if not serializer.is_valid():
return Response(
@@ -833,6 +935,17 @@ class ConnectProviderAPIView(APIView):
service = SocialProviderService()
result = service.connect_provider(request.user, provider, access_token)
# Log security event
log_security_event(
"social_linked",
request,
user=request.user,
metadata={"provider": provider},
)
# Send security notification
send_security_notification(request.user, "social_linked", {"provider": provider.title()})
response_serializer = ConnectProviderOutputSerializer(result)
return Response(response_serializer.data)
@@ -882,6 +995,11 @@ class DisconnectProviderAPIView(APIView):
)
try:
from apps.accounts.services.security_service import (
log_security_event,
send_security_notification,
)
service = SocialProviderService()
# Check if disconnection is safe
@@ -903,6 +1021,17 @@ class DisconnectProviderAPIView(APIView):
# Perform disconnection
result = service.disconnect_provider(request.user, provider)
# Log security event
log_security_event(
"social_unlinked",
request,
user=request.user,
metadata={"provider": provider},
)
# Send security notification
send_security_notification(request.user, "social_unlinked", {"provider": provider.title()})
response_serializer = DisconnectProviderOutputSerializer(result)
return Response(response_serializer.data)

View File

@@ -107,10 +107,15 @@ class CompanyCreateInputSerializer(serializers.Serializer):
allow_blank=True,
)
# Image URLs
# Image URLs (legacy - prefer using image IDs)
logo_url = serializers.URLField(required=False, allow_blank=True)
banner_image_url = serializers.URLField(required=False, allow_blank=True)
card_image_url = serializers.URLField(required=False, allow_blank=True)
# Cloudflare image IDs (preferred for new submissions)
logo_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
banner_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
card_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
class CompanyUpdateInputSerializer(serializers.Serializer):
@@ -144,10 +149,15 @@ class CompanyUpdateInputSerializer(serializers.Serializer):
allow_blank=True,
)
# Image URLs
# Image URLs (legacy - prefer using image IDs)
logo_url = serializers.URLField(required=False, allow_blank=True)
banner_image_url = serializers.URLField(required=False, allow_blank=True)
card_image_url = serializers.URLField(required=False, allow_blank=True)
# Cloudflare image IDs (preferred for new submissions)
logo_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
banner_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
card_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
# === RIDE MODEL SERIALIZERS ===

View File

@@ -493,6 +493,18 @@ def ensure_filter_option_format(options: list[Any]) -> list[dict[str, Any]]:
"count": option.get("count"),
"selected": option.get("selected", False),
}
elif isinstance(option, tuple):
# Tuple format: (value, label) or (value, label, count)
if len(option) >= 2:
standardized_option = {
"value": str(option[0]),
"label": str(option[1]),
"count": option[2] if len(option) > 2 else None,
"selected": False,
}
else:
# Single-element tuple, treat as simple value
standardized_option = {"value": str(option[0]), "label": str(option[0]), "count": None, "selected": False}
elif hasattr(option, "value") and hasattr(option, "label"):
# RichChoice object format
standardized_option = {