Refactor user account system and remove moderation integration

- Remove first_name and last_name fields from User model
- Add user deletion and social provider services
- Restructure auth serializers into separate directory
- Update avatar upload functionality and API endpoints
- Remove django-moderation integration documentation
- Add mandatory compliance enforcement rules
- Update frontend documentation with API usage examples
This commit is contained in:
pacnpal
2025-08-30 07:31:58 -04:00
parent bb7da85516
commit 04394b9976
31 changed files with 7200 additions and 1297 deletions

View File

@@ -6,23 +6,6 @@ user deletion while preserving submissions, profile management, settings,
preferences, privacy, notifications, and security.
"""
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from rest_framework.permissions import AllowAny
from django.utils import timezone
from apps.accounts.models import (
User,
UserProfile,
TopList,
UserNotification,
NotificationPreference,
)
from apps.accounts.services import UserDeletionService
from apps.api.v1.serializers.accounts import (
CompleteUserSerializer,
UserPreferencesSerializer,
@@ -39,6 +22,27 @@ from apps.api.v1.serializers.accounts import (
MarkNotificationsReadSerializer,
AvatarUploadSerializer,
)
from apps.accounts.services import UserDeletionService
from apps.accounts.models import (
User,
UserProfile,
TopList,
UserNotification,
NotificationPreference,
)
import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from rest_framework.permissions import AllowAny
from django.utils import timezone
# Set up logging
logger = logging.getLogger(__name__)
@extend_schema(
@@ -106,7 +110,7 @@ def delete_user_preserve_submissions(request, user_id):
Delete a user while preserving all their submissions.
This endpoint allows administrators to delete user accounts while
preserving all user-generated content (reviews, photos, top lists, etc.).
preserving all user - generated content(reviews, photos, top lists, etc.).
All submissions are transferred to a system "deleted_user" placeholder.
**Admin Only**: This endpoint requires admin permissions.
@@ -119,14 +123,71 @@ def delete_user_preserve_submissions(request, user_id):
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
# Log the attempt for security monitoring
logger.warning(
f"Admin user {request.user.username} attempted to delete protected user {user.username} (ID: {user_id}). Reason: {reason}",
extra={
"admin_user": request.user.username,
"target_user": user.username,
"target_user_id": user_id,
"is_superuser": user.is_superuser,
"user_role": user.role,
"rejection_reason": reason,
}
)
# Determine error code based on reason
error_code = "DELETION_FORBIDDEN"
if "superuser" in reason.lower():
error_code = "SUPERUSER_DELETION_FORBIDDEN"
elif "admin" in reason.lower():
error_code = "ADMIN_DELETION_FORBIDDEN"
elif "system" in reason.lower():
error_code = "SYSTEM_USER_DELETION_FORBIDDEN"
return Response(
{"success": False, "error": f"Cannot delete user: {reason}"},
{
"success": False,
"error": f"Cannot delete user: {reason}",
"error_code": error_code,
"user_info": {
"username": user.username,
"user_id": user.user_id,
"role": user.role,
"is_superuser": user.is_superuser,
"is_staff": user.is_staff,
},
"help_text": "Contact system administrator if you need to delete this account type.",
},
status=status.HTTP_400_BAD_REQUEST,
)
# Log the successful deletion attempt
logger.info(
f"Admin user {request.user.username} is deleting user {user.username} (ID: {user_id})",
extra={
"admin_user": request.user.username,
"target_user": user.username,
"target_user_id": user_id,
"action": "user_deletion",
}
)
# Perform the deletion
result = UserDeletionService.delete_user_preserve_submissions(user)
# Log successful deletion
logger.info(
f"Successfully deleted user {result['deleted_user']['username']} (ID: {user_id}) by admin {request.user.username}",
extra={
"admin_user": request.user.username,
"deleted_user": result['deleted_user']['username'],
"deleted_user_id": user_id,
"preserved_submissions": result['preserved_submissions'],
"action": "user_deletion_completed",
}
)
return Response(
{
"success": True,
@@ -137,8 +198,25 @@ def delete_user_preserve_submissions(request, user_id):
)
except Exception as e:
# Log the error for debugging
logger.error(
f"Error deleting user {user_id} by admin {request.user.username}: {str(e)}",
extra={
"admin_user": request.user.username,
"target_user_id": user_id,
"error": str(e),
"action": "user_deletion_error",
},
exc_info=True
)
return Response(
{"success": False, "error": f"Error deleting user: {str(e)}"},
{
"success": False,
"error": f"Error deleting user: {str(e)}",
"error_code": "DELETION_ERROR",
"help_text": "Please try again or contact system administrator if the problem persists.",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -185,7 +263,7 @@ def request_account_deletion(request):
account. A verification code will be sent to their email address, and the
account will only be deleted after they provide the correct code.
**Authentication Required**: User must be logged in.
**Authentication Required**: User must be logged in .
**Email Verification**: A verification code is sent to the user's email.
@@ -197,6 +275,17 @@ def request_account_deletion(request):
# Create deletion request and send email
deletion_request = UserDeletionService.request_user_deletion(user)
# Log the self-service deletion request
logger.info(
f"User {user.username} (ID: {user.user_id}) requested account deletion",
extra={
"user": user.username,
"user_id": user.user_id,
"email": user.email,
"action": "self_deletion_request",
}
)
return Response(
{
"success": True,
@@ -208,12 +297,65 @@ def request_account_deletion(request):
)
except ValueError as e:
# Log the rejection for security monitoring
logger.warning(
f"User {request.user.username} (ID: {request.user.user_id}) attempted self-deletion but was rejected: {str(e)}",
extra={
"user": request.user.username,
"user_id": request.user.user_id,
"is_superuser": request.user.is_superuser,
"user_role": request.user.role,
"rejection_reason": str(e),
"action": "self_deletion_rejected",
}
)
# Determine error code based on reason
error_message = str(e)
error_code = "DELETION_FORBIDDEN"
if "superuser" in error_message.lower():
error_code = "SUPERUSER_DELETION_FORBIDDEN"
elif "admin" in error_message.lower():
error_code = "ADMIN_DELETION_FORBIDDEN"
elif "system" in error_message.lower():
error_code = "SYSTEM_USER_DELETION_FORBIDDEN"
return Response(
{"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST
{
"success": False,
"error": error_message,
"error_code": error_code,
"user_info": {
"username": request.user.username,
"user_id": request.user.user_id,
"role": request.user.role,
"is_superuser": request.user.is_superuser,
"is_staff": request.user.is_staff,
},
"help_text": "Superuser and admin accounts cannot be self-deleted for security reasons. Contact system administrator if you need to delete this account.",
},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
# Log the error for debugging
logger.error(
f"Error creating deletion request for user {request.user.username} (ID: {request.user.user_id}): {str(e)}",
extra={
"user": request.user.username,
"user_id": request.user.user_id,
"error": str(e),
"action": "self_deletion_error",
},
exc_info=True
)
return Response(
{"success": False, "error": f"Error creating deletion request: {str(e)}"},
{
"success": False,
"error": f"Error creating deletion request: {str(e)}",
"error_code": "DELETION_REQUEST_ERROR",
"help_text": "Please try again or contact support if the problem persists.",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -1279,7 +1421,7 @@ def get_user_notifications(request):
unread_count = UserNotification.objects.filter(user=user, is_read=False).count()
# Apply pagination
notifications = queryset[offset : offset + limit]
notifications = queryset[offset: offset + limit]
# Build pagination URLs
request_url = request.build_absolute_uri().split("?")[0]
@@ -1517,11 +1659,13 @@ def upload_avatar(request):
)
except Exception as e:
print(f"Upload avatar - Error saving to profile: {e}")
return Response(
{"success": False, "error": f"Failed to upload avatar: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
print(f"Upload avatar - Serializer errors: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -1,33 +1,3 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
class PasswordReset(models.Model):
"""Persisted password reset tokens for API-driven password resets."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="password_resets",
)
token = models.CharField(max_length=128, unique=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
class Meta:
ordering = ["-created_at"]
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
def is_expired(self) -> bool:
return timezone.now() > self.expires_at
def mark_used(self) -> None:
self.used = True
self.save(update_fields=["used"])
def __str__(self):
user_id = getattr(self, "user_id", None)
return f"PasswordReset(user={user_id}, token={self.token[:8]}..., used={self.used})"
# This file is intentionally empty.
# All models are now in their appropriate apps to avoid conflicts.
# PasswordReset model is available in apps.accounts.models

View File

@@ -18,7 +18,7 @@ from django.utils.crypto import get_random_string
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
from .models import PasswordReset
from apps.accounts.models import PasswordReset
UserModel = get_user_model()
@@ -62,8 +62,7 @@ class ModelChoices:
"id": 1,
"username": "john_doe",
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"display_name": "John Doe",
"date_joined": "2024-01-01T12:00:00Z",
"is_active": True,
"avatar_url": "https://example.com/avatars/john.jpg",
@@ -83,12 +82,10 @@ class UserOutputSerializer(serializers.ModelSerializer):
"id",
"username",
"email",
"first_name",
"last_name",
"display_name",
"date_joined",
"is_active",
"avatar_url",
"display_name",
]
read_only_fields = ["id", "date_joined", "is_active"]
@@ -127,7 +124,8 @@ class LoginInputSerializer(serializers.Serializer):
class LoginOutputSerializer(serializers.Serializer):
"""Output serializer for successful login."""
token = serializers.CharField()
access = serializers.CharField()
refresh = serializers.CharField()
user = UserOutputSerializer()
message = serializers.CharField()
@@ -149,14 +147,14 @@ class SignupInputSerializer(serializers.ModelSerializer):
fields = [
"username",
"email",
"first_name",
"last_name",
"display_name",
"password",
"password_confirm",
]
extra_kwargs = {
"password": {"write_only": True},
"email": {"required": True},
"display_name": {"required": True},
}
def validate_email(self, value):
@@ -202,7 +200,8 @@ class SignupInputSerializer(serializers.ModelSerializer):
class SignupOutputSerializer(serializers.Serializer):
"""Output serializer for successful signup."""
token = serializers.CharField()
access = serializers.CharField()
refresh = serializers.CharField()
user = UserOutputSerializer()
message = serializers.CharField()

