Add email templates for user notifications and account management

- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
This commit is contained in:
pacnpal
2025-11-08 15:34:04 -05:00
parent 9c46ef8b03
commit d6ff4cc3a3
335 changed files with 61926 additions and 73 deletions

Binary file not shown.

Binary file not shown.

372
django/apps/users/admin.py Normal file
View File

@@ -0,0 +1,372 @@
"""
Django admin configuration for User models.
"""
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.html import format_html
from django.urls import reverse
from django.utils.safestring import mark_safe
from unfold.admin import ModelAdmin
from unfold.decorators import display
from import_export import resources
from import_export.admin import ImportExportModelAdmin
from .models import User, UserRole, UserProfile
class UserResource(resources.ModelResource):
"""Resource for importing/exporting users."""
class Meta:
model = User
fields = (
'id', 'email', 'username', 'first_name', 'last_name',
'date_joined', 'last_login', 'is_active', 'is_staff',
'banned', 'reputation_score', 'mfa_enabled'
)
export_order = fields
class UserRoleInline(admin.StackedInline):
"""Inline for user role."""
model = UserRole
can_delete = False
verbose_name_plural = 'Role'
fk_name = 'user'
fields = ('role', 'granted_by', 'granted_at')
readonly_fields = ('granted_at',)
class UserProfileInline(admin.StackedInline):
"""Inline for user profile."""
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile & Preferences'
fk_name = 'user'
fields = (
('email_notifications', 'email_on_submission_approved', 'email_on_submission_rejected'),
('profile_public', 'show_email'),
('total_submissions', 'approved_submissions'),
)
readonly_fields = ('total_submissions', 'approved_submissions')
@admin.register(User)
class UserAdmin(BaseUserAdmin, ModelAdmin, ImportExportModelAdmin):
"""Admin interface for User model."""
resource_class = UserResource
list_display = [
'email',
'username',
'display_name_admin',
'role_badge',
'reputation_badge',
'status_badge',
'mfa_badge',
'date_joined',
'last_login',
]
list_filter = [
'is_active',
'is_staff',
'is_superuser',
'banned',
'mfa_enabled',
'oauth_provider',
'date_joined',
'last_login',
]
search_fields = [
'email',
'username',
'first_name',
'last_name',
]
ordering = ['-date_joined']
fieldsets = (
('Account Information', {
'fields': ('email', 'username', 'password')
}),
('Personal Information', {
'fields': ('first_name', 'last_name', 'avatar_url', 'bio')
}),
('Permissions', {
'fields': (
'is_active',
'is_staff',
'is_superuser',
'groups',
'user_permissions',
)
}),
('Moderation', {
'fields': (
'banned',
'ban_reason',
'banned_at',
'banned_by',
)
}),
('OAuth', {
'fields': ('oauth_provider', 'oauth_sub'),
'classes': ('collapse',)
}),
('Security', {
'fields': ('mfa_enabled', 'reputation_score'),
}),
('Timestamps', {
'fields': ('date_joined', 'last_login'),
'classes': ('collapse',)
}),
)
add_fieldsets = (
('Create New User', {
'classes': ('wide',),
'fields': ('email', 'username', 'password1', 'password2'),
}),
)
readonly_fields = [
'date_joined',
'last_login',
'banned_at',
'oauth_provider',
'oauth_sub',
]
inlines = [UserRoleInline, UserProfileInline]
@display(description="Name", label=True)
def display_name_admin(self, obj):
"""Display user's display name."""
return obj.display_name or '-'
@display(description="Role", label=True)
def role_badge(self, obj):
"""Display user role with badge."""
try:
role = obj.role.role
colors = {
'admin': 'red',
'moderator': 'blue',
'user': 'green',
}
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">{}</span>',
colors.get(role, 'gray'),
role.upper()
)
except UserRole.DoesNotExist:
return format_html('<span style="color: gray;">No Role</span>')
@display(description="Reputation", label=True)
def reputation_badge(self, obj):
"""Display reputation score."""
score = obj.reputation_score
if score >= 100:
color = 'green'
elif score >= 50:
color = 'blue'
elif score >= 0:
color = 'gray'
else:
color = 'red'
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
color,
score
)
@display(description="Status", label=True)
def status_badge(self, obj):
"""Display user status."""
if obj.banned:
return format_html(
'<span style="background-color: red; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">BANNED</span>'
)
elif not obj.is_active:
return format_html(
'<span style="background-color: orange; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">INACTIVE</span>'
)
else:
return format_html(
'<span style="background-color: green; color: white; padding: 3px 8px; border-radius: 3px; font-size: 11px;">ACTIVE</span>'
)
@display(description="MFA", label=True)
def mfa_badge(self, obj):
"""Display MFA status."""
if obj.mfa_enabled:
return format_html(
'<span style="color: green;">✓ Enabled</span>'
)
else:
return format_html(
'<span style="color: gray;">✗ Disabled</span>'
)
def get_queryset(self, request):
"""Optimize queryset with select_related."""
qs = super().get_queryset(request)
return qs.select_related('role', 'banned_by')
actions = ['ban_users', 'unban_users', 'make_moderator', 'make_user']
@admin.action(description="Ban selected users")
def ban_users(self, request, queryset):
"""Ban selected users."""
count = 0
for user in queryset:
if not user.banned:
user.ban(reason="Banned by admin", banned_by=request.user)
count += 1
self.message_user(
request,
f"{count} user(s) have been banned."
)
@admin.action(description="Unban selected users")
def unban_users(self, request, queryset):
"""Unban selected users."""
count = 0
for user in queryset:
if user.banned:
user.unban()
count += 1
self.message_user(
request,
f"{count} user(s) have been unbanned."
)
@admin.action(description="Set role to Moderator")
def make_moderator(self, request, queryset):
"""Set users' role to moderator."""
from .services import RoleService
count = 0
for user in queryset:
RoleService.assign_role(user, 'moderator', request.user)
count += 1
self.message_user(
request,
f"{count} user(s) have been set to Moderator role."
)
@admin.action(description="Set role to User")
def make_user(self, request, queryset):
"""Set users' role to user."""
from .services import RoleService
count = 0
for user in queryset:
RoleService.assign_role(user, 'user', request.user)
count += 1
self.message_user(
request,
f"{count} user(s) have been set to User role."
)
@admin.register(UserRole)
class UserRoleAdmin(ModelAdmin):
"""Admin interface for UserRole model."""
list_display = ['user', 'role', 'is_moderator', 'is_admin', 'granted_at', 'granted_by']
list_filter = ['role', 'granted_at']
search_fields = ['user__email', 'user__username']
ordering = ['-granted_at']
readonly_fields = ['granted_at']
def get_queryset(self, request):
"""Optimize queryset."""
qs = super().get_queryset(request)
return qs.select_related('user', 'granted_by')
@admin.register(UserProfile)
class UserProfileAdmin(ModelAdmin):
"""Admin interface for UserProfile model."""
list_display = [
'user',
'total_submissions',
'approved_submissions',
'approval_rate',
'email_notifications',
'profile_public',
]
list_filter = [
'email_notifications',
'profile_public',
'show_email',
]
search_fields = ['user__email', 'user__username']
readonly_fields = ['created', 'modified', 'total_submissions', 'approved_submissions']
fieldsets = (
('User', {
'fields': ('user',)
}),
('Statistics', {
'fields': ('total_submissions', 'approved_submissions'),
}),
('Notification Preferences', {
'fields': (
'email_notifications',
'email_on_submission_approved',
'email_on_submission_rejected',
)
}),
('Privacy Settings', {
'fields': ('profile_public', 'show_email'),
}),
('Timestamps', {
'fields': ('created', 'modified'),
'classes': ('collapse',)
}),
)
@display(description="Approval Rate")
def approval_rate(self, obj):
"""Display approval rate percentage."""
if obj.total_submissions == 0:
return '-'
rate = (obj.approved_submissions / obj.total_submissions) * 100
if rate >= 80:
color = 'green'
elif rate >= 60:
color = 'blue'
elif rate >= 40:
color = 'orange'
else:
color = 'red'
return format_html(
'<span style="color: {}; font-weight: bold;">{:.1f}%</span>',
color,
rate
)
def get_queryset(self, request):
"""Optimize queryset."""
qs = super().get_queryset(request)
return qs.select_related('user')

