mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 19:51:13 -05:00
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:
BIN
django/apps/users/__pycache__/admin.cpython-313.pyc
Normal file
BIN
django/apps/users/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/apps/users/__pycache__/permissions.cpython-313.pyc
Normal file
BIN
django/apps/users/__pycache__/permissions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/apps/users/__pycache__/services.cpython-313.pyc
Normal file
BIN
django/apps/users/__pycache__/services.cpython-313.pyc
Normal file
Binary file not shown.
372
django/apps/users/admin.py
Normal file
372
django/apps/users/admin.py
Normal 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')
|
||||
310
django/apps/users/permissions.py
Normal file
310
django/apps/users/permissions.py
Normal 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)
|
||||
592
django/apps/users/services.py
Normal file
592
django/apps/users/services.py
Normal 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
343
django/apps/users/tasks.py
Normal 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))
|
||||
Reference in New Issue
Block a user