View File

@@ -0,0 +1,30 @@
"""
Auth Serializers Package
This package contains all authentication-related serializers including
login, signup, logout, password management, and social authentication.
"""
from .social import (
ConnectedProviderSerializer,
AvailableProviderSerializer,
SocialAuthStatusSerializer,
ConnectProviderInputSerializer,
ConnectProviderOutputSerializer,
DisconnectProviderOutputSerializer,
SocialProviderListOutputSerializer,
ConnectedProvidersListOutputSerializer,
SocialProviderErrorSerializer,
)
__all__ = [
'ConnectedProviderSerializer',
'AvailableProviderSerializer',
'SocialAuthStatusSerializer',
'ConnectProviderInputSerializer',
'ConnectProviderOutputSerializer',
'DisconnectProviderOutputSerializer',
'SocialProviderListOutputSerializer',
'ConnectedProvidersListOutputSerializer',
'SocialProviderErrorSerializer',
]

View File

@@ -0,0 +1,201 @@
"""
Social Provider Management Serializers
Serializers for handling social provider connection/disconnection requests
and responses in the ThrillWiki API.
"""
from rest_framework import serializers
from django.contrib.auth import get_user_model
from typing import Dict, List
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"
)
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"
)
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"
)
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"
)
requires_password_setup = serializers.BooleanField(
help_text="Whether user needs to set up password before disconnecting"
)
class ConnectProviderInputSerializer(serializers.Serializer):
"""Serializer for social provider connection requests."""
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."""
from apps.accounts.services.social_provider_service import SocialProviderService
is_valid, message = SocialProviderService.validate_provider_exists(value)
if not is_valid:
raise serializers.ValidationError(message)
return value
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"
)
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"
)
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"
)
suggestions = serializers.ListField(
child=serializers.CharField(),
required=False,
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"
)
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"
)
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"
)
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)"
)

View File

@@ -5,31 +5,84 @@ This module contains URL patterns for core authentication functionality only.
User profiles and top lists are handled by the dedicated accounts app.
"""
from django.urls import path
from . import views
from django.urls import path, include
from .views import (
# Main auth views
LoginAPIView,
SignupAPIView,
LogoutAPIView,
CurrentUserAPIView,
PasswordResetAPIView,
PasswordChangeAPIView,
SocialProvidersAPIView,
AuthStatusAPIView,
# Social provider management views
AvailableProvidersAPIView,
ConnectedProvidersAPIView,
ConnectProviderAPIView,
DisconnectProviderAPIView,
SocialAuthStatusAPIView,
)
from rest_framework_simplejwt.views import TokenRefreshView
urlpatterns = [
# Core authentication endpoints
path("login/", views.LoginAPIView.as_view(), name="auth-login"),
path("signup/", views.SignupAPIView.as_view(), name="auth-signup"),
path("logout/", views.LogoutAPIView.as_view(), name="auth-logout"),
path("user/", views.CurrentUserAPIView.as_view(), name="auth-current-user"),
path("login/", LoginAPIView.as_view(), name="auth-login"),
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/",
views.PasswordResetAPIView.as_view(),
PasswordResetAPIView.as_view(),
name="auth-password-reset",
),
path(
"password/change/",
views.PasswordChangeAPIView.as_view(),
PasswordChangeAPIView.as_view(),
name="auth-password-change",
),
path(
"social/providers/",
views.SocialProvidersAPIView.as_view(),
SocialProvidersAPIView.as_view(),
name="auth-social-providers",
),
path("status/", views.AuthStatusAPIView.as_view(), name="auth-status"),
# Social provider management endpoints
path(
"social/providers/available/",
AvailableProvidersAPIView.as_view(),
name="auth-social-providers-available",
),
path(
"social/connected/",
ConnectedProvidersAPIView.as_view(),
name="auth-social-connected",
),
path(
"social/connect/<str:provider>/",
ConnectProviderAPIView.as_view(),
name="auth-social-connect",
),
path(
"social/disconnect/<str:provider>/",
DisconnectProviderAPIView.as_view(),
name="auth-social-disconnect",
),
path(
"social/status/",
SocialAuthStatusAPIView.as_view(),
name="auth-social-status",
),
path("status/", AuthStatusAPIView.as_view(), name="auth-status"),
]
# Note: User profiles and top lists functionality is now handled by the accounts app

