mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:11:09 -05:00
update
This commit is contained in:
11
apps/accounts/services/__init__.py
Normal file
11
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']
|
||||
351
apps/accounts/services/notification_service.py
Normal file
351
apps/accounts/services/notification_service.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Notification service for creating and managing user notifications.
|
||||
|
||||
This service handles the creation, delivery, and management of notifications
|
||||
for various events including submission approvals/rejections.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from apps.accounts.models import User, UserNotification, NotificationPreference
|
||||
from django_forwardemail.services import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for creating and managing user notifications."""
|
||||
|
||||
@staticmethod
|
||||
def create_notification(
|
||||
user: User,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
related_object: Optional[Any] = None,
|
||||
priority: str = UserNotification.Priority.NORMAL,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a new notification for a user.
|
||||
|
||||
Args:
|
||||
user: The user to notify
|
||||
notification_type: Type of notification (from UserNotification.NotificationType)
|
||||
title: Notification title
|
||||
message: Notification message
|
||||
related_object: Optional related object (submission, review, etc.)
|
||||
priority: Notification priority
|
||||
extra_data: Additional data to store with notification
|
||||
expires_at: When the notification expires
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
# Get content type and object ID if related object provided
|
||||
content_type = None
|
||||
object_id = None
|
||||
if related_object:
|
||||
content_type = ContentType.objects.get_for_model(related_object)
|
||||
object_id = related_object.pk
|
||||
|
||||
# Create the notification
|
||||
notification = UserNotification.objects.create(
|
||||
user=user,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
object_id=object_id,
|
||||
priority=priority,
|
||||
extra_data=extra_data or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# Send notification through appropriate channels
|
||||
NotificationService._send_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
@staticmethod
|
||||
def create_submission_approved_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission approval.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The approved submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} has been approved!"
|
||||
message = f"Great news! Your {submission_type} submission has been approved and is now live on ThrillWiki."
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"moderator_message": additional_message,
|
||||
"approved_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_APPROVED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.NORMAL,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_rejected_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
rejection_reason: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission rejection.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The rejected submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
rejection_reason: Reason for rejection
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} needs attention"
|
||||
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
|
||||
message += f"\n\nReason: {rejection_reason}"
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
message += "\n\nYou can edit and resubmit your content from your profile page."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"rejection_reason": rejection_reason,
|
||||
"moderator_message": additional_message,
|
||||
"rejected_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_REJECTED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.HIGH,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_pending_notification(
|
||||
user: User, submission_object: Any, submission_type: str
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission pending review.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The pending submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} is under review"
|
||||
message = f"Thanks for your {submission_type} submission! It's now under review by our moderation team."
|
||||
message += "\n\nWe'll notify you once it's been reviewed. This usually takes 1-2 business days."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"submitted_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_PENDING,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.LOW,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send notification through appropriate channels based on user preferences.
|
||||
|
||||
Args:
|
||||
notification: The notification to send
|
||||
"""
|
||||
user = notification.user
|
||||
|
||||
# Get user's notification preferences
|
||||
try:
|
||||
preferences = user.notification_preference
|
||||
except NotificationPreference.DoesNotExist:
|
||||
# Create default preferences if they don't exist
|
||||
preferences = NotificationPreference.objects.create(user=user)
|
||||
|
||||
# Send email notification if enabled
|
||||
if preferences.should_send_notification(
|
||||
notification.notification_type, "email"
|
||||
):
|
||||
NotificationService._send_email_notification(notification)
|
||||
|
||||
# Toast notifications are always created (the notification object itself)
|
||||
# The frontend will display them as toast notifications based on preferences
|
||||
|
||||
@staticmethod
|
||||
def _send_email_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send email notification to user using the custom ForwardEmail service.
|
||||
|
||||
Args:
|
||||
notification: The notification to send via email
|
||||
"""
|
||||
try:
|
||||
user = notification.user
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"notification": notification,
|
||||
"site_name": "ThrillWiki",
|
||||
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
|
||||
}
|
||||
|
||||
# Render email templates
|
||||
subject = f"ThrillWiki: {notification.title}"
|
||||
html_message = render_to_string("emails/notification.html", context)
|
||||
plain_message = render_to_string("emails/notification.txt", context)
|
||||
|
||||
# Send email using custom ForwardEmail service
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject=subject,
|
||||
text=plain_message,
|
||||
html=html_message,
|
||||
)
|
||||
|
||||
# Mark as sent
|
||||
notification.email_sent = True
|
||||
notification.email_sent_at = timezone.now()
|
||||
notification.save(update_fields=["email_sent", "email_sent_at"])
|
||||
|
||||
logger.info(
|
||||
f"Email notification sent to {user.email} for notification {notification.id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send email notification {notification.id}: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_notifications(
|
||||
user: User,
|
||||
unread_only: bool = False,
|
||||
notification_types: Optional[List[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[UserNotification]:
|
||||
"""
|
||||
Get notifications for a user.
|
||||
|
||||
Args:
|
||||
user: User to get notifications for
|
||||
unread_only: Only return unread notifications
|
||||
notification_types: Filter by notification types
|
||||
limit: Limit number of results
|
||||
|
||||
Returns:
|
||||
List[UserNotification]: List of notifications
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user)
|
||||
|
||||
if unread_only:
|
||||
queryset = queryset.filter(is_read=False)
|
||||
|
||||
if notification_types:
|
||||
queryset = queryset.filter(notification_type__in=notification_types)
|
||||
|
||||
# Exclude expired notifications
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
||||
)
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def mark_notifications_read(
|
||||
user: User, notification_ids: Optional[List[int]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Mark notifications as read for a user.
|
||||
|
||||
Args:
|
||||
user: User whose notifications to mark as read
|
||||
notification_ids: Specific notification IDs to mark as read (if None, marks all)
|
||||
|
||||
Returns:
|
||||
int: Number of notifications marked as read
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user, is_read=False)
|
||||
|
||||
if notification_ids:
|
||||
queryset = queryset.filter(id__in=notification_ids)
|
||||
|
||||
return queryset.update(is_read=True, read_at=timezone.now())
|
||||
|
||||
@staticmethod
|
||||
def cleanup_old_notifications(days: int = 90) -> int:
|
||||
"""
|
||||
Clean up old read notifications.
|
||||
|
||||
Args:
|
||||
days: Number of days to keep read notifications
|
||||
|
||||
Returns:
|
||||
int: Number of notifications deleted
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
old_notifications = UserNotification.objects.filter(
|
||||
is_read=True, read_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
count = old_notifications.count()
|
||||
old_notifications.delete()
|
||||
|
||||
logger.info(f"Cleaned up {count} old notifications")
|
||||
return count
|
||||
257
apps/accounts/services/social_provider_service.py
Normal file
257
apps/accounts/services/social_provider_service.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
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, Tuple, TYPE_CHECKING
|
||||
from django.contrib.auth import get_user_model
|
||||
from allauth.socialaccount.models import 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
apps/accounts/services/user_deletion_service.py
Normal file
309
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 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
|
||||
Reference in New Issue
Block a user