""" 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 HttpResponseForbidden, 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.accounts.services import StaffActivityService from apps.core.services import AuditService from apps.core.decorators import hospital_admin_required, block_source_user, px_admin_required from apps.organizations.models import Department, Hospital, Staff from apps.observations.models import Observation from apps.px_sources.models import SourceUser, PXSource from .models import ( Complaint, ComplaintAttachment, ComplaintCategory, ComplaintExplanation, ComplaintSourceType, ComplaintStatus, ComplaintType, ComplaintUpdate, Inquiry, InquiryAttachment, InquiryUpdate, ComplaintAdverseAction, ComplaintAdverseActionAttachment, GovernmentTicket, ) from .services.complaint_service import ComplaintService, ComplaintServiceError from .forms import ( ComplaintInvolvedDepartmentForm, ComplaintInvolvedStaffForm, DepartmentResponseForm, StaffExplanationForm, GovernmentTicketForm, ) # Set up logger logger = logging.getLogger(__name__) def _format_duration(start, end): """Format duration between two datetimes as a human-readable string.""" if not start or not end: return None duration = end - start total_seconds = int(duration.total_seconds()) if total_seconds < 60: return "< 1m" days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 parts = [] if days > 0: parts.append(f"{days}d") if hours > 0: parts.append(f"{hours}h") if minutes > 0 and days == 0: parts.append(f"{minutes}m") return " ".join(parts) if parts else "< 1m" def _build_complaint_stage_timeline(complaint): """Build stage timeline with timestamps and durations for a complaint.""" stages = [] if complaint.created_at: stages.append({ "label": _("Created"), "timestamp": complaint.created_at, "color": "bg-slate-400", }) if complaint.activated_at: stages.append({ "label": _("Activated"), "timestamp": complaint.activated_at, "duration_from_prev": _format_duration(complaint.created_at, complaint.activated_at), "color": "bg-blue-500", }) if complaint.forwarded_to_dept_at: stages.append({ "label": _("Forwarded to Department"), "timestamp": complaint.forwarded_to_dept_at, "duration_from_prev": _format_duration(complaint.activated_at or complaint.created_at, complaint.forwarded_to_dept_at), "color": "bg-purple-500", }) if complaint.response_date: stages.append({ "label": _("Department Responded"), "timestamp": complaint.response_date, "duration_from_prev": _format_duration(complaint.forwarded_to_dept_at or complaint.activated_at, complaint.response_date), "color": "bg-amber-500", }) if complaint.resolved_at: stages.append({ "label": _("Resolved"), "timestamp": complaint.resolved_at, "duration_from_prev": _format_duration(complaint.response_date or complaint.forwarded_to_dept_at, complaint.resolved_at), "color": "bg-green-500", }) if complaint.closed_at: stages.append({ "label": _("Closed"), "timestamp": complaint.closed_at, "duration_from_prev": _format_duration(complaint.resolved_at or complaint.response_date, complaint.closed_at), "color": "bg-emerald-600", }) return stages def _build_inquiry_stage_timeline(inquiry): """Build stage timeline with timestamps and durations for an inquiry.""" stages = [] if inquiry.created_at: stages.append({ "label": _("Created"), "timestamp": inquiry.created_at, "color": "bg-slate-400", }) if inquiry.activated_at: stages.append({ "label": _("Activated"), "timestamp": inquiry.activated_at, "duration_from_prev": _format_duration(inquiry.created_at, inquiry.activated_at), "color": "bg-blue-500", }) if inquiry.transferred_at: stages.append({ "label": _("Transferred to Department"), "timestamp": inquiry.transferred_at, "duration_from_prev": _format_duration(inquiry.activated_at or inquiry.created_at, inquiry.transferred_at), "color": "bg-purple-500", }) if inquiry.department_responded_at: stages.append({ "label": _("Department Responded"), "timestamp": inquiry.department_responded_at, "duration_from_prev": _format_duration(inquiry.transferred_at or inquiry.activated_at, inquiry.department_responded_at), "color": "bg-amber-500", }) if inquiry.responded_at: stages.append({ "label": _("Response Sent to Inquirer"), "timestamp": inquiry.responded_at, "duration_from_prev": _format_duration(inquiry.department_responded_at or inquiry.transferred_at, inquiry.responded_at), "color": "bg-cyan-500", }) _resolved_at = getattr(inquiry, 'resolved_at', None) if _resolved_at: stages.append({ "label": _("Resolved"), "timestamp": _resolved_at, "duration_from_prev": _format_duration(inquiry.responded_at or inquiry.department_responded_at, _resolved_at), "color": "bg-green-500", }) _closed_at = getattr(inquiry, 'closed_at', None) if _closed_at: stages.append({ "label": _("Closed"), "timestamp": _closed_at, "duration_from_prev": _format_duration(_resolved_at or inquiry.responded_at, _closed_at), "color": "bg-emerald-600", }) return stages def can_manage_complaint(user, complaint): return ComplaintService.can_manage(user, complaint) @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 # Get selected hospital for PX Admins (from middleware) selected_hospital = getattr(request, "tenant_hospital", None) if user.is_px_admin(): if selected_hospital: queryset = queryset.filter(hospital=selected_hospital) 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.is_champion() and user.department: queryset = queryset.filter(department=user.department) elif user.is_source_user(): queryset = queryset.filter(created_by=user) 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: values = [v.strip() for v in status_filter.split(",")] queryset = queryset.filter(status__in=values) # 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: values = [v.strip() for v in severity_filter.split(",")] queryset = queryset.filter(severity__in=values) 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 not date_from and "date_from" not in request.GET: date_from = "2026-01-01" 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 + " 23:59:59" if len(date_to) == 10 else 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) # Annotate each complaint with can_edit flag for complaint in queryset: complaint.can_edit = ComplaintService.can_manage(user, complaint) # 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 from apps.core.utils import get_assignable_users assignable_users = get_assignable_users(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, "open_percentage": (queryset.filter(status=ComplaintStatus.OPEN).count() / total_count * 100) if total_count > 0 else 0, "in_progress_percentage": (queryset.filter(status=ComplaintStatus.IN_PROGRESS).count() / total_count * 100) if total_count > 0 else 0, "pending_percentage": (pending_count / total_count * 100) if total_count > 0 else 0, "overdue": queryset.filter(is_overdue=True).count(), "overdue_percentage": (queryset.filter(is_overdue=True).count() / total_count * 100) if total_count > 0 else 0, "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(), "reopened": queryset.filter(reopened_from__isnull=False).count(), "escalated_ovr": queryset.filter(is_escalated_ovr=True).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, "status_filter": status_filter, "severity_filter": severity_filter, "priority_filter": priority_filter, "category_filter": category_filter, "source_filter": source_filter, "hospital_filter": hospital_filter, "department_filter": department_filter, "assigned_to_filter": assigned_to_filter, "overdue_filter": overdue_filter, "search_query": search_query, "date_from": date_from, "date_to": date_to or "", } 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) stage_timeline = _build_complaint_stage_timeline(complaint) # 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 from apps.core.utils import get_assignable_users assignable_users = get_assignable_users(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() from apps.rca.models import RootCauseAnalysis as RCA complaint_ct = ContentType.objects.get_for_model(Complaint) linked_rcas = RCA.objects.filter( content_type=complaint_ct, object_id=complaint.pk, is_deleted=False ).select_related("assigned_to", "created_by") context = { "complaint": complaint, "timeline": timeline, "stage_timeline": stage_timeline, "attachments": attachments, "px_actions": px_actions, "assignable_users": assignable_users, "status_choices": ComplaintStatus.choices, "base_layout": base_layout, "source_user": source_user, "can_edit": can_manage_complaint(user, complaint), "is_active_status": complaint.is_active_status, "ai_department_suggested": ( bool(complaint.department) and not complaint.involved_departments.filter(department=complaint.department).exists() ), "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, "linked_rcas": linked_rcas, "show_delay_reason_closure": ( complaint.delay_reason_closure or complaint.is_overdue or (timezone.now() - complaint.created_at).total_seconds() / 3600 > 72 or complaint.status in ("closed", "resolved") ), } _status_label_map = dict(ComplaintStatus.choices) _valid_next = ComplaintService.VALID_STATUS_TRANSITIONS.get(complaint.status, []) context["available_transitions"] = [(s, _status_label_map.get(s, s)) for s in _valid_next] 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, request=request) 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 form if selected, otherwise from logged-in source user if form.cleaned_data.get("source"): # User explicitly selected a PX source complaint.source = form.cleaned_data["source"] elif source_user and source_user.source: # Source user is submitting (auto-assign their 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_en="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 # Auto-set priority to high for MOH/CHI complaints if complaint.source and complaint.source.name_en: source_name = complaint.source.name_en.lower() if "moh" in source_name or "chi" in source_name or "council of health" in source_name or "ministry of health" in source_name: complaint.priority = "high" # Link to patient if auto-lookup found a match patient_id = request.POST.get("patient_id") if patient_id: try: from uuid import UUID UUID(patient_id) complaint.patient_id = patient_id except (ValueError, AttributeError): pass # 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() comm_req_id = request.POST.get("comm_req") if comm_req_id: try: from apps.px_sources.models import CommunicationRequest cr = CommunicationRequest.objects.get(pk=comm_req_id) cr.link_to_record(complaint) except Exception: pass ComplaintService.post_create_hooks(complaint, request.user, request=request) StaffActivityService.log_from_request( request, activity_type="create", description=f"Created complaint {complaint.reference_number}", content_object=complaint, module="complaints", ) 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 communication_request = None comm_req_id = request.GET.get("comm_req") if comm_req_id: try: from apps.px_sources.models import CommunicationRequest cr_data = CommunicationRequest.get_initial_data(comm_req_id) communication_request = cr_data["communication_request"] for key in ("hospital", "patient_name", "description"): if key in cr_data["initial"] and cr_data["initial"][key]: initial_data[key] = cr_data["initial"][key] except Exception: pass form = ComplaintForm(request=request, initial=initial_data) context = { "form": form, "base_layout": base_layout, "source_user": source_user, "communication_request": communication_request, } return render(request, "complaints/complaint_form.html", context) @login_required @require_http_methods(["POST"]) def complaint_assign(request, pk): """Assign complaint to user - Admin or currently assigned user""" complaint = get_object_or_404(Complaint, 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) old_status = complaint.status ComplaintService.assign(complaint, assignee, request.user, request=request) except User.DoesNotExist: messages.error(request, "User not found.") return redirect("complaints:complaint_detail", pk=pk) except ComplaintServiceError as e: messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_complaint_assigned( assignee.email if assignee.email else None, complaint, ) except Exception: pass messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_send_to(request, pk): """ Unified AJAX endpoint to send complaint to either a person or department. """ from django.http import JsonResponse from .models import ComplaintInvolvedDepartment from apps.notifications.services import NotificationService complaint = get_object_or_404(Complaint, pk=pk) user = request.user # Check permission if not can_manage_complaint(user, complaint): return JsonResponse({ "success": False, "error": str(_("You don't have permission to send this complaint.")), }, status=403) recipient_type = request.POST.get("recipient_type", "department") note = request.POST.get("note", "").strip() try: if recipient_type == "person": person_id = request.POST.get("person_id") if not person_id: return JsonResponse({ "success": False, "error": str(_("Please select a person.")), }, status=400) try: person = User.objects.get(id=person_id) except User.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("User not found.")), }, status=400) # Assign complaint to person ComplaintService.assign(complaint, person, user, request=request) # Send notification if person.email: NotificationService.send_email( email=person.email, subject=f"Complaint Assigned - {complaint.reference_number}", message=f"You have been assigned to complaint #{complaint.reference_number}.", html_message=f"""
You have been assigned to complaint #{complaint.reference_number}.
Title: {complaint.title or 'N/A'}
{f'Note: {note}
' if note else ''} """, related_object=complaint, ) message = f"Complaint sent to {person.get_full_name()}." else: # department department_id = request.POST.get("department_id") if not department_id: return JsonResponse({ "success": False, "error": str(_("Please select a department.")), }, status=400) try: department = Department.objects.get(id=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) # Create involved department record involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create( complaint=complaint, department=department, defaults={ "role": "secondary", "added_by": user, "forwarded_at": timezone.now(), } ) if not created: involved_dept.forwarded_at = timezone.now() involved_dept.save() # Send notification to department champion if department.respondent and department.respondent.user and department.respondent.user.email: NotificationService.send_email( email=department.respondent.user.email, subject=f"Complaint Sent to Department - {complaint.reference_number}", message=f"Complaint #{complaint.reference_number} has been sent to your department ({department.name}).", html_message=f"""Complaint #{complaint.reference_number} has been sent to your department ({department.name}).
Title: {complaint.title or 'N/A'}
{f'Note: {note}
' if note else ''} """, related_object=complaint, ) message = f"Complaint sent to {department.name}." # Change status to contacted if active if complaint.is_active_status and complaint.status in ("open", "in_progress"): old_status = complaint.status complaint.status = "contacted" complaint.save(update_fields=["status"]) ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", message=f"Status changed from {old_status} to contacted - sent to {recipient_type}", created_by=user, ) return JsonResponse({ "success": True, "message": message, }) except Exception as e: logger.error(f"Error in complaint_send_to: {str(e)}") return JsonResponse({ "success": False, "error": str(_("An error occurred while sending the complaint.")), }, status=500) @login_required @require_http_methods(["POST"]) def complaint_change_status(request, pk): """Change complaint status""" complaint = get_object_or_404(Complaint, pk=pk) new_status = request.POST.get("status") note = request.POST.get("note", "") resolution = request.POST.get("resolution", "") resolution_outcome = request.POST.get("resolution_outcome", "") resolution_outcome_other = request.POST.get("resolution_outcome_other", "") try: ComplaintService.change_status( complaint, new_status, request.user, request=request, note=note, resolution=resolution, resolution_outcome=resolution_outcome, resolution_outcome_other=resolution_outcome_other, ) except ComplaintServiceError as e: messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_complaint_status_changed( complaint.assigned_to.email if complaint.assigned_to and complaint.assigned_to.email else None, complaint, complaint.status, new_status, ) except Exception: pass messages.success(request, f"Complaint status changed to {new_status}.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def update_satisfaction(request, pk): """Update complaint satisfaction rating (resolved/closed complaints only).""" complaint = get_object_or_404(Complaint, pk=pk) if complaint.status not in ("resolved", "closed"): messages.error(request, _("Satisfaction can only be set for resolved or closed complaints.")) return redirect("complaints:complaint_detail", pk=pk) if not can_manage_complaint(request.user, complaint): messages.error(request, _("You don't have permission to update satisfaction.")) return redirect("complaints:complaint_detail", pk=pk) satisfaction = request.POST.get("satisfaction", "") valid_choices = ["satisfied", "neutral", "dissatisfied", "no_response"] if satisfaction and satisfaction not in valid_choices: messages.error(request, _("Invalid satisfaction value.")) return redirect("complaints:complaint_detail", pk=pk) from django.utils import timezone complaint.satisfaction = satisfaction if satisfaction: complaint.satisfaction_set_at = timezone.now() else: complaint.satisfaction_set_at = None complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"]) if satisfaction: messages.success(request, _("Satisfaction updated to: {}").format(complaint.get_satisfaction_display())) else: messages.success(request, _("Satisfaction cleared.")) return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def toggle_escalated_ovr(request, pk): """Request OVR escalation or cancel pending request.""" complaint = get_object_or_404(Complaint, pk=pk) if not can_manage_complaint(request.user, complaint): messages.error(request, _("You don't have permission to update this complaint.")) return redirect("complaints:complaint_detail", pk=pk) if complaint.status == ComplaintStatus.OVR_PENDING: complaint.status = ComplaintStatus.IN_PROGRESS complaint.is_escalated_ovr = False complaint.escalated_ovr_by = None complaint.escalated_ovr_at = None complaint.metadata.pop("ovr_requested_by", None) complaint.metadata.pop("ovr_requested_at", None) messages.success(request, _("OVR escalation request cancelled.")) else: complaint.status = ComplaintStatus.OVR_PENDING complaint.is_escalated_ovr = False complaint.metadata["ovr_requested_by"] = str(request.user.pk) complaint.metadata["ovr_requested_at"] = timezone.now().isoformat() messages.success(request, _("OVR escalation requested. Waiting for admin approval.")) # Send email notification to PX admins and management _send_ovr_request_notification(complaint, request.user) complaint.save(update_fields=["status", "is_escalated_ovr", "escalated_ovr_by", "escalated_ovr_at", "metadata", "updated_at"]) return redirect("complaints:complaint_detail", pk=pk) def _send_ovr_request_notification(complaint, requested_by): """Send email notification to admins when OVR escalation is requested""" from django.conf import settings from apps.accounts.models import User from apps.notifications.services import NotificationService try: admin_users = User.objects.filter( is_active=True, groups__name__in=["PX Admin", "Hospital Admin", "PX Management"], hospital=complaint.hospital ).distinct() subject = f"OVR Escalation Request - Complaint #{complaint.reference_number}" for admin in admin_users: if admin.email: message = f"""A new OVR escalation request requires your approval. Complaint: #{complaint.reference_number} Title: {complaint.title} Requested by: {requested_by.get_full_name()} Hospital: {complaint.hospital.name} Please review and approve/reject this request in the complaints system. URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/ """ NotificationService.send_email( email=admin.email, subject=subject, message=message, ) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Failed to send OVR notification: {e}") @login_required @require_http_methods(["POST"]) def approve_ovr_escalation(request, pk): """Approve OVR escalation request.""" complaint = get_object_or_404(Complaint, pk=pk) if not request.user.is_px_admin() and not request.user.is_hospital_admin(): messages.error(request, _("You don't have permission to approve OVR escalation.")) return redirect("complaints:complaint_detail", pk=pk) if complaint.status != ComplaintStatus.OVR_PENDING: messages.error(request, _("This complaint does not have a pending OVR request.")) return redirect("complaints:complaint_detail", pk=pk) complaint.status = ComplaintStatus.IN_PROGRESS complaint.is_escalated_ovr = True complaint.escalated_ovr_by = request.user complaint.escalated_ovr_at = timezone.now() requested_by = complaint.metadata.get("ovr_requested_by", "") complaint.metadata.pop("ovr_requested_by", None) complaint.metadata.pop("ovr_requested_at", None) complaint.metadata["ovr_approved_by"] = str(request.user.pk) complaint.metadata["ovr_approved_at"] = timezone.now().isoformat() complaint.save(update_fields=["status", "is_escalated_ovr", "escalated_ovr_by", "escalated_ovr_at", "metadata", "updated_at"]) messages.success(request, _("OVR escalation approved.")) _send_ovr_decision_notification(complaint, request.user, "approved") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def reject_ovr_escalation(request, pk): """Reject OVR escalation request.""" complaint = get_object_or_404(Complaint, pk=pk) if not request.user.is_px_admin() and not request.user.is_hospital_admin(): messages.error(request, _("You don't have permission to reject OVR escalation.")) return redirect("complaints:complaint_detail", pk=pk) if complaint.status != ComplaintStatus.OVR_PENDING: messages.error(request, _("This complaint does not have a pending OVR request.")) return redirect("complaints:complaint_detail", pk=pk) complaint.status = ComplaintStatus.IN_PROGRESS complaint.metadata.pop("ovr_requested_by", None) complaint.metadata.pop("ovr_requested_at", None) complaint.metadata["ovr_rejected_by"] = str(request.user.pk) complaint.metadata["ovr_rejected_at"] = timezone.now().isoformat() complaint.save(update_fields=["status", "metadata", "updated_at"]) messages.success(request, _("OVR escalation request rejected.")) _send_ovr_decision_notification(complaint, request.user, "rejected") return redirect("complaints:complaint_detail", pk=pk) def _send_ovr_decision_notification(complaint, decided_by, decision): """Send email notification to requester when OVR is approved or rejected""" from django.conf import settings from apps.accounts.models import User from apps.notifications.services import NotificationService try: requested_by_pk = complaint.metadata.get("ovr_requested_by") if not requested_by_pk: return requested_by_user = User.objects.filter(pk=requested_by_pk).first() if not requested_by_user or not requested_by_user.email: return decision_text = "approved" if decision == "approved" else "rejected" subject = f"OVR Escalation {decision_text.title()} - Complaint #{complaint.reference_number}" message = f"""Your OVR escalation request for complaint #{complaint.reference_number} has been {decision_text}. Complaint: #{complaint.reference_number} Title: {complaint.title} Decision: {decision_text.title()} by {decided_by.get_full_name()} {"The complaint is now escalated as OVR." if decision == "approved" else "Please contact the admin for more information."} URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/ """ NotificationService.send_email( email=requested_by_user.email, subject=subject, message=message, ) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Failed to send OVR decision notification: {e}") @login_required @require_http_methods(["POST"]) def reject_ovr_escalation(request, pk): """Reject OVR escalation request.""" complaint = get_object_or_404(Complaint, pk=pk) if not request.user.is_px_admin() and not request.user.is_hospital_admin(): messages.error(request, _("You don't have permission to reject OVR escalation.")) return redirect("complaints:complaint_detail", pk=pk) if complaint.status != ComplaintStatus.OVR_PENDING: messages.error(request, _("This complaint does not have a pending OVR request.")) return redirect("complaints:complaint_detail", pk=pk) complaint.status = ComplaintStatus.IN_PROGRESS complaint.metadata.pop("ovr_requested_by", None) complaint.metadata.pop("ovr_requested_at", None) complaint.metadata["ovr_rejected_by"] = str(request.user.pk) complaint.metadata["ovr_rejected_at"] = timezone.now().isoformat() complaint.save(update_fields=["status", "metadata", "updated_at"]) messages.success(request, _("OVR escalation request rejected.")) _send_ovr_decision_notification(complaint, request.user, "rejected") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_reopen(request, pk): """Reopen a resolved or closed complaint""" complaint = get_object_or_404(Complaint, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or complaint.assigned_to == user or (user.is_department_manager() and user.department and user.department == complaint.department) ): messages.error(request, "You don't have permission to reopen this complaint.") return redirect("complaints:complaint_detail", pk=pk) note = request.POST.get("note", "") if complaint.status not in ("resolved", "closed"): messages.error(request, "Only resolved or closed complaints can be reopened.") return redirect("complaints:complaint_detail", pk=pk) try: ComplaintService.reopen(complaint, request.user, request=request, note=note) except ComplaintServiceError as e: messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_complaint_status_changed( complaint.assigned_to.email if complaint.assigned_to and complaint.assigned_to.email else None, complaint, complaint.status, "in_progress", ) except Exception: pass messages.success(request, "Complaint reopened successfully.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def update_explanation_delay_reason(request, pk): complaint = get_object_or_404(Complaint, pk=pk) if not can_manage_complaint(request.user, complaint): messages.error(request, "You don't have permission to update this complaint.") return redirect("complaints:complaint_detail", pk=pk) reason = request.POST.get("explanation_delay_reason", "") old_reason = complaint.explanation_delay_reason complaint.explanation_delay_reason = reason complaint.save(update_fields=["explanation_delay_reason", "updated_at"]) ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Explanation delay reason updated: {reason}" if reason else "Explanation delay reason cleared", created_by=request.user, ) AuditService.log_event( event_type="update", description="Explanation delay reason updated", user=request.user, content_object=complaint, metadata={"old_value": old_reason, "new_value": reason}, ) messages.success(request, "Explanation delay reason updated.") return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def update_delay_reason_closure(request, pk): complaint = get_object_or_404(Complaint, pk=pk) if not can_manage_complaint(request.user, complaint): messages.error(request, "You don't have permission to update this complaint.") return redirect("complaints:complaint_detail", pk=pk) if complaint.status in ("closed", "resolved"): messages.error(request, "Cannot update delay reason for closed/resolved complaints.") return redirect("complaints:complaint_detail", pk=pk) from django.utils import timezone hours_since_creation = (timezone.now() - complaint.created_at).total_seconds() / 3600 if not complaint.is_overdue and hours_since_creation < 72: messages.error(request, "Delay reason can only be set when complaint is overdue or past 72 hours.") return redirect("complaints:complaint_detail", pk=pk) reason = request.POST.get("delay_reason_closure", "") from .models import DelayReasonChoices valid_reasons = [choice[0] for choice in DelayReasonChoices.choices] if reason and reason not in valid_reasons: messages.error(request, "Invalid delay reason.") return redirect("complaints:complaint_detail", pk=pk) old_reason = complaint.delay_reason_closure complaint.delay_reason_closure = reason complaint.save(update_fields=["delay_reason_closure", "updated_at"]) ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Closure delay reason updated: {reason}" if reason else "Closure delay reason cleared", created_by=request.user, ) AuditService.log_event( event_type="update", description="Closure delay reason updated", user=request.user, content_object=complaint, metadata={"old_value": old_reason, "new_value": reason}, ) messages.success(request, "Closure delay reason updated.") 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) note = request.POST.get("note") try: ComplaintService.add_note(complaint, note, request.user, request=request) except ComplaintServiceError as e: messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) 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) 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) ComplaintService.change_department(complaint, department, request.user, request=request) except Department.DoesNotExist: messages.error(request, "Department not found.") return redirect("complaints:complaint_detail", pk=pk) except ComplaintServiceError as e: messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) messages.success(request, f"Department changed to {department.name}.") 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) - Admin and Dept Manager only""" 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 # Fallback: use escalation target resolver if still no target if not escalate_to_user: from apps.complaints.services.complaint_service import ComplaintService fallback_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff) if fallback_user: escalate_to_user = fallback_user reason += f" [fallback via {fallback_path}]" # 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.""" complaint = get_object_or_404(Complaint, pk=pk) try: ComplaintService.activate(complaint, request.user, request=request) except ComplaintServiceError as e: messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) messages.success(request, "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 def complaint_export_historical_excel(request): """Export complaints to historical Excel format with Arabic headers.""" from apps.complaints.utils import export_historical_excel # Get filtered queryset queryset = Complaint.objects.select_related( "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "created_by", "source", "domain", "category", "subcategory_obj", "classification_obj", ) # 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 date range filter date_start = request.GET.get("date_start") date_end = request.GET.get("date_end") if date_start: from datetime import datetime try: date_start_dt = datetime.strptime(date_start, "%Y-%m-%d") queryset = queryset.filter(created_at__date__gte=date_start_dt) except ValueError: pass if date_end: from datetime import datetime try: date_end_dt = datetime.strptime(date_end, "%Y-%m-%d") queryset = queryset.filter(created_at__date__lte=date_end_dt) except ValueError: pass # Apply other filters from request status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_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_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_historical_excel(queryset, date_start, date_end) @login_required def complaint_export_monthly_calculations(request): """ Step 1 — Export monthly calculations report to Excel. """ from apps.complaints.utils import export_monthly_calculations from django.core.exceptions import PermissionDenied if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can export.") year = request.GET.get("year") month = request.GET.get("month") if not year or not month: from django.contrib import messages messages.error(request, "Year and month are required for monthly calculations export.") return redirect("complaints:complaint_list") queryset = Complaint.objects.filter( created_at__year=int(year), created_at__month=int(month), ) if request.user.is_hospital_admin() and request.user.hospital: queryset = queryset.filter(hospital=request.user.hospital) elif request.user.is_px_admin() and request.tenant_hospital: queryset = queryset.filter(hospital=request.tenant_hospital) queryset = queryset.select_related( "hospital", "department", "main_section", "subsection", "assigned_to", "resolved_by", "closed_by", "created_by", "source", ).prefetch_related("involved_departments__department") return export_monthly_calculations(queryset, int(year), int(month)) @login_required def complaint_export_quarterly_calculations(request): """ Step 2 — Export quarterly calculations report to Excel. """ from apps.complaints.utils import export_quarterly_calculations from django.core.exceptions import PermissionDenied if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can export.") year = request.GET.get("year") quarter = request.GET.get("quarter") if not year or not quarter: from django.contrib import messages messages.error(request, "Year and quarter are required.") return redirect("complaints:complaint_list") year, quarter = int(year), int(quarter) quarter_months = {1: (1, 3), 2: (4, 6), 3: (7, 9), 4: (10, 12)} start_month, end_month = quarter_months[quarter] queryset = Complaint.objects.filter( created_at__year=year, created_at__month__gte=start_month, created_at__month__lte=end_month, ) if request.user.is_hospital_admin() and request.user.hospital: queryset = queryset.filter(hospital=request.user.hospital) elif request.user.is_px_admin() and request.tenant_hospital: queryset = queryset.filter(hospital=request.tenant_hospital) return export_quarterly_calculations(queryset, year, quarter) @login_required def complaint_export_yearly_calculations(request): """ Export yearly calculations report to Excel. """ from apps.complaints.utils import export_yearly_calculations from django.core.exceptions import PermissionDenied if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can export.") year = request.GET.get("year") if not year: from django.contrib import messages messages.error(request, "Year is required.") return redirect("complaints:complaint_list") year = int(year) queryset = Complaint.objects.filter(created_at__year=year) if request.user.is_hospital_admin() and request.user.hospital: queryset = queryset.filter(hospital=request.user.hospital) elif request.user.is_px_admin() and request.tenant_hospital: queryset = queryset.filter(hospital=request.tenant_hospital) return export_yearly_calculations(queryset, year) @hospital_admin_required @require_http_methods(["POST"]) def complaint_bulk_assign(request): """Bulk assign complaints - Admin only""" from apps.complaints.utils import bulk_assign_complaints import json 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) @hospital_admin_required @require_http_methods(["POST"]) def complaint_bulk_status(request): """Bulk change complaint status - Admin only""" from apps.complaints.utils import bulk_change_status import json 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) @hospital_admin_required @require_http_methods(["POST"]) def complaint_bulk_escalate(request): """Bulk escalate complaints - Admin only""" from apps.complaints.utils import bulk_escalate_complaints import json 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 # Get selected hospital for PX Admins (from middleware) selected_hospital = getattr(request, "tenant_hospital", None) if user.is_px_admin(): if selected_hospital: queryset = queryset.filter(hospital=selected_hospital) 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( Q(department=user.department) | Q(outgoing_department=user.department) ) elif user.is_champion() and user.department: queryset = queryset.filter( Q(department=user.department) | Q(outgoing_department=user.department) ) elif user.is_source_user(): queryset = queryset.filter(created_by=user) 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) # Statistics total_count = queryset.count() resolved_count = queryset.filter(status="resolved").count() stats = { "total": total_count, "open": queryset.filter(status="open").count(), "in_progress": queryset.filter(status="in_progress").count(), "resolved": resolved_count, "resolved_percentage": (resolved_count / total_count * 100) if total_count > 0 else 0, "overdue": queryset.filter(is_overdue=True).count(), } # Get departments for filter departments = Department.objects.filter(status="active") if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) context = { "page_obj": page_obj, "inquiries": page_obj.object_list, "stats": stats, "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", "location", "main_section", "subsection", "assigned_to", "responded_by", "outgoing_department", "department_responded_by", "taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification", ).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("inquiries:inquiry_list") elif ( user.is_champion() or user.is_department_manager() ) and ( (inquiry.department and inquiry.department == user.department) or (inquiry.outgoing_department and inquiry.outgoing_department == user.department) ): pass elif ( user.is_champion() or user.is_department_manager() ): messages.error(request, "You don't have permission to view this inquiry.") return redirect("inquiries:inquiry_list") elif user.hospital and inquiry.hospital != user.hospital: messages.error(request, "You don't have permission to view this inquiry.") return redirect("inquiries:inquiry_list") # Get timeline (updates) timeline = inquiry.updates.all().order_by("-created_at") stage_timeline = _build_inquiry_stage_timeline(inquiry) # Get attachments attachments = inquiry.attachments.all().order_by("-created_at") # Get assignable users from apps.core.utils import get_assignable_users assignable_users = get_assignable_users(inquiry.hospital) # Get departments for the inquiry's hospital hospital_departments = [] if inquiry.hospital: hospital_departments = Department.objects.filter(hospital=inquiry.hospital, status="active").order_by("name") # Status choices for the form status_choices = [ ("open", "Open"), ("in_progress", "In Progress"), ("contacted", "Contacted"), ("contacted_no_response", "Contacted, No Response"), ("resolved", "Resolved"), ("closed", "Closed"), ] from apps.rca.models import RootCauseAnalysis as RCA from django.contrib.contenttypes.models import ContentType as CT inquiry_ct = CT.objects.get_for_model(Inquiry) linked_rcas = RCA.objects.filter(content_type=inquiry_ct, object_id=inquiry.pk, is_deleted=False).select_related( "assigned_to", "created_by" ) context = { "inquiry": inquiry, "timeline": timeline, "stage_timeline": stage_timeline, "attachments": attachments, "assignable_users": assignable_users, "hospital_departments": hospital_departments, "status_choices": status_choices, "can_edit": user.is_px_admin() or user.is_hospital_admin(), "can_respond": ( user.is_px_admin() or user.is_hospital_admin() or inquiry.assigned_to == user or ( user.is_champion() and user.department and user.department in [inquiry.department, inquiry.outgoing_department] ) ), "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), "can_send_reminder": user.is_px_admin() or user.is_hospital_admin(), "base_layout": base_layout, "source_user": source_user, "linked_rcas": linked_rcas, } return render(request, "complaints/inquiry_detail.html", context) @login_required @require_http_methods(["POST"]) def inquiry_send_to_staff(request, pk): """Send inquiry explanation request to a specific staff member via token link.""" from .models import InquiryExplanation from apps.organizations.models import Staff from apps.notifications.services import NotificationService from django.contrib.sites.shortcuts import get_current_site import secrets inquiry = get_object_or_404(Inquiry, pk=pk) if not (request.user.is_px_admin() or request.user.is_hospital_admin()): messages.error(request, _("You don't have permission to perform this action.")) return redirect("inquiries:inquiry_detail", pk=pk) staff_id = request.POST.get("staff_id") request_message = request.POST.get("request_message", "").strip() if not staff_id: messages.error(request, _("Please select a staff member.")) return redirect("inquiries:inquiry_detail", pk=pk) staff = get_object_or_404(Staff, pk=staff_id) token = secrets.token_urlsafe(32) explanation = InquiryExplanation.objects.create( inquiry=inquiry, staff=staff, token=token, requested_by=request.user, request_message=request_message, email_sent_at=timezone.now(), ) from apps.complaints.models import InquirySLAConfig sla_config = InquirySLAConfig.get_active_config() if sla_config and sla_config.dept_response_sla_hours: explanation.sla_due_at = timezone.now() + timezone.timedelta(hours=sla_config.dept_response_sla_hours) explanation.save(update_fields=["sla_due_at"]) site = get_current_site(request) explanation_url = f"https://{site.domain}/inquiries/{inquiry.id}/explain/{token}/" if staff.email: NotificationService.send_email( to_email=staff.email, subject=f"Inquiry Response Requested - #{inquiry.reference_number or inquiry.id}", template_name="emails/inquiry_explanation_request", context={ "staff_name": f"{staff.first_name} {staff.last_name}", "inquiry_subject": inquiry.subject, "inquiry_reference": inquiry.reference_number or str(inquiry.id), "inquiry_message": inquiry.message[:500], "request_message": request_message, "explanation_url": explanation_url, "site_name": site.name, }, ) messages.success(request, _(f"Response request sent to {staff.first_name} {staff.last_name}")) return redirect("inquiries:inquiry_detail", pk=pk) @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, request=request) if form.is_valid(): try: inquiry = form.save(commit=False) inquiry.category = request.POST.get("category") inquiry.created_by = request.user is_outgoing = request.POST.get("is_outgoing") in ("true", "True", "on", "1") inquiry.is_outgoing = is_outgoing if is_outgoing: outgoing_dept_id = request.POST.get("outgoing_department") if outgoing_dept_id: from apps.organizations.models import Department try: inquiry.outgoing_department = Department.objects.get(id=outgoing_dept_id) except Department.DoesNotExist: pass # Link to patient if auto-lookup found a match patient_id = request.POST.get("patient_id") if patient_id: try: from uuid import UUID UUID(patient_id) inquiry.patient_id = patient_id except (ValueError, AttributeError): pass inquiry.save() comm_req_id = request.POST.get("comm_req") if comm_req_id: try: from apps.px_sources.models import CommunicationRequest as CR cr = CR.objects.get(pk=comm_req_id) cr.link_to_record(inquiry) except Exception: pass from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item analyze_inquiry_with_ai.delay(str(inquiry.id)) notify_staff_new_item.delay("inquiry", str(inquiry.id)) InquiryUpdate.objects.create( inquiry=inquiry, update_type="note", message="Inquiry created.", created_by=request.user, ) 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("inquiries: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 communication_request = None comm_req_id = request.GET.get("comm_req") if comm_req_id: try: from apps.px_sources.models import CommunicationRequest cr_data = CommunicationRequest.get_initial_data(comm_req_id) communication_request = cr_data["communication_request"] for key in ("hospital", "contact_name", "contact_phone", "message"): if key in cr_data["initial"] and cr_data["initial"][key]: initial_data[key] = cr_data["initial"][key] except Exception: pass form = InquiryForm(request=request, initial=initial_data) context = { "form": form, "base_layout": base_layout, "source_user": source_user, "communication_request": communication_request, } return render(request, "complaints/inquiry_form.html", context) @login_required @require_http_methods(["GET", "POST"]) def inquiry_edit(request, pk): """Edit existing inquiry""" from .forms import InquiryForm inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, _("You don't have permission to edit this inquiry.")) return redirect("inquiries:inquiry_detail", pk=inquiry.pk) if inquiry.status == "closed": messages.error(request, _("Closed inquiries cannot be edited.")) return redirect("inquiries:inquiry_detail", pk=inquiry.pk) if request.method == "POST": form = InquiryForm(request.POST, request=request, instance=inquiry) if form.is_valid(): try: inquiry = form.save(commit=False) inquiry.category = request.POST.get("category", inquiry.category) is_outgoing = request.POST.get("is_outgoing") in ("true", "True", "on", "1") inquiry.is_outgoing = is_outgoing if is_outgoing: outgoing_dept_id = request.POST.get("outgoing_department") if outgoing_dept_id: try: inquiry.outgoing_department = Department.objects.get(id=outgoing_dept_id) except Department.DoesNotExist: pass else: inquiry.outgoing_department = None inquiry.save() InquiryUpdate.objects.create( inquiry=inquiry, update_type="note", message="Inquiry updated.", created_by=request.user, ) AuditService.log_event( event_type="inquiry_updated", description=f"Inquiry updated: {inquiry.subject}", user=request.user, content_object=inquiry, ) messages.success(request, _("Inquiry updated successfully.")) return redirect("inquiries:inquiry_detail", pk=inquiry.pk) except Exception as e: messages.error(request, f"Error updating inquiry: {str(e)}") else: messages.error(request, f"Please correct the errors: {form.errors}") else: initial_data = {} if inquiry.location: initial_data["location"] = inquiry.location_id if inquiry.main_section: initial_data["main_section"] = inquiry.main_section_id if inquiry.subsection: initial_data["subsection"] = inquiry.subsection_id form = InquiryForm(request=request, instance=inquiry, initial=initial_data) context = { "form": form, "inquiry": inquiry, } 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("inquiries: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("inquiries: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("inquiries: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) user = request.user can_assign = ( user.is_px_admin() or user.is_hospital_admin() or (user.is_department_manager() and user.department and user.department == inquiry.department) ) if not can_assign: messages.error(request, "You don't have permission to assign inquiries.") return redirect("inquiries: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("inquiries:inquiry_detail", pk=pk) try: assignee = User.objects.get(id=user_id) old_assignee = inquiry.assigned_to old_status = inquiry.status inquiry.assigned_to = assignee inquiry.assigned_at = timezone.now() reopened = False if old_status in ("resolved", "closed"): inquiry.status = "in_progress" reopened = True inquiry.save() msg = f"{'Reopened and a' if reopened else 'A'}ssigned to {assignee.get_full_name()}" if old_assignee: msg += f" (reassigned from {old_assignee.get_full_name()})" InquiryUpdate.objects.create( inquiry=inquiry, update_type="assignment", message=msg, created_by=request.user, ) AuditService.log_event( event_type="assignment", description=f"Inquiry {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}", user=request.user, content_object=inquiry, metadata={"old_status": old_status, "reopened": reopened}, ) if inquiry.department: try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_inquiry_department_assigned(inquiry.department, inquiry) except Exception as e: logger.warning(f"Failed to send department assignment notification: {e}") try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_inquiry_assigned(inquiry) except Exception as e: logger.warning(f"Failed to send inquiry assignment notification: {e}") messages.success(request, f"Inquiry {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}.") except User.DoesNotExist: messages.error(request, "User not found.") return redirect("inquiries: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("inquiries: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("inquiries: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("inquiries: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("inquiries:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_reopen(request, pk): from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or (user.is_department_manager() and user.department == inquiry.department) ): messages.error(request, "You don't have permission to reopen inquiries.") return redirect("inquiries:inquiry_detail", pk=pk) if inquiry.status not in ("resolved", "closed"): messages.error(request, "Only resolved or closed inquiries can be reopened.") return redirect("inquiries:inquiry_detail", pk=pk) old_status = inquiry.status note = request.POST.get("note", "Inquiry reopened") inquiry.status = "in_progress" inquiry.save() InquiryUpdate.objects.create( inquiry=inquiry, update_type="status_change", message=note or f"Inquiry reopened from {old_status}", created_by=user, ) AuditService.log_event( event_type="inquiry_reopened", description=f"Inquiry reopened from {old_status} by {user.get_full_name()}", user=user, content_object=inquiry, metadata={"old_status": old_status, "new_status": "in_progress"}, ) try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_inquiry_reopened(inquiry) except Exception as e: logger.warning(f"Failed to send inquiry reopened notification: {e}") messages.success(request, "Inquiry reopened successfully.") return redirect("inquiries: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("inquiries: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("inquiries:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_respond(request, pk): """Respond to inquiry with bilingual support and notification""" from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user can_respond = ( user.is_px_admin() or user.is_hospital_admin() or inquiry.assigned_to == user or ( user.is_champion() and user.department and user.department in [inquiry.department, inquiry.outgoing_department] ) ) if not can_respond: messages.error(request, "You don't have permission to respond to inquiries.") return redirect("inquiries:inquiry_detail", pk=pk) response_en = request.POST.get("response_en", "").strip() response_ar = request.POST.get("response_ar", "").strip() response = response_en or response_ar if not response: messages.error(request, "Please enter a response in at least one language.") return redirect("inquiries:inquiry_detail", pk=pk) inquiry.response = response inquiry.response_en = response_en inquiry.response_ar = response_ar inquiry.responded_at = timezone.now() inquiry.responded_by = request.user inquiry.status = "resolved" inquiry.save() InquiryUpdate.objects.create( inquiry=inquiry, update_type="response", message="Response sent", created_by=request.user ) AuditService.log_event( event_type="inquiry_responded", description=f"Inquiry responded to: {inquiry.subject}", user=request.user, content_object=inquiry, ) try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_inquiry_resolved(inquiry) except Exception as e: logger.warning(f"Failed to send inquiry resolved notification: {e}") if inquiry.response_sent_at is None: inquiry.response_sent_at = timezone.now() inquiry.save(update_fields=["response_sent_at"]) try: from apps.notifications.services import NotificationService if inquiry.contact_phone: sms_text = response_en if response_en else response_ar if len(sms_text) > 200: sms_text = sms_text[:197] + "..." NotificationService.send_sms( phone=inquiry.contact_phone, message=f"PX360: Your inquiry #{inquiry.reference_number} has been responded to. {sms_text}", related_object=inquiry, metadata={"notification_type": "inquiry_response_sent"}, ) if inquiry.contact_email: email_subject = f"PX360: Response to Your Inquiry #{inquiry.reference_number}" email_body_parts = [] if response_en: email_body_parts.append(f"Response (English):\n\n{response_en}") if response_ar: email_body_parts.append(f"الرد (العربية):\n\n{response_ar}") email_body = "\n\n---\n\n".join(email_body_parts) email_body += f"\n\n---\nReference: {inquiry.reference_number}\nHospital: {inquiry.hospital.name}\n\nThis is an automated message from PX 360." NotificationService.send_email( email=inquiry.contact_email, subject=email_subject, message=email_body, related_object=inquiry, metadata={"notification_type": "inquiry_response_email"}, ) InquiryUpdate.objects.create( inquiry=inquiry, update_type="communication", message=f"Response notification sent to inquirer (phone: {bool(inquiry.contact_phone)}, email: {bool(inquiry.contact_email)})", ) except Exception as e: logger.error(f"Failed to send inquiry response notification: {e}") messages.success(request, "Response sent successfully.") return redirect("inquiries:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_transfer_to_department(request, pk): from .models import Inquiry, InquiryUpdate inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management() ): messages.error(request, _("You don't have permission to transfer inquiries to departments.")) return redirect("inquiries:inquiry_detail", pk=pk) department_id = request.POST.get("department_id") if not department_id: messages.error(request, _("Please select a department.")) return redirect("inquiries:inquiry_detail", pk=pk) from apps.organizations.models import Department try: department = Department.objects.get(pk=department_id, status="active") except Department.DoesNotExist: messages.error(request, _("Department not found.")) return redirect("inquiries:inquiry_detail", pk=pk) recipient_type = request.POST.get("recipient_type", "staff") note_en = request.POST.get("note_en", "").strip() note_ar = request.POST.get("note_ar", "").strip() note_parts = [] if note_en: note_parts.append(note_en) if note_ar: note_parts.append(note_ar) combined_note = "\n\n".join(note_parts) inquiry.outgoing_department = department inquiry.transferred_at = timezone.now() inquiry.transferred_by = user inquiry.transferred_to_department = department inquiry.transfer_count = (inquiry.transfer_count or 0) + 1 sla_config = inquiry.get_sla_config() if sla_config and sla_config.dept_response_hours: from datetime import timedelta inquiry.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours) inquiry.dept_response_is_overdue = False inquiry.dept_response_reminder_sent_at = None inquiry.dept_response_second_reminder_sent_at = None inquiry.dept_response_escalated_at = None if inquiry.status in ("open",): inquiry.status = "contacted" inquiry.save() InquiryUpdate.objects.create( inquiry=inquiry, update_type="transferred_to_department", message=combined_note or f"Inquiry transferred to {department.get_localized_name()} for response", created_by=user, ) try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_inquiry_department_assigned( department, inquiry, context_note_en=note_en, context_note_ar=note_ar, recipient_type=recipient_type, ) if department.respondent and department.respondent.user and department.respondent.user.email: from apps.notifications.services import NotificationService NotificationService.send_email( email=department.respondent.user.email, subject=f"Inquiry #{inquiry.reference_number} - Response Required", message=f"An inquiry has been transferred to your department ({department.get_localized_name()}) for response. Subject: {inquiry.subject}. Please submit your response before the deadline: {inquiry.dept_response_sla_due_at}", related_object=inquiry, ) except Exception as e: logger.warning(f"Failed to send department notification: {e}") if recipient_type == "department_email": messages.success(request, _("Inquiry transferred to %(dept)s department email.") % {"dept": department.get_localized_name()}) else: messages.success(request, _("Inquiry transferred to %(dept)s. Department respondents have been notified.") % {"dept": department.get_localized_name()}) return redirect("inquiries:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_send_to(request, pk): """ Unified AJAX endpoint to send inquiry to either a person or department. """ from django.http import JsonResponse from apps.notifications.services import NotificationService inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user # Check permission if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management() ): return JsonResponse({ "success": False, "error": str(_("You don't have permission to send this inquiry.")), }, status=403) recipient_type = request.POST.get("recipient_type", "department") note = request.POST.get("note", "").strip() try: if recipient_type == "person": person_id = request.POST.get("person_id") if not person_id: return JsonResponse({ "success": False, "error": str(_("Please select a person.")), }, status=400) try: person = User.objects.get(id=person_id) except User.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("User not found.")), }, status=400) # Assign inquiry to person inquiry.assigned_to = person inquiry.assigned_at = timezone.now() inquiry.save(update_fields=["assigned_to", "assigned_at"]) # Send notification if person.email: NotificationService.send_email( email=person.email, subject=f"Inquiry Assigned - {inquiry.reference_number}", message=f"You have been assigned to inquiry #{inquiry.reference_number}.", html_message=f"""You have been assigned to inquiry #{inquiry.reference_number}.
Subject: {inquiry.subject or 'N/A'}
{f'Note: {note}
' if note else ''} """, related_object=inquiry, ) message = f"Inquiry sent to {person.get_full_name()}." else: # department department_id = request.POST.get("department_id") if not department_id: return JsonResponse({ "success": False, "error": str(_("Please select a department.")), }, status=400) try: department = Department.objects.get(pk=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) # Transfer to department inquiry.outgoing_department = department inquiry.transferred_at = timezone.now() inquiry.transferred_by = user inquiry.transferred_to_department = department inquiry.transfer_count = (inquiry.transfer_count or 0) + 1 sla_config = inquiry.get_sla_config() if sla_config and sla_config.dept_response_hours: from datetime import timedelta inquiry.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours) inquiry.dept_response_is_overdue = False inquiry.dept_response_reminder_sent_at = None inquiry.dept_response_second_reminder_sent_at = None inquiry.dept_response_escalated_at = None message = f"Inquiry sent to {department.get_localized_name()}." # Change status to contacted if open if inquiry.status in ("open",): inquiry.status = "contacted" inquiry.save() InquiryUpdate.objects.create( inquiry=inquiry, update_type="transferred_to_department" if recipient_type == "department" else "assigned", message=note or f"Inquiry sent to {recipient_type}", created_by=user, ) # Send department notification if applicable if recipient_type == "department": try: from apps.notifications.settings_service import NotificationServiceWithSettings NotificationServiceWithSettings.send_inquiry_department_assigned( department, inquiry, context_note_en=note, context_note_ar="", ) except Exception as e: logger.warning(f"Failed to send department notification: {e}") return JsonResponse({ "success": True, "message": message, }) except Exception as e: logger.error(f"Error in inquiry_send_to: {str(e)}") return JsonResponse({ "success": False, "error": str(_("An error occurred while sending the inquiry.")), }, status=500) @login_required @require_http_methods(["GET", "POST"]) def inquiry_department_response(request, pk): from .models import Inquiry inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or (user.is_champion() and ( inquiry.department == user.department or inquiry.outgoing_department == user.department )) ): messages.error(request, "You don't have permission to respond to this inquiry.") return redirect("inquiries:inquiry_detail", pk=pk) if request.method == "POST": response_en = request.POST.get("response_en", "").strip() response_ar = request.POST.get("response_ar", "").strip() response = response_en or response_ar if not response: messages.error(request, "Please enter a response in at least one language.") return redirect("inquiries:inquiry_department_response", pk=pk) inquiry.department_response_en = response_en inquiry.department_response_ar = response_ar inquiry.department_responded_at = timezone.now() inquiry.department_responded_by = request.user inquiry.dept_response_is_overdue = False inquiry.dept_response_acceptance_status = "pending" inquiry.dept_response_accepted_by = None inquiry.dept_response_accepted_at = None inquiry.dept_response_acceptance_notes = "" inquiry.save() try: from apps.core.ai_service import AIService import json prompt = f"""You are an AI assistant for a hospital patient experience system. Summarize the following department response to a patient inquiry in 2-3 concise sentences. Inquiry subject: {inquiry.subject} Inquiry message: {inquiry.message[:500]} Department response: {response[:500]} Generate a JSON response with: - "summary_en": A brief summary of the department's response in English (2-3 sentences) - "summary_ar": The same summary translated to Modern Standard Arabic""" result = AIService.chat_completion( prompt=prompt, response_format="json_object", ) parsed = json.loads(result) inquiry.department_response_summary_en = parsed.get("summary_en", "") inquiry.department_response_summary_ar = parsed.get("summary_ar", "") inquiry.save(update_fields=["department_response_summary_en", "department_response_summary_ar"]) except Exception as e: logger.warning(f"AI summary of department response failed: {e}") InquiryUpdate.objects.create( inquiry=inquiry, update_type="department_response", message=f"Department response submitted by {user.get_full_name()}", created_by=user, ) AuditService.log_event( event_type="inquiry_department_response", description=f"Department response submitted for inquiry: {inquiry.subject}", user=user, content_object=inquiry, ) messages.success(request, "Department response submitted successfully.") return redirect("inquiries:inquiry_detail", pk=pk) context = { "inquiry": inquiry, } return render(request, "complaints/inquiry_department_response.html", context) @login_required @require_http_methods(["POST"]) def inquiry_review_dept_response(request, pk): from .models import Inquiry, InquiryUpdate inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to review department responses.") return redirect("inquiries:inquiry_detail", pk=pk) if not inquiry.department_responded_at: messages.error(request, "No department response to review.") return redirect("inquiries:inquiry_detail", pk=pk) status = request.POST.get("acceptance_status") if status not in ("acceptable", "not_acceptable"): messages.error(request, "Invalid acceptance status.") return redirect("inquiries:inquiry_detail", pk=pk) notes = request.POST.get("acceptance_notes", "").strip() inquiry.dept_response_acceptance_status = status inquiry.dept_response_accepted_by = user inquiry.dept_response_accepted_at = timezone.now() inquiry.dept_response_acceptance_notes = notes inquiry.save( update_fields=[ "dept_response_acceptance_status", "dept_response_accepted_by", "dept_response_accepted_at", "dept_response_acceptance_notes", ] ) InquiryUpdate.objects.create( inquiry=inquiry, update_type="note", message=f"Department response marked as {status} by {user.get_full_name()}. {notes}", created_by=user, ) AuditService.log_event( event_type="inquiry_dept_response_review", description=f"Department response for inquiry {inquiry.reference_number} marked as {status}", user=user, content_object=inquiry, ) messages.success(request, f"Department response marked as {status}.") return redirect("inquiries:inquiry_detail", pk=pk) @login_required @require_http_methods(["POST"]) def inquiry_send_dept_response_reminder(request, pk): from .models import Inquiry, InquiryUpdate inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to send reminders.") return redirect("inquiries:inquiry_detail", pk=pk) if inquiry.department_responded_at: messages.warning(request, "Department has already responded.") return redirect("inquiries:inquiry_detail", pk=pk) dept = inquiry.outgoing_department or inquiry.transferred_to_department if not dept: messages.error(request, "No department assigned.") return redirect("inquiries:inquiry_detail", pk=pk) reminder_type = request.POST.get("reminder_type", "first") try: from apps.notifications.services import NotificationService recipients = [] if dept.respondent and dept.respondent.user and dept.respondent.user.email: recipients.append(dept.respondent.user) for recipient in recipients: NotificationService.send_email( email=recipient.email, subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required", message=f"This is a reminder that inquiry #{inquiry.reference_number} is awaiting your department's response. Please submit your response as soon as possible.", related_object=inquiry, ) if reminder_type == "first": inquiry.dept_response_reminder_sent_at = timezone.now() else: inquiry.dept_response_second_reminder_sent_at = timezone.now() inquiry.save() InquiryUpdate.objects.create( inquiry=inquiry, update_type="note", message=f"Manual {reminder_type} reminder sent by {user.get_full_name()}", created_by=user, ) messages.success(request, f"Reminder sent to {len(recipients)} recipient(s).") except Exception as e: logger.error(f"Failed to send reminder: {e}") messages.error(request, "Failed to send reminder.") return redirect("inquiries:inquiry_detail", pk=pk) @login_required def inquiry_export_incoming(request): """Export incoming inquiries report.""" from .utils import export_inquiries_report year = request.GET.get("year") month = request.GET.get("month") if not year or not month: messages.error(request, "Year and month are required.") return redirect("inquiries:inquiry_list") queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=False) if request.user.is_hospital_admin() and request.user.hospital: queryset = queryset.filter(hospital=request.user.hospital) elif request.user.is_px_admin() and request.tenant_hospital: queryset = queryset.filter(hospital=request.tenant_hospital) return export_inquiries_report(queryset, int(year), int(month), is_outgoing=False) @login_required def inquiry_export_outgoing(request): """Export outgoing inquiries report.""" from .utils import export_inquiries_report year = request.GET.get("year") month = request.GET.get("month") if not year or not month: messages.error(request, "Year and month are required.") return redirect("inquiries:inquiry_list") queryset = Inquiry.objects.filter(created_at__year=int(year), created_at__month=int(month), is_outgoing=True) if request.user.is_hospital_admin() and request.user.hospital: queryset = queryset.filter(hospital=request.user.hospital) elif request.user.is_px_admin() and request.tenant_hospital: queryset = queryset.filter(hospital=request.tenant_hospital) return export_inquiries_report(queryset, int(year), int(month), is_outgoing=True) @login_required @require_http_methods(["POST"]) def inquiry_update_contact_stage(request, pk): """Update a contact tracking stage on the inquiry.""" inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user if not (user.is_px_admin() or user.is_hospital_admin() or inquiry.assigned_to == user): messages.error(request, _("You don't have permission to update contact tracking.")) return redirect("inquiries:inquiry_detail", pk=pk) stage = request.POST.get("stage") import datetime as _dt if stage == "contacted_nr": date_str = request.POST.get("contacted_nr_date") time_str = request.POST.get("contacted_nr_time") duration_str = request.POST.get("contacted_nr_duration") if date_str: inquiry.contacted_nr_at = _dt.datetime.strptime(date_str, "%Y-%m-%d") if time_str: inquiry.contacted_nr_time = _dt.datetime.strptime(time_str, "%H:%M").time() if duration_str: parts = duration_str.split(":") inquiry.contacted_nr_duration = _dt.timedelta( hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0), seconds=int(parts[2] if len(parts) > 2 else 0), ) inquiry.contacted_nr_by = user if inquiry.status == "open": inquiry.status = "contacted_no_response" elif stage == "under_process": date_str = request.POST.get("under_process_date") time_str = request.POST.get("under_process_time") duration_str = request.POST.get("under_process_duration") if date_str: inquiry.under_process_at = _dt.datetime.strptime(date_str, "%Y-%m-%d") if time_str: inquiry.under_process_time = _dt.datetime.strptime(time_str, "%H:%M").time() if duration_str: parts = duration_str.split(":") inquiry.under_process_duration = _dt.timedelta( hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0), seconds=int(parts[2] if len(parts) > 2 else 0), ) inquiry.under_process_by = user if inquiry.status in ("open", "contacted_no_response"): inquiry.status = "in_progress" elif stage == "contacted": date_str = request.POST.get("contacted_date") time_str = request.POST.get("contacted_time") duration_str = request.POST.get("contacted_duration") if date_str: inquiry.contacted_at = _dt.datetime.strptime(date_str, "%Y-%m-%d") if time_str: inquiry.contacted_time = _dt.datetime.strptime(time_str, "%H:%M").time() if duration_str: parts = duration_str.split(":") inquiry.contacted_duration = _dt.timedelta( hours=int(parts[0]), minutes=int(parts[1] if len(parts) > 1 else 0), seconds=int(parts[2] if len(parts) > 2 else 0), ) inquiry.contacted_by = user if inquiry.status in ("open", "in_progress", "contacted_no_response"): inquiry.status = "contacted" elif stage == "notes": inquiry.staff_notes = request.POST.get("staff_notes", inquiry.staff_notes) if user.is_px_admin() or user.is_hospital_admin(): inquiry.supervisor_notes = request.POST.get("supervisor_notes", inquiry.supervisor_notes) elif stage == "timeline_sla": inquiry.timeline_sla = request.POST.get("timeline_sla", inquiry.timeline_sla) inquiry.save() stage_labels = { "contacted_nr": "Contacted No Response", "under_process": "Under Process", "contacted": "Contacted", "notes": "Notes", "timeline_sla": "Timeline SLA", } InquiryUpdate.objects.create( inquiry=inquiry, update_type="note", message=f"Contact tracking updated: {stage_labels.get(stage, stage)}", created_by=user, ) messages.success(request, _("Contact tracking updated.")) return redirect("inquiries:inquiry_detail", pk=pk) @login_required def complaints_analytics(request): """ Complaints analytics dashboard. """ from .analytics import ComplaintAnalytics user = request.user hospital = None # Apply RBAC if user.is_px_admin() and request.tenant_hospital: hospital = request.tenant_hospital elif 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 incident_date: from datetime import datetime as dt, date try: parsed_date = dt.strptime(incident_date, "%Y-%m-%d").date() if parsed_date > date.today(): errors.append(_("Incident date cannot be in the future")) except (ValueError, TypeError): errors.append(_("Invalid incident date format")) 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 complaint_source_type=ComplaintSourceType.EXTERNAL, source=PXSource.objects.filter(name_en="Public Form").first(), 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, notify_staff_new_item analyze_complaint_with_ai.delay(str(complaint.id)) notify_staff_new_item.delay("complaint", 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.") public_status = None public_updates = [] if complaint: public_status = complaint.public_status public_updates = list( complaint.updates.filter(update_type__in=["status_change", "resolution", "communication"]).order_by( "-created_at" ) ) _status_map = { "open": str(_("Received")), "in_progress": str(_("In Progress")), "partially_resolved": str(_("In Progress")), "contacted": str(_("In Progress")), "contacted_no_response": str(_("In Progress")), "resolved": str(_("Resolved")), "closed": str(_("Closed")), "cancelled": str(_("Cancelled")), } for update in public_updates: if update.comments: for internal, public_label in _status_map.items(): update.comments = update.comments.replace(internal, public_label) if hasattr(update, "old_status") and update.old_status: update.old_status = _status_map.get(update.old_status, update.old_status) if hasattr(update, "new_status") and update.new_status: update.new_status = _status_map.get(update.new_status, update.new_status) context = { "complaint": complaint, "public_status": public_status, "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 public_inquiry_submit(request): """ Public inquiry submission form (accessible without login). Allows patients/public to submit general inquiries without authentication. """ if request.method == "POST": try: name = request.POST.get("name") email = request.POST.get("email") phone = request.POST.get("phone") hospital_id = request.POST.get("hospital") category = request.POST.get("category", "general") subject = request.POST.get("subject") message = request.POST.get("message") errors = [] if not name: errors.append(_("Name is required")) if not phone: errors.append(_("Phone number is required")) if not hospital_id: errors.append(_("Hospital is required")) if not subject: errors.append(_("Subject is required")) if not message: errors.append(_("Message is 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_inquiry_form.html", { "hospitals": Hospital.objects.filter(status="active").order_by("name"), "categories": [ ("appointment", "Appointment"), ("billing", "Billing"), ("medical_records", "Medical Records"), ("general", "General Information"), ("other", "Other"), ], }, ) hospital = Hospital.objects.get(id=hospital_id) import uuid from datetime import datetime today = datetime.now().strftime("%Y%m%d") random_suffix = str(uuid.uuid4().int)[:6] reference_number = f"INQ-{today}-{random_suffix}" inquiry = Inquiry.objects.create( patient=None, hospital=hospital, subject=subject, message=message, category=category, contact_name=name, contact_phone=phone, contact_email=email, status="open", reference_number=reference_number, is_outgoing=False, ) from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item analyze_inquiry_with_ai.delay(str(inquiry.id)) notify_staff_new_item.delay("inquiry", str(inquiry.id)) AuditService.log_event( event_type="inquiry_created_public", description=f"Public inquiry submitted: {subject}", content_object=inquiry, metadata={"reference": reference_number, "source": "public_form"}, ) if request.headers.get("x-requested-with") == "XMLHttpRequest": return JsonResponse( { "success": True, "message": _("Inquiry submitted successfully!"), "reference_number": reference_number, } ) return redirect("inquiries:public_inquiry_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) except Exception as e: if request.headers.get("x-requested-with") == "XMLHttpRequest": return JsonResponse({"success": False, "message": str(e)}, status=400) messages.error(request, str(e)) return render( request, "complaints/public_inquiry_form.html", { "hospitals": Hospital.objects.filter(status="active").order_by("name"), "categories": [ ("appointment", "Appointment"), ("billing", "Billing"), ("medical_records", "Medical Records"), ("general", "General Information"), ("other", "Other"), ], }, ) def public_inquiry_success(request, reference): """ Success page after public inquiry submission. """ return render(request, "complaints/public_inquiry_success.html", {"reference_number": reference}) def public_inquiry_track(request): """ Public inquiry tracking page. Allows users to check their inquiry status using the reference number received after submission. """ from .models import Inquiry, InquiryUpdate inquiry = None public_updates = [] public_status = None error_message = None reference = request.GET.get("reference", "").strip() or request.POST.get("reference", "").strip() if request.method == "POST": reference = request.POST.get("reference_number", "").strip() if not reference and request.GET.get("reference"): reference = request.GET.get("reference").strip() if reference: inquiry = ( Inquiry.objects.filter(reference_number__iexact=reference) .select_related("hospital", "department") .first() ) if inquiry: status_map = { "open": {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"}, "in_progress": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"}, "contacted": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"}, "contacted_no_response": {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"}, "resolved": {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"}, "closed": {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"}, } sm = status_map.get(inquiry.status, {"label": _("Received"), "slug": "received", "progress": 15, "css": "amber"}) public_status = { "label": str(sm["label"]), "slug": sm["slug"], "progress": sm["progress"], "css": sm["css"], } public_updates = list( InquiryUpdate.objects.filter(inquiry=inquiry) .select_related("created_by") .order_by("-created_at")[:20] ) else: error_message = _("No inquiry found with this reference number. Please check and try again.") if request.headers.get("x-requested-with") == "XMLHttpRequest": if inquiry: return JsonResponse({ "success": True, "reference": inquiry.reference_number, "status": inquiry.get_status_display(), "subject": inquiry.subject, "created_at": inquiry.created_at.strftime("%Y-%m-%d %H:%M"), "updates": [ { "type": u.update_type, "message": u.message, "date": u.created_at.strftime("%Y-%m-%d %H:%M"), } for u in public_updates ], }) else: return JsonResponse({"success": False, "error": "Inquiry not found"}) return render(request, "complaints/public_inquiry_track.html", { "inquiry": inquiry, "public_status": public_status, "public_updates": public_updates, "error_message": error_message, "reference_number": reference, }) def api_lookup_patient(request): """ AJAX endpoint to look up patient by national ID or phone. No authentication required for public form. """ from apps.organizations.models import Patient national_id = request.GET.get("national_id", "").strip() phone = request.GET.get("phone", "").strip() if not national_id and not phone: return JsonResponse({"found": False, "error": "National ID or phone required"}, status=400) patient = None lookup_method = None if national_id: from apps.core.encryption import compute_national_id_hash nid_hash = compute_national_id_hash(national_id) patient = Patient.objects.filter(national_id_hash=nid_hash, status="active").first() lookup_method = "national_id" if not patient and phone: patient = Patient.objects.filter(phone=phone, status="active").first() lookup_method = "phone" if patient: return JsonResponse( { "found": True, "id": str(patient.id), "mrn": patient.mrn, "name": patient.get_full_name(), "phone": patient.phone or "", "email": patient.email or "", "lookup_method": lookup_method, } ) 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}) @px_admin_required @login_required def sla_management(request): """ PX Admin-only SLA management page. Automatically uses the hospital from session (selected after login). Provides a clean interface to manage complaint SLA configurations. """ from .models import ComplaintSLAConfig from apps.organizations.models import Hospital # Get hospital from session (set after login) hospital_id = request.session.get("selected_hospital_id") if not hospital_id: messages.error(request, "Please select a hospital first.") return redirect("core:select_hospital") # Get the hospital try: hospital = Hospital.objects.get(id=hospital_id) except Hospital.DoesNotExist: messages.error(request, "Selected hospital not found.") return redirect("core:select_hospital") # Get all SLA configs for this hospital sla_configs = ( ComplaintSLAConfig.objects.filter(hospital=hospital, is_active=True) .select_related("source") .order_by("source", "severity", "priority") ) # Group configs by type source_based_configs = sla_configs.filter(source__isnull=False) severity_based_configs = sla_configs.filter(source__isnull=True) context = { "hospital": hospital, "source_based_configs": source_based_configs, "severity_based_configs": severity_based_configs, "total_configs": sla_configs.count(), } return render(request, "complaints/sla_management.html", context) @px_admin_required @login_required @require_http_methods(["GET", "POST"]) def sla_management_create(request): """ PX Admin-only: Create new SLA configuration for selected hospital. """ from .models import ComplaintSLAConfig from .forms import SLAConfigForm from apps.organizations.models import Hospital # Get hospital from session hospital_id = request.session.get("selected_hospital_id") if not hospital_id: messages.error(request, "Please select a hospital first.") return redirect("core:select_hospital") try: hospital = Hospital.objects.get(id=hospital_id) except Hospital.DoesNotExist: messages.error(request, "Selected hospital not found.") return redirect("core:select_hospital") if request.method == "POST": form = SLAConfigForm(request.POST, request=request) if form.is_valid(): # Force hospital to session hospital sla_config = form.save(commit=False) sla_config.hospital = hospital sla_config.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_management") else: messages.error(request, "Please correct the errors below.") else: form = SLAConfigForm(request=request) # Pre-select hospital (hidden field) form.fields["hospital"].initial = hospital.id context = { "form": form, "hospital": hospital, "title": "Create SLA Configuration", "action": "Create", } return render(request, "complaints/sla_management_form.html", context) @px_admin_required @login_required @require_http_methods(["GET", "POST"]) def sla_management_edit(request, pk): """ PX Admin-only: Edit SLA configuration for selected hospital. """ from .models import ComplaintSLAConfig from .forms import SLAConfigForm from apps.organizations.models import Hospital # Get hospital from session hospital_id = request.session.get("selected_hospital_id") if not hospital_id: messages.error(request, "Please select a hospital first.") return redirect("core:select_hospital") try: hospital = Hospital.objects.get(id=hospital_id) except Hospital.DoesNotExist: messages.error(request, "Selected hospital not found.") return redirect("core:select_hospital") sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk) if request.method == "POST": form = SLAConfigForm(request.POST, request=request, 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_management") else: messages.error(request, "Please correct the errors below.") else: form = SLAConfigForm(request=request, instance=sla_config) context = { "form": form, "sla_config": sla_config, "hospital": hospital, "title": "Edit SLA Configuration", "action": "Update", } return render(request, "complaints/sla_management_form.html", context) @px_admin_required @login_required @require_http_methods(["POST"]) def sla_management_toggle(request, pk): """ PX Admin-only: Toggle SLA configuration active/inactive status. """ from .models import ComplaintSLAConfig sla_config = get_object_or_404(ComplaintSLAConfig, pk=pk) # Toggle status sla_config.is_active = not sla_config.is_active sla_config.save() # Log audit AuditService.log_event( event_type="sla_config_toggled", description=f"SLA configuration {'activated' if sla_config.is_active else 'deactivated'}: {sla_config}", user=request.user, content_object=sla_config, metadata={ "hospital": str(sla_config.hospital), "is_active": sla_config.is_active, }, ) status_text = "activated" if sla_config.is_active else "deactivated" messages.success(request, f"SLA configuration {status_text} successfully.") return redirect("complaints:sla_management") @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) context = { "page_obj": page_obj, "escalation_rules": page_obj.object_list, "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, request=request) 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(request=request) 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, request=request, 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(request=request, 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) context = { "page_obj": page_obj, "thresholds": page_obj.object_list, "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, request=request) 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(request=request) 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, request=request, 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(request=request, 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(["POST"]) def confirm_ai_department_suggestion(request, complaint_pk): """ Confirm the AI-suggested department and add it as an involved department. """ from .models import ComplaintInvolvedDepartment complaint = get_object_or_404(Complaint, pk=complaint_pk) 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) suggested_department = complaint.department if not suggested_department: messages.warning(request, _("No AI department suggestion found.")) return redirect("complaints:complaint_detail", pk=complaint.pk) already_involved = complaint.involved_departments.filter(department=suggested_department).exists() if already_involved: messages.info(request, _("This department is already involved.")) return redirect("complaints:complaint_detail", pk=complaint.pk) ComplaintInvolvedDepartment.objects.create( complaint=complaint, department=suggested_department, role="primary", is_primary=True, added_by=user, assigned_at=timezone.now(), forwarded_at=timezone.now(), ) ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", message=f"AI suggested department confirmed: {suggested_department.name} (Primary)", created_by=user, ) AuditService.log_event( event_type="complaint_department_added", description=f"AI department suggestion confirmed: {suggested_department.name} for complaint {complaint.reference_number}", user=user, content_object=complaint, metadata={ "department_id": str(suggested_department.pk), "department_name": suggested_department.name, "role": "primary", "is_primary": True, "source": "ai_suggestion", }, ) messages.success( request, _("Department '%(dept)s' added as Primary (AI suggestion confirmed).") % {"dept": suggested_department.name}, ) return redirect("complaints:complaint_detail", pk=complaint.pk) @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() # Mark as forwarded to department (for tracking pending responses) if not involved_dept.forwarded_at: involved_dept.forwarded_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. Supports both regular POST and AJAX requests. """ from .models import ComplaintInvolvedDepartment from django.http import JsonResponse involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint user = request.user # Check permission - must be champion/manager of this department, assigned to it, or have complaint management rights is_dept_champion = ( user.is_champion() and user.department == involved_dept.department ) is_dept_manager = ( user.is_department_manager() and user.department == involved_dept.department ) can_respond = ( is_dept_champion or is_dept_manager or involved_dept.assigned_to == user or can_manage_complaint(user, complaint) ) if not can_respond: if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ "success": False, "error": str(_("You don't have permission to submit a response for this department.")), }, status=403) messages.error(request, _("You don't have permission to submit a response for this department.")) return redirect("complaints:complaint_detail", pk=complaint.pk) response_notes = request.POST.get("response_notes", "").strip() if not response_notes: if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ "success": False, "error": str(_("Please provide a valid response.")), }, status=400) messages.error(request, _("Please provide a valid response.")) return redirect("complaints:complaint_detail", pk=complaint.pk) involved_dept.response_notes = response_notes 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, ) # Send notification to complaint assigned_to if complaint.assigned_to and complaint.assigned_to.email: from apps.notifications.services import NotificationService NotificationService.send_email( complaint.assigned_to.email, subject=f"Department Response Received - {complaint.reference_number}", message=f"A response has been submitted by {involved_dept.department.name} for complaint {complaint.reference_number}.", html_message=f"""A response has been submitted by {involved_dept.department.name} for complaint {complaint.reference_number}.
""", ) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ "success": True, "message": str(_("Department response submitted successfully.")), }) messages.success(request, _("Department response submitted successfully.")) 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 - PX Employee: 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) def patient_complaint_portal(request, token): """Hospital selection page - first step of patient complaint flow.""" from apps.complaints.models import PatientComplaintSession from apps.integrations.models import HISPatientVisit from django.http import Http404 from django.db.models import Count try: session = PatientComplaintSession.objects.get(token=token, is_active=True) except PatientComplaintSession.DoesNotExist: raise Http404("Invalid or expired link") if session.is_expired(): return render(request, "complaints/patient_complaint_expired.html") patient = session.patient hospitals = ( HISPatientVisit.objects.filter(patient=patient) .values("hospital__id", "hospital__name", "hospital__name_ar") .annotate(visit_count=Count("id")) .order_by("-visit_count") ) context = { "patient": patient, "session": session, "hospitals": hospitals, } return render(request, "complaints/patient_complaint_portal.html", context) def patient_complaint_hospital_visits(request, token, hospital_id): """Visit list for a selected hospital - second step.""" from apps.complaints.models import PatientComplaintSession from apps.integrations.models import HISPatientVisit from django.http import Http404 try: session = PatientComplaintSession.objects.get(token=token, is_active=True) except PatientComplaintSession.DoesNotExist: raise Http404("Invalid or expired link") if session.is_expired(): return render(request, "complaints/patient_complaint_expired.html") patient = session.patient visits = HISPatientVisit.objects.filter(patient=patient, hospital_id=hospital_id).order_by("-admit_date")[:50] hospital = None if visits: hospital = visits[0].hospital context = { "patient": patient, "session": session, "hospital": hospital, "visits": visits, } return render(request, "complaints/patient_complaint_visits.html", context) def patient_complaint_visit_form(request, token, visit_id): """Complaint form for a selected visit - third step.""" from apps.complaints.models import PatientComplaintSession, Complaint from apps.integrations.models import HISPatientVisit from django.http import Http404 try: session = PatientComplaintSession.objects.get(token=token, is_active=True) except PatientComplaintSession.DoesNotExist: raise Http404("Invalid or expired link") if session.is_expired(): return render(request, "complaints/patient_complaint_expired.html") patient = session.patient visit = get_object_or_404(HISPatientVisit, id=visit_id, patient=patient) if request.method == "POST": description = request.POST.get("description", "").strip() if not description: return render( request, "complaints/patient_complaint_visit_form.html", { "patient": patient, "session": session, "visit": visit, "error": "Please enter a description of your complaint.", }, ) title = f"Complaint - {visit.patient_type} Visit ({visit.admission_id})" complaint = Complaint.objects.create( patient=patient, hospital=visit.hospital, title=title, description=description, encounter_id=visit.admission_id, complaint_source_type="external", priority="medium", severity="medium", status="open", metadata={ "session_id": str(session.id), "visit_id": str(visit.id), "visit_type": visit.patient_type, "submitted_via": "patient_link", }, ) session.is_active = False session.save() return render( request, "complaints/patient_complaint_success.html", { "complaint": complaint, "patient": patient, }, ) context = { "patient": patient, "session": session, "visit": visit, } return render(request, "complaints/patient_complaint_visit_form.html", context) # ==================== Government Ticket Views ==================== @login_required def government_ticket_list(request): """List government tickets with filters""" # Permission check: PX Admin or PX Employee only if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to view government tickets.")) return redirect("dashboard:index") # Base queryset queryset = GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to").all() # Filters source_filter = request.GET.get("source") status_filter = request.GET.get("status") department_filter = request.GET.get("department") search_query = request.GET.get("q", "").strip() converted_filter = request.GET.get("converted") if source_filter: queryset = queryset.filter(source_id=source_filter) if status_filter: queryset = queryset.filter(status=status_filter) if department_filter: queryset = queryset.filter(main_section_id=department_filter) if converted_filter: is_converted = converted_filter == "yes" queryset = queryset.filter(converted_to_complaint=is_converted) if search_query: queryset = queryset.filter( Q(ticket_number__icontains=search_query) | Q(complainant_name__icontains=search_query) | Q(national_id__icontains=search_query) | Q(content__icontains=search_query) ) # Pagination paginator = Paginator(queryset.order_by("-received_date"), 25) page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) # Context context = { "page_obj": page_obj, "sources": PXSource.objects.filter(source_type="government", is_active=True), "departments": Department.objects.filter(status="active"), "status_choices": GovernmentTicket.TicketStatus.choices, "source_filter": source_filter, "status_filter": status_filter, "department_filter": department_filter, "converted_filter": converted_filter, "search_query": search_query, "total_count": queryset.count(), } return render(request, "complaints/government_ticket_list.html", context) @login_required def government_ticket_detail(request, pk): """Detail view for a government ticket""" ticket = get_object_or_404( GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to", "complaint"), pk=pk, ) # Permission check if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to view this ticket.")) return redirect("complaints:government_ticket_list") context = { "ticket": ticket, } return render(request, "complaints/government_ticket_detail.html", context) @login_required def government_ticket_create(request): """Create a new government ticket""" # Permission check if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to create government tickets.")) return redirect("complaints:government_ticket_list") if request.method == "POST": form = GovernmentTicketForm(request.POST) if form.is_valid(): ticket = form.save() messages.success(request, _("Government ticket created successfully.")) return redirect("complaints:government_ticket_detail", pk=ticket.pk) else: form = GovernmentTicketForm() context = { "form": form, "is_create": True, } return render(request, "complaints/government_ticket_form.html", context) @login_required def government_ticket_update(request, pk): """Update an existing government ticket""" ticket = get_object_or_404(GovernmentTicket, pk=pk) # Permission check if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to update this ticket.")) return redirect("complaints:government_ticket_list") if request.method == "POST": form = GovernmentTicketForm(request.POST, instance=ticket) if form.is_valid(): form.save() messages.success(request, _("Government ticket updated successfully.")) return redirect("complaints:government_ticket_detail", pk=ticket.pk) else: form = GovernmentTicketForm(instance=ticket) context = { "form": form, "ticket": ticket, "is_create": False, } return render(request, "complaints/government_ticket_form.html", context) @login_required def convert_to_complaint(request, pk): """Convert a government ticket to a complaint""" ticket = get_object_or_404(GovernmentTicket, pk=pk) # Permission check if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to convert this ticket.")) return redirect("complaints:government_ticket_list") if ticket.converted_to_complaint: messages.warning(request, _("This ticket has already been converted to a complaint.")) return redirect("complaints:government_ticket_detail", pk=ticket.pk) # Build redirect URL to complaint create form with pre-filled data from django.urls import reverse import urllib.parse params = { "source": ticket.source_id, "complaint_source_type": "external", "title": f"[{ticket.ticket_number}] {ticket.classification or 'Government Ticket'}", "description": ticket.content, "main_section": ticket.main_section_id or "", "subsection": ticket.subsection_id or "", "patient_name": ticket.complainant_name, "contact_phone": ticket.contact_number or "", "moh_reference": ticket.ticket_number if "moh" in ticket.source.code.lower() else "", "chi_reference": ticket.ticket_number if "chi" in ticket.source.code.lower() else "", } query_string = urllib.parse.urlencode({k: v for k, v in params.items() if v}) url = f"{reverse('complaints:complaint_create')}?{query_string}" return redirect(url) @login_required def government_ticket_import(request): """Import government tickets from Excel file""" if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to import government tickets.")) return redirect("complaints:government_ticket_list") preview_data = None import_count = 0 error_count = 0 errors = [] if request.method == "POST": action = request.POST.get("action") if action == "preview": # Handle file upload and show preview file = request.FILES.get("file") if not file: messages.error(request, _("Please select a file to import.")) return redirect("complaints:government_ticket_import") try: import pandas as pd # Read Excel file df = pd.read_excel(file, header=1) # Header is on row 2 (index 1) # Column mapping (Arabic to English) column_map = { "رقم التذكرة": "ticket_number", "اسم المشتكي": "complainant_name", "رقم الهوية": "national_id", "رقم التواصل": "contact_number", "الموقع": "location", "القسم الرئيسي": "main_section", "القسم الفرعي": "subsection", "تاريخ إنشاء التذكرة": "received_date", "وقت إنشاء التذكرة": "received_time", "تصنيف الشكوى": "classification", "محتوى الشكوى": "content", "حالة الشكوى": "status", "اسم الموظف": "assigned_to", } # Rename columns df.rename(columns=column_map, inplace=True) # Take first 5 rows for preview preview_df = df.head(5) preview_data = preview_df.fillna("").to_dict("records") # Store full dataframe in session for actual import request.session["import_df"] = df.to_json() request.session["import_filename"] = file.name messages.info(request, _("Preview ready. Review the data below and confirm to import.")) except Exception as e: messages.error(request, f"Error reading file: {str(e)}") return redirect("complaints:government_ticket_import") elif action == "import": # Handle actual import json_data = request.session.get("import_df") if not json_data: messages.error(request, _("No data to import. Please upload a file first.")) return redirect("complaints:government_ticket_import") try: import pandas as pd from apps.px_sources.models import PXSource from apps.organizations.models import Location, MainSection, SubSection from apps.accounts.models import User df = pd.read_json(json_data) # Get MOH source (default for import) moh_source = PXSource.objects.filter(code__icontains="MOH", source_type="government").first() if not moh_source: messages.error(request, _("MOH source not found in system. Please configure it first.")) return redirect("complaints:government_ticket_import") # Status mapping status_map = { "مفتوحة": "pending", "قيد المعالجة": "in_progress", "تم الحل": "resolved", "مغلقة": "closed", "pending": "pending", "in_progress": "in_progress", "resolved": "resolved", "closed": "closed", } for idx, row in df.iterrows(): try: # Skip if ticket number is missing if pd.isna(row.get("ticket_number")): errors.append(f"Row {idx + 1}: Missing ticket number") error_count += 1 continue # Check for duplicate if GovernmentTicket.objects.filter(ticket_number=str(row["ticket_number"])).exists(): errors.append(f"Row {idx + 1}: Ticket {row['ticket_number']} already exists") error_count += 1 continue # Parse received date received_date = None if pd.notna(row.get("received_date")): received_date = pd.to_datetime(row["received_date"]) if pd.notna(row.get("received_time")): time_part = pd.to_datetime(row["received_time"]) received_date = received_date.replace( hour=time_part.hour, minute=time_part.minute, second=time_part.second, ) else: received_date = timezone.now() # Lookup location ticket_location = None if pd.notna(row.get("location")): loc_name = str(row["location"]).strip() ticket_location = Location.objects.filter( models.Q(name_en__icontains=loc_name) | models.Q(name_ar__icontains=loc_name) ).first() # Lookup main section ticket_main_section = None if pd.notna(row.get("main_section")): sec_name = str(row["main_section"]).strip() ticket_main_section = MainSection.objects.filter( models.Q(name_en__icontains=sec_name) | models.Q(name_ar__icontains=sec_name) ).first() # Lookup subsection ticket_subsection = None if pd.notna(row.get("subsection")): sub_name = str(row["subsection"]).strip() qs = SubSection.objects.filter( models.Q(name_en__icontains=sub_name) | models.Q(name_ar__icontains=sub_name) ) if ticket_location: qs = qs.filter(location=ticket_location) if ticket_main_section: qs = qs.filter(main_section=ticket_main_section) ticket_subsection = qs.first() # Lookup assigned user assigned_user = None if pd.notna(row.get("assigned_to")): assigned_user = User.objects.filter( models.Q(first_name__icontains=str(row["assigned_to"]).strip()) | models.Q(last_name__icontains=str(row["assigned_to"]).strip()) | models.Q(get_full_name__icontains=str(row["assigned_to"]).strip()) ).first() # Map status status = "pending" if pd.notna(row.get("status")): status = status_map.get(str(row["status"]).strip().lower(), "pending") # Create ticket GovernmentTicket.objects.create( source=moh_source, ticket_number=str(row["ticket_number"]).strip(), complainant_name=str(row.get("complainant_name", "")).strip() or "Unknown", national_id=str(row.get("national_id", "")).strip() if pd.notna(row.get("national_id")) else "", contact_number=str(row.get("contact_number", "")).strip() if pd.notna(row.get("contact_number")) else "", location=ticket_location, main_section=ticket_main_section, subsection=ticket_subsection, received_date=received_date, classification=str(row.get("classification", "")).strip() if pd.notna(row.get("classification")) else "", content=str(row.get("content", "")).strip() if pd.notna(row.get("content")) else "", status=status, assigned_to=assigned_user, ) import_count += 1 except Exception as e: errors.append(f"Row {idx + 1}: {str(e)}") error_count += 1 # Clear session data del request.session["import_df"] del request.session["import_filename"] if import_count > 0: messages.success(request, f"Successfully imported {import_count} tickets.") if error_count > 0: messages.warning(request, f"{error_count} rows had errors.") return redirect("complaints:government_ticket_list") except Exception as e: messages.error(request, f"Import failed: {str(e)}") return redirect("complaints:government_ticket_import") context = { "preview_data": preview_data, "errors": errors, "import_count": import_count, "error_count": error_count, } return render(request, "complaints/government_ticket_import.html", context) @login_required def government_ticket_export(request): """Export government tickets to Excel""" if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to export government tickets.")) return redirect("complaints:government_ticket_list") import pandas as pd from django.http import HttpResponse # Get filtered queryset (same filters as list view) queryset = GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to").all() source_filter = request.GET.get("source") status_filter = request.GET.get("status") department_filter = request.GET.get("department") search_query = request.GET.get("q", "").strip() if source_filter: queryset = queryset.filter(source_id=source_filter) if status_filter: queryset = queryset.filter(status=status_filter) if department_filter: queryset = queryset.filter(main_section_id=department_filter) if search_query: queryset = queryset.filter( models.Q(ticket_number__icontains=search_query) | models.Q(complainant_name__icontains=search_query) | models.Q(national_id__icontains=search_query) | models.Q(content__icontains=search_query) ) # Prepare data data = [] for ticket in queryset.order_by("-received_date"): data.append({ "رقم التذكرة": ticket.ticket_number, "اسم المشتكي": ticket.complainant_name, "رقم الهوية": ticket.national_id or "", "رقم التواصل": ticket.contact_number or "", "الموقع": ticket.location.name_en if ticket.location else "", "القسم الرئيسي": ticket.main_section.name_en if ticket.main_section else "", "القسم الفرعي": ticket.subsection.name_en if ticket.subsection else "", "تاريخ إنشاء التذكرة": ticket.received_date.strftime("%Y-%m-%d") if ticket.received_date else "", "وقت إنشاء التذكرة": ticket.received_date.strftime("%H:%M:%S") if ticket.received_date else "", "تصنيف الشكوى": ticket.classification or "", "محتوى الشكوى": ticket.content or "", "حالة الشكوى": ticket.get_status_display(), "اسم الموظف": ticket.assigned_to.get_full_name() if ticket.assigned_to else "", "تم التحويل": "نعم" if ticket.converted_to_complaint else "لا", }) df = pd.DataFrame(data) # Create response response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = 'attachment; filename="government_tickets.xlsx"' with pd.ExcelWriter(response, engine="openpyxl") as writer: df.to_excel(writer, index=False, sheet_name="Government Tickets") return response @login_required @require_http_methods(["POST"]) def complaint_soft_delete(request, pk): complaint = get_object_or_404(Complaint, pk=pk) if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return HttpResponseForbidden(_("You don't have permission to delete complaints.")) complaint.soft_delete(user=request.user) messages.success(request, _("Complaint moved to trash.")) return redirect("complaints:complaint_list") @login_required @require_http_methods(["POST"]) def complaint_restore(request, pk): complaint = get_object_or_404(Complaint.all_objects, pk=pk, is_deleted=True) if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return HttpResponseForbidden(_("You don't have permission to restore complaints.")) complaint.restore() messages.success(request, _("Complaint restored successfully.")) return redirect("config:deleted_items") @login_required @require_http_methods(["POST"]) def inquiry_restore(request, pk): inquiry = get_object_or_404(Inquiry.all_objects, pk=pk, is_deleted=True) if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return HttpResponseForbidden(_("You don't have permission to restore inquiries.")) inquiry.restore() messages.success(request, _("Inquiry restored successfully.")) return redirect("config:deleted_items") @login_required def trash_list(request): if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return HttpResponseForbidden(_("You don't have permission to view trash.")) deleted_complaints = Complaint.all_objects.filter(is_deleted=True).select_related( "hospital", "department", "created_by" ).order_by("-deleted_at") deleted_observations = Observation.all_objects.filter(is_deleted=True).select_related( "hospital", "assigned_department" ).order_by("-deleted_at") return render(request, "complaints/trash_list.html", { "deleted_complaints": deleted_complaints, "deleted_observations": deleted_observations, })