476 lines
15 KiB
Python
476 lines
15 KiB
Python
"""
|
|
Accounts services - Onboarding, email notifications, and other account services
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from datetime import timedelta
|
|
|
|
from django.conf import settings
|
|
from django.core.mail import send_mail
|
|
from django.template.loader import render_to_string
|
|
from django.utils import timezone
|
|
|
|
from django.db import models as db_models
|
|
|
|
from .models import (
|
|
AcknowledgementChecklistItem,
|
|
AcknowledgementContent,
|
|
UserAcknowledgement,
|
|
UserProvisionalLog,
|
|
)
|
|
|
|
|
|
class OnboardingService:
|
|
"""Service for managing user onboarding and acknowledgements"""
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@staticmethod
|
|
def create_provisional_user(user_data):
|
|
"""
|
|
Create a provisional user with invitation token
|
|
|
|
Args:
|
|
user_data: Dict with user fields (email, first_name, last_name, etc.)
|
|
|
|
Returns:
|
|
User instance with is_provisional=True
|
|
"""
|
|
from django.contrib.auth import get_user_model
|
|
|
|
User = get_user_model()
|
|
|
|
# Create user with unusable password
|
|
user_data["is_provisional"] = True
|
|
user_data["is_active"] = True # Active but needs onboarding
|
|
user_data["invitation_token"] = OnboardingService.generate_token()
|
|
user_data["invitation_expires_at"] = timezone.now() + timedelta(days=7)
|
|
|
|
user = User.objects.create(**user_data)
|
|
user.set_unusable_password()
|
|
user.save()
|
|
|
|
# Log creation (only store simple data, not objects)
|
|
log_metadata = {
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"hospital_id": str(user.hospital_id) if user.hospital_id else None,
|
|
"department_id": str(user.department_id) if user.department_id else None,
|
|
}
|
|
UserProvisionalLog.objects.create(
|
|
user=user, event_type="created", description=f"Provisional user created", metadata=log_metadata
|
|
)
|
|
|
|
return user
|
|
|
|
@staticmethod
|
|
def generate_token():
|
|
"""Generate a secure invitation token"""
|
|
return secrets.token_urlsafe(32)
|
|
|
|
@staticmethod
|
|
def validate_token(token):
|
|
"""
|
|
Validate invitation token
|
|
|
|
Args:
|
|
token: Invitation token string
|
|
|
|
Returns:
|
|
User instance if valid, None otherwise
|
|
"""
|
|
from django.contrib.auth import get_user_model
|
|
|
|
User = get_user_model()
|
|
|
|
try:
|
|
user = User.objects.get(invitation_token=token, is_provisional=True)
|
|
|
|
# Check if expired
|
|
if user.invitation_expires_at and user.invitation_expires_at < timezone.now():
|
|
# Log expiration
|
|
UserProvisionalLog.objects.create(
|
|
user=user, event_type="invitation_expired", description="Invitation token expired"
|
|
)
|
|
return None
|
|
|
|
return user
|
|
except User.DoesNotExist:
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_wizard_content(user):
|
|
return AcknowledgementContent.objects.filter(is_active=True).order_by("category", "order")
|
|
|
|
@staticmethod
|
|
def get_checklist_items(user):
|
|
return AcknowledgementChecklistItem.objects.filter(is_active=True).order_by("category", "order", "code")
|
|
|
|
@staticmethod
|
|
def get_user_acknowledgements(user):
|
|
"""
|
|
Get all acknowledgements for a user
|
|
|
|
Args:
|
|
user: User instance
|
|
|
|
Returns:
|
|
QuerySet of UserAcknowledgement
|
|
"""
|
|
return UserAcknowledgement.objects.filter(user=user)
|
|
|
|
@staticmethod
|
|
def get_user_progress_percentage(user):
|
|
"""
|
|
Calculate user's onboarding progress percentage
|
|
|
|
Args:
|
|
user: User instance
|
|
|
|
Returns:
|
|
Float percentage (0-100)
|
|
"""
|
|
# Get all required checklist items for user
|
|
required_items = OnboardingService.get_checklist_items(user).filter(is_required=True)
|
|
|
|
if not required_items.exists():
|
|
return 100.0
|
|
|
|
# Count acknowledged items
|
|
acknowledged_count = UserAcknowledgement.objects.filter(
|
|
user=user, checklist_item__in=required_items, is_acknowledged=True
|
|
).count()
|
|
|
|
total_count = required_items.count()
|
|
|
|
return (acknowledged_count / total_count) * 100
|
|
|
|
@staticmethod
|
|
def save_wizard_step(user, step_id):
|
|
"""
|
|
Save completed wizard step
|
|
|
|
Args:
|
|
user: User instance
|
|
step_id: Step identifier
|
|
"""
|
|
if step_id not in user.wizard_completed_steps:
|
|
user.wizard_completed_steps.append(step_id)
|
|
user.current_wizard_step = max(user.current_wizard_step, step_id)
|
|
user.save(update_fields=["wizard_completed_steps", "current_wizard_step"])
|
|
|
|
# Log step completion
|
|
UserProvisionalLog.objects.create(
|
|
user=user,
|
|
event_type="step_completed",
|
|
description=f"Completed wizard step {step_id}",
|
|
metadata={"step_id": step_id},
|
|
)
|
|
|
|
@staticmethod
|
|
def acknowledge_item(user, checklist_item, signature=None, request=None):
|
|
"""
|
|
Acknowledge a checklist item
|
|
|
|
Args:
|
|
user: User instance
|
|
checklist_item: AcknowledgementChecklistItem instance
|
|
signature: Optional signature data
|
|
request: Optional request object for IP/user agent
|
|
|
|
Returns:
|
|
UserAcknowledgement instance
|
|
"""
|
|
from .pdf_service import AcknowledgementPDFService
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
import uuid
|
|
|
|
# Get or create acknowledgement
|
|
acknowledgement, created = UserAcknowledgement.objects.get_or_create(
|
|
user=user,
|
|
checklist_item=checklist_item,
|
|
defaults={
|
|
"is_acknowledged": True,
|
|
"signature": signature or "",
|
|
"signature_ip": OnboardingService._get_client_ip(request) if request else None,
|
|
"signature_user_agent": request.META.get("HTTP_USER_AGENT", "") if request else "",
|
|
},
|
|
)
|
|
|
|
if created:
|
|
# Generate PDF
|
|
try:
|
|
language = user.language or "en"
|
|
pdf_data = AcknowledgementPDFService.generate_pdf(user, acknowledgement, language)
|
|
|
|
# Create filename
|
|
filename = (
|
|
f"acknowledgement_{user.employee_id or user.id}_{checklist_item.code}_{uuid.uuid4().hex[:8]}.pdf"
|
|
)
|
|
|
|
# Save PDF
|
|
acknowledgement.pdf_file.save(
|
|
filename, SimpleUploadedFile(filename, pdf_data, content_type="application/pdf"), save=True
|
|
)
|
|
except Exception as e:
|
|
print(f"Error generating PDF for acknowledgement: {e}")
|
|
|
|
# Log acknowledgement
|
|
UserProvisionalLog.objects.create(
|
|
user=user,
|
|
event_type="step_completed",
|
|
description=f"Acknowledged item: {checklist_item.code}",
|
|
metadata={"checklist_item_id": str(checklist_item.id)},
|
|
)
|
|
|
|
return acknowledgement
|
|
|
|
@staticmethod
|
|
def complete_wizard(user, username, password, signature_data, request=None):
|
|
"""
|
|
Complete wizard and activate user account
|
|
|
|
Args:
|
|
user: User instance
|
|
username: Desired username
|
|
password: Desired password
|
|
signature_data: Final signature data
|
|
request: Optional request object
|
|
|
|
Returns:
|
|
Boolean indicating success
|
|
"""
|
|
from django.contrib.auth import get_user_model
|
|
|
|
User = get_user_model()
|
|
|
|
# Check if username is available
|
|
if User.objects.filter(username=username).exists():
|
|
return False
|
|
|
|
# Check if all required items are acknowledged
|
|
required_items = OnboardingService.get_checklist_items(user).filter(is_required=True)
|
|
acknowledged_items = UserAcknowledgement.objects.filter(
|
|
user=user, checklist_item__in=required_items, is_acknowledged=True
|
|
)
|
|
|
|
if acknowledged_items.count() != required_items.count():
|
|
return False
|
|
|
|
# Activate user
|
|
user.is_provisional = False
|
|
user.acknowledgement_completed = True
|
|
user.acknowledgement_completed_at = timezone.now()
|
|
user.username = username
|
|
user.set_password(password)
|
|
user.invitation_token = None
|
|
user.invitation_expires_at = None
|
|
user.save(
|
|
update_fields=[
|
|
"is_provisional",
|
|
"acknowledgement_completed",
|
|
"acknowledgement_completed_at",
|
|
"username",
|
|
"invitation_token",
|
|
"invitation_expires_at",
|
|
]
|
|
)
|
|
|
|
# Log activation
|
|
UserProvisionalLog.objects.create(
|
|
user=user,
|
|
event_type="user_activated",
|
|
description="User account activated after completing onboarding",
|
|
ip_address=OnboardingService._get_client_ip(request) if request else None,
|
|
user_agent=request.META.get("HTTP_USER_AGENT", "") if request else "",
|
|
)
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def _get_client_ip(request):
|
|
"""Get client IP address from request"""
|
|
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
if x_forwarded_for:
|
|
ip = x_forwarded_for.split(",")[0]
|
|
else:
|
|
ip = request.META.get("REMOTE_ADDR")
|
|
return ip
|
|
|
|
@staticmethod
|
|
def get_pending_onboarding_users():
|
|
"""
|
|
Get count of pending onboarding users
|
|
|
|
Returns:
|
|
Integer count
|
|
"""
|
|
from django.contrib.auth import get_user_model
|
|
|
|
User = get_user_model()
|
|
|
|
return User.objects.filter(is_provisional=True, acknowledgement_completed=False).count()
|
|
|
|
|
|
class EmailService:
|
|
"""Service for sending onboarding-related emails"""
|
|
|
|
@staticmethod
|
|
def send_invitation_email(user, request=None):
|
|
"""
|
|
Send invitation email to provisional user
|
|
|
|
Args:
|
|
user: User instance
|
|
request: Optional request object for building URLs
|
|
|
|
Returns:
|
|
Boolean indicating success
|
|
"""
|
|
# Build activation URL
|
|
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
|
activation_url = f"{base_url}/accounts/onboarding/activate/{user.invitation_token}/"
|
|
|
|
# Render email content
|
|
context = {
|
|
"user": user,
|
|
"activation_url": activation_url,
|
|
"expires_at": user.invitation_expires_at,
|
|
}
|
|
|
|
subject = render_to_string("accounts/onboarding/invitation_subject.txt", context).strip()
|
|
message_html = render_to_string("accounts/onboarding/invitation_email.html", context)
|
|
message_text = render_to_string("accounts/onboarding/invitation_email.txt", context)
|
|
|
|
# Send email
|
|
try:
|
|
send_mail(
|
|
subject=subject,
|
|
message=message_text,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=[user.email],
|
|
html_message=message_html,
|
|
fail_silently=False,
|
|
)
|
|
|
|
OnboardingService.logger.info(
|
|
"Invitation email sent | to=%s | subject=%s | activation_url=%s",
|
|
user.email,
|
|
subject,
|
|
activation_url,
|
|
)
|
|
|
|
# Log invitation sent
|
|
UserProvisionalLog.objects.create(
|
|
user=user,
|
|
event_type="invitation_sent",
|
|
description="Invitation email sent",
|
|
metadata={"email": user.email},
|
|
)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error sending invitation email: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def send_reminder_email(user, request=None):
|
|
"""
|
|
Send reminder email to pending user
|
|
|
|
Args:
|
|
user: User instance
|
|
request: Optional request object
|
|
|
|
Returns:
|
|
Boolean indicating success
|
|
"""
|
|
# Build activation URL
|
|
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
|
activation_url = f"{base_url}/accounts/onboarding/activate/{user.invitation_token}/"
|
|
|
|
# Render email content
|
|
context = {
|
|
"user": user,
|
|
"activation_url": activation_url,
|
|
"expires_at": user.invitation_expires_at,
|
|
}
|
|
|
|
subject = render_to_string("accounts/onboarding/reminder_subject.txt", context).strip()
|
|
message_html = render_to_string("accounts/onboarding/reminder_email.html", context)
|
|
message_text = render_to_string("accounts/onboarding/reminder_email.txt", context)
|
|
|
|
# Send email
|
|
try:
|
|
send_mail(
|
|
subject=subject,
|
|
message=message_text,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=[user.email],
|
|
html_message=message_html,
|
|
fail_silently=False,
|
|
)
|
|
|
|
# Log reminder sent
|
|
UserProvisionalLog.objects.create(
|
|
user=user,
|
|
event_type="invitation_resent",
|
|
description="Reminder email sent",
|
|
metadata={"email": user.email},
|
|
)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error sending reminder email: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def send_completion_notification(user, admin_users, request=None):
|
|
"""
|
|
Send notification to admins about user completion
|
|
|
|
Args:
|
|
user: User instance who completed onboarding
|
|
admin_users: QuerySet of admin users to notify
|
|
request: Optional request object
|
|
|
|
Returns:
|
|
Boolean indicating success
|
|
"""
|
|
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
|
user_detail_url = f"{base_url}/accounts/onboarding/provisional/{user.id}/progress/"
|
|
|
|
# Render email content
|
|
context = {
|
|
"user": user,
|
|
"user_detail_url": user_detail_url,
|
|
}
|
|
|
|
subject = render_to_string("accounts/onboarding/completion_subject.txt", context).strip()
|
|
message_html = render_to_string("accounts/onboarding/completion_email.html", context)
|
|
message_text = render_to_string("accounts/onboarding/completion_email.txt", context)
|
|
|
|
# Get admin email list
|
|
admin_emails = [admin.email for admin in admin_users if admin.email]
|
|
|
|
if not admin_emails:
|
|
return False
|
|
|
|
# Send email
|
|
try:
|
|
send_mail(
|
|
subject=subject,
|
|
message=message_text,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=admin_emails,
|
|
html_message=message_html,
|
|
fail_silently=False,
|
|
)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error sending completion notification: {e}")
|
|
return False
|