"""
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_users': provisional_users,
'roles': roles,
'hospitals': hospitals,
'departments': departments,
'total_count': total_count,
'completed_count': completed_count,
'in_progress_count': in_progress_count,
}
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')