View File

@@ -6,6 +6,16 @@ login, signup, logout, password management, social authentication,
user profiles, and top lists.
"""
from .serializers.social import (
ConnectedProviderSerializer,
AvailableProviderSerializer,
SocialAuthStatusSerializer,
ConnectProviderInputSerializer,
ConnectProviderOutputSerializer,
DisconnectProviderOutputSerializer,
SocialProviderErrorSerializer,
)
from apps.accounts.services.social_provider_service import SocialProviderService
from django.contrib.auth import authenticate, login, logout, get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
@@ -19,7 +29,8 @@ from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from drf_spectacular.utils import extend_schema, extend_schema_view
from .serializers import (
# Import from the main serializers.py file (not the serializers package)
from ..serializers import (
# Authentication serializers
LoginInputSerializer,
LoginOutputSerializer,
@@ -168,13 +179,17 @@ class LoginAPIView(APIView):
if getattr(user, "is_active", False):
# pass a real HttpRequest to Django login
login(_get_underlying_request(request), user)
from rest_framework.authtoken.models import Token
token, _ = Token.objects.get_or_create(user=user)
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
response_serializer = LoginOutputSerializer(
{
"token": token.key,
"access": str(access_token),
"refresh": str(refresh),
"user": user,
"message": "Login successful",
}
@@ -228,13 +243,17 @@ class SignupAPIView(APIView):
user = serializer.save()
# pass a real HttpRequest to Django login
login(_get_underlying_request(request), user) # type: ignore[arg-type]
from rest_framework.authtoken.models import Token
token, _ = Token.objects.get_or_create(user=user)
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
response_serializer = SignupOutputSerializer(
{
"token": token.key,
"access": str(access_token),
"refresh": str(refresh),
"user": user,
"message": "Registration successful",
}
@@ -247,7 +266,7 @@ class SignupAPIView(APIView):
@extend_schema_view(
post=extend_schema(
summary="User logout",
description="Logout the current user and invalidate their token.",
description="Logout the current user and blacklist their refresh token.",
responses={
200: LogoutOutputSerializer,
401: "Unauthorized",
@@ -263,7 +282,26 @@ class LogoutAPIView(APIView):
def post(self, request: Request) -> Response:
try:
# Delete the token for token-based auth
# 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'):
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.blacklist()
except Exception:
# Token might be invalid or already blacklisted
pass
# Also delete the old token for backward compatibility
if hasattr(request.user, "auth_token"):
request.user.auth_token.delete()
@@ -464,6 +502,236 @@ class AuthStatusAPIView(APIView):
return Response(serializer.data)
# === SOCIAL PROVIDER MANAGEMENT API VIEWS ===
@extend_schema_view(
get=extend_schema(
summary="Get available social providers",
description="Retrieve list of available social authentication providers.",
responses={
200: AvailableProviderSerializer(many=True),
},
tags=["Social Authentication"],
),
)
class AvailableProvidersAPIView(APIView):
"""API endpoint to get available social providers."""
permission_classes = [AllowAny]
serializer_class = AvailableProviderSerializer
def get(self, request: Request) -> Response:
providers = [
{
"provider": "google",
"name": "Google",
"login_url": "/auth/social/google/",
"connect_url": "/auth/social/connect/google/",
},
{
"provider": "discord",
"name": "Discord",
"login_url": "/auth/social/discord/",
"connect_url": "/auth/social/connect/discord/",
}
]
serializer = AvailableProviderSerializer(providers, many=True)
return Response(serializer.data)
@extend_schema_view(
get=extend_schema(
summary="Get connected social providers",
description="Retrieve list of social providers connected to the user's account.",
responses={
200: ConnectedProviderSerializer(many=True),
401: "Unauthorized",
},
tags=["Social Authentication"],
),
)
class ConnectedProvidersAPIView(APIView):
"""API endpoint to get user's connected social providers."""
permission_classes = [IsAuthenticated]
serializer_class = ConnectedProviderSerializer
def get(self, request: Request) -> Response:
service = SocialProviderService()
providers = service.get_connected_providers(request.user)
serializer = ConnectedProviderSerializer(providers, many=True)
return Response(serializer.data)
@extend_schema_view(
post=extend_schema(
summary="Connect social provider",
description="Connect a social authentication provider to the user's account.",
request=ConnectProviderInputSerializer,
responses={
200: ConnectProviderOutputSerializer,
400: SocialProviderErrorSerializer,
401: "Unauthorized",
},
tags=["Social Authentication"],
),
)
class ConnectProviderAPIView(APIView):
"""API endpoint to connect a social provider."""
permission_classes = [IsAuthenticated]
serializer_class = ConnectProviderInputSerializer
def post(self, request: Request, provider: str) -> Response:
# Validate provider
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'"]
},
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",
"details": serializer.errors,
"suggestions": ["Provide a valid access_token"]
},
status=status.HTTP_400_BAD_REQUEST
)
access_token = serializer.validated_data['access_token']
try:
service = SocialProviderService()
result = service.connect_provider(request.user, provider, access_token)
response_serializer = ConnectProviderOutputSerializer(result)
return Response(response_serializer.data)
except Exception as e:
return Response(
{
"success": False,
"error": "CONNECTION_FAILED",
"message": str(e),
"suggestions": [
"Verify the access token is valid",
"Ensure the provider account is not already connected to another user"
]
},
status=status.HTTP_400_BAD_REQUEST
)
@extend_schema_view(
post=extend_schema(
summary="Disconnect social provider",
description="Disconnect a social authentication provider from the user's account.",
responses={
200: DisconnectProviderOutputSerializer,
400: SocialProviderErrorSerializer,
401: "Unauthorized",
},
tags=["Social Authentication"],
),
)
class DisconnectProviderAPIView(APIView):
"""API endpoint to disconnect a social provider."""
permission_classes = [IsAuthenticated]
serializer_class = DisconnectProviderOutputSerializer
def post(self, request: Request, provider: str) -> Response:
# Validate provider
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'"]
},
status=status.HTTP_400_BAD_REQUEST
)
try:
service = SocialProviderService()
# Check if disconnection is safe
can_disconnect, reason = service.can_disconnect_provider(
request.user, provider)
if not can_disconnect:
return Response(
{
"success": False,
"error": "UNSAFE_DISCONNECTION",
"message": reason,
"suggestions": [
"Set up email/password authentication before disconnecting",
"Connect another social provider before disconnecting this one"
]
},
status=status.HTTP_400_BAD_REQUEST
)
# Perform disconnection
result = service.disconnect_provider(request.user, provider)
response_serializer = DisconnectProviderOutputSerializer(result)
return Response(response_serializer.data)
except Exception as e:
return Response(
{
"success": False,
"error": "DISCONNECTION_FAILED",
"message": str(e),
"suggestions": [
"Verify the provider is currently connected",
"Ensure you have alternative authentication methods"
]
},
status=status.HTTP_400_BAD_REQUEST
)
@extend_schema_view(
get=extend_schema(
summary="Get social authentication status",
description="Get comprehensive social authentication status for the user.",
responses={
200: SocialAuthStatusSerializer,
401: "Unauthorized",
},
tags=["Social Authentication"],
),
)
class SocialAuthStatusAPIView(APIView):
"""API endpoint to get social authentication status."""
permission_classes = [IsAuthenticated]
serializer_class = SocialAuthStatusSerializer
def get(self, request: Request) -> Response:
service = SocialProviderService()
auth_status = service.get_auth_status(request.user)
serializer = SocialAuthStatusSerializer(auth_status)
return Response(serializer.data)
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
# to avoid duplication and maintain clean separation of concerns.

