mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-01-01 22:07:03 -05:00
feat: Implement initial schema and add various API, service, and management command enhancements across the application.
This commit is contained in:
@@ -17,6 +17,7 @@ from rest_framework.response import Response
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
|
||||
HAS_QRCODE = True
|
||||
except ImportError:
|
||||
HAS_QRCODE = False
|
||||
@@ -59,12 +60,14 @@ def get_mfa_status(request):
|
||||
except Authenticator.DoesNotExist:
|
||||
pass
|
||||
|
||||
return Response({
|
||||
"mfa_enabled": totp_enabled,
|
||||
"totp_enabled": totp_enabled,
|
||||
"recovery_codes_enabled": recovery_enabled,
|
||||
"recovery_codes_count": recovery_count,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"mfa_enabled": totp_enabled,
|
||||
"totp_enabled": totp_enabled,
|
||||
"recovery_codes_enabled": recovery_enabled,
|
||||
"recovery_codes_count": recovery_count,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -110,11 +113,13 @@ def setup_totp(request):
|
||||
# Store secret in session for later verification
|
||||
request.session["pending_totp_secret"] = secret
|
||||
|
||||
return Response({
|
||||
"secret": secret,
|
||||
"provisioning_uri": uri,
|
||||
"qr_code_base64": qr_code_base64,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"secret": secret,
|
||||
"provisioning_uri": uri,
|
||||
"qr_code_base64": qr_code_base64,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -138,8 +143,7 @@ def setup_totp(request):
|
||||
200: {
|
||||
"description": "TOTP activated successfully",
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "Two-factor authentication enabled",
|
||||
"detail": "Two-factor authentication enabled",
|
||||
"recovery_codes": ["ABCD1234", "EFGH5678"],
|
||||
},
|
||||
},
|
||||
@@ -160,7 +164,7 @@ def activate_totp(request):
|
||||
|
||||
if not code:
|
||||
return Response(
|
||||
{"success": False, "error": "Verification code is required"},
|
||||
{"detail": "Verification code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -168,21 +172,21 @@ def activate_totp(request):
|
||||
secret = request.session.get("pending_totp_secret")
|
||||
if not secret:
|
||||
return Response(
|
||||
{"success": False, "error": "No pending TOTP setup. Please start setup again."},
|
||||
{"detail": "No pending TOTP setup. Please start setup again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Verify the code
|
||||
if not totp_auth.validate_totp_code(secret, code):
|
||||
return Response(
|
||||
{"success": False, "error": "Invalid verification code"},
|
||||
{"detail": "Invalid verification code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if already has TOTP
|
||||
if Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
|
||||
return Response(
|
||||
{"success": False, "error": "TOTP is already enabled"},
|
||||
{"detail": "TOTP is already enabled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -204,11 +208,12 @@ def activate_totp(request):
|
||||
# Clear session
|
||||
del request.session["pending_totp_secret"]
|
||||
|
||||
return Response({
|
||||
"success": True,
|
||||
"message": "Two-factor authentication enabled",
|
||||
"recovery_codes": codes,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"detail": "Two-factor authentication enabled",
|
||||
"recovery_codes": codes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -230,7 +235,7 @@ def activate_totp(request):
|
||||
responses={
|
||||
200: {
|
||||
"description": "TOTP disabled",
|
||||
"example": {"success": True, "message": "Two-factor authentication disabled"},
|
||||
"example": {"detail": "Two-factor authentication disabled"},
|
||||
},
|
||||
400: {"description": "Invalid password or MFA not enabled"},
|
||||
},
|
||||
@@ -248,26 +253,26 @@ def deactivate_totp(request):
|
||||
# Verify password
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{"success": False, "error": "Invalid password"},
|
||||
{"detail": "Invalid password"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Remove TOTP and recovery codes
|
||||
deleted_count, _ = Authenticator.objects.filter(
|
||||
user=user,
|
||||
type__in=[Authenticator.Type.TOTP, Authenticator.Type.RECOVERY_CODES]
|
||||
user=user, type__in=[Authenticator.Type.TOTP, Authenticator.Type.RECOVERY_CODES]
|
||||
).delete()
|
||||
|
||||
if deleted_count == 0:
|
||||
return Response(
|
||||
{"success": False, "error": "Two-factor authentication is not enabled"},
|
||||
{"detail": "Two-factor authentication is not enabled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response({
|
||||
"success": True,
|
||||
"message": "Two-factor authentication disabled",
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"detail": "Two-factor authentication disabled",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -277,9 +282,7 @@ def deactivate_totp(request):
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "6-digit TOTP code"}
|
||||
},
|
||||
"properties": {"code": {"type": "string", "description": "6-digit TOTP code"}},
|
||||
"required": ["code"],
|
||||
}
|
||||
},
|
||||
@@ -301,7 +304,7 @@ def verify_totp(request):
|
||||
|
||||
if not code:
|
||||
return Response(
|
||||
{"success": False, "error": "Verification code is required"},
|
||||
{"detail": "Verification code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -313,12 +316,12 @@ def verify_totp(request):
|
||||
return Response({"success": True})
|
||||
else:
|
||||
return Response(
|
||||
{"success": False, "error": "Invalid verification code"},
|
||||
{"detail": "Invalid verification code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Authenticator.DoesNotExist:
|
||||
return Response(
|
||||
{"success": False, "error": "TOTP is not enabled"},
|
||||
{"detail": "TOTP is not enabled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -330,9 +333,7 @@ def verify_totp(request):
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {"type": "string", "description": "Current password"}
|
||||
},
|
||||
"properties": {"password": {"type": "string", "description": "Current password"}},
|
||||
"required": ["password"],
|
||||
}
|
||||
},
|
||||
@@ -358,14 +359,14 @@ def regenerate_recovery_codes(request):
|
||||
# Verify password
|
||||
if not user.check_password(password):
|
||||
return Response(
|
||||
{"success": False, "error": "Invalid password"},
|
||||
{"detail": "Invalid password"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if TOTP is enabled
|
||||
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
|
||||
return Response(
|
||||
{"success": False, "error": "Two-factor authentication is not enabled"},
|
||||
{"detail": "Two-factor authentication is not enabled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -379,7 +380,9 @@ def regenerate_recovery_codes(request):
|
||||
defaults={"data": {"codes": codes}},
|
||||
)
|
||||
|
||||
return Response({
|
||||
"success": True,
|
||||
"recovery_codes": codes,
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"recovery_codes": codes,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -38,8 +38,6 @@ class ModelChoices:
|
||||
"""Model choices utility class."""
|
||||
|
||||
|
||||
|
||||
|
||||
# === AUTHENTICATION SERIALIZERS ===
|
||||
|
||||
|
||||
@@ -95,12 +93,8 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
||||
class LoginInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for user login."""
|
||||
|
||||
username = serializers.CharField(
|
||||
max_length=254, help_text="Username or email address"
|
||||
)
|
||||
password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}, trim_whitespace=False
|
||||
)
|
||||
username = serializers.CharField(max_length=254, help_text="Username or email address")
|
||||
password = serializers.CharField(max_length=128, style={"input_type": "password"}, trim_whitespace=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get("username")
|
||||
@@ -129,9 +123,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True, style={"input_type": "password"}
|
||||
)
|
||||
password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"})
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
@@ -158,9 +150,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique."""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
raise serializers.ValidationError("A user with this username already exists.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -169,9 +159,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -204,8 +192,7 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Create or update email verification record
|
||||
verification, created = EmailVerification.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={'token': get_random_string(64)}
|
||||
user=user, defaults={"token": get_random_string(64)}
|
||||
)
|
||||
|
||||
if not created:
|
||||
@@ -214,14 +201,12 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
||||
verification.save()
|
||||
|
||||
# Get current site from request context
|
||||
request = self.context.get('request')
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
site = get_current_site(request._request)
|
||||
|
||||
# Build verification URL
|
||||
verification_url = request.build_absolute_uri(
|
||||
f"/api/v1/auth/verify-email/{verification.token}/"
|
||||
)
|
||||
verification_url = request.build_absolute_uri(f"/api/v1/auth/verify-email/{verification.token}/")
|
||||
|
||||
# Send verification email
|
||||
try:
|
||||
@@ -243,13 +228,11 @@ The ThrillWiki Team
|
||||
)
|
||||
|
||||
# Log the ForwardEmail email ID from the response
|
||||
email_id = response.get('id') if response else None
|
||||
email_id = response.get("id") if response else None
|
||||
if email_id:
|
||||
logger.info(
|
||||
f"Verification email sent successfully to {user.email}. ForwardEmail ID: {email_id}")
|
||||
logger.info(f"Verification email sent successfully to {user.email}. ForwardEmail ID: {email_id}")
|
||||
else:
|
||||
logger.info(
|
||||
f"Verification email sent successfully to {user.email}. No email ID in response.")
|
||||
logger.info(f"Verification email sent successfully to {user.email}. No email ID in response.")
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but don't fail registration
|
||||
@@ -312,17 +295,13 @@ class PasswordResetOutputSerializer(serializers.Serializer):
|
||||
class PasswordChangeInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password change."""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
old_password = serializers.CharField(max_length=128, style={"input_type": "password"})
|
||||
new_password = serializers.CharField(
|
||||
max_length=128,
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
new_password_confirm = serializers.CharField(max_length=128, style={"input_type": "password"})
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct."""
|
||||
@@ -337,9 +316,7 @@ class PasswordChangeInputSerializer(serializers.Serializer):
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"new_password_confirm": "New passwords do not match."}
|
||||
)
|
||||
raise serializers.ValidationError({"new_password_confirm": "New passwords do not match."})
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -471,6 +448,3 @@ class UserProfileUpdateInputSerializer(serializers.Serializer):
|
||||
dark_ride_credits = serializers.IntegerField(required=False)
|
||||
flat_ride_credits = serializers.IntegerField(required=False)
|
||||
water_ride_credits = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ from .social import (
|
||||
|
||||
__all__ = [
|
||||
# Social authentication serializers
|
||||
'ConnectedProviderSerializer',
|
||||
'AvailableProviderSerializer',
|
||||
'SocialAuthStatusSerializer',
|
||||
'ConnectProviderInputSerializer',
|
||||
'ConnectProviderOutputSerializer',
|
||||
'DisconnectProviderOutputSerializer',
|
||||
'SocialProviderListOutputSerializer',
|
||||
'ConnectedProvidersListOutputSerializer',
|
||||
'SocialProviderErrorSerializer',
|
||||
"ConnectedProviderSerializer",
|
||||
"AvailableProviderSerializer",
|
||||
"SocialAuthStatusSerializer",
|
||||
"ConnectProviderInputSerializer",
|
||||
"ConnectProviderOutputSerializer",
|
||||
"DisconnectProviderOutputSerializer",
|
||||
"SocialProviderListOutputSerializer",
|
||||
"ConnectedProvidersListOutputSerializer",
|
||||
"SocialProviderErrorSerializer",
|
||||
]
|
||||
|
||||
@@ -14,74 +14,36 @@ User = get_user_model()
|
||||
class ConnectedProviderSerializer(serializers.Serializer):
|
||||
"""Serializer for connected social provider information."""
|
||||
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider ID (e.g., 'google', 'discord')"
|
||||
)
|
||||
provider_name = serializers.CharField(
|
||||
help_text="Human-readable provider name"
|
||||
)
|
||||
uid = serializers.CharField(
|
||||
help_text="User ID on the social provider"
|
||||
)
|
||||
date_joined = serializers.DateTimeField(
|
||||
help_text="When this provider was connected"
|
||||
)
|
||||
can_disconnect = serializers.BooleanField(
|
||||
help_text="Whether this provider can be safely disconnected"
|
||||
)
|
||||
provider = serializers.CharField(help_text="Provider ID (e.g., 'google', 'discord')")
|
||||
provider_name = serializers.CharField(help_text="Human-readable provider name")
|
||||
uid = serializers.CharField(help_text="User ID on the social provider")
|
||||
date_joined = serializers.DateTimeField(help_text="When this provider was connected")
|
||||
can_disconnect = serializers.BooleanField(help_text="Whether this provider can be safely disconnected")
|
||||
disconnect_reason = serializers.CharField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="Reason why provider cannot be disconnected (if applicable)"
|
||||
)
|
||||
extra_data = serializers.JSONField(
|
||||
required=False,
|
||||
help_text="Additional data from the social provider"
|
||||
allow_null=True, required=False, help_text="Reason why provider cannot be disconnected (if applicable)"
|
||||
)
|
||||
extra_data = serializers.JSONField(required=False, help_text="Additional data from the social provider")
|
||||
|
||||
|
||||
class AvailableProviderSerializer(serializers.Serializer):
|
||||
"""Serializer for available social provider information."""
|
||||
|
||||
id = serializers.CharField(
|
||||
help_text="Provider ID (e.g., 'google', 'discord')"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Human-readable provider name"
|
||||
)
|
||||
auth_url = serializers.URLField(
|
||||
help_text="URL to initiate authentication with this provider"
|
||||
)
|
||||
connect_url = serializers.URLField(
|
||||
help_text="API URL to connect this provider"
|
||||
)
|
||||
id = serializers.CharField(help_text="Provider ID (e.g., 'google', 'discord')")
|
||||
name = serializers.CharField(help_text="Human-readable provider name")
|
||||
auth_url = serializers.URLField(help_text="URL to initiate authentication with this provider")
|
||||
connect_url = serializers.URLField(help_text="API URL to connect this provider")
|
||||
|
||||
|
||||
class SocialAuthStatusSerializer(serializers.Serializer):
|
||||
"""Serializer for comprehensive social authentication status."""
|
||||
|
||||
user_id = serializers.IntegerField(
|
||||
help_text="User's ID"
|
||||
)
|
||||
username = serializers.CharField(
|
||||
help_text="User's username"
|
||||
)
|
||||
email = serializers.EmailField(
|
||||
help_text="User's email address"
|
||||
)
|
||||
has_password_auth = serializers.BooleanField(
|
||||
help_text="Whether user has email/password authentication set up"
|
||||
)
|
||||
connected_providers = ConnectedProviderSerializer(
|
||||
many=True,
|
||||
help_text="List of connected social providers"
|
||||
)
|
||||
total_auth_methods = serializers.IntegerField(
|
||||
help_text="Total number of authentication methods available"
|
||||
)
|
||||
can_disconnect_any = serializers.BooleanField(
|
||||
help_text="Whether user can safely disconnect any provider"
|
||||
)
|
||||
user_id = serializers.IntegerField(help_text="User's ID")
|
||||
username = serializers.CharField(help_text="User's username")
|
||||
email = serializers.EmailField(help_text="User's email address")
|
||||
has_password_auth = serializers.BooleanField(help_text="Whether user has email/password authentication set up")
|
||||
connected_providers = ConnectedProviderSerializer(many=True, help_text="List of connected social providers")
|
||||
total_auth_methods = serializers.IntegerField(help_text="Total number of authentication methods available")
|
||||
can_disconnect_any = serializers.BooleanField(help_text="Whether user can safely disconnect any provider")
|
||||
requires_password_setup = serializers.BooleanField(
|
||||
help_text="Whether user needs to set up password before disconnecting"
|
||||
)
|
||||
@@ -90,9 +52,7 @@ class SocialAuthStatusSerializer(serializers.Serializer):
|
||||
class ConnectProviderInputSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider connection requests."""
|
||||
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider ID to connect (e.g., 'google', 'discord')"
|
||||
)
|
||||
provider = serializers.CharField(help_text="Provider ID to connect (e.g., 'google', 'discord')")
|
||||
|
||||
def validate_provider(self, value):
|
||||
"""Validate that the provider is supported and configured."""
|
||||
@@ -108,93 +68,51 @@ class ConnectProviderInputSerializer(serializers.Serializer):
|
||||
class ConnectProviderOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider connection responses."""
|
||||
|
||||
success = serializers.BooleanField(
|
||||
help_text="Whether the connection was successful"
|
||||
)
|
||||
message = serializers.CharField(
|
||||
help_text="Success or error message"
|
||||
)
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider that was connected"
|
||||
)
|
||||
auth_url = serializers.URLField(
|
||||
required=False,
|
||||
help_text="URL to complete the connection process"
|
||||
)
|
||||
success = serializers.BooleanField(help_text="Whether the connection was successful")
|
||||
message = serializers.CharField(help_text="Success or error message")
|
||||
provider = serializers.CharField(help_text="Provider that was connected")
|
||||
auth_url = serializers.URLField(required=False, help_text="URL to complete the connection process")
|
||||
|
||||
|
||||
class DisconnectProviderOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider disconnection responses."""
|
||||
|
||||
success = serializers.BooleanField(
|
||||
help_text="Whether the disconnection was successful"
|
||||
)
|
||||
message = serializers.CharField(
|
||||
help_text="Success or error message"
|
||||
)
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider that was disconnected"
|
||||
)
|
||||
success = serializers.BooleanField(help_text="Whether the disconnection was successful")
|
||||
message = serializers.CharField(help_text="Success or error message")
|
||||
provider = serializers.CharField(help_text="Provider that was disconnected")
|
||||
remaining_providers = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
help_text="List of remaining connected providers"
|
||||
)
|
||||
has_password_auth = serializers.BooleanField(
|
||||
help_text="Whether user still has password authentication"
|
||||
child=serializers.CharField(), help_text="List of remaining connected providers"
|
||||
)
|
||||
has_password_auth = serializers.BooleanField(help_text="Whether user still has password authentication")
|
||||
suggestions = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
help_text="Suggestions for maintaining account access (if applicable)"
|
||||
help_text="Suggestions for maintaining account access (if applicable)",
|
||||
)
|
||||
|
||||
|
||||
class SocialProviderListOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for listing available social providers."""
|
||||
|
||||
available_providers = AvailableProviderSerializer(
|
||||
many=True,
|
||||
help_text="List of available social providers"
|
||||
)
|
||||
count = serializers.IntegerField(
|
||||
help_text="Number of available providers"
|
||||
)
|
||||
available_providers = AvailableProviderSerializer(many=True, help_text="List of available social providers")
|
||||
count = serializers.IntegerField(help_text="Number of available providers")
|
||||
|
||||
|
||||
class ConnectedProvidersListOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for listing connected social providers."""
|
||||
|
||||
connected_providers = ConnectedProviderSerializer(
|
||||
many=True,
|
||||
help_text="List of connected social providers"
|
||||
)
|
||||
count = serializers.IntegerField(
|
||||
help_text="Number of connected providers"
|
||||
)
|
||||
has_password_auth = serializers.BooleanField(
|
||||
help_text="Whether user has password authentication"
|
||||
)
|
||||
can_disconnect_any = serializers.BooleanField(
|
||||
help_text="Whether user can safely disconnect any provider"
|
||||
)
|
||||
connected_providers = ConnectedProviderSerializer(many=True, help_text="List of connected social providers")
|
||||
count = serializers.IntegerField(help_text="Number of connected providers")
|
||||
has_password_auth = serializers.BooleanField(help_text="Whether user has password authentication")
|
||||
can_disconnect_any = serializers.BooleanField(help_text="Whether user can safely disconnect any provider")
|
||||
|
||||
|
||||
class SocialProviderErrorSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider error responses."""
|
||||
|
||||
error = serializers.CharField(
|
||||
help_text="Error message"
|
||||
)
|
||||
code = serializers.CharField(
|
||||
required=False,
|
||||
help_text="Error code for programmatic handling"
|
||||
)
|
||||
error = serializers.CharField(help_text="Error message")
|
||||
code = serializers.CharField(required=False, help_text="Error code for programmatic handling")
|
||||
suggestions = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
help_text="Suggestions for resolving the error"
|
||||
)
|
||||
provider = serializers.CharField(
|
||||
required=False,
|
||||
help_text="Provider related to the error (if applicable)"
|
||||
child=serializers.CharField(), required=False, help_text="Suggestions for resolving the error"
|
||||
)
|
||||
provider = serializers.CharField(required=False, help_text="Provider related to the error (if applicable)")
|
||||
|
||||
@@ -36,13 +36,10 @@ urlpatterns = [
|
||||
path("signup/", SignupAPIView.as_view(), name="auth-signup"),
|
||||
path("logout/", LogoutAPIView.as_view(), name="auth-logout"),
|
||||
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||
|
||||
# JWT token management
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"),
|
||||
|
||||
# Social authentication endpoints (dj-rest-auth)
|
||||
path("social/", include("dj_rest_auth.registration.urls")),
|
||||
|
||||
path(
|
||||
"password/reset/",
|
||||
PasswordResetAPIView.as_view(),
|
||||
@@ -58,7 +55,6 @@ urlpatterns = [
|
||||
SocialProvidersAPIView.as_view(),
|
||||
name="auth-social-providers",
|
||||
),
|
||||
|
||||
# Social provider management endpoints
|
||||
path(
|
||||
"social/providers/available/",
|
||||
@@ -85,9 +81,7 @@ urlpatterns = [
|
||||
SocialAuthStatusAPIView.as_view(),
|
||||
name="auth-social-status",
|
||||
),
|
||||
|
||||
path("status/", AuthStatusAPIView.as_view(), name="auth-status"),
|
||||
|
||||
# Email verification endpoints
|
||||
path(
|
||||
"verify-email/<str:token>/",
|
||||
@@ -99,7 +93,6 @@ urlpatterns = [
|
||||
ResendVerificationAPIView.as_view(),
|
||||
name="auth-resend-verification",
|
||||
),
|
||||
|
||||
# MFA (Multi-Factor Authentication) endpoints
|
||||
path("mfa/status/", mfa_views.get_mfa_status, name="auth-mfa-status"),
|
||||
path("mfa/totp/setup/", mfa_views.setup_totp, name="auth-mfa-totp-setup"),
|
||||
|
||||
@@ -85,9 +85,7 @@ def _get_underlying_request(request: Request) -> HttpRequest:
|
||||
|
||||
|
||||
# Helper: encapsulate user lookup + authenticate to reduce complexity in view
|
||||
def _authenticate_user_by_lookup(
|
||||
email_or_username: str, password: str, request: Request
|
||||
) -> UserModel | None:
|
||||
def _authenticate_user_by_lookup(email_or_username: str, password: str, request: Request) -> UserModel | None:
|
||||
"""
|
||||
Try a single optimized query to find a user by email OR username then authenticate.
|
||||
Returns authenticated user or None.
|
||||
@@ -154,7 +152,7 @@ class LoginAPIView(APIView):
|
||||
# instantiate mixin before calling to avoid type-mismatch in static analysis
|
||||
TurnstileMixin().validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception:
|
||||
# If mixin doesn't do anything, continue
|
||||
pass
|
||||
@@ -168,7 +166,7 @@ class LoginAPIView(APIView):
|
||||
|
||||
if not email_or_username or not password:
|
||||
return Response(
|
||||
{"error": "username and password are required"},
|
||||
{"detail": "username and password are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -177,8 +175,7 @@ class LoginAPIView(APIView):
|
||||
if user:
|
||||
if getattr(user, "is_active", False):
|
||||
# pass a real HttpRequest to Django login with backend specified
|
||||
login(_get_underlying_request(request), user,
|
||||
backend='django.contrib.auth.backends.ModelBackend')
|
||||
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
|
||||
# Generate JWT tokens
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
@@ -191,22 +188,22 @@ class LoginAPIView(APIView):
|
||||
"access": str(access_token),
|
||||
"refresh": str(refresh),
|
||||
"user": user,
|
||||
"message": "Login successful",
|
||||
"detail": "Login successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Email verification required",
|
||||
"message": "Please verify your email address before logging in. Check your email for a verification link.",
|
||||
"email_verification_required": True
|
||||
"detail": "Please verify your email address before logging in. Check your email for a verification link.",
|
||||
"code": "EMAIL_VERIFICATION_REQUIRED",
|
||||
"email_verification_required": True,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Invalid credentials"},
|
||||
{"detail": "Invalid credentials"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -237,7 +234,7 @@ class SignupAPIView(APIView):
|
||||
# instantiate mixin before calling to avoid type-mismatch in static analysis
|
||||
TurnstileMixin().validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception:
|
||||
# If mixin doesn't do anything, continue
|
||||
pass
|
||||
@@ -252,7 +249,7 @@ class SignupAPIView(APIView):
|
||||
"access": None,
|
||||
"refresh": None,
|
||||
"user": user,
|
||||
"message": "Registration successful. Please check your email to verify your account.",
|
||||
"detail": "Registration successful. Please check your email to verify your account.",
|
||||
"email_verification_required": True,
|
||||
}
|
||||
)
|
||||
@@ -282,18 +279,18 @@ class LogoutAPIView(APIView):
|
||||
try:
|
||||
# Get refresh token from request data with proper type handling
|
||||
refresh_token = None
|
||||
if hasattr(request, 'data') and request.data is not None:
|
||||
data = getattr(request, 'data', {})
|
||||
if hasattr(data, 'get'):
|
||||
if hasattr(request, "data") and request.data is not None:
|
||||
data = getattr(request, "data", {})
|
||||
if hasattr(data, "get"):
|
||||
refresh_token = data.get("refresh")
|
||||
|
||||
if refresh_token and isinstance(refresh_token, str):
|
||||
# Blacklist the refresh token
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
try:
|
||||
# Create RefreshToken from string and blacklist it
|
||||
refresh_token_obj = RefreshToken(
|
||||
refresh_token) # type: ignore[arg-type]
|
||||
refresh_token_obj = RefreshToken(refresh_token) # type: ignore[arg-type]
|
||||
refresh_token_obj.blacklist()
|
||||
except Exception:
|
||||
# Token might be invalid or already blacklisted
|
||||
@@ -306,14 +303,10 @@ class LogoutAPIView(APIView):
|
||||
# Logout from session using the underlying HttpRequest
|
||||
logout(_get_underlying_request(request))
|
||||
|
||||
response_serializer = LogoutOutputSerializer(
|
||||
{"message": "Logout successful"}
|
||||
)
|
||||
response_serializer = LogoutOutputSerializer({"detail": "Logout successful"})
|
||||
return Response(response_serializer.data)
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
return Response({"detail": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -357,15 +350,11 @@ class PasswordResetAPIView(APIView):
|
||||
serializer_class = PasswordResetInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordResetInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
serializer = PasswordResetInputSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordResetOutputSerializer(
|
||||
{"detail": "Password reset email sent"}
|
||||
)
|
||||
response_serializer = PasswordResetOutputSerializer({"detail": "Password reset email sent"})
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -391,15 +380,11 @@ class PasswordChangeAPIView(APIView):
|
||||
serializer_class = PasswordChangeInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordChangeInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
serializer = PasswordChangeInputSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordChangeOutputSerializer(
|
||||
{"detail": "Password changed successfully"}
|
||||
)
|
||||
response_serializer = PasswordChangeOutputSerializer({"detail": "Password changed successfully"})
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -443,13 +428,9 @@ class SocialProvidersAPIView(APIView):
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
provider_name = (
|
||||
social_app.name or getattr(social_app, "provider", "").title()
|
||||
)
|
||||
provider_name = social_app.name or getattr(social_app, "provider", "").title()
|
||||
|
||||
auth_url = request.build_absolute_uri(
|
||||
f"/accounts/{social_app.provider}/login/"
|
||||
)
|
||||
auth_url = request.build_absolute_uri(f"/accounts/{social_app.provider}/login/")
|
||||
|
||||
providers_list.append(
|
||||
{
|
||||
@@ -532,7 +513,7 @@ class AvailableProvidersAPIView(APIView):
|
||||
"name": "Discord",
|
||||
"login_url": "/auth/social/discord/",
|
||||
"connect_url": "/auth/social/connect/discord/",
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
serializer = AvailableProviderSerializer(providers, many=True)
|
||||
@@ -585,31 +566,29 @@ class ConnectProviderAPIView(APIView):
|
||||
|
||||
def post(self, request: Request, provider: str) -> Response:
|
||||
# Validate provider
|
||||
if provider not in ['google', 'discord']:
|
||||
if provider not in ["google", "discord"]:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "INVALID_PROVIDER",
|
||||
"message": f"Provider '{provider}' is not supported",
|
||||
"suggestions": ["Use 'google' or 'discord'"]
|
||||
"detail": f"Provider '{provider}' is not supported",
|
||||
"code": "INVALID_PROVIDER",
|
||||
"suggestions": ["Use 'google' or 'discord'"],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ConnectProviderInputSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": "Invalid request data",
|
||||
"detail": "Invalid request data",
|
||||
"code": "VALIDATION_ERROR",
|
||||
"details": serializer.errors,
|
||||
"suggestions": ["Provide a valid access_token"]
|
||||
"suggestions": ["Provide a valid access_token"],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
access_token = serializer.validated_data['access_token']
|
||||
access_token = serializer.validated_data["access_token"]
|
||||
|
||||
try:
|
||||
service = SocialProviderService()
|
||||
@@ -622,14 +601,14 @@ class ConnectProviderAPIView(APIView):
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "CONNECTION_FAILED",
|
||||
"detail": "CONNECTION_FAILED",
|
||||
"message": str(e),
|
||||
"suggestions": [
|
||||
"Verify the access token is valid",
|
||||
"Ensure the provider account is not already connected to another user"
|
||||
]
|
||||
"Ensure the provider account is not already connected to another user",
|
||||
],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@@ -653,35 +632,33 @@ class DisconnectProviderAPIView(APIView):
|
||||
|
||||
def post(self, request: Request, provider: str) -> Response:
|
||||
# Validate provider
|
||||
if provider not in ['google', 'discord']:
|
||||
if provider not in ["google", "discord"]:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "INVALID_PROVIDER",
|
||||
"message": f"Provider '{provider}' is not supported",
|
||||
"suggestions": ["Use 'google' or 'discord'"]
|
||||
"detail": f"Provider '{provider}' is not supported",
|
||||
"code": "INVALID_PROVIDER",
|
||||
"suggestions": ["Use 'google' or 'discord'"],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
service = SocialProviderService()
|
||||
|
||||
# Check if disconnection is safe
|
||||
can_disconnect, reason = service.can_disconnect_provider(
|
||||
request.user, provider)
|
||||
can_disconnect, reason = service.can_disconnect_provider(request.user, provider)
|
||||
if not can_disconnect:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "UNSAFE_DISCONNECTION",
|
||||
"detail": "UNSAFE_DISCONNECTION",
|
||||
"message": reason,
|
||||
"suggestions": [
|
||||
"Set up email/password authentication before disconnecting",
|
||||
"Connect another social provider before disconnecting this one"
|
||||
]
|
||||
"Connect another social provider before disconnecting this one",
|
||||
],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Perform disconnection
|
||||
@@ -694,14 +671,14 @@ class DisconnectProviderAPIView(APIView):
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "DISCONNECTION_FAILED",
|
||||
"detail": "DISCONNECTION_FAILED",
|
||||
"message": str(e),
|
||||
"suggestions": [
|
||||
"Verify the provider is currently connected",
|
||||
"Ensure you have alternative authentication methods"
|
||||
]
|
||||
"Ensure you have alternative authentication methods",
|
||||
],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@@ -755,7 +732,7 @@ class EmailVerificationAPIView(APIView):
|
||||
from apps.accounts.models import EmailVerification
|
||||
|
||||
try:
|
||||
verification = EmailVerification.objects.select_related('user').get(token=token)
|
||||
verification = EmailVerification.objects.select_related("user").get(token=token)
|
||||
user = verification.user
|
||||
|
||||
# Activate the user
|
||||
@@ -765,16 +742,10 @@ class EmailVerificationAPIView(APIView):
|
||||
# Delete the verification record
|
||||
verification.delete()
|
||||
|
||||
return Response({
|
||||
"message": "Email verified successfully. You can now log in.",
|
||||
"success": True
|
||||
})
|
||||
return Response({"detail": "Email verified successfully. You can now log in.", "success": True})
|
||||
|
||||
except EmailVerification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Invalid or expired verification token"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"detail": "Invalid or expired verification token"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -803,27 +774,20 @@ class ResendVerificationAPIView(APIView):
|
||||
|
||||
from apps.accounts.models import EmailVerification
|
||||
|
||||
email = request.data.get('email')
|
||||
email = request.data.get("email")
|
||||
if not email:
|
||||
return Response(
|
||||
{"error": "Email address is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": "Email address is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
user = UserModel.objects.get(email__iexact=email.strip().lower())
|
||||
|
||||
# Don't resend if user is already active
|
||||
if user.is_active:
|
||||
return Response(
|
||||
{"error": "Email is already verified"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": "Email is already verified"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Create or update verification record
|
||||
verification, created = EmailVerification.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={'token': get_random_string(64)}
|
||||
user=user, defaults={"token": get_random_string(64)}
|
||||
)
|
||||
|
||||
if not created:
|
||||
@@ -833,9 +797,7 @@ class ResendVerificationAPIView(APIView):
|
||||
|
||||
# Send verification email
|
||||
site = get_current_site(_get_underlying_request(request))
|
||||
verification_url = request.build_absolute_uri(
|
||||
f"/api/v1/auth/verify-email/{verification.token}/"
|
||||
)
|
||||
verification_url = request.build_absolute_uri(f"/api/v1/auth/verify-email/{verification.token}/")
|
||||
|
||||
try:
|
||||
EmailService.send_email(
|
||||
@@ -855,27 +817,21 @@ The ThrillWiki Team
|
||||
site=site,
|
||||
)
|
||||
|
||||
return Response({
|
||||
"message": "Verification email sent successfully",
|
||||
"success": True
|
||||
})
|
||||
return Response({"detail": "Verification email sent successfully", "success": True})
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send verification email to {user.email}: {e}")
|
||||
|
||||
return Response(
|
||||
{"error": "Failed to send verification email"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
{"detail": "Failed to send verification email"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
except UserModel.DoesNotExist:
|
||||
# Don't reveal whether email exists
|
||||
return Response({
|
||||
"message": "If the email exists, a verification email has been sent",
|
||||
"success": True
|
||||
})
|
||||
return Response({"detail": "If the email exists, a verification email has been sent", "success": True})
|
||||
|
||||
|
||||
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
|
||||
|
||||
Reference in New Issue
Block a user