""" 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')