""" Complaints UI views - Server-rendered templates for complaints console """ import logging from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Q, Count, Prefetch from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods from apps.accounts.models import User from apps.core.services import AuditService from apps.organizations.models import Department, Hospital, Staff from apps.px_sources.models import SourceUser, PXSource from apps.px_sources.models import SourceUser, PXSource from .models import ( Complaint, ComplaintAttachment, ComplaintCategory, ComplaintExplanation, ComplaintSourceType, ComplaintStatus, ComplaintType, ComplaintUpdate, Inquiry, InquiryAttachment, InquiryUpdate, ComplaintAdverseAction, ComplaintAdverseActionAttachment, ) from .forms import ( ComplaintInvolvedDepartmentForm, ComplaintInvolvedStaffForm, DepartmentResponseForm, StaffExplanationForm, ) # Set up logger logger = logging.getLogger(__name__) def can_manage_complaint(user, complaint): """ Check if user can manage a complaint. Returns True if: - User is PX Admin - User is Hospital Admin for complaint's hospital - User is Department Manager for complaint's department - User is assigned to the complaint - User is assigned to one of the involved departments """ if user.is_px_admin(): return True if user.is_hospital_admin() and user.hospital == complaint.hospital: return True if user.is_department_manager() and user.department == complaint.department: return True if complaint.assigned_to == user: return True # Check if user is assigned to any involved department if hasattr(complaint, 'involved_departments'): for involved in complaint.involved_departments.all(): if involved.assigned_to == user: return True return False @login_required def complaint_list(request): """ Complaints list view with advanced filters and pagination. Features: - Server-side pagination - Advanced filters (status, severity, priority, hospital, department, etc.) - Search by title, description, patient MRN - Bulk actions support - Export capability """ # Base queryset with optimizations queryset = Complaint.objects.select_related( "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "source", "created_by", "domain", "category", "resolution_survey" ) # Apply RBAC filters user = request.user if user.is_px_admin(): pass # See all elif user.is_hospital_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) elif user.is_department_manager() and user.department: queryset = queryset.filter(department=user.department) elif user.hospital: queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() # Apply filters from request status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) # Complaint Type filter (complaint vs appreciation) complaint_type_filter = request.GET.get("complaint_type") if complaint_type_filter: queryset = queryset.filter(complaint_type=complaint_type_filter) # Source Type filter (internal vs external) source_type_filter = request.GET.get("complaint_source_type") if source_type_filter: queryset = queryset.filter(complaint_source_type=source_type_filter) # PX Source filter px_source_filter = request.GET.get("px_source") if px_source_filter: queryset = queryset.filter(source_id=px_source_filter) # Domain filter (taxonomy) domain_filter = request.GET.get("domain") if domain_filter: queryset = queryset.filter(domain_id=domain_filter) # Resolution survey filter has_survey_filter = request.GET.get("has_survey") if has_survey_filter == "yes": queryset = queryset.filter(resolution_survey__isnull=False) elif has_survey_filter == "no": queryset = queryset.filter(resolution_survey__isnull=True) severity_filter = request.GET.get("severity") if severity_filter: queryset = queryset.filter(severity=severity_filter) priority_filter = request.GET.get("priority") if priority_filter: queryset = queryset.filter(priority=priority_filter) category_filter = request.GET.get("category") if category_filter: queryset = queryset.filter(category=category_filter) source_filter = request.GET.get("source") if source_filter: queryset = queryset.filter(source=source_filter) hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) department_filter = request.GET.get("department") if department_filter: queryset = queryset.filter(department_id=department_filter) staff_filter = request.GET.get("staff") if staff_filter: queryset = queryset.filter(staff_id=staff_filter) assigned_to_filter = request.GET.get("assigned_to") if assigned_to_filter: queryset = queryset.filter(assigned_to_id=assigned_to_filter) overdue_filter = request.GET.get("is_overdue") if overdue_filter == "true": queryset = queryset.filter(is_overdue=True) # Search search_query = request.GET.get("search") if search_query: queryset = queryset.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) | Q(reference_number__icontains=search_query) | Q(patient__mrn__icontains=search_query) | Q(patient__first_name__icontains=search_query) | Q(patient__last_name__icontains=search_query) ) # Date range filters (created date) date_from = request.GET.get("date_from") if date_from: queryset = queryset.filter(created_at__gte=date_from) date_to = request.GET.get("date_to") if date_to: queryset = queryset.filter(created_at__lte=date_to) # Incident date range filters incident_from = request.GET.get("incident_from") if incident_from: queryset = queryset.filter(incident_date__gte=incident_from) incident_to = request.GET.get("incident_to") if incident_to: queryset = queryset.filter(incident_date__lte=incident_to) # Ordering order_by = request.GET.get("order_by", "-created_at") queryset = queryset.order_by(order_by) # Pagination page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get filter options hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) departments = Department.objects.filter(status="active") if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) # Get assignable users assignable_users = User.objects.filter(is_active=True) if user.hospital: assignable_users = assignable_users.filter(hospital=user.hospital) # Statistics - more comprehensive total_count = queryset.count() resolved_count = queryset.filter(status=ComplaintStatus.RESOLVED).count() pending_count = queryset.filter(status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]).count() base_stats = { "total": total_count, "open": queryset.filter(status=ComplaintStatus.OPEN).count(), "in_progress": queryset.filter(status=ComplaintStatus.IN_PROGRESS).count(), "resolved": resolved_count, "pending": pending_count, "resolved_percentage": (resolved_count / total_count * 100) if total_count > 0 else 0, "overdue": queryset.filter(is_overdue=True).count(), "complaints": queryset.filter(complaint_type=ComplaintType.COMPLAINT).count(), "appreciations": queryset.filter(complaint_type=ComplaintType.APPRECIATION).count(), "from_px_sources": queryset.filter(source__isnull=False).count(), "internal": queryset.filter(complaint_source_type=ComplaintSourceType.INTERNAL).count(), } # Get filter options from apps.px_sources.models import PXSource from apps.complaints.models import ComplaintCategory px_sources = PXSource.objects.filter(is_active=True) # Get domains for taxonomy filter domains = ComplaintCategory.objects.filter( level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True ) context = { "complaints": page_obj, "stats": base_stats, "hospitals": hospitals, "departments": departments, "assignable_users": assignable_users, "px_sources": px_sources, "domains": domains, "status_choices": ComplaintStatus.choices, "complaint_type_choices": ComplaintType.choices, "source_type_choices": ComplaintSourceType.choices, "filters": request.GET, } return render(request, "complaints/complaint_list.html", context) @login_required def complaint_detail(request, pk): """ Complaint detail view with timeline, attachments, and actions. Features: - Full complaint details - Timeline of all updates - Attachments management - Related surveys and journey - Linked PX actions - Workflow actions (assign, status change, add note) PERFORMANCE OPTIMIZATIONS: - Added missing select_related fields to main query - Annotated counts to avoid N+1 queries in template - Used prefetched data instead of re-querying - Optimized escalation targets query - Prefetched explanations with attachments """ from apps.px_sources.models import SourceUser source_user = SourceUser.objects.filter(user=request.user).first() base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' # OPTIMIZED: Added missing select_related fields and annotated counts complaint_queryset = Complaint.objects.select_related( "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "resolution_survey", "source", "created_by", "domain", "category", # ADD: Missing foreign keys that are accessed in template "subcategory_obj", "classification_obj", "location", "main_section", "subsection" ).prefetch_related( "attachments", "updates__created_by", "involved_departments__department", "involved_departments__assigned_to", "involved_staff__staff__department", # ADD: Prefetch explanations with their attachments Prefetch( "explanations", queryset=ComplaintExplanation.objects.select_related("staff").prefetch_related("attachments").order_by("-created_at") ), # ADD: Prefetch adverse actions with related data Prefetch( "adverse_actions", queryset=ComplaintAdverseAction.objects.select_related('reported_by').prefetch_related('involved_staff') ) ).annotate( # ADD: Annotate counts to avoid N+1 queries in template updates_count=Count("updates", distinct=True), attachments_count=Count("attachments", distinct=True), involved_departments_count=Count("involved_departments", distinct=True), involved_staff_count=Count("involved_staff", distinct=True), explanations_count=Count("explanations", distinct=True), adverse_actions_count=Count("adverse_actions", distinct=True), ) complaint = get_object_or_404(complaint_queryset, pk=pk) # Check access user = request.user if not user.is_px_admin(): if user.is_hospital_admin() and complaint.hospital != user.hospital: messages.error(request, "You don't have permission to view this complaint.") return redirect("complaints:complaint_list") elif user.is_department_manager() and complaint.department != user.department: messages.error(request, "You don't have permission to view this complaint.") return redirect("complaints:complaint_list") elif user.hospital and complaint.hospital != user.hospital: messages.error(request, "You don't have permission to view this complaint.") return redirect("complaints:complaint_list") # OPTIMIZED: Use prefetched data instead of re-querying timeline = sorted(complaint.updates.all(), key=lambda x: x.created_at, reverse=True) attachments = sorted(complaint.attachments.all(), key=lambda x: x.created_at, reverse=True) # Get related PX actions (using ContentType since PXAction uses GenericForeignKey) from django.contrib.contenttypes.models import ContentType from apps.px_action_center.models import PXAction complaint_ct = ContentType.objects.get_for_model(Complaint) px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at") # Get assignable users assignable_users = User.objects.filter(is_active=True) if complaint.hospital: assignable_users = assignable_users.filter(hospital=complaint.hospital) # Get departments for the complaint's hospital hospital_departments = [] if complaint.hospital: hospital_departments = Department.objects.filter(hospital=complaint.hospital, status="active").order_by("name") # Check if overdue (only update if necessary) if complaint.is_active_status and not complaint.is_overdue: complaint.check_overdue() # OPTIMIZED: Use prefetched explanations explanations = complaint.explanations.all() explanation = explanations.first() if explanations else None # OPTIMIZED: Attachments are already prefetched explanation_attachments = explanation.attachments.all() if explanation else [] # OPTIMIZED: Escalation targets - only query managers and direct reports escalation_targets = [] default_escalation_target = None if complaint.hospital: # OPTIMIZED: Only query managers and potential escalation targets # instead of ALL staff in the hospital from django.db.models import Q # Get potential escalation targets: # 1. The staff's direct manager (if exists) # 2. Department managers in the hospital # 3. Hospital admins in the hospital escalation_targets_qs = Staff.objects.filter( hospital=complaint.hospital, status='active', user__isnull=False, user__is_active=True ).filter( # Either is the staff's manager, or is a manager/admin Q(id=complaint.staff.report_to.id if complaint.staff and complaint.staff.report_to else None) | Q(user__groups__name__in=['Hospital Admin', 'Department Manager']) | Q(direct_reports__isnull=False) ).exclude( id=complaint.staff.id if complaint.staff else None ).select_related( 'user', 'department', 'report_to' ).distinct().order_by('first_name', 'last_name') # Build list of escalation targets for staff in escalation_targets_qs: escalation_targets.append({ 'staff': staff, 'has_user': True, # Already filtered for active user 'user_id': str(staff.user.id), 'is_manager': staff.direct_reports.exists(), 'is_line_manager': complaint.staff and complaint.staff.report_to == staff }) # Sort: Line manager first, then other managers, then others escalation_targets.sort(key=lambda x: ( not x['is_line_manager'], not x['is_manager'], x['staff'].get_full_name() )) # Set default to staff's line manager if exists if complaint.staff and complaint.staff.report_to: default_escalation_target = str(complaint.staff.report_to.id) # OPTIMIZED: Use prefetched adverse actions adverse_actions = complaint.adverse_actions.all() context = { 'complaint': complaint, 'timeline': timeline, 'attachments': attachments, 'px_actions': px_actions, 'assignable_users': assignable_users, 'status_choices': ComplaintStatus.choices, 'base_layout': base_layout, 'source_user': source_user, "complaint": complaint, "timeline": timeline, "attachments": attachments, "px_actions": px_actions, "assignable_users": assignable_users, "status_choices": ComplaintStatus.choices, "can_edit": can_manage_complaint(user, complaint), "is_active_status": complaint.is_active_status, "hospital_departments": hospital_departments, "explanation": explanation, "explanations": explanations, "explanation_attachments": explanation_attachments, "escalation_targets": escalation_targets, "default_escalation_target": default_escalation_target, "current_user": user, "adverse_actions": adverse_actions, } return render(request, "complaints/complaint_detail.html", context) @login_required @require_http_methods(["GET", "POST"]) def complaint_create(request): """Create new complaint with AI-powered classification""" from apps.complaints.forms import ComplaintForm # Determine base layout based on user type from apps.px_sources.models import SourceUser source_user = SourceUser.objects.filter(user=request.user).first() base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' if request.method == 'POST': # Handle form submission form = ComplaintForm(request.POST, user=request.user) if not form.is_valid(): # Debug: print form errors print("Form validation errors:", form.errors) messages.error(request, f"Please correct the errors: {form.errors}") context = { 'form': form, 'base_layout': base_layout, 'source_user': source_user, } return render(request, 'complaints/complaint_form.html', context) try: # Create complaint with AI defaults complaint = form.save(commit=False) # Set AI-determined defaults complaint.title = 'Complaint' # AI will generate title # category can be None, AI will determine it complaint.subcategory = '' # AI will determine # Set source from logged-in source user if source_user and source_user.source: complaint.source = source_user.source else: # Fallback: get or create a 'staff' source from apps.px_sources.models import PXSource try: source_obj = PXSource.objects.get(code='staff') except PXSource.DoesNotExist: source_obj = PXSource.objects.create( code='staff', name='Staff', description='Complaints submitted by staff members' ) complaint.source = source_obj complaint.priority = 'medium' # AI will update complaint.severity = 'medium' # AI will update complaint.created_by = request.user # Generate unique reference number: CMP-YYYYMMDD-XXXXX import uuid from datetime import datetime today = datetime.now().strftime("%Y%m%d") random_suffix = str(uuid.uuid4().int)[:6] complaint.reference_number = f"CMP-{today}-{random_suffix}" complaint.save() # Create initial update ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message="Complaint created. AI analysis running in background.", created_by=request.user, ) # Trigger AI analysis in background using Celery from .tasks import analyze_complaint_with_ai analyze_complaint_with_ai.delay(str(complaint.id)) # Notify PX Admins about new complaint # During working hours: ALL admins notified # Outside working hours: Only ON-CALL admins notified from .tasks import notify_admins_new_complaint notify_admins_new_complaint.delay(str(complaint.id)) # Log audit AuditService.log_event( event_type="complaint_created", description=f"Complaint created: {complaint.title}", user=request.user, content_object=complaint, metadata={ 'severity': complaint.severity, "patient_name": complaint.patient_name, "national_id": complaint.national_id, "hospital": complaint.hospital.name if complaint.hospital else None, "ai_analysis_pending": True, }, ) messages.success( request, f"Complaint #{complaint.id} created successfully. AI is analyzing and classifying the complaint.", ) return redirect("complaints:complaint_detail", pk=complaint.id) except ComplaintCategory.DoesNotExist: messages.error(request, "Selected category not found.") return redirect("complaints:complaint_create") # GET request - show form # Check for hospital parameter from URL (for pre-selection) initial_data = {} hospital_id = request.GET.get('hospital') if hospital_id: initial_data['hospital'] = hospital_id form = ComplaintForm(user=request.user, initial=initial_data) context = { 'form': form, 'base_layout': base_layout, 'source_user': source_user, # "hospitals": hospitals, } return render(request, "complaints/complaint_form.html", context) @login_required @require_http_methods(["POST"]) def complaint_assign(request, pk): """Assign complaint to user""" complaint = get_object_or_404(Complaint, pk=pk) # Check if complaint is in active status if not complaint.is_active_status: messages.error(request, f"Cannot assign complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") return redirect("complaints:complaint_detail", pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to assign complaints.") return redirect("complaints:complaint_detail", pk=pk) user_id = request.POST.get("user_id") if not user_id: messages.error(request, "Please select a user to assign.") return redirect("complaints:complaint_detail", pk=pk) try: assignee = User.objects.get(id=user_id) complaint.assigned_to = assignee complaint.assigned_at = timezone.now() complaint.save(update_fields=["assigned_to", "assigned_at"]) # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Assigned to {assignee.get_full_name()}", created_by=request.user, ) # Log audit AuditService.log_event( event_type="assignment", description=f"Complaint assigned to {assignee.get_full_name()}", user=request.user, content_object=complaint, ) messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.") except User.DoesNotExist: messages.error(request, "User not found.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_change_status(request, pk): """Change complaint status""" complaint = get_object_or_404(Complaint, pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to change complaint status.") return redirect("complaints:complaint_detail", pk=pk) new_status = request.POST.get("status") note = request.POST.get("note", "") resolution = request.POST.get("resolution", "") if not new_status: messages.error(request, "Please select a status.") return redirect("complaints:complaint_detail", pk=pk) old_status = complaint.status complaint.status = new_status # Handle status-specific logic if new_status == ComplaintStatus.RESOLVED: complaint.resolved_at = timezone.now() complaint.resolved_by = request.user # Save resolution note if provided if resolution: complaint.resolution = resolution complaint.resolution_sent_at = timezone.now() elif new_status == ComplaintStatus.CLOSED: complaint.closed_at = timezone.now() complaint.closed_by = request.user # Trigger resolution satisfaction survey from apps.complaints.tasks import send_complaint_resolution_survey send_complaint_resolution_survey.delay(str(complaint.id)) complaint.save() # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", message=note or f"Status changed from {old_status} to {new_status}", created_by=request.user, old_status=old_status, new_status=new_status, ) # Log audit AuditService.log_event( event_type="status_change", description=f"Complaint status changed from {old_status} to {new_status}", user=request.user, content_object=complaint, metadata={"old_status": old_status, "new_status": new_status}, ) messages.success(request, f"Complaint status changed to {new_status}.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_add_note(request, pk): """Add note to complaint""" complaint = get_object_or_404(Complaint, pk=pk) # Check if complaint is in active status if not complaint.is_active_status: messages.error(request, f"Cannot add notes to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") return redirect("complaints:complaint_detail", pk=pk) note = request.POST.get("note") if not note: messages.error(request, "Please enter a note.") return redirect("complaints:complaint_detail", pk=pk) # Create update ComplaintUpdate.objects.create(complaint=complaint, update_type="note", message=note, created_by=request.user) messages.success(request, "Note added successfully.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_change_department(request, pk): """Change complaint department""" complaint = get_object_or_404(Complaint, pk=pk) # Check if complaint is in active status if not complaint.is_active_status: messages.error(request, f"Cannot change department for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") return redirect("complaints:complaint_detail", pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to change complaint department.") return redirect("complaints:complaint_detail", pk=pk) department_id = request.POST.get("department_id") if not department_id: messages.error(request, "Please select a department.") return redirect("complaints:complaint_detail", pk=pk) try: department = Department.objects.get(id=department_id) # Check department belongs to same hospital if department.hospital != complaint.hospital: messages.error(request, "Department does not belong to this complaint's hospital.") return redirect("complaints:complaint_detail", pk=pk) old_department = complaint.department complaint.department = department complaint.save(update_fields=["department"]) # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Department changed to {department.name}", created_by=request.user, metadata={ "old_department_id": str(old_department.id) if old_department else None, "new_department_id": str(department.id), }, ) # Log audit AuditService.log_event( event_type="department_change", description=f"Complaint department changed to {department.name}", user=request.user, content_object=complaint, metadata={ "old_department_id": str(old_department.id) if old_department else None, "new_department_id": str(department.id), }, ) messages.success(request, f"Department changed to {department.name}.") except Department.DoesNotExist: messages.error(request, "Department not found.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_escalate(request, pk): """Escalate complaint to selected staff (default is staff's manager)""" complaint = get_object_or_404(Complaint, pk=pk) # Check if complaint is in active status if not complaint.is_active_status: messages.error(request, f"Cannot escalate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") return redirect("complaints:complaint_detail", pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to escalate complaints.") return redirect("complaints:complaint_detail", pk=pk) reason = request.POST.get("reason", "") escalate_to_id = request.POST.get("escalate_to", "") # Get the escalation target staff escalate_to_staff = None escalate_to_user = None if escalate_to_id: try: escalate_to_staff = Staff.objects.get(id=escalate_to_id) if escalate_to_staff.user and escalate_to_staff.user.is_active: escalate_to_user = escalate_to_staff.user except Staff.DoesNotExist: pass # If no staff selected or not found, default to staff's manager if not escalate_to_staff and complaint.staff and complaint.staff.report_to: escalate_to_staff = complaint.staff.report_to if escalate_to_staff.user and escalate_to_staff.user.is_active: escalate_to_user = escalate_to_staff.user # Mark as escalated and assign to selected user complaint.escalated_at = timezone.now() if escalate_to_user: complaint.assigned_to = escalate_to_user complaint.save(update_fields=["escalated_at", "assigned_to"]) # Create update with escalation details escalation_message = f"Complaint escalated. Reason: {reason}" if escalate_to_user: escalation_message += f" Escalated to: {escalate_to_user.get_full_name()}" ComplaintUpdate.objects.create( complaint=complaint, update_type="escalation", message=escalation_message, created_by=request.user, metadata={ "reason": reason, "escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None, "escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None, }, ) # Log audit AuditService.log_event( event_type="escalation", description=f"Complaint escalated to {escalate_to_user.get_full_name() if escalate_to_user else 'manager'}", user=request.user, content_object=complaint, metadata={ "reason": reason, "escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None, "escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None, }, ) # Send notification to the escalated user if escalate_to_user and escalate_to_user.email: from apps.notifications.services import NotificationService try: NotificationService.send_email( email=escalate_to_user.email, subject=f"Complaint Escalated - #{complaint.id}", message=f""" Dear {escalate_to_user.get_full_name()}, A complaint has been escalated to you for attention. COMPLAINT DETAILS: ------------------ Reference: #{complaint.id} Title: {complaint.title} Severity: {complaint.get_severity_display()} Priority: {complaint.get_priority_display()} ESCALATION REASON: ------------------ {reason} Please review and take necessary action. View complaint: https://{request.get_host()}/complaints/{complaint.id}/ --- This is an automated message from PX360 Complaint Management System. """, related_object=complaint, metadata={ 'notification_type': 'complaint_escalated', 'escalated_by': str(request.user.id), 'reason': reason } ) except Exception as e: logger.error(f"Failed to send escalation notification: {e}") messages.success(request, f"Complaint escalated successfully{f' to {escalate_to_user.get_full_name()}' if escalate_to_user else ''}.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_activate(request, pk): """ Activate complaint and assign to current user. This allows a user to take ownership of an unassigned complaint or reassign it to themselves. """ complaint = get_object_or_404(Complaint, pk=pk) # Check if complaint is in active status if not complaint.is_active_status: messages.error(request, f"Cannot activate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") return redirect("complaints:complaint_detail", pk=pk) # Check permission - must be able to edit the complaint user = request.user can_activate = ( user.is_px_admin() or user.is_hospital_admin() or (user.is_department_manager() and complaint.department == user.department) or (complaint.hospital == user.hospital) ) if not can_activate: messages.error(request, "You don't have permission to activate this complaint.") return redirect("complaints:complaint_detail", pk=pk) # Store previous assignee for logging previous_assignee = complaint.assigned_to # Assign to current user complaint.assigned_to = user complaint.assigned_at = timezone.now() complaint.save(update_fields=["assigned_to", "assigned_at"]) # Create update assign_message = f"Complaint activated and assigned to {user.get_full_name()}" if previous_assignee: assign_message += f" (reassigned from {previous_assignee.get_full_name()})" ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=assign_message, created_by=user, metadata={ "assigned_to_user_id": str(user.id), "assigned_to_user_name": user.get_full_name(), "previous_assignee_id": str(previous_assignee.id) if previous_assignee else None, "previous_assignee_name": previous_assignee.get_full_name() if previous_assignee else None, "activation": True, }, ) # Log audit AuditService.log_event( event_type="activation", description=f"Complaint activated and assigned to {user.get_full_name()}", user=user, content_object=complaint, metadata={ "assigned_to_user_id": str(user.id), "assigned_to_user_name": user.get_full_name(), "previous_assignee_id": str(previous_assignee.id) if previous_assignee else None, "previous_assignee_name": previous_assignee.get_full_name() if previous_assignee else None, }, ) messages.success(request, f"Complaint activated and assigned to you successfully.") return redirect("complaints:complaint_detail", pk=pk) @login_required def complaint_export_csv(request): """Export complaints to CSV""" from apps.complaints.utils import export_complaints_csv # Get filtered queryset (reuse list view logic) queryset = Complaint.objects.select_related( "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by" ) # Apply RBAC filters user = request.user if user.is_px_admin(): pass elif user.is_hospital_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) elif user.is_department_manager() and user.department: queryset = queryset.filter(department=user.department) elif user.hospital: queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() # Apply filters from request status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) severity_filter = request.GET.get("severity") if severity_filter: queryset = queryset.filter(severity=severity_filter) priority_filter = request.GET.get("priority") if priority_filter: queryset = queryset.filter(priority=priority_filter) hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) department_filter = request.GET.get("department") if department_filter: queryset = queryset.filter(department_id=department_filter) overdue_filter = request.GET.get("is_overdue") if overdue_filter == "true": queryset = queryset.filter(is_overdue=True) search_query = request.GET.get("search") if search_query: queryset = queryset.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) | Q(patient__mrn__icontains=search_query) ) return export_complaints_csv(queryset, request.GET.dict()) @login_required def complaint_export_excel(request): """Export complaints to Excel""" from apps.complaints.utils import export_complaints_excel # Get filtered queryset (same as CSV) queryset = Complaint.objects.select_related( "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by" ) # Apply RBAC filters user = request.user if user.is_px_admin(): pass elif user.is_hospital_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) elif user.is_department_manager() and user.department: queryset = queryset.filter(department=user.department) elif user.hospital: queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() # Apply filters from request status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) severity_filter = request.GET.get("severity") if severity_filter: queryset = queryset.filter(severity=severity_filter) priority_filter = request.GET.get("priority") if priority_filter: queryset = queryset.filter(priority=priority_filter) hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) department_filter = request.GET.get("department") if department_filter: queryset = queryset.filter(department_id=department_filter) overdue_filter = request.GET.get("is_overdue") if overdue_filter == "true": queryset = queryset.filter(is_overdue=True) search_query = request.GET.get("search") if search_query: queryset = queryset.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) | Q(patient__mrn__icontains=search_query) ) return export_complaints_excel(queryset, request.GET.dict()) @login_required @require_http_methods(["POST"]) def complaint_bulk_assign(request): """Bulk assign complaints""" from apps.complaints.utils import bulk_assign_complaints import json # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({"success": False, "error": "Permission denied"}, status=403) try: data = json.loads(request.body) complaint_ids = data.get("complaint_ids", []) user_id = data.get("user_id") if not complaint_ids or not user_id: return JsonResponse({"success": False, "error": "Missing required fields"}, status=400) result = bulk_assign_complaints(complaint_ids, user_id, request.user) if result["success"]: messages.success(request, f"Successfully assigned {result['success_count']} complaints.") return JsonResponse(result) except json.JSONDecodeError: return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400) except Exception as e: return JsonResponse({"success": False, "error": str(e)}, status=500) @login_required @require_http_methods(["POST"]) def complaint_bulk_status(request): """Bulk change complaint status""" from apps.complaints.utils import bulk_change_status import json # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({"success": False, "error": "Permission denied"}, status=403) try: data = json.loads(request.body) complaint_ids = data.get("complaint_ids", []) new_status = data.get("status") note = data.get("note", "") if not complaint_ids or not new_status: return JsonResponse({"success": False, "error": "Missing required fields"}, status=400) result = bulk_change_status(complaint_ids, new_status, request.user, note) if result["success"]: messages.success(request, f"Successfully updated {result['success_count']} complaints.") return JsonResponse(result) except json.JSONDecodeError: return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400) except Exception as e: return JsonResponse({"success": False, "error": str(e)}, status=500) @login_required @require_http_methods(["POST"]) def complaint_bulk_escalate(request): """Bulk escalate complaints""" from apps.complaints.utils import bulk_escalate_complaints import json # Check permission if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({"success": False, "error": "Permission denied"}, status=403) try: data = json.loads(request.body) complaint_ids = data.get("complaint_ids", []) reason = data.get("reason", "") if not complaint_ids: return JsonResponse({"success": False, "error": "No complaints selected"}, status=400) result = bulk_escalate_complaints(complaint_ids, request.user, reason) if result["success"]: messages.success(request, f"Successfully escalated {result['success_count']} complaints.") return JsonResponse(result) except json.JSONDecodeError: return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400) except Exception as e: return JsonResponse({"success": False, "error": str(e)}, status=500) # ============================================================================ # INQUIRIES VIEWS # ============================================================================ @login_required def inquiry_list(request): """ Inquiries list view with filters and pagination. """ from .models import Inquiry # Base queryset with optimizations queryset = Inquiry.objects.select_related("patient", "hospital", "department", "assigned_to", "responded_by") # Apply RBAC filters user = request.user if user.is_px_admin(): pass # See all elif user.is_hospital_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) elif user.is_department_manager() and user.department: queryset = queryset.filter(department=user.department) elif user.hospital: queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() # Apply filters status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) category_filter = request.GET.get("category") if category_filter: queryset = queryset.filter(category=category_filter) hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) department_filter = request.GET.get("department") if department_filter: queryset = queryset.filter(department_id=department_filter) # Search search_query = request.GET.get("search") if search_query: queryset = queryset.filter( Q(subject__icontains=search_query) | Q(message__icontains=search_query) | Q(contact_name__icontains=search_query) | Q(contact_email__icontains=search_query) ) # Ordering order_by = request.GET.get("order_by", "-created_at") queryset = queryset.order_by(order_by) # Pagination page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get filter options hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) departments = Department.objects.filter(status="active") if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) # Statistics stats = { "total": queryset.count(), "open": queryset.filter(status="open").count(), "in_progress": queryset.filter(status="in_progress").count(), "resolved": queryset.filter(status="resolved").count(), } context = { "page_obj": page_obj, "inquiries": page_obj.object_list, "stats": stats, "hospitals": hospitals, "departments": departments, "filters": request.GET, } return render(request, "complaints/inquiry_list.html", context) @login_required def inquiry_detail(request, pk): """ Inquiry detail view with timeline and attachments. Features: - Full inquiry details - Timeline of all updates - Attachments management - Workflow actions (assign, status change, add note, respond) """ from apps.px_sources.models import SourceUser source_user = SourceUser.objects.filter(user=request.user).first() base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' inquiry = get_object_or_404( Inquiry.objects.select_related( "patient", "hospital", "department", "assigned_to", "responded_by" ).prefetch_related("attachments", "updates__created_by"), pk=pk, ) # Check access user = request.user if not user.is_px_admin(): if user.is_hospital_admin() and inquiry.hospital != user.hospital: messages.error(request, "You don't have permission to view this inquiry.") return redirect("complaints:inquiry_list") elif user.hospital and inquiry.hospital != user.hospital: messages.error(request, "You don't have permission to view this inquiry.") return redirect("complaints:inquiry_list") # Get timeline (updates) timeline = inquiry.updates.all().order_by("-created_at") # Get attachments attachments = inquiry.attachments.all().order_by("-created_at") # Get assignable users assignable_users = User.objects.filter(is_active=True) if inquiry.hospital: assignable_users = assignable_users.filter(hospital=inquiry.hospital) # Status choices for the form status_choices = [ ("open", "Open"), ("in_progress", "In Progress"), ("resolved", "Resolved"), ("closed", "Closed"), ] context = { 'inquiry': inquiry, 'timeline': timeline, 'attachments': attachments, 'assignable_users': assignable_users, 'status_choices': status_choices, 'can_edit': user.is_px_admin() or user.is_hospital_admin(), 'base_layout': base_layout, 'source_user': source_user, } return render(request, "complaints/inquiry_detail.html", context) @login_required @require_http_methods(["GET", "POST"]) def inquiry_create(request): """Create new inquiry""" from .models import Inquiry from .forms import InquiryForm from apps.px_sources.models import SourceUser # Determine base layout based on user type source_user = SourceUser.objects.filter(user=request.user).first() base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' if request.method == "POST": form = InquiryForm(request.POST, user=request.user) if form.is_valid(): try: inquiry = form.save(commit=False) # Set category from form inquiry.category = request.POST.get("category") inquiry.save() # Log audit AuditService.log_event( event_type="inquiry_created", description=f"Inquiry created: {inquiry.subject}", user=request.user, content_object=inquiry, metadata={"category": inquiry.category}, ) messages.success(request, f"Inquiry #{inquiry.id} created successfully.") return redirect("complaints:inquiry_detail", pk=inquiry.id) except Exception as e: messages.error(request, f"Error creating inquiry: {str(e)}") else: messages.error(request, f"Please correct the errors: {form.errors}") else: # GET request - show form # Check for hospital parameter from URL (for pre-selection) initial_data = {} hospital_id = request.GET.get('hospital') if hospital_id: initial_data['hospital'] = hospital_id form = InquiryForm(user=request.user, initial=initial_data) context = { "form": form, "base_layout": base_layout, "source_user": source_user, } return render(request, "complaints/inquiry_form.html", context) @login_required @require_http_methods(["POST"]) def inquiry_activate(request, pk): """Activate inquiry by assigning it to current logged-in user""" from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to activate inquiries.") return redirect("complaints:inquiry_detail", pk=pk) # Check if already assigned to current user if inquiry.assigned_to == user: messages.info(request, "This inquiry is already assigned to you.") return redirect("complaints:inquiry_detail", pk=pk) old_assignee = inquiry.assigned_to old_status = inquiry.status # Update inquiry inquiry.assigned_to = user inquiry.assigned_at = timezone.now() # Only change status to in_progress if it's currently open if inquiry.status == "open": inquiry.status = "in_progress" inquiry.save(update_fields=["assigned_to", "assigned_at", "status"]) # Create update roles_display = ', '.join(user.get_role_names()) InquiryUpdate.objects.create( inquiry=inquiry, update_type="assignment", message=f"Inquiry activated and assigned to {user.get_full_name()} ({roles_display})", created_by=request.user, metadata={ 'old_assignee_id': str(old_assignee.id) if old_assignee else None, 'new_assignee_id': str(user.id), 'assignee_roles': user.get_role_names(), 'old_status': old_status, 'new_status': inquiry.status, 'activated_by_current_user': True } ) # Log audit AuditService.log_event( event_type="inquiry_activated", description=f"Inquiry activated by {user.get_full_name()}", user=request.user, content_object=inquiry, metadata={ 'old_assignee_id': str(old_assignee.id) if old_assignee else None, 'new_assignee_id': str(user.id), 'old_status': old_status, 'new_status': inquiry.status } ) messages.success(request, f"Inquiry activated and assigned to you successfully.") return redirect("complaints:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_assign(request, pk): """Assign inquiry to user""" from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to assign inquiries.") return redirect("complaints:inquiry_detail", pk=pk) user_id = request.POST.get("user_id") if not user_id: messages.error(request, "Please select a user to assign.") return redirect("complaints:inquiry_detail", pk=pk) try: assignee = User.objects.get(id=user_id) inquiry.assigned_to = assignee inquiry.save(update_fields=["assigned_to"]) # Create update InquiryUpdate.objects.create( inquiry=inquiry, update_type="assignment", message=f"Assigned to {assignee.get_full_name()}", created_by=request.user, ) # Log audit AuditService.log_event( event_type="assignment", description=f"Inquiry assigned to {assignee.get_full_name()}", user=request.user, content_object=inquiry, ) messages.success(request, f"Inquiry assigned to {assignee.get_full_name()}.") except User.DoesNotExist: messages.error(request, "User not found.") return redirect("complaints:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_change_status(request, pk): """Change inquiry status""" from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to change inquiry status.") return redirect("complaints:inquiry_detail", pk=pk) new_status = request.POST.get("status") note = request.POST.get("note", "") if not new_status: messages.error(request, "Please select a status.") return redirect("complaints:inquiry_detail", pk=pk) old_status = inquiry.status inquiry.status = new_status # Handle status-specific logic if new_status == "resolved" and not inquiry.response: messages.error(request, "Please add a response before resolving.") return redirect("complaints:inquiry_detail", pk=pk) inquiry.save() # Create update InquiryUpdate.objects.create( inquiry=inquiry, update_type="status_change", message=note or f"Status changed from {old_status} to {new_status}", created_by=request.user, old_status=old_status, new_status=new_status, ) # Log audit AuditService.log_event( event_type="status_change", description=f"Inquiry status changed from {old_status} to {new_status}", user=request.user, content_object=inquiry, metadata={"old_status": old_status, "new_status": new_status}, ) messages.success(request, f"Inquiry status changed to {new_status}.") return redirect("complaints:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_add_note(request, pk): """Add note to inquiry""" from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) note = request.POST.get("note") if not note: messages.error(request, "Please enter a note.") return redirect("complaints:inquiry_detail", pk=pk) # Create update InquiryUpdate.objects.create(inquiry=inquiry, update_type="note", message=note, created_by=request.user) messages.success(request, "Note added successfully.") return redirect("complaints:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_respond(request, pk): """Respond to inquiry""" from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to respond to inquiries.") return redirect("complaints:inquiry_detail", pk=pk) response = request.POST.get("response") if not response: messages.error(request, "Please enter a response.") return redirect("complaints:inquiry_detail", pk=pk) inquiry.response = response inquiry.responded_at = timezone.now() inquiry.responded_by = request.user inquiry.status = "resolved" inquiry.save() # Create update InquiryUpdate.objects.create( inquiry=inquiry, update_type="response", message="Response sent", created_by=request.user ) # Log audit AuditService.log_event( event_type="inquiry_responded", description=f"Inquiry responded to: {inquiry.subject}", user=request.user, content_object=inquiry, ) messages.success(request, "Response sent successfully.") return redirect("complaints:inquiry_detail", pk=pk) # ============================================================================ # ANALYTICS VIEWS # ============================================================================ @login_required def complaints_analytics(request): """ Complaints analytics dashboard. """ from .analytics import ComplaintAnalytics user = request.user hospital = None # Apply RBAC if not user.is_px_admin() and user.hospital: hospital = user.hospital # Get date range from request date_range = int(request.GET.get("date_range", 30)) # Get analytics data dashboard_summary = ComplaintAnalytics.get_dashboard_summary(hospital) trends = ComplaintAnalytics.get_complaint_trends(hospital, date_range) sla_compliance = ComplaintAnalytics.get_sla_compliance(hospital, date_range) resolution_rate = ComplaintAnalytics.get_resolution_rate(hospital, date_range) top_categories = ComplaintAnalytics.get_top_categories(hospital, date_range) overdue_complaints = ComplaintAnalytics.get_overdue_complaints(hospital) context = { "dashboard_summary": dashboard_summary, "trends": trends, "sla_compliance": sla_compliance, "resolution_rate": resolution_rate, "top_categories": top_categories, "overdue_complaints": overdue_complaints, "date_range": date_range, } return render(request, "complaints/analytics.html", context) # ============================================================================ # PUBLIC COMPLAINT FORM (No Authentication Required) # ============================================================================ def public_complaint_submit(request): """ Public complaint submission form (accessible without login). Handles both GET (show form) and POST (submit complaint). Updated to match form structure: - Location hierarchy: Location, Main Section, Subsection (3-level dropdowns) - Complainant information: name, email, mobile, relation to patient - Patient information: name, national ID, incident date - Staff reference: staff name - Complaint details and expected result """ if request.method == "POST": try: # Get form data from public complaint form complainant_name = request.POST.get("complainant_name") email = request.POST.get("email") mobile_number = request.POST.get("mobile_number") relation_to_patient = request.POST.get("relation_to_patient") hospital_id = request.POST.get("hospital") location_id = request.POST.get("location") main_section_id = request.POST.get("main_section") subsection_id = request.POST.get("subsection") patient_name = request.POST.get("patient_name") national_id = request.POST.get("national_id") incident_date = request.POST.get("incident_date") staff_name = request.POST.get("staff_name") complaint_details = request.POST.get("complaint_details") expected_result = request.POST.get("expected_result") # Validate required fields errors = [] if not complainant_name: errors.append(_("Complainant name is required")) if not mobile_number: errors.append(_("Mobile number is required")) if not hospital_id: errors.append(_("Hospital is required")) if not location_id: errors.append(_("Location is required")) if not main_section_id: errors.append(_("Main section is required")) if not complaint_details: errors.append(_("Complaint details are required")) if errors: if request.headers.get("x-requested-with") == "XMLHttpRequest": return JsonResponse({"success": False, "errors": errors}, status=400) else: messages.error(request, _("Please fill in all required fields.")) return render( request, "complaints/public_complaint_form.html", { "hospitals": Hospital.objects.filter(status="active").order_by("name"), }, ) # Get hospital hospital = Hospital.objects.get(id=hospital_id) # Get location hierarchy objects from apps.organizations.models import Location, MainSection, SubSection location = Location.objects.get(id=location_id) main_section = MainSection.objects.get(id=main_section_id) subsection = None if subsection_id: subsection = SubSection.objects.get(internal_id=subsection_id) # Generate unique reference number: CMP-YYYYMMDD-XXXXX import uuid from datetime import datetime today = datetime.now().strftime("%Y%m%d") random_suffix = str(uuid.uuid4().int)[:6] reference_number = f"CMP-{today}-{random_suffix}" # Create complaint with location hierarchy and all form fields complaint = Complaint.objects.create( patient=None, # No patient record for public submissions hospital=hospital, department=None, # AI will determine this title="Complaint", # AI will generate title description=complaint_details, severity="medium", # Default, AI will update priority="medium", # Default, AI will update status="open", # Start as open reference_number=reference_number, # Location hierarchy (FK relationships) location=location, main_section=main_section, subsection=subsection, # Complainant information contact_name=complainant_name, contact_phone=mobile_number, contact_email=email, # Store additional information in metadata metadata={ 'relation_to_patient': relation_to_patient, 'patient_name': patient_name, 'national_id': national_id, 'incident_date': incident_date, 'staff_name': staff_name, 'expected_result': expected_result, } ) # Create initial update ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message="Complaint submitted via public form. AI analysis running in background.", ) # Trigger AI analysis in the background using Celery from .tasks import analyze_complaint_with_ai analyze_complaint_with_ai.delay(str(complaint.id)) # If form was submitted via AJAX, return JSON if request.headers.get("x-requested-with") == "XMLHttpRequest": return JsonResponse( { "success": True, "reference_number": reference_number, "message": "Complaint submitted successfully. AI has analyzed and classified your complaint.", } ) # Otherwise, redirect to success page return redirect("complaints:public_complaint_success", reference=reference_number) except Hospital.DoesNotExist: error_msg = _("Selected hospital not found.") if request.headers.get("x-requested-with") == "XMLHttpRequest": return JsonResponse({"success": False, "message": error_msg}, status=400) messages.error(request, error_msg) return render( request, "complaints/public_complaint_form.html", { "hospitals": Hospital.objects.filter(status="active").order_by("name"), }, ) except Exception as e: import traceback traceback.print_exc() # If AJAX, return error JSON if request.headers.get("x-requested-with") == "XMLHttpRequest": return JsonResponse({"success": False, "message": str(e)}, status=400) # Otherwise, show error message return render( request, "complaints/public_complaint_form.html", {"hospitals": Hospital.objects.filter(status="active").order_by("name"), "error": str(e)}, ) # GET request - show form return render( request, "complaints/public_complaint_form.html", { "hospitals": Hospital.objects.filter(status="active").order_by("name"), }, ) def public_complaint_track(request): """ Public complaint tracking page. Allows complainants to check the status of their complaint using the reference number. No authentication required. Features: - Form to enter reference number - Display complaint status when found - Show basic information (status, category, submission date, last update) - Timeline of public updates (without exposing internal notes) - SLA deadline information """ complaint = None error_message = None reference_number = request.GET.get("reference", "").strip() if request.method == "POST": reference_number = request.POST.get("reference_number", "").strip() if not reference_number: error_message = _("Please enter a reference number.") else: # Try to find complaint by reference number try: complaint = Complaint.objects.select_related( "hospital", "department", "location", "main_section", "subsection" ).prefetch_related("updates").get(reference_number__iexact=reference_number) # Check overdue status complaint.check_overdue() except Complaint.DoesNotExist: error_message = _("No complaint found with this reference number. Please check and try again.") elif reference_number: # GET request with reference parameter try: complaint = Complaint.objects.select_related( "hospital", "department", "location", "main_section", "subsection" ).prefetch_related("updates").get(reference_number__iexact=reference_number) # Check overdue status complaint.check_overdue() except Complaint.DoesNotExist: error_message = _("No complaint found with this reference number. Please check and try again.") # Get public updates only (exclude internal notes) public_updates = [] if complaint: public_updates = complaint.updates.filter( update_type__in=["status_change", "resolution", "communication"] ).order_by("-created_at") context = { "complaint": complaint, "public_updates": public_updates, "error_message": error_message, "reference_number": reference_number, } return render(request, "complaints/public_complaint_track.html", context) def public_complaint_success(request, reference): """ Success page after public complaint submission. """ return render(request, "complaints/public_complaint_success.html", {"reference_number": reference}) def api_lookup_patient(request): """ AJAX endpoint to look up patient by national ID. No authentication required for public form. """ from apps.organizations.models import Patient national_id = request.GET.get("national_id") if not national_id: return JsonResponse({"found": False, "error": "National ID required"}, status=400) try: patient = Patient.objects.get(national_id=national_id, status="active") return JsonResponse( { "found": True, "mrn": patient.mrn, "name": patient.get_full_name(), "phone": patient.phone or "", "email": patient.email or "", } ) except Patient.DoesNotExist: return JsonResponse({"found": False, "message": "Patient not found"}) def api_load_departments(request): """ AJAX endpoint to load departments for a hospital. No authentication required for public form. """ hospital_id = request.GET.get("hospital_id") if not hospital_id: return JsonResponse({"departments": []}) departments = Department.objects.filter(hospital_id=hospital_id, status="active").values("id", "name") return JsonResponse({"departments": list(departments)}) def api_load_categories(request): """ AJAX endpoint to load complaint categories for a hospital. Shows hospital-specific categories first, then system-wide categories. Returns both parent categories and their subcategories with parent_id. Now includes level field for 4-level hierarchy support (Domain, Category, Subcategory, Classification). No authentication required for public form. Updated: Always returns system-wide categories even without hospital_id, to support initial form loading. """ from .models import ComplaintCategory hospital_id = request.GET.get("hospital_id") # Build queryset - always include system-wide categories if hospital_id: # Return hospital-specific and system-wide categories # Empty hospitals list = system-wide categories_queryset = ( ComplaintCategory.objects.filter( Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True), is_active=True ) .distinct() .order_by("level", "order", "name_en") ) else: # Return all system-wide categories (empty hospitals list) # This allows form to load domains on initial page load categories_queryset = ComplaintCategory.objects.filter( Q(hospitals__isnull=True), is_active=True ).order_by("level", "order", "name_en") # Get all categories with parent_id, level, domain_type, and descriptions categories = categories_queryset.values( "id", "name_en", "name_ar", "code", "parent_id", "level", "domain_type", "description_en", "description_ar" ) return JsonResponse({"categories": list(categories)}) # ============================================================================ # AJAX/API HELPERS (Authentication Required) # ============================================================================ @login_required def get_departments_by_hospital(request): """Get departments for a hospital (AJAX)""" hospital_id = request.GET.get("hospital_id") if not hospital_id: return JsonResponse({"departments": []}) departments = Department.objects.filter(hospital_id=hospital_id, status="active").values("id", "name", "name_ar") return JsonResponse({"departments": list(departments)}) @login_required def get_staff_by_department(request): """Get staff for a department (AJAX)""" department_id = request.GET.get("department_id") if not department_id: return JsonResponse({"staff": []}) staff_members = Staff.objects.filter(department_id=department_id, status="active").values( "id", "first_name", "last_name", "staff_type", "job_title" ) return JsonResponse({"staff": list(staff_members)}) @login_required def search_patients(request): """Search patients by MRN or name (AJAX)""" from apps.organizations.models import Patient query = request.GET.get("q", "") if len(query) < 2: return JsonResponse({"patients": []}) patients = Patient.objects.filter( Q(mrn__icontains=query) | Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(national_id__icontains=query) )[:10] results = [ { "id": str(p.id), "mrn": p.mrn, "name": p.get_full_name(), "phone": p.phone, "email": p.email, } for p in patients ] return JsonResponse({"patients": results}) # ============================================================================ # SLA CONFIGURATION MANAGEMENT # ============================================================================ @login_required def sla_config_list(request): """ SLA Configuration list view with filters. Allows Hospital Admins and PX Admins to manage SLA configurations. """ from .models import ComplaintSLAConfig # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to manage SLA configurations.") return redirect("accounts:settings") # Base queryset queryset = ComplaintSLAConfig.objects.select_related("hospital").all() # Apply hospital filter if not user.is_px_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) # Apply filters from request hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) severity_filter = request.GET.get("severity") if severity_filter: queryset = queryset.filter(severity=severity_filter) priority_filter = request.GET.get("priority") if priority_filter: queryset = queryset.filter(priority=priority_filter) is_active_filter = request.GET.get("is_active") if is_active_filter: queryset = queryset.filter(is_active=(is_active_filter == "true")) # Ordering order_by = request.GET.get("order_by", "hospital__name") queryset = queryset.order_by(order_by) # Pagination page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get filter options hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) from apps.core.models import SeverityChoices, PriorityChoices context = { "page_obj": page_obj, "sla_configs": page_obj.object_list, "hospitals": hospitals, "severity_choices": SeverityChoices.choices, "priority_choices": PriorityChoices.choices, "filters": request.GET, } return render(request, "complaints/sla_config_list.html", context) @login_required @require_http_methods(["GET", "POST"]) def sla_config_create(request): """ Create new SLA configuration. """ from .models import ComplaintSLAConfig from .forms import SLAConfigForm # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to create SLA configurations.") return redirect("accounts:settings") if request.method == "POST": form = SLAConfigForm(request.POST, user=user) if form.is_valid(): sla_config = form.save() # Log audit AuditService.log_event( event_type="sla_config_created", description=f"SLA configuration created: {sla_config}", user=request.user, content_object=sla_config, metadata={ "hospital": str(sla_config.hospital), "severity": sla_config.severity, "priority": sla_config.priority, "sla_hours": sla_config.sla_hours, }, ) messages.success(request, "SLA configuration created successfully.") return redirect("complaints:sla_config_list") else: messages.error(request, "Please correct the errors below.") else: form = SLAConfigForm(user=user) context = { "form": form, "title": "Create SLA Configuration", "action": "Create", } return render(request, "complaints/sla_config_form.html", context) @login_required @require_http_methods(["GET", "POST"]) def sla_config_edit(request, pk): """ Edit existing SLA configuration. """ from .models import ComplaintSLAConfig from .forms import SLAConfigForm # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to edit SLA configurations.") return redirect("accounts:settings") sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk) # Check if user can edit this config if not user.is_px_admin() and sla_config.hospital != user.hospital: messages.error(request, "You don't have permission to edit this SLA configuration.") return redirect("complaints:sla_config_list") if request.method == "POST": form = SLAConfigForm(request.POST, user=user, instance=sla_config) if form.is_valid(): sla_config = form.save() # Log audit AuditService.log_event( event_type="sla_config_updated", description=f"SLA configuration updated: {sla_config}", user=request.user, content_object=sla_config, metadata={ "hospital": str(sla_config.hospital), "severity": sla_config.severity, "priority": sla_config.priority, "sla_hours": sla_config.sla_hours, }, ) messages.success(request, "SLA configuration updated successfully.") return redirect("complaints:sla_config_list") else: messages.error(request, "Please correct the errors below.") else: form = SLAConfigForm(user=user, instance=sla_config) context = { "form": form, "sla_config": sla_config, "title": "Edit SLA Configuration", "action": "Update", } return render(request, "complaints/sla_config_form.html", context) @login_required @require_http_methods(["POST"]) def sla_config_delete(request, pk): """ Delete SLA configuration. """ from .models import ComplaintSLAConfig # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to delete SLA configurations.") return redirect("accounts:settings") sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk) # Check if user can delete this config if not user.is_px_admin() and sla_config.hospital != user.hospital: messages.error(request, "You don't have permission to delete this SLA configuration.") return redirect("complaints:sla_config_list") sla_config.delete() # Log audit AuditService.log_event( event_type="sla_config_deleted", description=f"SLA configuration deleted: {sla_config}", user=request.user, metadata={ "hospital": str(sla_config.hospital), "severity": sla_config.severity, "priority": sla_config.priority, }, ) messages.success(request, "SLA configuration deleted successfully.") return redirect("complaints:sla_config_list") @login_required def escalation_rule_list(request): """ Escalation Rules list view with filters. Allows Hospital Admins and PX Admins to manage escalation rules. """ from .models import EscalationRule # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to manage escalation rules.") return redirect("accounts:settings") # Base queryset queryset = EscalationRule.objects.select_related("hospital", "escalate_to_user").all() # Apply hospital filter if not user.is_px_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) # Apply filters from request hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) escalation_level_filter = request.GET.get("escalation_level") if escalation_level_filter: queryset = queryset.filter(escalation_level=escalation_level_filter) is_active_filter = request.GET.get("is_active") if is_active_filter: queryset = queryset.filter(is_active=(is_active_filter == "true")) # Ordering order_by = request.GET.get("order_by", "hospital__name") queryset = queryset.order_by(order_by) # Pagination page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get filter options hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) context = { "page_obj": page_obj, "escalation_rules": page_obj.object_list, "hospitals": hospitals, "filters": request.GET, } return render(request, "complaints/escalation_rule_list.html", context) @login_required @require_http_methods(["GET", "POST"]) def escalation_rule_create(request): """ Create new escalation rule. """ from .models import EscalationRule from .forms import EscalationRuleForm # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to create escalation rules.") return redirect("accounts:settings") if request.method == "POST": form = EscalationRuleForm(request.POST, user=user) if form.is_valid(): escalation_rule = form.save() # Log audit AuditService.log_event( event_type="escalation_rule_created", description=f"Escalation rule created: {escalation_rule}", user=request.user, content_object=escalation_rule, metadata={ "hospital": str(escalation_rule.hospital), "name": escalation_rule.name, "escalation_level": escalation_rule.escalation_level, }, ) messages.success(request, "Escalation rule created successfully.") return redirect("complaints:escalation_rule_list") else: messages.error(request, "Please correct the errors below.") else: form = EscalationRuleForm(user=user) context = { "form": form, "title": "Create Escalation Rule", "action": "Create", } return render(request, "complaints/escalation_rule_form.html", context) @login_required @require_http_methods(["GET", "POST"]) def escalation_rule_edit(request, pk): """ Edit existing escalation rule. """ from .models import EscalationRule from .forms import EscalationRuleForm # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to edit escalation rules.") return redirect("accounts:settings") escalation_rule = get_object_or_404(EscalationRule, pk=pk) # Check if user can edit this rule if not user.is_px_admin() and escalation_rule.hospital != user.hospital: messages.error(request, "You don't have permission to edit this escalation rule.") return redirect("complaints:escalation_rule_list") if request.method == "POST": form = EscalationRuleForm(request.POST, user=user, instance=escalation_rule) if form.is_valid(): escalation_rule = form.save() # Log audit AuditService.log_event( event_type="escalation_rule_updated", description=f"Escalation rule updated: {escalation_rule}", user=request.user, content_object=escalation_rule, metadata={ "hospital": str(escalation_rule.hospital), "name": escalation_rule.name, "escalation_level": escalation_rule.escalation_level, }, ) messages.success(request, "Escalation rule updated successfully.") return redirect("complaints:escalation_rule_list") else: messages.error(request, "Please correct the errors below.") else: form = EscalationRuleForm(user=user, instance=escalation_rule) context = { "form": form, "escalation_rule": escalation_rule, "title": "Edit Escalation Rule", "action": "Update", } return render(request, "complaints/escalation_rule_form.html", context) @login_required @require_http_methods(["POST"]) def escalation_rule_delete(request, pk): """ Delete escalation rule. """ from .models import EscalationRule # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to delete escalation rules.") return redirect("accounts:settings") escalation_rule = get_object_or_404(EscalationRule, pk=pk) # Check if user can delete this rule if not user.is_px_admin() and escalation_rule.hospital != user.hospital: messages.error(request, "You don't have permission to delete this escalation rule.") return redirect("complaints:escalation_rule_list") escalation_rule.delete() # Log audit AuditService.log_event( event_type="escalation_rule_deleted", description=f"Escalation rule deleted: {escalation_rule}", user=request.user, metadata={ "hospital": str(escalation_rule.hospital), "name": escalation_rule.name, }, ) messages.success(request, "Escalation rule deleted successfully.") return redirect("complaints:escalation_rule_list") @login_required def complaint_threshold_list(request): """ Complaint Threshold list view with filters. Allows Hospital Admins and PX Admins to manage complaint thresholds. """ from .models import ComplaintThreshold # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to manage complaint thresholds.") return redirect("accounts:settings") # Base queryset queryset = ComplaintThreshold.objects.select_related("hospital").all() # Apply hospital filter if not user.is_px_admin() and user.hospital: queryset = queryset.filter(hospital=user.hospital) # Apply filters from request hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) threshold_type_filter = request.GET.get("threshold_type") if threshold_type_filter: queryset = queryset.filter(threshold_type=threshold_type_filter) is_active_filter = request.GET.get("is_active") if is_active_filter: queryset = queryset.filter(is_active=(is_active_filter == "true")) # Ordering order_by = request.GET.get("order_by", "hospital__name") queryset = queryset.order_by(order_by) # Pagination page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get filter options hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) context = { "page_obj": page_obj, "thresholds": page_obj.object_list, "hospitals": hospitals, "filters": request.GET, } return render(request, "complaints/complaint_threshold_list.html", context) @login_required @require_http_methods(["GET", "POST"]) def complaint_threshold_create(request): """ Create new complaint threshold. """ from .models import ComplaintThreshold from .forms import ComplaintThresholdForm # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to create complaint thresholds.") return redirect("accounts:settings") if request.method == "POST": form = ComplaintThresholdForm(request.POST, user=user) if form.is_valid(): threshold = form.save() # Log audit AuditService.log_event( event_type="complaint_threshold_created", description=f"Complaint threshold created: {threshold}", user=request.user, content_object=threshold, metadata={ "hospital": str(threshold.hospital), "threshold_type": threshold.threshold_type, "threshold_value": threshold.threshold_value, }, ) messages.success(request, "Complaint threshold created successfully.") return redirect("complaints:complaint_threshold_list") else: messages.error(request, "Please correct the errors below.") else: form = ComplaintThresholdForm(user=user) context = { "form": form, "title": "Create Complaint Threshold", "action": "Create", } return render(request, "complaints/complaint_threshold_form.html", context) @login_required @require_http_methods(["GET", "POST"]) def complaint_threshold_edit(request, pk): """ Edit existing complaint threshold. """ from .models import ComplaintThreshold from .forms import ComplaintThresholdForm # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to edit complaint thresholds.") return redirect("accounts:settings") threshold = get_object_or_404(ComplaintThreshold, pk=pk) # Check if user can edit this threshold if not user.is_px_admin() and threshold.hospital != user.hospital: messages.error(request, "You don't have permission to edit this complaint threshold.") return redirect("complaints:complaint_threshold_list") if request.method == "POST": form = ComplaintThresholdForm(request.POST, user=user, instance=threshold) if form.is_valid(): threshold = form.save() # Log audit AuditService.log_event( event_type="complaint_threshold_updated", description=f"Complaint threshold updated: {threshold}", user=request.user, content_object=threshold, metadata={ "hospital": str(threshold.hospital), "threshold_type": threshold.threshold_type, "threshold_value": threshold.threshold_value, }, ) messages.success(request, "Complaint threshold updated successfully.") return redirect("complaints:complaint_threshold_list") else: messages.error(request, "Please correct the errors below.") else: form = ComplaintThresholdForm(user=user, instance=threshold) context = { "form": form, "threshold": threshold, "title": "Edit Complaint Threshold", "action": "Update", } return render(request, "complaints/complaint_threshold_form.html", context) @login_required @require_http_methods(["POST"]) def complaint_threshold_delete(request, pk): """ Delete complaint threshold. """ from .models import ComplaintThreshold # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to delete complaint thresholds.") return redirect("accounts:settings") threshold = get_object_or_404(ComplaintThreshold, pk=pk) # Check if user can delete this threshold if not user.is_px_admin() and threshold.hospital != user.hospital: messages.error(request, "You don't have permission to delete this complaint threshold.") return redirect("complaints:complaint_threshold_list") threshold.delete() # Log audit AuditService.log_event( event_type="complaint_threshold_deleted", description=f"Complaint threshold deleted: {threshold}", user=request.user, metadata={ "hospital": str(threshold.hospital), "threshold_type": threshold.threshold_type, }, ) messages.success(request, "Complaint threshold deleted successfully.") return redirect("complaints:complaint_threshold_list") # ============================================================================ # Involved Departments and Staff Views # ============================================================================ @login_required @require_http_methods(["GET", "POST"]) def involved_department_add(request, complaint_pk): """ Add an involved department to a complaint. Allows assigning multiple departments with different roles: - Primary: Main responsible department - Secondary/Supporting: Assisting departments - Coordination: Only for coordination - Investigating: Leading the investigation """ complaint = get_object_or_404(Complaint, pk=complaint_pk) # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) if request.method == "POST": form = ComplaintInvolvedDepartmentForm( request.POST, complaint=complaint, user=user ) if form.is_valid(): involved_dept = form.save(commit=False) involved_dept.complaint = complaint involved_dept.added_by = user # Set assignment timestamp if assigned if involved_dept.assigned_to and not involved_dept.assigned_at: involved_dept.assigned_at = timezone.now() involved_dept.save() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Department added: {involved_dept.department.name} ({involved_dept.get_role_display()})", created_by=user, ) # Audit log AuditService.log_event( event_type="complaint_department_added", description=f"Department {involved_dept.department.name} added to complaint {complaint.reference_number}", user=user, content_object=complaint, metadata={ "department_id": str(involved_dept.department.pk), "department_name": involved_dept.department.name, "role": involved_dept.role, "is_primary": involved_dept.is_primary, }, ) messages.success( request, _("Department '%(dept)s' added successfully as %(role)s.") % { 'dept': involved_dept.department.name, 'role': involved_dept.get_role_display() } ) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedDepartmentForm(complaint=complaint, user=user) context = { "form": form, "complaint": complaint, "title": _("Add Involved Department"), } return render(request, "complaints/involved_department_form.html", context) @login_required @require_http_methods(["GET", "POST"]) def involved_department_edit(request, pk): """ Edit an involved department's details. """ from .models import ComplaintInvolvedDepartment involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) if request.method == "POST": form = ComplaintInvolvedDepartmentForm( request.POST, instance=involved_dept, complaint=complaint, user=user ) if form.is_valid(): # Check if assignment is being changed old_assigned_to = involved_dept.assigned_to involved_dept = form.save(commit=False) # Set assignment timestamp if newly assigned if involved_dept.assigned_to and not old_assigned_to: involved_dept.assigned_at = timezone.now() involved_dept.save() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Department updated: {involved_dept.department.name} ({involved_dept.get_role_display()})", created_by=user, ) messages.success(request, _("Department involvement updated successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedDepartmentForm( instance=involved_dept, complaint=complaint, user=user ) context = { "form": form, "complaint": complaint, "involved_dept": involved_dept, "title": _("Edit Involved Department"), } return render(request, "complaints/involved_department_form.html", context) @login_required @require_http_methods(["POST"]) def involved_department_remove(request, pk): """ Remove an involved department from a complaint. """ from .models import ComplaintInvolvedDepartment involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint department_name = involved_dept.department.name # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) involved_dept.delete() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Department removed: {department_name}", created_by=user, ) # Audit log AuditService.log_event( event_type="complaint_department_removed", description=f"Department {department_name} removed from complaint {complaint.reference_number}", user=user, content_object=complaint, ) messages.success(request, _("Department removed successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @login_required @require_http_methods(["POST"]) def involved_department_response(request, pk): """ Submit a department's response to the complaint. """ from .models import ComplaintInvolvedDepartment involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint user = request.user # Check permission - must be assigned to this department or have complaint management rights can_respond = ( involved_dept.assigned_to == user or can_manage_complaint(user, complaint) ) if not can_respond: messages.error(request, _("You don't have permission to submit a response for this department.")) return redirect("complaints:complaint_detail", pk=complaint.pk) form = DepartmentResponseForm(request.POST, instance=involved_dept) if form.is_valid(): involved_dept = form.save(commit=False) involved_dept.response_submitted = True involved_dept.response_submitted_at = timezone.now() involved_dept.save() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Response submitted by {involved_dept.department.name}", created_by=user, ) messages.success(request, _("Department response submitted successfully.")) else: messages.error(request, _("Please provide a valid response.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @login_required @require_http_methods(["GET", "POST"]) def involved_staff_add(request, complaint_pk): """ Add an involved staff member to a complaint. Allows assigning multiple staff members with different roles: - Accused/Involved: Staff member involved in the incident - Witness: Staff member who witnessed the incident - Responsible: Staff responsible for resolution - Investigator: Staff investigating the complaint - Support: Support staff - Coordinator: Coordination role """ complaint = get_object_or_404(Complaint, pk=complaint_pk) # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) if request.method == "POST": form = ComplaintInvolvedStaffForm( request.POST, complaint=complaint, user=user ) if form.is_valid(): involved_staff = form.save(commit=False) involved_staff.complaint = complaint involved_staff.added_by = user involved_staff.save() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Staff added: {involved_staff.staff} ({involved_staff.get_role_display()})", created_by=user, ) # Audit log AuditService.log_event( event_type="complaint_staff_added", description=f"Staff {involved_staff.staff} added to complaint {complaint.reference_number}", user=user, content_object=complaint, metadata={ "staff_id": str(involved_staff.staff.pk), "staff_name": str(involved_staff.staff), "role": involved_staff.role, }, ) messages.success( request, _("Staff member '%(staff)s' added successfully as %(role)s.") % { 'staff': involved_staff.staff, 'role': involved_staff.get_role_display() } ) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedStaffForm(complaint=complaint, user=user) context = { "form": form, "complaint": complaint, "title": _("Add Involved Staff"), } return render(request, "complaints/involved_staff_form.html", context) @login_required @require_http_methods(["GET", "POST"]) def involved_staff_edit(request, pk): """ Edit an involved staff member's details. """ from .models import ComplaintInvolvedStaff involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk) complaint = involved_staff.complaint # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) if request.method == "POST": form = ComplaintInvolvedStaffForm( request.POST, instance=involved_staff, complaint=complaint, user=user ) if form.is_valid(): form.save() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Staff updated: {involved_staff.staff} ({involved_staff.get_role_display()})", created_by=user, ) messages.success(request, _("Staff involvement updated successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedStaffForm( instance=involved_staff, complaint=complaint, user=user ) context = { "form": form, "complaint": complaint, "involved_staff": involved_staff, "title": _("Edit Involved Staff"), } return render(request, "complaints/involved_staff_form.html", context) @login_required @require_http_methods(["POST"]) def involved_staff_remove(request, pk): """ Remove an involved staff member from a complaint. """ from .models import ComplaintInvolvedStaff involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk) complaint = involved_staff.complaint staff_name = str(involved_staff.staff) # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) involved_staff.delete() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"Staff removed: {staff_name}", created_by=user, ) # Audit log AuditService.log_event( event_type="complaint_staff_removed", description=f"Staff {staff_name} removed from complaint {complaint.reference_number}", user=user, content_object=complaint, ) messages.success(request, _("Staff member removed successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @login_required @require_http_methods(["POST"]) def involved_staff_explanation(request, pk): """ Submit an explanation as an involved staff member. """ from .models import ComplaintInvolvedStaff involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk) complaint = involved_staff.complaint user = request.user # Check permission - must be the staff member themselves or have management rights can_submit = ( (involved_staff.staff.user == user if hasattr(involved_staff.staff, 'user') else False) or can_manage_complaint(user, complaint) ) if not can_submit: messages.error(request, _("You don't have permission to submit an explanation for this staff member.")) return redirect("complaints:complaint_detail", pk=complaint.pk) form = StaffExplanationForm(request.POST, instance=involved_staff) if form.is_valid(): involved_staff = form.save(commit=False) involved_staff.explanation_received = True involved_staff.explanation_received_at = timezone.now() involved_staff.save() # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Explanation submitted by {involved_staff.staff}", created_by=user, ) messages.success(request, _("Explanation submitted successfully.")) else: messages.error(request, _("Please provide a valid explanation.")) return redirect("complaints:complaint_detail", pk=complaint.pk) # ============================================================================= # Complaint Adverse Action Views # ============================================================================= @login_required def adverse_action_list(request): """ List all adverse actions related to complaints. Features: - Filter by status, severity, action type - Search by complaint reference or description - Paginated results """ # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, _("You don't have permission to view adverse actions.")) return redirect('complaints:complaint_list') # Base queryset queryset = ComplaintAdverseAction.objects.select_related( 'complaint', 'complaint__hospital', 'reported_by' ).prefetch_related('involved_staff') # Apply filters status_filter = request.GET.get('status') if status_filter: queryset = queryset.filter(status=status_filter) severity_filter = request.GET.get('severity') if severity_filter: queryset = queryset.filter(severity=severity_filter) action_type_filter = request.GET.get('action_type') if action_type_filter: queryset = queryset.filter(action_type=action_type_filter) # Filter by hospital for hospital admins if user.is_hospital_admin() and user.hospital: queryset = queryset.filter(complaint__hospital=user.hospital) # Search search_query = request.GET.get('search') if search_query: queryset = queryset.filter( Q(complaint__reference_number__icontains=search_query) | Q(description__icontains=search_query) | Q(patient_impact__icontains=search_query) ) # Pagination paginator = Paginator(queryset.order_by('-incident_date'), 25) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) context = { 'page_obj': page_obj, 'status_choices': ComplaintAdverseAction.VerificationStatus.choices, 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, 'action_type_choices': ComplaintAdverseAction.ActionType.choices, 'filters': request.GET, } return render(request, 'complaints/adverse_action_list.html', context) @login_required def adverse_action_add(request, complaint_pk): """ Add a new adverse action to a complaint. """ complaint = get_object_or_404(Complaint, pk=complaint_pk) user = request.user # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to add adverse actions to this complaint.")) return redirect('complaints:complaint_detail', pk=complaint_pk) if request.method == 'POST': try: # Parse form data action_type = request.POST.get('action_type') severity = request.POST.get('severity') description = request.POST.get('description', '').strip() patient_impact = request.POST.get('patient_impact', '').strip() incident_date = request.POST.get('incident_date') location = request.POST.get('location', '').strip() involved_staff_ids = request.POST.getlist('involved_staff') if not description: messages.error(request, _("Description is required.")) return render(request, 'complaints/adverse_action_form.html', { 'complaint': complaint, 'action_type_choices': ComplaintAdverseAction.ActionType.choices, 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, 'staff_list': Staff.objects.filter(hospital=complaint.hospital, is_active=True), }) # Create adverse action adverse_action = ComplaintAdverseAction.objects.create( complaint=complaint, action_type=action_type or ComplaintAdverseAction.ActionType.OTHER, severity=severity or ComplaintAdverseAction.SeverityLevel.MEDIUM, description=description, patient_impact=patient_impact, incident_date=incident_date or timezone.now(), location=location, reported_by=user, status=ComplaintAdverseAction.VerificationStatus.REPORTED ) # Add involved staff if involved_staff_ids: adverse_action.involved_staff.set(involved_staff_ids) # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=f"Adverse action reported: {adverse_action.get_action_type_display()}", created_by=user, ) # Log audit AuditService.log_event( event_type='adverse_action_created', description=f"Adverse action added to complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}", user=user, content_object=adverse_action, metadata={ 'complaint_id': str(complaint.id), 'action_type': action_type, 'severity': severity, } ) messages.success(request, _("Adverse action reported successfully.")) return redirect('complaints:complaint_detail', pk=complaint_pk) except Exception as e: logger.error(f"Error creating adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error creating adverse action: {str(e)}") context = { 'complaint': complaint, 'action_type_choices': ComplaintAdverseAction.ActionType.choices, 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, 'staff_list': Staff.objects.filter(hospital=complaint.hospital, is_active=True) if complaint.hospital else Staff.objects.filter(is_active=True), } return render(request, 'complaints/adverse_action_form.html', context) @login_required def adverse_action_edit(request, pk): """ Edit an existing adverse action. """ adverse_action = get_object_or_404( ComplaintAdverseAction.objects.select_related('complaint'), pk=pk ) complaint = adverse_action.complaint user = request.user # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to edit this adverse action.")) return redirect('complaints:complaint_detail', pk=complaint.id) if request.method == 'POST': try: # Update fields adverse_action.action_type = request.POST.get('action_type', adverse_action.action_type) adverse_action.severity = request.POST.get('severity', adverse_action.severity) adverse_action.description = request.POST.get('description', adverse_action.description).strip() adverse_action.patient_impact = request.POST.get('patient_impact', adverse_action.patient_impact).strip() adverse_action.location = request.POST.get('location', adverse_action.location).strip() incident_date = request.POST.get('incident_date') if incident_date: adverse_action.incident_date = incident_date # Update involved staff involved_staff_ids = request.POST.getlist('involved_staff') if involved_staff_ids: adverse_action.involved_staff.set(involved_staff_ids) adverse_action.save() # Log audit AuditService.log_event( event_type='adverse_action_updated', description=f"Adverse action updated for complaint {complaint.reference_number}", user=user, content_object=adverse_action, ) messages.success(request, _("Adverse action updated successfully.")) return redirect('complaints:complaint_detail', pk=complaint.id) except Exception as e: logger.error(f"Error updating adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error updating adverse action: {str(e)}") context = { 'adverse_action': adverse_action, 'complaint': complaint, 'action_type_choices': ComplaintAdverseAction.ActionType.choices, 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, 'staff_list': Staff.objects.filter(hospital=complaint.hospital, is_active=True) if complaint.hospital else Staff.objects.filter(is_active=True), 'selected_staff': list(adverse_action.involved_staff.values_list('id', flat=True)), } return render(request, 'complaints/adverse_action_form.html', context) @login_required def adverse_action_update_status(request, pk): """ Update the status of an adverse action (investigation, resolution). """ adverse_action = get_object_or_404( ComplaintAdverseAction.objects.select_related('complaint'), pk=pk ) complaint = adverse_action.complaint user = request.user # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to update this adverse action.")) return redirect('complaints:complaint_detail', pk=complaint.id) if request.method == 'POST': try: new_status = request.POST.get('status') investigation_notes = request.POST.get('investigation_notes', '').strip() resolution = request.POST.get('resolution', '').strip() old_status = adverse_action.status adverse_action.status = new_status if investigation_notes: adverse_action.investigation_notes = investigation_notes if resolution: adverse_action.resolution = resolution # Handle status-specific updates if new_status == ComplaintAdverseAction.VerificationStatus.UNDER_INVESTIGATION: adverse_action.investigated_by = user adverse_action.investigated_at = timezone.now() elif new_status == ComplaintAdverseAction.VerificationStatus.RESOLVED: adverse_action.resolved_by = user adverse_action.resolved_at = timezone.now() elif new_status == ComplaintAdverseAction.VerificationStatus.VERIFIED: adverse_action.investigated_by = user adverse_action.investigated_at = timezone.now() adverse_action.save() # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=f"Adverse action status changed from {adverse_action.get_status_display()} to {dict(ComplaintAdverseAction.VerificationStatus.choices)[new_status]}", created_by=user, ) # Log audit AuditService.log_event( event_type='adverse_action_status_changed', description=f"Adverse action status changed from {old_status} to {new_status} for complaint {complaint.reference_number}", user=user, content_object=adverse_action, metadata={ 'old_status': old_status, 'new_status': new_status, } ) messages.success(request, _("Adverse action status updated successfully.")) except Exception as e: logger.error(f"Error updating adverse action status: {str(e)}", exc_info=True) messages.error(request, f"Error updating status: {str(e)}") return redirect('complaints:complaint_detail', pk=complaint.id) @login_required def adverse_action_escalate(request, pk): """ Escalate an adverse action to management. """ adverse_action = get_object_or_404( ComplaintAdverseAction.objects.select_related('complaint'), pk=pk ) complaint = adverse_action.complaint user = request.user # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to escalate this adverse action.")) return redirect('complaints:complaint_detail', pk=complaint.id) if request.method == 'POST': try: adverse_action.is_escalated = True adverse_action.escalated_at = timezone.now() adverse_action.save() # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, update_type='escalation', message=f"Adverse action escalated: {adverse_action.get_action_type_display()}", created_by=user, ) # Log audit AuditService.log_event( event_type='adverse_action_escalated', description=f"Adverse action escalated for complaint {complaint.reference_number}", user=user, content_object=adverse_action, ) messages.success(request, _("Adverse action escalated successfully.")) except Exception as e: logger.error(f"Error escalating adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error escalating: {str(e)}") return redirect('complaints:complaint_detail', pk=complaint.id) @login_required def adverse_action_delete(request, pk): """ Delete an adverse action. """ adverse_action = get_object_or_404( ComplaintAdverseAction.objects.select_related('complaint'), pk=pk ) complaint = adverse_action.complaint user = request.user # Check permission - only PX Admin or Hospital Admin can delete if not (user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital)): messages.error(request, _("You don't have permission to delete adverse actions.")) return redirect('complaints:complaint_detail', pk=complaint.id) if request.method == 'POST': try: complaint_pk = complaint.id # Log before deletion AuditService.log_event( event_type='adverse_action_deleted', description=f"Adverse action deleted from complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}", user=user, metadata={ 'complaint_id': str(complaint.id), 'action_type': adverse_action.action_type, 'description': adverse_action.description[:100], } ) adverse_action.delete() messages.success(request, _("Adverse action deleted successfully.")) return redirect('complaints:complaint_detail', pk=complaint_pk) except Exception as e: logger.error(f"Error deleting adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error deleting: {str(e)}") return redirect('complaints:complaint_detail', pk=complaint.id)