This commit is contained in:
pacnpal
2025-09-21 20:04:42 -04:00
parent 42a3dc7637
commit 75cc618c2b
610 changed files with 1719 additions and 4816 deletions

View 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']

View 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

View 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."

View 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