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

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