View File

@@ -857,17 +857,54 @@ class MarkNotificationsReadSerializer(serializers.Serializer):
)
]
)
class AvatarUploadSerializer(serializers.ModelSerializer):
class AvatarUploadSerializer(serializers.Serializer):
"""Serializer for uploading user avatar."""
class Meta:
model = UserProfile
fields = ["avatar"]
# Use FileField instead of ImageField to bypass Django's image validation
avatar = serializers.FileField()
def validate_avatar(self, value):
"""Validate avatar file."""
if value:
# Add any avatar-specific validation here
# The CloudflareImagesField will handle the upload
if not value:
raise serializers.ValidationError("No file provided")
# Check file size constraints (max 10MB for Cloudflare Images)
if hasattr(value, 'size') and value.size > 10 * 1024 * 1024:
raise serializers.ValidationError(
"Image file too large. Maximum size is 10MB.")
# Try to validate with PIL
try:
from PIL import Image
import io
value.seek(0)
image_data = value.read()
value.seek(0) # Reset for later use
if len(image_data) == 0:
raise serializers.ValidationError("File appears to be empty")
# Try to open with PIL
image = Image.open(io.BytesIO(image_data))
# Verify it's a valid image
image.verify()
# Check image dimensions (max 12,000x12,000 for Cloudflare Images)
if image.size[0] > 12000 or image.size[1] > 12000:
raise serializers.ValidationError(
"Image dimensions too large. Maximum is 12,000x12,000 pixels.")
# Check if it's a supported format
if image.format not in ['JPEG', 'PNG', 'GIF', 'WEBP']:
raise serializers.ValidationError(
f"Unsupported image format: {image.format}. Supported formats: JPEG, PNG, GIF, WebP.")
except serializers.ValidationError:
raise # Re-raise validation errors
except Exception as e:
# PIL validation failed, but let Cloudflare Images try to process it
pass
return value