View File

@@ -0,0 +1,310 @@
"""
Permission utilities and decorators for API endpoints.
Provides:
- Permission checking decorators
- Role-based access control
- Object-level permissions
"""
from functools import wraps
from typing import Optional, Callable
from django.http import HttpRequest
from ninja import Router
from ninja.security import HttpBearer
from rest_framework_simplejwt.tokens import AccessToken
from rest_framework_simplejwt.exceptions import TokenError
from django.core.exceptions import PermissionDenied
import logging
from .models import User, UserRole
logger = logging.getLogger(__name__)
class JWTAuth(HttpBearer):
"""JWT authentication for django-ninja"""
def authenticate(self, request: HttpRequest, token: str) -> Optional[User]:
"""
Authenticate user from JWT token.
Args:
request: HTTP request
token: JWT access token
Returns:
User instance if valid, None otherwise
"""
try:
# Decode token
access_token = AccessToken(token)
user_id = access_token['user_id']
# Get user
user = User.objects.get(id=user_id)
# Check if banned
if user.banned:
logger.warning(f"Banned user attempted API access: {user.email}")
return None
return user
except TokenError as e:
logger.debug(f"Invalid token: {e}")
return None
except User.DoesNotExist:
logger.warning(f"Token for non-existent user: {user_id}")
return None
except Exception as e:
logger.error(f"Authentication error: {e}")
return None
# Global JWT auth instance
jwt_auth = JWTAuth()
def require_auth(func: Callable) -> Callable:
"""
Decorator to require authentication.
Usage:
@api.get("/protected")
@require_auth
def protected_endpoint(request):
return {"user": request.auth.email}
"""
@wraps(func)
def wrapper(request: HttpRequest, *args, **kwargs):
if not request.auth or not isinstance(request.auth, User):
raise PermissionDenied("Authentication required")
return func(request, *args, **kwargs)
return wrapper
def require_role(role: str) -> Callable:
"""
Decorator to require specific role.
Args:
role: Required role (user, moderator, admin)
Usage:
@api.post("/moderate")
@require_role("moderator")
def moderate_endpoint(request):
return {"message": "Access granted"}
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(request: HttpRequest, *args, **kwargs):
if not request.auth or not isinstance(request.auth, User):
raise PermissionDenied("Authentication required")
user = request.auth
try:
user_role = user.role
# Admin has access to everything
if user_role.is_admin:
return func(request, *args, **kwargs)
# Check specific role
if role == 'moderator' and user_role.is_moderator:
return func(request, *args, **kwargs)
elif role == 'user':
return func(request, *args, **kwargs)
raise PermissionDenied(f"Role '{role}' required")
except UserRole.DoesNotExist:
raise PermissionDenied("User role not assigned")
return wrapper
return decorator
def require_moderator(func: Callable) -> Callable:
"""
Decorator to require moderator or admin role.
Usage:
@api.post("/approve")
@require_moderator
def approve_endpoint(request):
return {"message": "Access granted"}
"""
return require_role("moderator")(func)
def require_admin(func: Callable) -> Callable:
"""
Decorator to require admin role.
Usage:
@api.delete("/delete-user")
@require_admin
def delete_user_endpoint(request):
return {"message": "Access granted"}
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(request: HttpRequest, *args, **kwargs):
if not request.auth or not isinstance(request.auth, User):
raise PermissionDenied("Authentication required")
user = request.auth
try:
user_role = user.role
if not user_role.is_admin:
raise PermissionDenied("Admin role required")
return func(request, *args, **kwargs)
except UserRole.DoesNotExist:
raise PermissionDenied("User role not assigned")
return wrapper
return decorator
def is_owner_or_moderator(user: User, obj_user_id) -> bool:
"""
Check if user is the owner of an object or a moderator.
Args:
user: User to check
obj_user_id: User ID of the object owner
Returns:
True if user is owner or moderator
"""
if str(user.id) == str(obj_user_id):
return True
try:
return user.role.is_moderator
except UserRole.DoesNotExist:
return False
def can_moderate(user: User) -> bool:
"""
Check if user can moderate content.
Args:
user: User to check
Returns:
True if user is moderator or admin
"""
if user.banned:
return False
try:
return user.role.is_moderator
except UserRole.DoesNotExist:
return False
def can_submit(user: User) -> bool:
"""
Check if user can submit content.
Args:
user: User to check
Returns:
True if user is not banned
"""
return not user.banned
class PermissionChecker:
"""Helper class for checking permissions"""
def __init__(self, user: User):
self.user = user
try:
self.user_role = user.role
except UserRole.DoesNotExist:
self.user_role = None
@property
def is_authenticated(self) -> bool:
"""Check if user is authenticated"""
return self.user is not None
@property
def is_moderator(self) -> bool:
"""Check if user is moderator or admin"""
if self.user.banned:
return False
return self.user_role and self.user_role.is_moderator
@property
def is_admin(self) -> bool:
"""Check if user is admin"""
if self.user.banned:
return False
return self.user_role and self.user_role.is_admin
@property
def can_submit(self) -> bool:
"""Check if user can submit content"""
return not self.user.banned
@property
def can_moderate(self) -> bool:
"""Check if user can moderate content"""
return self.is_moderator
def can_edit(self, obj_user_id) -> bool:
"""Check if user can edit an object"""
if self.user.banned:
return False
return str(self.user.id) == str(obj_user_id) or self.is_moderator
def can_delete(self, obj_user_id) -> bool:
"""Check if user can delete an object"""
if self.user.banned:
return False
return str(self.user.id) == str(obj_user_id) or self.is_admin
def require_permission(self, permission: str) -> None:
"""
Raise PermissionDenied if user doesn't have permission.
Args:
permission: Permission to check (submit, moderate, admin)
Raises:
PermissionDenied: If user doesn't have permission
"""
if permission == 'submit' and not self.can_submit:
raise PermissionDenied("You are banned from submitting content")
elif permission == 'moderate' and not self.can_moderate:
raise PermissionDenied("Moderator role required")
elif permission == 'admin' and not self.is_admin:
raise PermissionDenied("Admin role required")
def get_permission_checker(request: HttpRequest) -> Optional[PermissionChecker]:
"""
Get permission checker for request user.
Args:
request: HTTP request
Returns:
PermissionChecker instance or None if not authenticated
"""
if not request.auth or not isinstance(request.auth, User):
return None
return PermissionChecker(request.auth)

