mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:31:08 -05:00
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:
@@ -0,0 +1,68 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 21:32
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0007_add_display_name_to_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="first_name",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="last_name",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="userevent",
|
||||
name="first_name",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="userevent",
|
||||
name="last_name",
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="1ffd9209b0e1949c05de2548585cda9179288b68",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="e5f0a1acc20a9aad226004bc93ca8dbc3511052f",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -44,6 +44,10 @@ class User(AbstractUser):
|
||||
FRIENDS = "friends", _("Friends Only")
|
||||
PRIVATE = "private", _("Private")
|
||||
|
||||
# Override inherited fields to remove them
|
||||
first_name = None
|
||||
last_name = None
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
@@ -179,7 +183,10 @@ class UserProfile(models.Model):
|
||||
"""
|
||||
if self.avatar:
|
||||
# Return Cloudflare Images URL with avatar variant
|
||||
return self.avatar.url_variant("avatar")
|
||||
base_url = self.avatar.url
|
||||
if '/public' in base_url:
|
||||
return base_url.replace('/public', '/avatar')
|
||||
return base_url
|
||||
|
||||
# Generate default letter-based avatar using first letter of username
|
||||
first_letter = self.user.username[0].upper() if self.user.username else "U"
|
||||
@@ -191,11 +198,20 @@ class UserProfile(models.Model):
|
||||
Return avatar variants for different use cases
|
||||
"""
|
||||
if self.avatar:
|
||||
return {
|
||||
"thumbnail": self.avatar.url_variant("thumbnail"),
|
||||
"avatar": self.avatar.url_variant("avatar"),
|
||||
"large": self.avatar.url_variant("large"),
|
||||
}
|
||||
base_url = self.avatar.url
|
||||
if '/public' in base_url:
|
||||
return {
|
||||
"thumbnail": base_url.replace('/public', '/thumbnail'),
|
||||
"avatar": base_url.replace('/public', '/avatar'),
|
||||
"large": base_url.replace('/public', '/large'),
|
||||
}
|
||||
else:
|
||||
# If no variant in URL, return the same URL for all variants
|
||||
return {
|
||||
"thumbnail": base_url,
|
||||
"avatar": base_url,
|
||||
"large": base_url,
|
||||
}
|
||||
|
||||
# For default avatars, return the same URL for all variants
|
||||
default_url = self.get_avatar_url()
|
||||
|
||||
@@ -176,8 +176,7 @@ def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
||||
"""
|
||||
return User.objects.filter(
|
||||
Q(username__icontains=query)
|
||||
| Q(first_name__icontains=query)
|
||||
| Q(last_name__icontains=query),
|
||||
| Q(display_name__icontains=query),
|
||||
is_active=True,
|
||||
).order_by("username")[:limit]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -26,8 +27,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"display_name",
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
@@ -40,6 +40,10 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
return obj.profile.avatar.url
|
||||
return None
|
||||
|
||||
def get_display_name(self, obj) -> str:
|
||||
"""Get user display name"""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -82,14 +86,14 @@ class SignupSerializer(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):
|
||||
|
||||
@@ -28,8 +28,6 @@ class UserDeletionService:
|
||||
username=cls.DELETED_USER_USERNAME,
|
||||
defaults={
|
||||
"email": cls.DELETED_USER_EMAIL,
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"is_active": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
@@ -177,7 +175,11 @@ class UserDeletionService:
|
||||
return False, "Cannot delete the system deleted user placeholder"
|
||||
|
||||
if user.is_superuser:
|
||||
return False, "Cannot delete superuser accounts"
|
||||
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
|
||||
|
||||
# Check if user has critical admin role
|
||||
if user.role == User.Roles.ADMIN and user.is_staff:
|
||||
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
|
||||
|
||||
# Add any other business rules here
|
||||
|
||||
|
||||
11
backend/apps/accounts/services/__init__.py
Normal file
11
backend/apps/accounts/services/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Accounts Services Package
|
||||
|
||||
This package contains business logic services for account management,
|
||||
including social provider management, user authentication, and profile services.
|
||||
"""
|
||||
|
||||
from .social_provider_service import SocialProviderService
|
||||
from .user_deletion_service import UserDeletionService
|
||||
|
||||
__all__ = ['SocialProviderService', 'UserDeletionService']
|
||||
258
backend/apps/accounts/services/social_provider_service.py
Normal file
258
backend/apps/accounts/services/social_provider_service.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Social Provider Management Service
|
||||
|
||||
This service handles the business logic for connecting and disconnecting
|
||||
social authentication providers while ensuring users never lock themselves
|
||||
out of their accounts.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||
from allauth.socialaccount.providers import registry
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.http import HttpRequest
|
||||
import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from apps.accounts.models import User
|
||||
else:
|
||||
User = get_user_model()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SocialProviderService:
|
||||
"""Service for managing social provider connections."""
|
||||
|
||||
@staticmethod
|
||||
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if a user can safely disconnect a social provider.
|
||||
|
||||
Args:
|
||||
user: The user attempting to disconnect
|
||||
provider: The provider to disconnect (e.g., 'google', 'discord')
|
||||
|
||||
Returns:
|
||||
Tuple of (can_disconnect: bool, reason: str)
|
||||
"""
|
||||
try:
|
||||
# Count remaining social accounts after disconnection
|
||||
remaining_social_accounts = user.socialaccount_set.exclude(
|
||||
provider=provider
|
||||
).count()
|
||||
|
||||
# Check if user has email/password auth
|
||||
has_password_auth = (
|
||||
user.email and
|
||||
user.has_usable_password() and
|
||||
bool(user.password) # Not empty/unusable
|
||||
)
|
||||
|
||||
# Allow disconnection only if alternative auth exists
|
||||
can_disconnect = remaining_social_accounts > 0 or has_password_auth
|
||||
|
||||
if not can_disconnect:
|
||||
if remaining_social_accounts == 0 and not has_password_auth:
|
||||
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
|
||||
elif not has_password_auth:
|
||||
return False, "Please set up email/password authentication before disconnecting this provider."
|
||||
else:
|
||||
return False, "Cannot disconnect this provider at this time."
|
||||
|
||||
return True, "Provider can be safely disconnected."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
|
||||
return False, "Unable to verify disconnection safety. Please try again."
|
||||
|
||||
@staticmethod
|
||||
def get_connected_providers(user: "User") -> List[Dict]:
|
||||
"""
|
||||
Get all social providers connected to a user's account.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
List of connected provider information
|
||||
"""
|
||||
try:
|
||||
connected_providers = []
|
||||
|
||||
for social_account in user.socialaccount_set.all():
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||
user, social_account.provider
|
||||
)
|
||||
|
||||
provider_info = {
|
||||
'provider': social_account.provider,
|
||||
'provider_name': social_account.get_provider().name,
|
||||
'uid': social_account.uid,
|
||||
'date_joined': social_account.date_joined,
|
||||
'can_disconnect': can_disconnect,
|
||||
'disconnect_reason': reason if not can_disconnect else None,
|
||||
'extra_data': social_account.extra_data
|
||||
}
|
||||
|
||||
connected_providers.append(provider_info)
|
||||
|
||||
return connected_providers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting connected providers for user {user.id}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_available_providers(request: HttpRequest) -> List[Dict]:
|
||||
"""
|
||||
Get all available social providers for the current site.
|
||||
|
||||
Args:
|
||||
request: The HTTP request
|
||||
|
||||
Returns:
|
||||
List of available provider information
|
||||
"""
|
||||
try:
|
||||
site = get_current_site(request)
|
||||
available_providers = []
|
||||
|
||||
# Get all social apps configured for this site
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
provider = registry.by_id(social_app.provider)
|
||||
|
||||
provider_info = {
|
||||
'id': social_app.provider,
|
||||
'name': provider.name,
|
||||
'auth_url': request.build_absolute_uri(
|
||||
f'/accounts/{social_app.provider}/login/'
|
||||
),
|
||||
'connect_url': request.build_absolute_uri(
|
||||
f'/api/v1/auth/social/connect/{social_app.provider}/'
|
||||
)
|
||||
}
|
||||
|
||||
available_providers.append(provider_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error processing provider {social_app.provider}: {e}")
|
||||
continue
|
||||
|
||||
return available_providers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available providers: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Disconnect a social provider from a user's account.
|
||||
|
||||
Args:
|
||||
user: The user to disconnect from
|
||||
provider: The provider to disconnect
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# First check if disconnection is allowed
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||
user, provider)
|
||||
|
||||
if not can_disconnect:
|
||||
return False, reason
|
||||
|
||||
# Find and delete the social account
|
||||
social_accounts = user.socialaccount_set.filter(provider=provider)
|
||||
|
||||
if not social_accounts.exists():
|
||||
return False, f"No {provider} account found to disconnect."
|
||||
|
||||
# Delete all social accounts for this provider (in case of duplicates)
|
||||
deleted_count = social_accounts.count()
|
||||
social_accounts.delete()
|
||||
|
||||
logger.info(
|
||||
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
|
||||
|
||||
return True, f"{provider.title()} account disconnected successfully."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting {provider} for user {user.id}: {e}")
|
||||
return False, f"Failed to disconnect {provider} account. Please try again."
|
||||
|
||||
@staticmethod
|
||||
def get_auth_status(user: "User") -> Dict:
|
||||
"""
|
||||
Get comprehensive authentication status for a user.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
Dictionary with authentication status information
|
||||
"""
|
||||
try:
|
||||
connected_providers = SocialProviderService.get_connected_providers(user)
|
||||
|
||||
has_password_auth = (
|
||||
user.email and
|
||||
user.has_usable_password() and
|
||||
bool(user.password)
|
||||
)
|
||||
|
||||
auth_methods_count = len(connected_providers) + \
|
||||
(1 if has_password_auth else 0)
|
||||
|
||||
return {
|
||||
'user_id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'has_password_auth': has_password_auth,
|
||||
'connected_providers': connected_providers,
|
||||
'total_auth_methods': auth_methods_count,
|
||||
'can_disconnect_any': auth_methods_count > 1,
|
||||
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting auth status for user {user.id}: {e}")
|
||||
return {
|
||||
'error': 'Unable to retrieve authentication status'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate that a social provider is configured and available.
|
||||
|
||||
Args:
|
||||
provider: The provider ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# Check if provider is registered with allauth
|
||||
if provider not in registry.provider_map:
|
||||
return False, f"Provider '{provider}' is not supported."
|
||||
|
||||
# Check if provider has a social app configured
|
||||
if not SocialApp.objects.filter(provider=provider).exists():
|
||||
return False, f"Provider '{provider}' is not configured on this site."
|
||||
|
||||
return True, f"Provider '{provider}' is valid and available."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating provider {provider}: {e}")
|
||||
return False, "Unable to validate provider."
|
||||
309
backend/apps/accounts/services/user_deletion_service.py
Normal file
309
backend/apps/accounts/services/user_deletion_service.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
User Deletion Service
|
||||
|
||||
This service handles user account deletion while preserving submissions
|
||||
and maintaining data integrity across the platform.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from typing import Dict, Any, Tuple, Optional
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserDeletionRequest:
|
||||
"""Model for tracking user deletion requests."""
|
||||
|
||||
def __init__(self, user: User, verification_code: str, expires_at: datetime):
|
||||
self.user = user
|
||||
self.verification_code = verification_code
|
||||
self.expires_at = expires_at
|
||||
self.created_at = timezone.now()
|
||||
|
||||
|
||||
class UserDeletionService:
|
||||
"""Service for handling user account deletion with submission preservation."""
|
||||
|
||||
# In-memory storage for deletion requests (in production, use Redis or database)
|
||||
_deletion_requests = {}
|
||||
|
||||
@staticmethod
|
||||
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user can be safely deleted.
|
||||
|
||||
Args:
|
||||
user: User to check for deletion eligibility
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (can_delete, reason_if_not)
|
||||
"""
|
||||
# Prevent deletion of superusers
|
||||
if user.is_superuser:
|
||||
return False, "Cannot delete superuser accounts"
|
||||
|
||||
# Prevent deletion of staff/admin users
|
||||
if user.is_staff:
|
||||
return False, "Cannot delete staff accounts"
|
||||
|
||||
# Check for system users (if you have any special system accounts)
|
||||
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
|
||||
return False, "Cannot delete admin or moderator accounts"
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def request_user_deletion(user: User) -> UserDeletionRequest:
|
||||
"""
|
||||
Create a deletion request for a user and send verification email.
|
||||
|
||||
Args:
|
||||
user: User requesting deletion
|
||||
|
||||
Returns:
|
||||
UserDeletionRequest: The deletion request object
|
||||
|
||||
Raises:
|
||||
ValueError: If user cannot be deleted
|
||||
"""
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise ValueError(reason)
|
||||
|
||||
# Generate verification code
|
||||
verification_code = ''.join(secrets.choice(
|
||||
string.ascii_uppercase + string.digits) for _ in range(8))
|
||||
|
||||
# Set expiration (24 hours from now)
|
||||
expires_at = timezone.now() + timezone.timedelta(hours=24)
|
||||
|
||||
# Create deletion request
|
||||
deletion_request = UserDeletionRequest(user, verification_code, expires_at)
|
||||
|
||||
# Store request (in production, use Redis or database)
|
||||
UserDeletionService._deletion_requests[verification_code] = deletion_request
|
||||
|
||||
# Send verification email
|
||||
UserDeletionService._send_deletion_verification_email(
|
||||
user, verification_code, expires_at)
|
||||
|
||||
return deletion_request
|
||||
|
||||
@staticmethod
|
||||
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify deletion code and delete user account.
|
||||
|
||||
Args:
|
||||
verification_code: Verification code from email
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Deletion result information
|
||||
|
||||
Raises:
|
||||
ValueError: If verification code is invalid or expired
|
||||
"""
|
||||
# Find deletion request
|
||||
deletion_request = UserDeletionService._deletion_requests.get(verification_code)
|
||||
if not deletion_request:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Check if expired
|
||||
if timezone.now() > deletion_request.expires_at:
|
||||
# Clean up expired request
|
||||
del UserDeletionService._deletion_requests[verification_code]
|
||||
raise ValueError("Verification code has expired")
|
||||
|
||||
user = deletion_request.user
|
||||
|
||||
# Perform deletion
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
# Clean up deletion request
|
||||
del UserDeletionService._deletion_requests[verification_code]
|
||||
|
||||
# Add verification info to result
|
||||
result['deletion_request'] = {
|
||||
'verification_code': verification_code,
|
||||
'created_at': deletion_request.created_at,
|
||||
'verified_at': timezone.now(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def cancel_deletion_request(user: User) -> bool:
|
||||
"""
|
||||
Cancel a pending deletion request for a user.
|
||||
|
||||
Args:
|
||||
user: User whose deletion request to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if request was found and cancelled, False if no request found
|
||||
"""
|
||||
# Find and remove any deletion requests for this user
|
||||
to_remove = []
|
||||
for code, request in UserDeletionService._deletion_requests.items():
|
||||
if request.user.id == user.id:
|
||||
to_remove.append(code)
|
||||
|
||||
for code in to_remove:
|
||||
del UserDeletionService._deletion_requests[code]
|
||||
|
||||
return len(to_remove) > 0
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a user account while preserving all their submissions.
|
||||
|
||||
Args:
|
||||
user: User to delete
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Information about the deletion and preserved submissions
|
||||
"""
|
||||
# Get or create the "deleted_user" placeholder
|
||||
deleted_user_placeholder, created = User.objects.get_or_create(
|
||||
username='deleted_user',
|
||||
defaults={
|
||||
'email': 'deleted@thrillwiki.com',
|
||||
'first_name': 'Deleted',
|
||||
'last_name': 'User',
|
||||
'is_active': False,
|
||||
}
|
||||
)
|
||||
|
||||
# Count submissions before transfer
|
||||
submission_counts = UserDeletionService._count_user_submissions(user)
|
||||
|
||||
# Transfer submissions to placeholder user
|
||||
UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder)
|
||||
|
||||
# Store user info before deletion
|
||||
deleted_user_info = {
|
||||
'username': user.username,
|
||||
'user_id': getattr(user, 'user_id', user.id),
|
||||
'email': user.email,
|
||||
'date_joined': user.date_joined,
|
||||
}
|
||||
|
||||
# Delete the user account
|
||||
user.delete()
|
||||
|
||||
return {
|
||||
'deleted_user': deleted_user_info,
|
||||
'preserved_submissions': submission_counts,
|
||||
'transferred_to': {
|
||||
'username': deleted_user_placeholder.username,
|
||||
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _count_user_submissions(user: User) -> Dict[str, int]:
|
||||
"""Count all submissions for a user."""
|
||||
counts = {}
|
||||
|
||||
# Count different types of submissions
|
||||
# Note: These are placeholder counts - adjust based on your actual models
|
||||
counts['park_reviews'] = getattr(
|
||||
user, 'park_reviews', user.__class__.objects.none()).count()
|
||||
counts['ride_reviews'] = getattr(
|
||||
user, 'ride_reviews', user.__class__.objects.none()).count()
|
||||
counts['uploaded_park_photos'] = getattr(
|
||||
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
|
||||
counts['uploaded_ride_photos'] = getattr(
|
||||
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
|
||||
counts['top_lists'] = getattr(
|
||||
user, 'top_lists', user.__class__.objects.none()).count()
|
||||
counts['edit_submissions'] = getattr(
|
||||
user, 'edit_submissions', user.__class__.objects.none()).count()
|
||||
counts['photo_submissions'] = getattr(
|
||||
user, 'photo_submissions', user.__class__.objects.none()).count()
|
||||
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _transfer_user_submissions(user: User, placeholder_user: User) -> None:
|
||||
"""Transfer all user submissions to placeholder user."""
|
||||
|
||||
# Transfer different types of submissions
|
||||
# Note: Adjust these based on your actual model relationships
|
||||
|
||||
# Park reviews
|
||||
if hasattr(user, 'park_reviews'):
|
||||
user.park_reviews.all().update(user=placeholder_user)
|
||||
|
||||
# Ride reviews
|
||||
if hasattr(user, 'ride_reviews'):
|
||||
user.ride_reviews.all().update(user=placeholder_user)
|
||||
|
||||
# Uploaded photos
|
||||
if hasattr(user, 'uploaded_park_photos'):
|
||||
user.uploaded_park_photos.all().update(user=placeholder_user)
|
||||
|
||||
if hasattr(user, 'uploaded_ride_photos'):
|
||||
user.uploaded_ride_photos.all().update(user=placeholder_user)
|
||||
|
||||
# Top lists
|
||||
if hasattr(user, 'top_lists'):
|
||||
user.top_lists.all().update(user=placeholder_user)
|
||||
|
||||
# Edit submissions
|
||||
if hasattr(user, 'edit_submissions'):
|
||||
user.edit_submissions.all().update(user=placeholder_user)
|
||||
|
||||
# Photo submissions
|
||||
if hasattr(user, 'photo_submissions'):
|
||||
user.photo_submissions.all().update(user=placeholder_user)
|
||||
|
||||
@staticmethod
|
||||
def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None:
|
||||
"""Send verification email for account deletion."""
|
||||
try:
|
||||
context = {
|
||||
'user': user,
|
||||
'verification_code': verification_code,
|
||||
'expires_at': expires_at,
|
||||
'site_name': 'ThrillWiki',
|
||||
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
||||
}
|
||||
|
||||
subject = 'ThrillWiki: Confirm Account Deletion'
|
||||
html_message = render_to_string(
|
||||
'emails/account_deletion_verification.html', context)
|
||||
plain_message = render_to_string(
|
||||
'emails/account_deletion_verification.txt', context)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
html_message=html_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Deletion verification email sent to {user.email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send deletion verification email to {user.email}: {str(e)}")
|
||||
raise
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
30
backend/apps/api/v1/auth/serializers/__init__.py
Normal file
30
backend/apps/api/v1/auth/serializers/__init__.py
Normal 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',
|
||||
]
|
||||
201
backend/apps/api/v1/auth/serializers/social.py
Normal file
201
backend/apps/api/v1/auth/serializers/social.py
Normal 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)"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = "apps.core.apps.CoreConfig"
|
||||
|
||||
@@ -1030,8 +1030,7 @@ class Command(BaseCommand):
|
||||
username="testuser",
|
||||
defaults={
|
||||
"email": "test@example.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"display_name": "Test User",
|
||||
},
|
||||
)
|
||||
if created:
|
||||
|
||||
@@ -3,6 +3,7 @@ Base Django settings for thrillwiki project.
|
||||
Common settings shared across all environments.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from decouple import config
|
||||
@@ -64,7 +65,12 @@ DJANGO_APPS = [
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
"rest_framework", # Django REST Framework
|
||||
"rest_framework.authtoken", # Token authentication
|
||||
# Token authentication (kept for backward compatibility)
|
||||
"rest_framework.authtoken",
|
||||
"rest_framework_simplejwt", # JWT authentication
|
||||
"rest_framework_simplejwt.token_blacklist", # JWT token blacklist
|
||||
"dj_rest_auth", # REST authentication with JWT support
|
||||
"dj_rest_auth.registration", # REST registration support
|
||||
"drf_spectacular", # OpenAPI 3.0 documentation
|
||||
"corsheaders", # CORS headers for API
|
||||
"pghistory", # django-pghistory
|
||||
@@ -180,9 +186,9 @@ STORAGES = {
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_ID = config("CLOUDFLARE_IMAGES_ACCOUNT_ID")
|
||||
CLOUDFLARE_IMAGES_API_TOKEN = config("CLOUDFLARE_IMAGES_API_TOKEN")
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH = config("CLOUDFLARE_IMAGES_ACCOUNT_HASH")
|
||||
CLOUDFLARE_IMAGES_DOMAIN = config(
|
||||
"CLOUDFLARE_IMAGES_DOMAIN", default="imagedelivery.net"
|
||||
)
|
||||
# CLOUDFLARE_IMAGES_DOMAIN should only be set if using a custom domain
|
||||
# When not set, it defaults to imagedelivery.net with the correct URL format
|
||||
# CLOUDFLARE_IMAGES_DOMAIN = config("CLOUDFLARE_IMAGES_DOMAIN", default=None)
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
@@ -228,7 +234,11 @@ AUTHENTICATION_BACKENDS = [
|
||||
|
||||
# django-allauth settings
|
||||
SITE_ID = 1
|
||||
|
||||
# CORRECTED: Django allauth still expects the old format with asterisks for required fields
|
||||
# The deprecation warnings are from dj_rest_auth, not our configuration
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
||||
|
||||
ACCOUNT_LOGIN_METHODS = {"email", "username"}
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
|
||||
@@ -292,8 +302,9 @@ FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")
|
||||
# Django REST Framework Settings
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication", # Kept for backward compatibility
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
@@ -443,3 +454,44 @@ SESSION_COOKIE_AGE = 86400 # 24 hours
|
||||
# Cache middleware settings
|
||||
CACHE_MIDDLEWARE_SECONDS = 300 # 5 minutes
|
||||
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
||||
|
||||
# JWT Settings
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), # 1 hour
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7), # 7 days
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"BLACKLIST_AFTER_ROTATION": True,
|
||||
"UPDATE_LAST_LOGIN": True,
|
||||
"ALGORITHM": "HS256",
|
||||
"SIGNING_KEY": SECRET_KEY,
|
||||
"VERIFYING_KEY": None,
|
||||
"AUDIENCE": None,
|
||||
"ISSUER": None,
|
||||
"JWK_URL": None,
|
||||
"LEEWAY": 0,
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
|
||||
"USER_ID_FIELD": "id",
|
||||
"USER_ID_CLAIM": "user_id",
|
||||
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
|
||||
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
|
||||
"TOKEN_TYPE_CLAIM": "token_type",
|
||||
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
|
||||
"JTI_CLAIM": "jti",
|
||||
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
|
||||
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=60),
|
||||
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
|
||||
}
|
||||
|
||||
# dj-rest-auth settings
|
||||
REST_AUTH = {
|
||||
"USE_JWT": True,
|
||||
"JWT_AUTH_COOKIE": "thrillwiki-auth",
|
||||
"JWT_AUTH_REFRESH_COOKIE": "thrillwiki-refresh",
|
||||
"JWT_AUTH_SECURE": not DEBUG, # Use secure cookies in production
|
||||
"JWT_AUTH_HTTPONLY": True,
|
||||
"JWT_AUTH_SAMESITE": "Lax",
|
||||
"JWT_AUTH_RETURN_EXPIRATION": True,
|
||||
"JWT_TOKEN_CLAIMS_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ dependencies = [
|
||||
"celery>=5.5.3",
|
||||
"django-celery-beat>=2.8.1",
|
||||
"django-celery-results>=2.6.0",
|
||||
"djangorestframework-simplejwt>=5.5.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
1
backend/test_avatar.txt
Normal file
1
backend/test_avatar.txt
Normal file
@@ -0,0 +1 @@
|
||||
Testing image file validation
|
||||
16
backend/uv.lock
generated
16
backend/uv.lock
generated
@@ -857,6 +857,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework-simplejwt"
|
||||
version = "5.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drf-spectacular"
|
||||
version = "0.28.0"
|
||||
@@ -2170,6 +2184,7 @@ dependencies = [
|
||||
{ name = "django-webpack-loader" },
|
||||
{ name = "django-widget-tweaks" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "djangorestframework-simplejwt" },
|
||||
{ name = "drf-spectacular" },
|
||||
{ name = "factory-boy" },
|
||||
{ name = "flake8" },
|
||||
@@ -2239,6 +2254,7 @@ requires-dist = [
|
||||
{ name = "django-webpack-loader", specifier = ">=3.1.1" },
|
||||
{ name = "django-widget-tweaks", specifier = ">=1.5.0" },
|
||||
{ name = "djangorestframework", specifier = ">=3.14.0" },
|
||||
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
|
||||
{ name = "drf-spectacular", specifier = ">=0.27.0" },
|
||||
{ name = "factory-boy", specifier = ">=3.3.3" },
|
||||
{ name = "flake8", specifier = ">=7.1.1" },
|
||||
|
||||
Reference in New Issue
Block a user