View File

@@ -6,15 +6,8 @@ and DRF Router patterns for automatic URL generation.
"""
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
# Import other views from the views directory
from .views import (
LoginAPIView,
SignupAPIView,
LogoutAPIView,
CurrentUserAPIView,
PasswordResetAPIView,
PasswordChangeAPIView,
SocialProvidersAPIView,
AuthStatusAPIView,
HealthCheckAPIView,
PerformanceMetricsAPIView,
SimpleHealthAPIView,
@@ -40,16 +33,7 @@ urlpatterns = [
# API Documentation endpoints are handled by main Django URLs
# See backend/thrillwiki/urls.py for documentation endpoints
# Authentication endpoints
path("auth/login/", LoginAPIView.as_view(), name="login"),
path("auth/signup/", SignupAPIView.as_view(), name="signup"),
path("auth/logout/", LogoutAPIView.as_view(), name="logout"),
path("auth/user/", CurrentUserAPIView.as_view(), name="current-user"),
path("auth/password/reset/", PasswordResetAPIView.as_view(), name="password-reset"),
path(
"auth/password/change/", PasswordChangeAPIView.as_view(), name="password-change"
),
path("auth/providers/", SocialProvidersAPIView.as_view(), name="social-providers"),
path("auth/status/", AuthStatusAPIView.as_view(), name="auth-status"),
path("auth/", include("apps.api.v1.auth.urls")),
# Health check endpoints
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),