View File

@@ -0,0 +1,592 @@
"""
User authentication and management services.
Provides business logic for:
- User registration and authentication
- OAuth integration
- MFA/2FA management
- Permission and role management
"""
from typing import Optional, Dict, Any
from django.contrib.auth import authenticate
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from django_otp.plugins.otp_totp.models import TOTPDevice
from allauth.socialaccount.models import SocialAccount
import logging
from .models import User, UserRole, UserProfile
logger = logging.getLogger(__name__)
class AuthenticationService:
"""Service for handling user authentication operations"""
@staticmethod
@transaction.atomic
def register_user(
email: str,
password: str,
username: Optional[str] = None,
first_name: str = '',
last_name: str = ''
) -> User:
"""
Register a new user with email and password.
Args:
email: User's email address
password: User's password (will be validated and hashed)
username: Optional username (defaults to email prefix)
first_name: User's first name
last_name: User's last name
Returns:
Created User instance
Raises:
ValidationError: If email exists or password is invalid
"""
# Normalize email
email = email.lower().strip()
# Check if user exists
if User.objects.filter(email=email).exists():
raise ValidationError({'email': 'A user with this email already exists.'})
# Set username if not provided
if not username:
username = email.split('@')[0]
# Make unique if needed
base_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Validate password
try:
validate_password(password)
except ValidationError as e:
raise ValidationError({'password': e.messages})
# Create user
user = User.objects.create_user(
email=email,
username=username,
password=password,
first_name=first_name,
last_name=last_name
)
# Create role (default: user)
UserRole.objects.create(user=user, role='user')
# Create profile
UserProfile.objects.create(user=user)
logger.info(f"New user registered: {user.email}")
return user
@staticmethod
def authenticate_user(email: str, password: str) -> Optional[User]:
"""
Authenticate user with email and password.
Args:
email: User's email address
password: User's password
Returns:
User instance if authentication successful, None otherwise
"""
email = email.lower().strip()
user = authenticate(username=email, password=password)
if user and user.banned:
logger.warning(f"Banned user attempted login: {email}")
raise ValidationError("This account has been banned.")
if user:
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
logger.info(f"User authenticated: {email}")
return user
@staticmethod
@transaction.atomic
def create_oauth_user(
email: str,
provider: str,
oauth_sub: str,
username: Optional[str] = None,
first_name: str = '',
last_name: str = '',
avatar_url: str = ''
) -> User:
"""
Create or get user from OAuth provider.
Args:
email: User's email from OAuth provider
provider: OAuth provider name (google, discord)
oauth_sub: OAuth subject identifier
username: Optional username
first_name: User's first name
last_name: User's last name
avatar_url: URL to user's avatar
Returns:
User instance
"""
email = email.lower().strip()
# Check if user exists with this email
try:
user = User.objects.get(email=email)
# Update OAuth info if not set
if not user.oauth_provider:
user.oauth_provider = provider
user.oauth_sub = oauth_sub
user.save(update_fields=['oauth_provider', 'oauth_sub'])
return user
except User.DoesNotExist:
pass
# Create new user
if not username:
username = email.split('@')[0]
base_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
user = User.objects.create(
email=email,
username=username,
first_name=first_name,
last_name=last_name,
avatar_url=avatar_url,
oauth_provider=provider,
oauth_sub=oauth_sub
)
# No password needed for OAuth users
user.set_unusable_password()
user.save()
# Create role and profile
UserRole.objects.create(user=user, role='user')
UserProfile.objects.create(user=user)
logger.info(f"OAuth user created: {email} via {provider}")
return user
@staticmethod
def change_password(user: User, old_password: str, new_password: str) -> bool:
"""
Change user's password.
Args:
user: User instance
old_password: Current password
new_password: New password
Returns:
True if successful
Raises:
ValidationError: If old password is incorrect or new password is invalid
"""
# Check old password
if not user.check_password(old_password):
raise ValidationError({'old_password': 'Incorrect password.'})
# Validate new password
try:
validate_password(new_password, user=user)
except ValidationError as e:
raise ValidationError({'new_password': e.messages})
# Set new password
user.set_password(new_password)
user.save()
logger.info(f"Password changed for user: {user.email}")
return True
@staticmethod
def reset_password(user: User, new_password: str) -> bool:
"""
Reset user's password (admin/forgot password flow).
Args:
user: User instance
new_password: New password
Returns:
True if successful
Raises:
ValidationError: If new password is invalid
"""
# Validate new password
try:
validate_password(new_password, user=user)
except ValidationError as e:
raise ValidationError({'password': e.messages})
# Set new password
user.set_password(new_password)
user.save()
logger.info(f"Password reset for user: {user.email}")
return True
class MFAService:
"""Service for handling multi-factor authentication"""
@staticmethod
def enable_totp(user: User, device_name: str = 'default') -> TOTPDevice:
"""
Enable TOTP-based MFA for user.
Args:
user: User instance
device_name: Name for the TOTP device
Returns:
TOTPDevice instance with QR code data
"""
# Check if device already exists
device = TOTPDevice.objects.filter(
user=user,
name=device_name
).first()
if not device:
device = TOTPDevice.objects.create(
user=user,
name=device_name,
confirmed=False
)
return device
@staticmethod
@transaction.atomic
def confirm_totp(user: User, token: str, device_name: str = 'default') -> bool:
"""
Confirm TOTP device with verification token.
Args:
user: User instance
token: 6-digit TOTP token
device_name: Name of the TOTP device
Returns:
True if successful
Raises:
ValidationError: If token is invalid
"""
device = TOTPDevice.objects.filter(
user=user,
name=device_name
).first()
if not device:
raise ValidationError("TOTP device not found.")
# Verify token
if not device.verify_token(token):
raise ValidationError("Invalid verification code.")
# Confirm device
device.confirmed = True
device.save()
# Enable MFA on user
user.mfa_enabled = True
user.save(update_fields=['mfa_enabled'])
logger.info(f"MFA enabled for user: {user.email}")
return True
@staticmethod
def verify_totp(user: User, token: str) -> bool:
"""
Verify TOTP token for authentication.
Args:
user: User instance
token: 6-digit TOTP token
Returns:
True if valid
"""
device = TOTPDevice.objects.filter(
user=user,
confirmed=True
).first()
if not device:
return False
return device.verify_token(token)
@staticmethod
@transaction.atomic
def disable_totp(user: User) -> bool:
"""
Disable TOTP-based MFA for user.
Args:
user: User instance
Returns:
True if successful
"""
# Delete all TOTP devices
TOTPDevice.objects.filter(user=user).delete()
# Disable MFA on user
user.mfa_enabled = False
user.save(update_fields=['mfa_enabled'])
logger.info(f"MFA disabled for user: {user.email}")
return True
class RoleService:
"""Service for managing user roles and permissions"""
@staticmethod
@transaction.atomic
def assign_role(
user: User,
role: str,
granted_by: Optional[User] = None
) -> UserRole:
"""
Assign role to user.
Args:
user: User to assign role to
role: Role name (user, moderator, admin)
granted_by: User granting the role
Returns:
UserRole instance
Raises:
ValidationError: If role is invalid
"""
valid_roles = ['user', 'moderator', 'admin']
if role not in valid_roles:
raise ValidationError(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
# Get or create role
user_role, created = UserRole.objects.get_or_create(
user=user,
defaults={'role': role, 'granted_by': granted_by}
)
if not created and user_role.role != role:
user_role.role = role
user_role.granted_by = granted_by
user_role.granted_at = timezone.now()
user_role.save()
logger.info(f"Role '{role}' assigned to user: {user.email}")
return user_role
@staticmethod
def has_role(user: User, role: str) -> bool:
"""
Check if user has specific role.
Args:
user: User instance
role: Role name to check
Returns:
True if user has the role
"""
try:
user_role = user.role
if role == 'moderator':
return user_role.is_moderator
elif role == 'admin':
return user_role.is_admin
return user_role.role == role
except UserRole.DoesNotExist:
return False
@staticmethod
def get_user_permissions(user: User) -> Dict[str, bool]:
"""
Get user's permission summary.
Args:
user: User instance
Returns:
Dictionary of permissions
"""
try:
user_role = user.role
is_moderator = user_role.is_moderator
is_admin = user_role.is_admin
except UserRole.DoesNotExist:
is_moderator = False
is_admin = False
return {
'can_submit': not user.banned,
'can_moderate': is_moderator and not user.banned,
'can_admin': is_admin and not user.banned,
'can_edit_own': not user.banned,
'can_delete_own': not user.banned,
}
class UserManagementService:
"""Service for user profile and account management"""
@staticmethod
@transaction.atomic
def update_profile(
user: User,
**kwargs
) -> User:
"""
Update user profile information.
Args:
user: User instance
**kwargs: Fields to update
Returns:
Updated User instance
"""
allowed_fields = [
'first_name', 'last_name', 'username',
'avatar_url', 'bio'
]
updated_fields = []
for field, value in kwargs.items():
if field in allowed_fields and hasattr(user, field):
setattr(user, field, value)
updated_fields.append(field)
if updated_fields:
user.save(update_fields=updated_fields)
logger.info(f"Profile updated for user: {user.email}")
return user
@staticmethod
@transaction.atomic
def update_preferences(
user: User,
**kwargs
) -> UserProfile:
"""
Update user preferences.
Args:
user: User instance
**kwargs: Preference fields to update
Returns:
Updated UserProfile instance
"""
profile = user.profile
allowed_fields = [
'email_notifications',
'email_on_submission_approved',
'email_on_submission_rejected',
'profile_public',
'show_email'
]
updated_fields = []
for field, value in kwargs.items():
if field in allowed_fields and hasattr(profile, field):
setattr(profile, field, value)
updated_fields.append(field)
if updated_fields:
profile.save(update_fields=updated_fields)
logger.info(f"Preferences updated for user: {user.email}")
return profile
@staticmethod
@transaction.atomic
def ban_user(
user: User,
reason: str,
banned_by: User
) -> User:
"""
Ban a user.
Args:
user: User to ban
reason: Reason for ban
banned_by: User performing the ban
Returns:
Updated User instance
"""
user.ban(reason=reason, banned_by=banned_by)
logger.warning(f"User banned: {user.email} by {banned_by.email}. Reason: {reason}")
return user
@staticmethod
@transaction.atomic
def unban_user(user: User) -> User:
"""
Unban a user.
Args:
user: User to unban
Returns:
Updated User instance
"""
user.unban()
logger.info(f"User unbanned: {user.email}")
return user
@staticmethod
def get_user_stats(user: User) -> Dict[str, Any]:
"""
Get user statistics.
Args:
user: User instance
Returns:
Dictionary of user stats
"""
profile = user.profile
return {
'total_submissions': profile.total_submissions,
'approved_submissions': profile.approved_submissions,
'reputation_score': user.reputation_score,
'member_since': user.date_joined,
'last_active': user.last_login,
}

343
django/apps/users/tasks.py Normal file
View File

@@ -0,0 +1,343 @@
"""
Background tasks for user management and notifications.
"""
import logging
from celery import shared_task
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from django.utils import timezone
from datetime import timedelta
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_welcome_email(self, user_id):
"""
Send a welcome email to a newly registered user.
Args:
user_id: ID of the User
Returns:
str: Email send result
"""
from apps.users.models import User
try:
user = User.objects.get(id=user_id)
context = {
'user': user,
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
}
html_message = render_to_string('emails/welcome.html', context)
send_mail(
subject='Welcome to ThrillWiki! 🎢',
message='',
html_message=html_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
)
logger.info(f"Welcome email sent to {user.email}")
return f"Welcome email sent to {user.email}"
except User.DoesNotExist:
logger.error(f"User {user_id} not found")
raise
except Exception as exc:
logger.error(f"Error sending welcome email to user {user_id}: {str(exc)}")
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_password_reset_email(self, user_id, token, reset_url):
"""
Send a password reset email with a secure token.
Args:
user_id: ID of the User
token: Password reset token
reset_url: Full URL for password reset
Returns:
str: Email send result
"""
from apps.users.models import User
try:
user = User.objects.get(id=user_id)
context = {
'user': user,
'reset_url': reset_url,
'request_time': timezone.now(),
'expiry_hours': 24, # Configurable
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
}
html_message = render_to_string('emails/password_reset.html', context)
send_mail(
subject='Reset Your ThrillWiki Password',
message='',
html_message=html_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
)
logger.info(f"Password reset email sent to {user.email}")
return f"Password reset email sent to {user.email}"
except User.DoesNotExist:
logger.error(f"User {user_id} not found")
raise
except Exception as exc:
logger.error(f"Error sending password reset email: {str(exc)}")
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=2)
def cleanup_expired_tokens(self):
"""
Clean up expired JWT tokens and password reset tokens.
This task runs daily to remove old tokens from the database.
Returns:
dict: Cleanup statistics
"""
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken
from django.contrib.auth.tokens import default_token_generator
try:
# Clean up blacklisted JWT tokens older than 7 days
cutoff = timezone.now() - timedelta(days=7)
# Note: Actual implementation depends on token storage strategy
# This is a placeholder for the concept
logger.info("Token cleanup completed")
return {
'jwt_tokens_cleaned': 0,
'reset_tokens_cleaned': 0,
}
except Exception as exc:
logger.error(f"Error cleaning up tokens: {str(exc)}")
raise self.retry(exc=exc, countdown=300)
@shared_task(bind=True, max_retries=3)
def send_account_notification(self, user_id, notification_type, context_data=None):
"""
Send a generic account notification email.
Args:
user_id: ID of the User
notification_type: Type of notification (e.g., 'security_alert', 'profile_update')
context_data: Additional context data for the email
Returns:
str: Email send result
"""
from apps.users.models import User
try:
user = User.objects.get(id=user_id)
context = {
'user': user,
'notification_type': notification_type,
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
}
if context_data:
context.update(context_data)
# For now, just log (would need specific templates for each type)
logger.info(f"Account notification ({notification_type}) for user {user.email}")
return f"Notification sent to {user.email}"
except User.DoesNotExist:
logger.error(f"User {user_id} not found")
raise
except Exception as exc:
logger.error(f"Error sending account notification: {str(exc)}")
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=2)
def cleanup_inactive_users(self, days_inactive=365):
"""
Clean up or flag users who haven't logged in for a long time.
Args:
days_inactive: Number of days of inactivity before flagging (default: 365)
Returns:
dict: Cleanup statistics
"""
from apps.users.models import User
try:
cutoff = timezone.now() - timedelta(days=days_inactive)
inactive_users = User.objects.filter(
last_login__lt=cutoff,
is_active=True
)
count = inactive_users.count()
# For now, just log inactive users
# In production, you might want to send reactivation emails
# or mark accounts for deletion
logger.info(f"Found {count} inactive users (last login before {cutoff})")
return {
'inactive_count': count,
'cutoff_date': cutoff.isoformat(),
}
except Exception as exc:
logger.error(f"Error cleaning up inactive users: {str(exc)}")
raise self.retry(exc=exc, countdown=300)
@shared_task
def update_user_statistics():
"""
Update user-related statistics across the database.
Returns:
dict: Updated statistics
"""
from apps.users.models import User
from django.db.models import Count
from datetime import timedelta
try:
now = timezone.now()
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
stats = {
'total_users': User.objects.count(),
'active_users': User.objects.filter(is_active=True).count(),
'new_this_week': User.objects.filter(date_joined__gte=week_ago).count(),
'new_this_month': User.objects.filter(date_joined__gte=month_ago).count(),
'verified_users': User.objects.filter(email_verified=True).count(),
'by_role': dict(
User.objects.values('role__name')
.annotate(count=Count('id'))
.values_list('role__name', 'count')
),
}
logger.info(f"User statistics updated: {stats}")
return stats
except Exception as e:
logger.error(f"Error updating user statistics: {str(e)}")
raise
@shared_task(bind=True, max_retries=3)
def send_bulk_notification(self, user_ids, subject, message, html_message=None):
"""
Send bulk email notifications to multiple users.
This is useful for announcements, feature updates, etc.
Args:
user_ids: List of User IDs
subject: Email subject
message: Plain text message
html_message: HTML version of message (optional)
Returns:
dict: Send statistics
"""
from apps.users.models import User
try:
users = User.objects.filter(id__in=user_ids, is_active=True)
sent_count = 0
failed_count = 0
for user in users:
try:
send_mail(
subject=subject,
message=message,
html_message=html_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
)
sent_count += 1
except Exception as e:
logger.error(f"Failed to send to {user.email}: {str(e)}")
failed_count += 1
continue
result = {
'total': len(user_ids),
'sent': sent_count,
'failed': failed_count,
}
logger.info(f"Bulk notification sent: {result}")
return result
except Exception as exc:
logger.error(f"Error sending bulk notification: {str(exc)}")
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
@shared_task(bind=True, max_retries=2)
def send_email_verification_reminder(self, user_id):
"""
Send a reminder to users who haven't verified their email.
Args:
user_id: ID of the User
Returns:
str: Reminder result
"""
from apps.users.models import User
try:
user = User.objects.get(id=user_id)
if user.email_verified:
logger.info(f"User {user.email} already verified, skipping reminder")
return "User already verified"
# Send verification reminder
logger.info(f"Sending email verification reminder to {user.email}")
# In production, generate new verification token and send email
# For now, just log
return f"Verification reminder sent to {user.email}"
except User.DoesNotExist:
logger.error(f"User {user_id} not found")
raise
except Exception as exc:
logger.error(f"Error sending verification reminder: {str(exc)}")
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))