mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:31:09 -05:00
- Created an empty migration file for the moderation app to enable migrations. - Documented the resolution of the seed command failure due to missing moderation tables. - Identified and fixed a VARCHAR(10) constraint violation in the User model during seed data generation. - Updated role assignment in the seed command to comply with the field length constraint.
367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""
|
|
User management services for ThrillWiki.
|
|
|
|
This module contains services for user account management including
|
|
user deletion while preserving submissions.
|
|
"""
|
|
|
|
from typing import Optional
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from django.conf import settings
|
|
from django.contrib.sites.models import Site
|
|
from django_forwardemail.services import EmailService
|
|
from .models import User, UserProfile, UserDeletionRequest
|
|
|
|
|
|
class UserDeletionService:
|
|
"""Service for handling user deletion while preserving submissions."""
|
|
|
|
DELETED_USER_USERNAME = "deleted_user"
|
|
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
|
|
DELETED_DISPLAY_NAME = "Deleted User"
|
|
|
|
@classmethod
|
|
def get_or_create_deleted_user(cls) -> User:
|
|
"""Get or create the system deleted user placeholder."""
|
|
deleted_user, created = User.objects.get_or_create(
|
|
username=cls.DELETED_USER_USERNAME,
|
|
defaults={
|
|
"email": cls.DELETED_USER_EMAIL,
|
|
"is_active": False,
|
|
"is_staff": False,
|
|
"is_superuser": False,
|
|
"role": "USER",
|
|
"is_banned": True,
|
|
"ban_reason": "System placeholder for deleted users",
|
|
"ban_date": timezone.now(),
|
|
},
|
|
)
|
|
|
|
if created:
|
|
# Create profile for deleted user
|
|
UserProfile.objects.create(
|
|
user=deleted_user,
|
|
display_name=cls.DELETED_DISPLAY_NAME,
|
|
bio="This user account has been deleted.",
|
|
)
|
|
|
|
return deleted_user
|
|
|
|
@classmethod
|
|
@transaction.atomic
|
|
def delete_user_preserve_submissions(cls, user: User) -> dict:
|
|
"""
|
|
Delete a user while preserving all their submissions.
|
|
|
|
This method:
|
|
1. Transfers all user submissions to a system "deleted_user" placeholder
|
|
2. Deletes the user's profile and account data
|
|
3. Returns a summary of what was preserved
|
|
|
|
Args:
|
|
user: The user to delete
|
|
|
|
Returns:
|
|
dict: Summary of preserved submissions
|
|
"""
|
|
if user.username == cls.DELETED_USER_USERNAME:
|
|
raise ValueError("Cannot delete the system deleted user placeholder")
|
|
|
|
deleted_user = cls.get_or_create_deleted_user()
|
|
|
|
# Count submissions before transfer
|
|
submission_counts = {
|
|
"park_reviews": getattr(
|
|
user, "park_reviews", user.__class__.objects.none()
|
|
).count(),
|
|
"ride_reviews": getattr(
|
|
user, "ride_reviews", user.__class__.objects.none()
|
|
).count(),
|
|
"uploaded_park_photos": getattr(
|
|
user, "uploaded_park_photos", user.__class__.objects.none()
|
|
).count(),
|
|
"uploaded_ride_photos": getattr(
|
|
user, "uploaded_ride_photos", user.__class__.objects.none()
|
|
).count(),
|
|
"top_lists": getattr(
|
|
user, "top_lists", user.__class__.objects.none()
|
|
).count(),
|
|
"edit_submissions": getattr(
|
|
user, "edit_submissions", user.__class__.objects.none()
|
|
).count(),
|
|
"photo_submissions": getattr(
|
|
user, "photo_submissions", user.__class__.objects.none()
|
|
).count(),
|
|
"moderated_park_reviews": getattr(
|
|
user, "moderated_park_reviews", user.__class__.objects.none()
|
|
).count(),
|
|
"moderated_ride_reviews": getattr(
|
|
user, "moderated_ride_reviews", user.__class__.objects.none()
|
|
).count(),
|
|
"handled_submissions": getattr(
|
|
user, "handled_submissions", user.__class__.objects.none()
|
|
).count(),
|
|
"handled_photos": getattr(
|
|
user, "handled_photos", user.__class__.objects.none()
|
|
).count(),
|
|
}
|
|
|
|
# Transfer all submissions to deleted user
|
|
# Reviews
|
|
if hasattr(user, "park_reviews"):
|
|
getattr(user, "park_reviews").update(user=deleted_user)
|
|
if hasattr(user, "ride_reviews"):
|
|
getattr(user, "ride_reviews").update(user=deleted_user)
|
|
|
|
# Photos
|
|
if hasattr(user, "uploaded_park_photos"):
|
|
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
|
|
if hasattr(user, "uploaded_ride_photos"):
|
|
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
|
|
|
|
# Top Lists
|
|
if hasattr(user, "top_lists"):
|
|
getattr(user, "top_lists").update(user=deleted_user)
|
|
|
|
# Moderation submissions
|
|
if hasattr(user, "edit_submissions"):
|
|
getattr(user, "edit_submissions").update(user=deleted_user)
|
|
if hasattr(user, "photo_submissions"):
|
|
getattr(user, "photo_submissions").update(user=deleted_user)
|
|
|
|
# Moderation actions - these can be set to NULL since they're not user content
|
|
if hasattr(user, "moderated_park_reviews"):
|
|
getattr(user, "moderated_park_reviews").update(moderated_by=None)
|
|
if hasattr(user, "moderated_ride_reviews"):
|
|
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
|
|
if hasattr(user, "handled_submissions"):
|
|
getattr(user, "handled_submissions").update(handled_by=None)
|
|
if hasattr(user, "handled_photos"):
|
|
getattr(user, "handled_photos").update(handled_by=None)
|
|
|
|
# Store user info for the summary
|
|
user_info = {
|
|
"username": user.username,
|
|
"user_id": user.user_id,
|
|
"email": user.email,
|
|
"date_joined": user.date_joined,
|
|
}
|
|
|
|
# Delete the user (this will cascade delete the profile)
|
|
user.delete()
|
|
|
|
return {
|
|
"deleted_user": user_info,
|
|
"preserved_submissions": submission_counts,
|
|
"transferred_to": {
|
|
"username": deleted_user.username,
|
|
"user_id": deleted_user.user_id,
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Check if a user can be safely deleted.
|
|
|
|
Args:
|
|
user: The user to check
|
|
|
|
Returns:
|
|
tuple: (can_delete: bool, reason: Optional[str])
|
|
"""
|
|
if user.username == cls.DELETED_USER_USERNAME:
|
|
return False, "Cannot delete the system deleted user placeholder"
|
|
|
|
if user.is_superuser:
|
|
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 == "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
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
|
|
"""
|
|
Create a user deletion request and send verification email.
|
|
|
|
Args:
|
|
user: The user requesting deletion
|
|
|
|
Returns:
|
|
UserDeletionRequest: The created deletion request
|
|
"""
|
|
# Check if user can be deleted
|
|
can_delete, reason = cls.can_delete_user(user)
|
|
if not can_delete:
|
|
raise ValueError(f"Cannot delete user: {reason}")
|
|
|
|
# Remove any existing deletion request for this user
|
|
UserDeletionRequest.objects.filter(user=user).delete()
|
|
|
|
# Create new deletion request
|
|
deletion_request = UserDeletionRequest.objects.create(user=user)
|
|
|
|
# Send verification email
|
|
cls.send_deletion_verification_email(deletion_request)
|
|
|
|
return deletion_request
|
|
|
|
@classmethod
|
|
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
|
|
"""
|
|
Send verification email for account deletion.
|
|
|
|
Args:
|
|
deletion_request: The deletion request to send email for
|
|
"""
|
|
user = deletion_request.user
|
|
|
|
# Get current site for email service
|
|
try:
|
|
site = Site.objects.get_current()
|
|
except Site.DoesNotExist:
|
|
# Fallback to default site
|
|
site = Site.objects.get_or_create(
|
|
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
|
|
)[0]
|
|
|
|
# Prepare email context
|
|
context = {
|
|
"user": user,
|
|
"verification_code": deletion_request.verification_code,
|
|
"expires_at": deletion_request.expires_at,
|
|
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
|
|
"frontend_domain": getattr(
|
|
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
|
|
),
|
|
}
|
|
|
|
# Render email content
|
|
subject = f"Confirm Account Deletion - {context['site_name']}"
|
|
|
|
# Create email message with 1-hour expiration notice
|
|
message = f"""
|
|
Hello {user.get_display_name()},
|
|
|
|
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
|
|
|
|
Verification Code: {deletion_request.verification_code}
|
|
|
|
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
|
|
|
|
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
|
|
|
|
If you did not request this deletion, please ignore this email and your account will remain active.
|
|
|
|
To complete the deletion, enter the verification code in the account deletion form on our website.
|
|
|
|
Best regards,
|
|
The ThrillWiki Team
|
|
""".strip()
|
|
|
|
# Send email using custom email service
|
|
try:
|
|
EmailService.send_email(
|
|
to=user.email,
|
|
subject=subject,
|
|
text=message,
|
|
site=site,
|
|
from_email="no-reply@thrillwiki.com",
|
|
)
|
|
|
|
# Update email sent timestamp
|
|
deletion_request.email_sent_at = timezone.now()
|
|
deletion_request.save(update_fields=["email_sent_at"])
|
|
|
|
except Exception as e:
|
|
# Log the error but don't fail the request creation
|
|
print(f"Failed to send deletion verification email to {user.email}: {e}")
|
|
|
|
@classmethod
|
|
@transaction.atomic
|
|
def verify_and_delete_user(cls, verification_code: str) -> dict:
|
|
"""
|
|
Verify deletion code and delete the user account.
|
|
|
|
Args:
|
|
verification_code: The verification code from the email
|
|
|
|
Returns:
|
|
dict: Summary of the deletion
|
|
|
|
Raises:
|
|
ValueError: If verification fails
|
|
"""
|
|
try:
|
|
deletion_request = UserDeletionRequest.objects.get(
|
|
verification_code=verification_code
|
|
)
|
|
except UserDeletionRequest.DoesNotExist:
|
|
raise ValueError("Invalid verification code")
|
|
|
|
# Check if request is still valid
|
|
if not deletion_request.is_valid():
|
|
if deletion_request.is_expired():
|
|
raise ValueError("Verification code has expired")
|
|
elif deletion_request.is_used:
|
|
raise ValueError("Verification code has already been used")
|
|
elif deletion_request.attempts >= deletion_request.max_attempts:
|
|
raise ValueError("Too many verification attempts")
|
|
else:
|
|
raise ValueError("Invalid verification code")
|
|
|
|
# Increment attempts
|
|
deletion_request.increment_attempts()
|
|
|
|
# Mark as used
|
|
deletion_request.mark_as_used()
|
|
|
|
# Delete the user
|
|
user = deletion_request.user
|
|
result = cls.delete_user_preserve_submissions(user)
|
|
|
|
# Add deletion request info to result
|
|
result["deletion_request"] = {
|
|
"verification_code": verification_code,
|
|
"created_at": deletion_request.created_at,
|
|
"verified_at": timezone.now(),
|
|
}
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def cancel_deletion_request(cls, user: User) -> bool:
|
|
"""
|
|
Cancel a pending deletion request.
|
|
|
|
Args:
|
|
user: The user whose deletion request to cancel
|
|
|
|
Returns:
|
|
bool: True if a request was cancelled, False if no request existed
|
|
"""
|
|
try:
|
|
deletion_request = getattr(user, "deletion_request", None)
|
|
if deletion_request:
|
|
deletion_request.delete()
|
|
return True
|
|
return False
|
|
except UserDeletionRequest.DoesNotExist:
|
|
return False
|
|
|
|
@classmethod
|
|
def cleanup_expired_deletion_requests(cls) -> int:
|
|
"""
|
|
Clean up expired deletion requests.
|
|
|
|
Returns:
|
|
int: Number of expired requests cleaned up
|
|
"""
|
|
return UserDeletionRequest.cleanup_expired()
|