feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -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,
}
)

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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)")

View File

@@ -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"),

View File

@@ -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