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

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