1275 lines
44 KiB
Python
1275 lines
44 KiB
Python
"""
|
|
Accounts UI views - Handle HTML rendering for onboarding and authentication
|
|
"""
|
|
|
|
from django.shortcuts import redirect, render
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.contrib.auth import authenticate, login, logout, update_session_auth_hash
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
|
|
from django.contrib.auth.views import PasswordResetConfirmView
|
|
from django.utils import timezone
|
|
from django.views.decorators.http import require_http_methods
|
|
from django.http import JsonResponse
|
|
from django.contrib import messages
|
|
from django.views.decorators.cache import never_cache
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.contrib.auth.tokens import default_token_generator
|
|
|
|
from .models import (
|
|
AcknowledgementContent,
|
|
AcknowledgementChecklistItem,
|
|
UserAcknowledgement,
|
|
)
|
|
from .permissions import IsPXAdmin, CanManageOnboarding, CanViewOnboarding
|
|
from .services import OnboardingService
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
# ==================== Authentication Views ====================
|
|
|
|
|
|
@never_cache
|
|
def login_view(request):
|
|
"""
|
|
Login view for users to authenticate
|
|
"""
|
|
# If user is already authenticated, redirect based on role
|
|
if request.user.is_authenticated:
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
if SourceUser.objects.filter(user=request.user, is_active=True).exists():
|
|
return redirect("/px-sources/dashboard/")
|
|
return redirect("/")
|
|
|
|
if request.method == "POST":
|
|
email = request.POST.get("email", "").strip()
|
|
password = request.POST.get("password", "")
|
|
remember_me = request.POST.get("remember_me")
|
|
|
|
if email and password:
|
|
# Authenticate user
|
|
user = authenticate(request, username=email, password=password)
|
|
|
|
if user is not None:
|
|
# Check if user is active
|
|
if not user.is_active:
|
|
messages.error(request, "This account has been deactivated. Please contact your administrator.")
|
|
return render(request, "accounts/login.html")
|
|
|
|
# Login user
|
|
login(request, user)
|
|
|
|
# Set session expiry based on remember_me
|
|
if not remember_me:
|
|
request.session.set_expiry(0) # Session expires when browser closes
|
|
else:
|
|
request.session.set_expiry(1209600) # 2 weeks in seconds
|
|
|
|
# Check if user is a Source User
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
if SourceUser.objects.filter(user=user, is_active=True).exists():
|
|
return redirect("/px-sources/dashboard/")
|
|
|
|
# Redirect to next URL or dashboard
|
|
next_url = request.GET.get("next", "")
|
|
if next_url:
|
|
return redirect(next_url)
|
|
return redirect("/")
|
|
else:
|
|
messages.error(request, "Invalid email or password. Please try again.")
|
|
else:
|
|
messages.error(request, "Please provide both email and password.")
|
|
|
|
context = {
|
|
"page_title": "Login - PX360",
|
|
}
|
|
return render(request, "accounts/login.html", context)
|
|
|
|
|
|
@login_required
|
|
def logout_view(request):
|
|
"""
|
|
Logout view for users to sign out
|
|
"""
|
|
logout(request)
|
|
messages.success(request, "You have been logged out successfully.")
|
|
return redirect("accounts:login")
|
|
|
|
|
|
@never_cache
|
|
def password_reset_view(request):
|
|
"""
|
|
Password reset view - allows users to request a password reset email
|
|
"""
|
|
if request.user.is_authenticated:
|
|
messages.info(request, "You are already logged in. You can change your password from your profile.")
|
|
return redirect("/")
|
|
|
|
if request.method == "POST":
|
|
form = PasswordResetForm(request.POST)
|
|
if form.is_valid():
|
|
form.save(
|
|
request=request,
|
|
use_https=request.is_secure(),
|
|
email_template_name="accounts/email/password_reset_email.html",
|
|
subject_template_name="accounts/email/password_reset_subject.txt",
|
|
)
|
|
messages.success(
|
|
request, "We've sent you an email with instructions to reset your password. Please check your inbox."
|
|
)
|
|
return redirect("accounts:login")
|
|
else:
|
|
form = PasswordResetForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
"page_title": "Reset Password - PX360",
|
|
}
|
|
return render(request, "accounts/password_reset.html", context)
|
|
|
|
|
|
class CustomPasswordResetConfirmView(PasswordResetConfirmView):
|
|
"""
|
|
Custom password reset confirm view with custom template
|
|
"""
|
|
|
|
template_name = "accounts/password_reset_confirm.html"
|
|
success_url = "/accounts/login/"
|
|
|
|
def form_valid(self, form):
|
|
messages.success(
|
|
self.request, "Your password has been reset successfully. You can now login with your new password."
|
|
)
|
|
return super().form_valid(form)
|
|
|
|
|
|
@login_required
|
|
def change_password_view(request):
|
|
"""
|
|
Change password view for authenticated users
|
|
"""
|
|
if request.method == "POST":
|
|
form = SetPasswordForm(request.user, request.POST)
|
|
if form.is_valid():
|
|
user = form.save()
|
|
update_session_auth_hash(request, user) # Keep user logged in
|
|
messages.success(request, "Your password has been changed successfully.")
|
|
|
|
# Check if user is a Source User
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
if SourceUser.objects.filter(user=user, is_active=True).exists():
|
|
return redirect("/px-sources/dashboard/")
|
|
else:
|
|
return redirect("/")
|
|
else:
|
|
form = SetPasswordForm(request.user)
|
|
|
|
# Determine redirect URL based on user type
|
|
from apps.px_sources.models import SourceUser
|
|
|
|
redirect_url = (
|
|
"/px-sources/dashboard/" if SourceUser.objects.filter(user=request.user, is_active=True).exists() else "/"
|
|
)
|
|
|
|
context = {
|
|
"form": form,
|
|
"page_title": "Change Password - PX360",
|
|
"redirect_url": redirect_url,
|
|
}
|
|
return render(request, "accounts/change_password.html", context)
|
|
|
|
|
|
# ==================== Onboarding Wizard Views ====================
|
|
|
|
|
|
@never_cache
|
|
def onboarding_activate(request, token):
|
|
"""
|
|
Activate provisional user account using invitation token.
|
|
Validates token, logs user in, and redirects to onboarding wizard.
|
|
"""
|
|
# If user is already authenticated and not provisional, redirect to dashboard
|
|
if request.user.is_authenticated and not request.user.is_provisional:
|
|
return redirect("/")
|
|
|
|
# Validate the token
|
|
user = OnboardingService.validate_token(token)
|
|
|
|
if user is None:
|
|
# Invalid or expired token
|
|
messages.error(request, "Invalid or expired activation link. Please contact your administrator for assistance.")
|
|
return render(
|
|
request,
|
|
"accounts/onboarding/activation_error.html",
|
|
{
|
|
"page_title": "Activation Error",
|
|
},
|
|
)
|
|
|
|
# Token is valid - log the user in
|
|
# Use a backend that doesn't require password
|
|
from django.contrib.auth.backends import ModelBackend
|
|
|
|
backend = ModelBackend()
|
|
user.backend = f"{backend.__module__}.{backend.__class__.__name__}"
|
|
login(request, user)
|
|
|
|
# Log the wizard start
|
|
from .models import UserProvisionalLog
|
|
|
|
UserProvisionalLog.objects.create(
|
|
user=user,
|
|
event_type="wizard_started",
|
|
description="User started onboarding wizard via activation link",
|
|
metadata={"token": token[:10] + "..."}, # Only log partial token for security
|
|
)
|
|
|
|
# Store token in session for the wizard flow
|
|
request.session["onboarding_token"] = token
|
|
|
|
# Redirect to welcome page
|
|
messages.success(request, f"Welcome {user.first_name}! Please complete your onboarding.")
|
|
return redirect("accounts:onboarding-welcome")
|
|
|
|
|
|
@never_cache
|
|
def onboarding_welcome(request, token=None):
|
|
"""
|
|
Welcome page for onboarding wizard
|
|
"""
|
|
# If user is already authenticated and not provisional, redirect to dashboard
|
|
if request.user.is_authenticated and not request.user.is_provisional:
|
|
return redirect("/")
|
|
|
|
# Check if user is authenticated and provisional
|
|
if not request.user.is_authenticated:
|
|
# Not logged in - redirect to login
|
|
messages.warning(request, "Please use your activation link to access the onboarding wizard.")
|
|
return redirect("accounts:login")
|
|
|
|
if not request.user.is_provisional:
|
|
# User is already activated
|
|
messages.info(request, "Your account is already activated.")
|
|
return redirect("/")
|
|
|
|
context = {
|
|
"page_title": "Welcome to PX360",
|
|
"user": request.user,
|
|
}
|
|
return render(request, "accounts/onboarding/welcome.html", context)
|
|
|
|
|
|
@login_required
|
|
def onboarding_step_content(request, step):
|
|
"""
|
|
Display content step of the onboarding wizard
|
|
"""
|
|
user = request.user
|
|
|
|
# Check if user is provisional
|
|
if not user.is_provisional:
|
|
return redirect("/")
|
|
|
|
# Get content for user's role
|
|
content_list = get_wizard_content_for_user(user)
|
|
|
|
# Get current step content
|
|
try:
|
|
current_content = content_list[step - 1]
|
|
except IndexError:
|
|
# Step doesn't exist, go to checklist
|
|
return redirect("/accounts/onboarding/wizard/checklist/")
|
|
|
|
# Get completed steps
|
|
completed_steps = user.wizard_completed_steps or []
|
|
|
|
# Calculate progress
|
|
progress_percentage = int((len(completed_steps) / len(content_list)) * 100)
|
|
|
|
# Get previous and next steps
|
|
previous_step = step - 1 if step > 1 else None
|
|
next_step = step + 1 if step < len(content_list) else None
|
|
|
|
context = {
|
|
"page_title": f"Onboarding - Step {step}",
|
|
"step": step,
|
|
"content": content_list,
|
|
"current_content": current_content,
|
|
"completed_steps": completed_steps,
|
|
"progress_percentage": progress_percentage,
|
|
"previous_step": previous_step,
|
|
"next_step": next_step,
|
|
"user": user,
|
|
}
|
|
return render(request, "accounts/onboarding/step_content.html", context)
|
|
|
|
|
|
@login_required
|
|
def onboarding_step_checklist(request):
|
|
"""
|
|
Display checklist step of the onboarding wizard
|
|
"""
|
|
user = request.user
|
|
|
|
# Check if user is provisional
|
|
if not user.is_provisional:
|
|
return redirect("/dashboard/")
|
|
|
|
# Get checklist items for user's role
|
|
checklist_items = get_checklist_items_for_user(user)
|
|
|
|
# Get acknowledged items
|
|
acknowledged_ids = UserAcknowledgement.objects.filter(user=user, is_acknowledged=True).values_list(
|
|
"checklist_item_id", flat=True
|
|
)
|
|
|
|
# Add acknowledgement status to items
|
|
for item in checklist_items:
|
|
item.is_acknowledged = item.id in acknowledged_ids
|
|
|
|
# Get required items IDs
|
|
required_items = [str(item.id) for item in checklist_items if item.is_required]
|
|
|
|
# Calculate progress
|
|
total_count = len(checklist_items)
|
|
acknowledged_count = len([i for i in checklist_items if i.is_acknowledged])
|
|
progress_percentage = int((acknowledged_count / total_count) * 100) if total_count > 0 else 0
|
|
|
|
context = {
|
|
"page_title": "Acknowledgement Checklist",
|
|
"checklist_items": checklist_items,
|
|
"acknowledged_count": acknowledged_count,
|
|
"total_count": total_count,
|
|
"progress_percentage": progress_percentage,
|
|
"required_items_json": required_items,
|
|
"user": user,
|
|
}
|
|
return render(request, "accounts/onboarding/step_checklist.html", context)
|
|
|
|
|
|
@login_required
|
|
def onboarding_step_activation(request):
|
|
"""
|
|
Display account activation step
|
|
"""
|
|
user = request.user
|
|
|
|
# Check if user is provisional
|
|
if not user.is_provisional:
|
|
return redirect("/dashboard/")
|
|
|
|
# Check if all required acknowledgements are completed
|
|
required_items = get_checklist_items_for_user(user).filter(is_required=True)
|
|
acknowledged_items = UserAcknowledgement.objects.filter(
|
|
user=user, checklist_item__in=required_items, is_acknowledged=True
|
|
)
|
|
|
|
if required_items.count() != acknowledged_items.count():
|
|
messages.warning(request, "Please complete all required acknowledgements first.")
|
|
return redirect("/accounts/onboarding/wizard/checklist/")
|
|
|
|
context = {
|
|
"page_title": "Account Activation",
|
|
"user": user,
|
|
}
|
|
return render(request, "accounts/onboarding/step_activation.html", context)
|
|
|
|
|
|
@login_required
|
|
def onboarding_complete(request):
|
|
"""
|
|
Display completion page
|
|
"""
|
|
user = request.user
|
|
|
|
# Check if user is not provisional (i.e., completed onboarding)
|
|
if user.is_provisional:
|
|
return redirect("/accounts/onboarding/wizard/step/1/")
|
|
|
|
context = {
|
|
"page_title": "Onboarding Complete",
|
|
"user": user,
|
|
}
|
|
return render(request, "accounts/onboarding/complete.html", context)
|
|
|
|
|
|
# ==================== Provisional User Management Views ====================
|
|
|
|
|
|
@login_required
|
|
def preview_wizard_as_role(request, role=None):
|
|
"""
|
|
Preview the onboarding wizard as a specific role.
|
|
Allows admins to see exactly what users with different roles will see.
|
|
"""
|
|
if not request.user.is_px_admin():
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
from .models import Role
|
|
|
|
# Get all available roles
|
|
available_roles = Role.objects.all()
|
|
|
|
preview_content = []
|
|
preview_checklist = []
|
|
selected_role = None
|
|
|
|
if role:
|
|
try:
|
|
selected_role = Role.objects.get(name=role)
|
|
role_code = role.lower().replace(" ", "_")
|
|
|
|
# Get content for this role
|
|
preview_content = (
|
|
AcknowledgementContent.objects.filter(is_active=True)
|
|
.filter(db_models.Q(role=role_code) | db_models.Q(role__isnull=True))
|
|
.order_by("order")
|
|
)
|
|
|
|
# Get checklist for this role
|
|
preview_checklist = (
|
|
AcknowledgementChecklistItem.objects.filter(is_active=True)
|
|
.filter(db_models.Q(role=role_code) | db_models.Q(role__isnull=True))
|
|
.order_by("order")
|
|
)
|
|
|
|
except Role.DoesNotExist:
|
|
messages.error(request, f'Role "{role}" not found.')
|
|
return redirect("accounts:preview-wizard")
|
|
|
|
context = {
|
|
"page_title": "Wizard Preview",
|
|
"available_roles": available_roles,
|
|
"selected_role": selected_role,
|
|
"preview_content": preview_content,
|
|
"preview_checklist": preview_checklist,
|
|
"content_count": len(preview_content),
|
|
"checklist_count": len(preview_checklist),
|
|
}
|
|
|
|
return render(request, "accounts/onboarding/preview_wizard.html", context)
|
|
|
|
|
|
@login_required
|
|
def acknowledgement_dashboard(request):
|
|
"""
|
|
Acknowledgement reporting dashboard for admins.
|
|
Shows completion statistics, pending users, and analytics.
|
|
"""
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
from django.db.models import Count, Avg, Case, When, IntegerField, F
|
|
from apps.organizations.models import Hospital
|
|
|
|
# Base queryset - filter by hospital for hospital admins
|
|
base_queryset = User.objects.all()
|
|
if request.user.is_hospital_admin() and not request.user.is_px_admin():
|
|
base_queryset = base_queryset.filter(hospital=request.user.hospital)
|
|
|
|
# ===== Overall Statistics =====
|
|
total_users = base_queryset.filter(is_provisional=False).count()
|
|
provisional_users = base_queryset.filter(is_provisional=True)
|
|
|
|
stats = {
|
|
"total_provisional": provisional_users.count(),
|
|
"completed_onboarding": base_queryset.filter(is_provisional=False, acknowledgement_completed=True).count(),
|
|
"in_progress": provisional_users.filter(acknowledgement_completed=False).count(),
|
|
"expired_invitations": provisional_users.filter(invitation_expires_at__lt=timezone.now()).count(),
|
|
}
|
|
|
|
# Calculate completion rate
|
|
total_onboarded = stats["completed_onboarding"] + stats["in_progress"]
|
|
stats["completion_rate"] = (
|
|
round((stats["completed_onboarding"] / total_onboarded) * 100, 1) if total_onboarded > 0 else 0
|
|
)
|
|
|
|
# ===== Recent Activity =====
|
|
recent_activations = (
|
|
base_queryset.filter(acknowledgement_completed=True, acknowledgement_completed_at__isnull=False)
|
|
.select_related("hospital", "department")
|
|
.order_by("-acknowledgement_completed_at")[:10]
|
|
)
|
|
|
|
# ===== Pending Users List =====
|
|
pending_users = (
|
|
provisional_users.filter(acknowledgement_completed=False)
|
|
.select_related("hospital", "department")
|
|
.order_by("invitation_expires_at")[:20]
|
|
)
|
|
|
|
# Add days remaining for each pending user
|
|
for user in pending_users:
|
|
if user.invitation_expires_at:
|
|
user.days_remaining = (user.invitation_expires_at - timezone.now()).days
|
|
user.is_expiring_soon = user.days_remaining <= 2
|
|
else:
|
|
user.days_remaining = None
|
|
user.is_expiring_soon = False
|
|
|
|
# ===== Completion by Role =====
|
|
completion_by_role = []
|
|
role_names = ["PX Admin", "Hospital Admin", "Department Manager", "Staff", "Physician", "Nurse"]
|
|
|
|
for role_name in role_names:
|
|
role_users = base_queryset.filter(groups__name=role_name)
|
|
total = role_users.count()
|
|
completed = role_users.filter(acknowledgement_completed=True).count()
|
|
|
|
if total > 0:
|
|
completion_by_role.append(
|
|
{
|
|
"role": role_name,
|
|
"total": total,
|
|
"completed": completed,
|
|
"pending": total - completed,
|
|
"rate": round((completed / total) * 100, 1),
|
|
}
|
|
)
|
|
|
|
# Sort by completion rate
|
|
completion_by_role.sort(key=lambda x: x["rate"], reverse=True)
|
|
|
|
# ===== Completion by Hospital (PX Admin only) =====
|
|
completion_by_hospital = []
|
|
if request.user.is_px_admin():
|
|
for hospital in Hospital.objects.filter(status="active"):
|
|
hospital_users = User.objects.filter(hospital=hospital)
|
|
total = hospital_users.count()
|
|
completed = hospital_users.filter(acknowledgement_completed=True).count()
|
|
|
|
if total > 0:
|
|
completion_by_hospital.append(
|
|
{
|
|
"hospital": hospital,
|
|
"total": total,
|
|
"completed": completed,
|
|
"pending": total - completed,
|
|
"rate": round((completed / total) * 100, 1),
|
|
}
|
|
)
|
|
|
|
completion_by_hospital.sort(key=lambda x: x["rate"], reverse=True)
|
|
|
|
# ===== Daily Activity Chart Data (Last 30 days) =====
|
|
from datetime import timedelta
|
|
|
|
daily_data = []
|
|
for i in range(29, -1, -1):
|
|
date = timezone.now().date() - timedelta(days=i)
|
|
count = UserAcknowledgement.objects.filter(acknowledged_at__date=date).count()
|
|
daily_data.append({"date": date.strftime("%Y-%m-%d"), "count": count})
|
|
|
|
# ===== Checklist Item Completion Rates =====
|
|
checklist_stats = []
|
|
checklist_items = AcknowledgementChecklistItem.objects.filter(is_active=True, is_required=True)
|
|
|
|
for item in checklist_items:
|
|
total_ack = UserAcknowledgement.objects.filter(checklist_item=item).count()
|
|
# Estimate total users who should acknowledge this item
|
|
if item.role:
|
|
eligible_users = base_queryset.filter(groups__name__iexact=item.role.replace("_", " ")).count()
|
|
else:
|
|
eligible_users = base_queryset.count()
|
|
|
|
if eligible_users > 0:
|
|
checklist_stats.append(
|
|
{
|
|
"item": item,
|
|
"acknowledged_count": total_ack,
|
|
"eligible_count": eligible_users,
|
|
"completion_rate": round((total_ack / eligible_users) * 100, 1),
|
|
}
|
|
)
|
|
|
|
# Sort by completion rate (lowest first to highlight items needing attention)
|
|
checklist_stats.sort(key=lambda x: x["completion_rate"])
|
|
|
|
context = {
|
|
"page_title": "Acknowledgement Dashboard",
|
|
"stats": stats,
|
|
"recent_activations": recent_activations,
|
|
"pending_users": pending_users,
|
|
"completion_by_role": completion_by_role,
|
|
"completion_by_hospital": completion_by_hospital,
|
|
"daily_data": daily_data,
|
|
"checklist_stats": checklist_stats[:10], # Top 10 items needing attention
|
|
"is_px_admin": request.user.is_px_admin(),
|
|
}
|
|
|
|
return render(request, "accounts/onboarding/dashboard.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def bulk_invite_users(request):
|
|
"""
|
|
Bulk invite users via CSV upload.
|
|
Expected CSV columns: email, first_name, last_name, role, hospital_id, department_id (optional)
|
|
"""
|
|
if not request.user.is_px_admin():
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
import csv
|
|
import io
|
|
from .services import OnboardingService, EmailService
|
|
from apps.organizations.models import Hospital, Department
|
|
from .models import Role
|
|
|
|
results = {"success": [], "errors": [], "total": 0}
|
|
|
|
if request.method == "POST":
|
|
csv_file = request.FILES.get("csv_file")
|
|
|
|
if not csv_file:
|
|
messages.error(request, "Please select a CSV file to upload.")
|
|
return redirect("accounts:bulk-invite-users")
|
|
|
|
if not csv_file.name.endswith(".csv"):
|
|
messages.error(request, "Please upload a valid CSV file.")
|
|
return redirect("accounts:bulk-invite-users")
|
|
|
|
try:
|
|
# Read CSV file
|
|
decoded_file = csv_file.read().decode("utf-8")
|
|
io_string = io.StringIO(decoded_file)
|
|
reader = csv.DictReader(io_string)
|
|
|
|
required_fields = ["email", "first_name", "last_name", "role"]
|
|
|
|
# Validate headers
|
|
if reader.fieldnames:
|
|
missing_fields = [f for f in required_fields if f not in reader.fieldnames]
|
|
if missing_fields:
|
|
messages.error(request, f"Missing required columns: {', '.join(missing_fields)}")
|
|
return redirect("accounts:bulk-invite-users")
|
|
|
|
for row in reader:
|
|
results["total"] += 1
|
|
|
|
try:
|
|
# Validate required fields
|
|
email = row.get("email", "").strip()
|
|
first_name = row.get("first_name", "").strip()
|
|
last_name = row.get("last_name", "").strip()
|
|
role_name = row.get("role", "").strip()
|
|
|
|
if not all([email, first_name, last_name, role_name]):
|
|
results["errors"].append(
|
|
{"row": results["total"], "email": email or "N/A", "error": "Missing required fields"}
|
|
)
|
|
continue
|
|
|
|
# Check if user already exists
|
|
if User.objects.filter(email=email).exists():
|
|
results["errors"].append(
|
|
{"row": results["total"], "email": email, "error": "User with this email already exists"}
|
|
)
|
|
continue
|
|
|
|
# Get role
|
|
try:
|
|
role = Role.objects.get(name=role_name)
|
|
except Role.DoesNotExist:
|
|
results["errors"].append(
|
|
{"row": results["total"], "email": email, "error": f'Role "{role_name}" does not exist'}
|
|
)
|
|
continue
|
|
|
|
# Get hospital
|
|
hospital_id = row.get("hospital_id", "").strip()
|
|
hospital = None
|
|
if hospital_id:
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
results["errors"].append(
|
|
{
|
|
"row": results["total"],
|
|
"email": email,
|
|
"error": f'Hospital with ID "{hospital_id}" not found',
|
|
}
|
|
)
|
|
continue
|
|
|
|
# Get department
|
|
department_id = row.get("department_id", "").strip()
|
|
department = None
|
|
if department_id:
|
|
try:
|
|
department = Department.objects.get(id=department_id)
|
|
except Department.DoesNotExist:
|
|
results["errors"].append(
|
|
{
|
|
"row": results["total"],
|
|
"email": email,
|
|
"error": f'Department with ID "{department_id}" not found',
|
|
}
|
|
)
|
|
continue
|
|
|
|
# Create provisional user
|
|
user_data = {
|
|
"email": email,
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"hospital": hospital,
|
|
"department": department,
|
|
}
|
|
|
|
user = OnboardingService.create_provisional_user(user_data)
|
|
|
|
# Assign role
|
|
user.groups.add(role.group)
|
|
|
|
# Send invitation email
|
|
EmailService.send_invitation_email(user, request)
|
|
|
|
results["success"].append({"email": email, "name": f"{first_name} {last_name}"})
|
|
|
|
except Exception as e:
|
|
results["errors"].append(
|
|
{"row": results["total"], "email": email if "email" in locals() else "N/A", "error": str(e)}
|
|
)
|
|
|
|
# Show results
|
|
if results["success"]:
|
|
messages.success(request, f"Successfully invited {len(results['success'])} users.")
|
|
|
|
if results["errors"]:
|
|
messages.warning(request, f"Failed to invite {len(results['errors'])} users. See details below.")
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error processing CSV file: {str(e)}")
|
|
|
|
# Get data for template
|
|
roles = Role.objects.all()
|
|
hospitals = Hospital.objects.filter(status="active").order_by("name")
|
|
|
|
context = {
|
|
"page_title": "Bulk Invite Users",
|
|
"results": results,
|
|
"roles": roles,
|
|
"hospitals": hospitals,
|
|
}
|
|
|
|
return render(request, "accounts/onboarding/bulk_invite.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def bulk_resend_invitations(request):
|
|
"""
|
|
Bulk resend invitation emails to selected provisional users.
|
|
"""
|
|
if not request.user.is_px_admin():
|
|
messages.error(request, "You do not have permission to perform this action.")
|
|
return redirect("/dashboard/")
|
|
|
|
from .services import OnboardingService, EmailService
|
|
|
|
user_ids = request.POST.getlist("user_ids")
|
|
|
|
if not user_ids:
|
|
messages.warning(request, "No users selected.")
|
|
return redirect("accounts:provisional-user-list")
|
|
|
|
success_count = 0
|
|
error_count = 0
|
|
|
|
for user_id in user_ids:
|
|
try:
|
|
user = User.objects.get(id=user_id, is_provisional=True)
|
|
|
|
# Generate new token if expired or missing
|
|
if not user.invitation_token or (
|
|
user.invitation_expires_at and user.invitation_expires_at < timezone.now()
|
|
):
|
|
user.invitation_token = OnboardingService.generate_token()
|
|
user.invitation_expires_at = timezone.now() + timedelta(days=7)
|
|
user.save(update_fields=["invitation_token", "invitation_expires_at"])
|
|
|
|
# Resend invitation email
|
|
EmailService.send_invitation_email(user, request)
|
|
|
|
# Log the resend
|
|
UserProvisionalLog.objects.create(
|
|
user=user,
|
|
event_type="invitation_resent",
|
|
description="Invitation email resent via bulk operation",
|
|
metadata={"resent_by": str(request.user.id)},
|
|
)
|
|
|
|
success_count += 1
|
|
|
|
except User.DoesNotExist:
|
|
error_count += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to resend invitation to user {user_id}: {str(e)}")
|
|
error_count += 1
|
|
|
|
if success_count > 0:
|
|
messages.success(request, f"Successfully resent invitations to {success_count} users.")
|
|
|
|
if error_count > 0:
|
|
messages.warning(request, f"Failed to resend invitations to {error_count} users.")
|
|
|
|
return redirect("accounts:provisional-user-list")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def bulk_deactivate_users(request):
|
|
"""
|
|
Bulk deactivate (delete) selected provisional users.
|
|
"""
|
|
if not request.user.is_px_admin():
|
|
messages.error(request, "You do not have permission to perform this action.")
|
|
return redirect("/dashboard/")
|
|
|
|
user_ids = request.POST.getlist("user_ids")
|
|
|
|
if not user_ids:
|
|
messages.warning(request, "No users selected.")
|
|
return redirect("accounts:provisional-user-list")
|
|
|
|
# Require confirmation for bulk deletion
|
|
confirm = request.POST.get("confirm_deletion")
|
|
if confirm != "yes":
|
|
messages.warning(request, "Please confirm the deletion by checking the confirmation box.")
|
|
return redirect("accounts:provisional-user-list")
|
|
|
|
success_count = 0
|
|
error_count = 0
|
|
|
|
for user_id in user_ids:
|
|
try:
|
|
user = User.objects.get(id=user_id, is_provisional=True)
|
|
email = user.email # Store for logging
|
|
|
|
# Delete the user
|
|
user.delete()
|
|
|
|
success_count += 1
|
|
logger.info(f"User {email} (ID: {user_id}) deleted by {request.user.email}")
|
|
|
|
except User.DoesNotExist:
|
|
error_count += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete user {user_id}: {str(e)}")
|
|
error_count += 1
|
|
|
|
if success_count > 0:
|
|
messages.success(request, f"Successfully deactivated {success_count} users.")
|
|
|
|
if error_count > 0:
|
|
messages.warning(request, f"Failed to deactivate {error_count} users.")
|
|
|
|
return redirect("accounts:provisional-user-list")
|
|
|
|
|
|
@login_required
|
|
def export_acknowledgements(request):
|
|
"""
|
|
Export user acknowledgements to CSV for compliance/audit purposes.
|
|
"""
|
|
import csv
|
|
from django.http import HttpResponse
|
|
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
# Get filter parameters
|
|
format_type = request.GET.get("format", "csv")
|
|
status_filter = request.GET.get("status", "all")
|
|
|
|
# Build queryset
|
|
acknowledgements = UserAcknowledgement.objects.select_related("user", "checklist_item").order_by("-acknowledged_at")
|
|
|
|
# Filter by hospital for hospital admins
|
|
if request.user.is_hospital_admin() and not request.user.is_px_admin():
|
|
acknowledgements = acknowledgements.filter(user__hospital=request.user.hospital)
|
|
|
|
# Apply status filter if provided
|
|
if status_filter == "completed":
|
|
acknowledgements = acknowledgements.filter(is_acknowledged=True)
|
|
elif status_filter == "pending":
|
|
# Get users who haven't acknowledged
|
|
pass
|
|
|
|
# Create response
|
|
response = HttpResponse(content_type="text/csv")
|
|
response["Content-Disposition"] = (
|
|
f'attachment; filename="acknowledgements_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
|
|
)
|
|
|
|
writer = csv.writer(response)
|
|
|
|
# Write header
|
|
writer.writerow(
|
|
[
|
|
"User ID",
|
|
"Email",
|
|
"First Name",
|
|
"Last Name",
|
|
"Employee ID",
|
|
"Hospital",
|
|
"Department",
|
|
"Role",
|
|
"Checklist Item Code",
|
|
"Checklist Item Text",
|
|
"Is Acknowledged",
|
|
"Acknowledged At",
|
|
"Signature IP",
|
|
"PDF File URL",
|
|
]
|
|
)
|
|
|
|
# Write data
|
|
for ack in acknowledgements:
|
|
user = ack.user
|
|
writer.writerow(
|
|
[
|
|
str(user.id),
|
|
user.email,
|
|
user.first_name,
|
|
user.last_name,
|
|
user.employee_id or "",
|
|
user.hospital.name if user.hospital else "",
|
|
user.department.name if user.department else "",
|
|
user.groups.first().name if user.groups.exists() else "",
|
|
ack.checklist_item.code,
|
|
ack.checklist_item.text_en,
|
|
"Yes" if ack.is_acknowledged else "No",
|
|
ack.acknowledged_at.strftime("%Y-%m-%d %H:%M:%S") if ack.acknowledged_at else "",
|
|
ack.signature_ip or "",
|
|
request.build_absolute_uri(ack.pdf_file.url) if ack.pdf_file else "",
|
|
]
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@login_required
|
|
def export_provisional_users(request):
|
|
"""
|
|
Export provisional users to CSV.
|
|
"""
|
|
import csv
|
|
from django.http import HttpResponse
|
|
|
|
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
# Build queryset
|
|
users = User.objects.filter(is_provisional=True).select_related("hospital", "department")
|
|
|
|
# Filter by hospital for hospital admins
|
|
if request.user.is_hospital_admin() and not request.user.is_px_admin():
|
|
users = users.filter(hospital=request.user.hospital)
|
|
|
|
# Create response
|
|
response = HttpResponse(content_type="text/csv")
|
|
response["Content-Disposition"] = (
|
|
f'attachment; filename="provisional_users_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
|
|
)
|
|
|
|
writer = csv.writer(response)
|
|
|
|
# Write header
|
|
writer.writerow(
|
|
[
|
|
"User ID",
|
|
"Email",
|
|
"First Name",
|
|
"Last Name",
|
|
"Employee ID",
|
|
"Hospital",
|
|
"Department",
|
|
"Role",
|
|
"Invitation Expires At",
|
|
"Days Remaining",
|
|
"Status",
|
|
]
|
|
)
|
|
|
|
# Write data
|
|
for user in users:
|
|
days_remaining = None
|
|
if user.invitation_expires_at:
|
|
days_remaining = (user.invitation_expires_at - timezone.now()).days
|
|
|
|
status = "Active"
|
|
if user.invitation_expires_at and user.invitation_expires_at < timezone.now():
|
|
status = "Expired"
|
|
elif user.acknowledgement_completed:
|
|
status = "Completed"
|
|
|
|
writer.writerow(
|
|
[
|
|
str(user.id),
|
|
user.email,
|
|
user.first_name,
|
|
user.last_name,
|
|
user.employee_id or "",
|
|
user.hospital.name if user.hospital else "",
|
|
user.department.name if user.department else "",
|
|
user.groups.first().name if user.groups.exists() else "",
|
|
user.invitation_expires_at.strftime("%Y-%m-%d %H:%M:%S") if user.invitation_expires_at else "",
|
|
days_remaining if days_remaining is not None else "",
|
|
status,
|
|
]
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def provisional_user_list(request):
|
|
"""
|
|
List and manage provisional users (PX Admin only)
|
|
"""
|
|
if not request.user.is_px_admin():
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
if request.method == "POST":
|
|
# Handle create provisional user
|
|
from .serializers import ProvisionalUserSerializer
|
|
from .services import OnboardingService, EmailService
|
|
|
|
serializer = ProvisionalUserSerializer(data=request.POST)
|
|
if serializer.is_valid():
|
|
user_data = serializer.validated_data.copy()
|
|
roles = request.POST.getlist("roles", [])
|
|
|
|
# Remove roles from user_data (not a User model field)
|
|
user_data.pop("roles", None)
|
|
|
|
# Create provisional user
|
|
user = OnboardingService.create_provisional_user(user_data)
|
|
|
|
# Assign roles
|
|
for role_name in roles:
|
|
try:
|
|
from .models import Role
|
|
|
|
role = Role.objects.get(name=role_name)
|
|
user.groups.add(role.group)
|
|
except Role.DoesNotExist:
|
|
pass
|
|
|
|
# Send invitation email
|
|
EmailService.send_invitation_email(user, request)
|
|
|
|
messages.success(request, f"Provisional user {user.email} created successfully.")
|
|
return redirect("accounts:provisional-user-list")
|
|
else:
|
|
messages.error(request, "Failed to create provisional user. Please check the form.")
|
|
|
|
# Get all provisional users
|
|
provisional_users = (
|
|
User.objects.filter(is_provisional=True).select_related("hospital", "department").order_by("-created_at")
|
|
)
|
|
|
|
# Calculate statistics
|
|
total_count = provisional_users.count()
|
|
completed_count = provisional_users.filter(acknowledgement_completed_at__isnull=False).count()
|
|
in_progress_count = total_count - completed_count
|
|
|
|
# Get available roles
|
|
from .models import Role
|
|
|
|
roles = Role.objects.all()
|
|
|
|
# Get hospitals and departments for the form
|
|
from apps.organizations.models import Hospital, Department
|
|
|
|
hospitals = Hospital.objects.filter(status="active").order_by("name")
|
|
departments = Department.objects.filter(status="active").order_by("name")
|
|
|
|
context = {
|
|
"page_title": "Provisional Users",
|
|
"provisional_accounts": provisional_users,
|
|
"roles": roles,
|
|
"hospitals": hospitals,
|
|
"departments": departments,
|
|
"total_count": total_count,
|
|
"completed_count": completed_count,
|
|
"in_progress_count": in_progress_count,
|
|
"now": timezone.now(),
|
|
}
|
|
return render(request, "accounts/onboarding/provisional_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def provisional_user_progress(request, user_id):
|
|
"""
|
|
View onboarding progress for a specific user
|
|
"""
|
|
if not (request.user.is_px_admin() or request.user.id == user_id):
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
user = User.objects.get(id=user_id)
|
|
|
|
# Get checklist items
|
|
checklist_items = get_checklist_items_for_user(user)
|
|
|
|
# Get acknowledged items
|
|
acknowledged_items = UserAcknowledgement.objects.filter(user=user, is_acknowledged=True).select_related(
|
|
"checklist_item"
|
|
)
|
|
|
|
# Create a lookup dict: checklist_item_id -> acknowledged_at timestamp
|
|
acknowledged_timestamps = {}
|
|
for ack in acknowledged_items:
|
|
if ack.checklist_item_id:
|
|
acknowledged_timestamps[ack.checklist_item_id] = ack.acknowledged_at
|
|
|
|
# Get logs
|
|
from .models import UserProvisionalLog
|
|
|
|
logs = UserProvisionalLog.objects.filter(user=user).order_by("-created_at")
|
|
|
|
# Calculate progress
|
|
total_items = checklist_items.filter(is_required=True).count()
|
|
acknowledged_count = acknowledged_items.filter(checklist_item__is_required=True).count()
|
|
progress_percentage = int((acknowledged_count / total_items) * 100) if total_items > 0 else 0
|
|
remaining_count = total_items - acknowledged_count
|
|
|
|
# Attach acknowledged_at timestamp to each checklist item
|
|
checklist_items_with_timestamps = []
|
|
for item in checklist_items:
|
|
item_dict = {
|
|
"item": item,
|
|
"acknowledged_at": acknowledged_timestamps.get(item.id),
|
|
}
|
|
checklist_items_with_timestamps.append(item_dict)
|
|
|
|
context = {
|
|
"page_title": f"Onboarding Progress - {user.email}",
|
|
"user": user,
|
|
"checklist_items": checklist_items_with_timestamps,
|
|
"logs": logs,
|
|
"total_items": total_items,
|
|
"acknowledged_count": acknowledged_count,
|
|
"remaining_count": remaining_count,
|
|
"progress_percentage": progress_percentage,
|
|
}
|
|
return render(request, "accounts/onboarding/progress_detail.html", context)
|
|
|
|
|
|
# ==================== Acknowledgement Management Views ====================
|
|
|
|
|
|
@login_required
|
|
def acknowledgement_content_list(request):
|
|
"""
|
|
List acknowledgement content (PX Admin only)
|
|
"""
|
|
if not request.user.is_px_admin():
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
# Get all content
|
|
content_list = AcknowledgementContent.objects.all().order_by("role", "order")
|
|
|
|
context = {
|
|
"page_title": "Acknowledgement Content",
|
|
"content_list": content_list,
|
|
}
|
|
return render(request, "accounts/onboarding/content_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def acknowledgement_checklist_list(request):
|
|
"""
|
|
List acknowledgement checklist items (PX Admin only)
|
|
"""
|
|
if not request.user.is_px_admin():
|
|
messages.error(request, "You do not have permission to view this page.")
|
|
return redirect("/dashboard/")
|
|
|
|
# Get all checklist items
|
|
checklist_items = AcknowledgementChecklistItem.objects.select_related("content").order_by("role", "order")
|
|
|
|
# Get all content for the modal dropdown
|
|
content_list = AcknowledgementContent.objects.filter(is_active=True).order_by("role", "order")
|
|
|
|
context = {
|
|
"page_title": "Acknowledgement Checklist Items",
|
|
"checklist_items": checklist_items,
|
|
"content_list": content_list,
|
|
}
|
|
return render(request, "accounts/onboarding/checklist_list.html", context)
|
|
|
|
|
|
# ==================== Helper Functions ====================
|
|
|
|
|
|
def get_wizard_content_for_user(user):
|
|
"""
|
|
Get wizard content based on user's role
|
|
"""
|
|
from .models import Role
|
|
|
|
# Get user's role
|
|
user_role = None
|
|
if user.groups.filter(name="PX Admin").exists():
|
|
user_role = "px_admin"
|
|
elif user.groups.filter(name="Hospital Admin").exists():
|
|
user_role = "hospital_admin"
|
|
elif user.groups.filter(name="Department Manager").exists():
|
|
user_role = "department_manager"
|
|
elif user.groups.filter(name="Staff").exists():
|
|
user_role = "staff"
|
|
elif user.groups.filter(name="Physician").exists():
|
|
user_role = "physician"
|
|
|
|
# Get content for role or general content
|
|
if user_role:
|
|
content = AcknowledgementContent.objects.filter(role__in=[user_role, "all"])
|
|
else:
|
|
content = AcknowledgementContent.objects.filter(role="all")
|
|
|
|
return content.filter(is_active=True).order_by("order")
|
|
|
|
|
|
def get_checklist_items_for_user(user):
|
|
"""
|
|
Get checklist items based on user's role
|
|
"""
|
|
from .models import Role
|
|
|
|
# Get user's role
|
|
user_role = None
|
|
if user.groups.filter(name="PX Admin").exists():
|
|
user_role = "px_admin"
|
|
elif user.groups.filter(name="Hospital Admin").exists():
|
|
user_role = "hospital_admin"
|
|
elif user.groups.filter(name="Department Manager").exists():
|
|
user_role = "department_manager"
|
|
elif user.groups.filter(name="Staff").exists():
|
|
user_role = "staff"
|
|
elif user.groups.filter(name="Physician").exists():
|
|
user_role = "physician"
|
|
|
|
# Get checklist items for role or general items
|
|
if user_role:
|
|
items = AcknowledgementChecklistItem.objects.filter(role__in=[user_role, "all"])
|
|
else:
|
|
items = AcknowledgementChecklistItem.objects.filter(role="all")
|
|
|
|
return items.filter(is_active=True).order_by("order")
|