""" Complaints UI views - Server-rendered templates for complaints console """ import logging from .workflow_log import build_workflow_log 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.urls import reverse 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 def _safe_next_redirect(request, fallback): """Redirect to request.POST['next'] if it's a safe relative URL. Blocks open-redirect attacks. A URL is considered safe only if it starts with a single leading slash and is not a protocol-relative form (``//`` or ``/\\``) which browsers may resolve against a different host. Falls back to ``fallback`` (a redirect()/reverse() result) otherwise. """ next_url = request.POST.get("next") if hasattr(request, "POST") else None if ( next_url and next_url.startswith("/") and not next_url.startswith("//") and not next_url.startswith("/\\") ): return redirect(next_url) return fallback 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 ( ComplaintForm, 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.""" from .models import ComplaintExplanation, ComplaintInvolvedDepartment, ComplaintUpdate stages = [] status_updates = list( ComplaintUpdate.objects.filter( complaint=complaint, update_type__in=["status_change", "escalation", "assignment"], ).select_related("created_by").order_by("created_at") ) def _find_update(new_status=None, msg_contains=None, update_type=None): for u in status_updates: if update_type and u.update_type != update_type: continue if new_status and u.new_status != new_status: continue if msg_contains and msg_contains not in (u.message or ""): continue return u return None def _user_name(user): return user.get_full_name() if user else None if complaint.created_at: performed_by = _user_name(complaint.created_by) if complaint.created_by else str(_("Patient")) stages.append({"label": _("Created"), "timestamp": complaint.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by}) if complaint.activated_at: activate_update = _find_update(msg_contains="activated", update_type="assignment") if not activate_update: activate_update = _find_update(new_status="in_progress") performed_by = _user_name(activate_update.created_by) if activate_update else None stages.append({"label": _("Activated"), "timestamp": complaint.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by}) forwarded_ts = complaint.forwarded_to_dept_at if not forwarded_ts: earliest_dept = ComplaintInvolvedDepartment.objects.filter( complaint=complaint, forwarded_at__isnull=False, ).select_related("department").order_by("forwarded_at").first() if earliest_dept: forwarded_ts = earliest_dept.forwarded_at if forwarded_ts: fwd_update = _find_update(msg_contains="sent to") performed_by = _user_name(fwd_update.created_by) if fwd_update else None stages.append({"label": _("Forwarded to Department"), "timestamp": forwarded_ts, "color": "bg-purple-500", "icon": "send", "performed_by": performed_by}) status_update = _find_update(msg_contains="contacted") if status_update: performed_by = _user_name(status_update.created_by) stages.append({"label": _("Contacted"), "timestamp": status_update.created_at, "color": "bg-indigo-500", "icon": "phone", "performed_by": performed_by}) if complaint.escalated_at: esc_update = _find_update(update_type="escalation") performed_by = _user_name(esc_update.created_by) if esc_update else None stages.append({"label": _("Escalated"), "timestamp": complaint.escalated_at, "color": "bg-red-500", "icon": "alert-triangle", "performed_by": performed_by}) if complaint.escalated_ovr_at: performed_by = _user_name(complaint.escalated_ovr_by) stages.append({"label": _("OVR Escalated"), "timestamp": complaint.escalated_ovr_at, "color": "bg-rose-600", "icon": "shield-alert", "performed_by": performed_by}) earliest_response = complaint.response_date first_explanation = None if not earliest_response: first_explanation = ComplaintExplanation.objects.filter( complaint=complaint, responded_at__isnull=False, ).select_related("staff").order_by("responded_at").first() if first_explanation: earliest_response = first_explanation.responded_at if earliest_response: performed_by = _user_name(first_explanation.staff) if first_explanation else None stages.append({"label": _("Department Responded"), "timestamp": earliest_response, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by}) if complaint.resolved_at: performed_by = _user_name(complaint.resolved_by) if not performed_by: resolved_update = _find_update(new_status="resolved") performed_by = _user_name(resolved_update.created_by) if resolved_update else None stages.append({"label": _("Resolved"), "timestamp": complaint.resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by}) if complaint.partially_resolved_at: performed_by = _user_name(complaint.partially_resolved_by) if not performed_by: pr_update = _find_update(new_status="partially_resolved") performed_by = _user_name(pr_update.created_by) if pr_update else None stages.append({"label": _("Partially Resolved"), "timestamp": complaint.partially_resolved_at, "color": "bg-yellow-500", "icon": "alert-circle", "performed_by": performed_by}) if complaint.pending_external_set_at: pe_update = _find_update(new_status="pending_external") performed_by = _user_name(pe_update.created_by) if pe_update else None stages.append({"label": _("Pending External"), "timestamp": complaint.pending_external_set_at, "color": "bg-orange-500", "icon": "clock", "performed_by": performed_by}) if complaint.closed_at: performed_by = _user_name(complaint.closed_by) if not performed_by: closed_update = _find_update(new_status="closed") performed_by = _user_name(closed_update.created_by) if closed_update else None stages.append({"label": _("Closed"), "timestamp": complaint.closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by}) if complaint.cancelled_at: performed_by = _user_name(complaint.cancelled_by) if not performed_by: cancelled_update = _find_update(new_status="cancelled") performed_by = _user_name(cancelled_update.created_by) if cancelled_update else None stages.append({"label": _("Cancelled"), "timestamp": complaint.cancelled_at, "color": "bg-rose-500", "icon": "x-circle", "performed_by": performed_by}) stages.sort(key=lambda s: s["timestamp"]) for i, stage in enumerate(stages): stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None total_time = None if len(stages) >= 2: total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"]) return {"stages": stages, "total_time": total_time} def _build_inquiry_stage_timeline(inquiry): """Build stage timeline with timestamps and durations for an inquiry.""" from .models import InquiryUpdate stages = [] status_updates = list( InquiryUpdate.objects.filter( inquiry=inquiry, ).select_related("created_by").order_by("created_at") ) def _find_update(new_status=None, msg_contains=None, update_type=None): for u in status_updates: if update_type and u.update_type != update_type: continue if new_status and u.new_status != new_status: continue if msg_contains and msg_contains not in (u.message or ""): continue return u return None def _user_name(user): return user.get_full_name() if user else None if inquiry.created_at: performed_by = _user_name(inquiry.created_by) if inquiry.created_by else str(_("Patient")) stages.append({"label": _("Created"), "timestamp": inquiry.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by}) if inquiry.activated_at: activate_update = _find_update(msg_contains="activated", update_type="assignment") if not activate_update: activate_update = _find_update(new_status="in_progress") performed_by = _user_name(activate_update.created_by) if activate_update else None stages.append({"label": _("Activated"), "timestamp": inquiry.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by}) if inquiry.transferred_at: performed_by = _user_name(inquiry.transferred_by) if hasattr(inquiry, 'transferred_by') else None if not performed_by: fwd_update = _find_update(update_type="transferred_to_department") performed_by = _user_name(fwd_update.created_by) if fwd_update else None stages.append({"label": _("Transferred to Department"), "timestamp": inquiry.transferred_at, "color": "bg-purple-500", "icon": "send", "performed_by": performed_by}) if inquiry.contacted_at: performed_by = _user_name(inquiry.contacted_by) if hasattr(inquiry, 'contacted_by') else None stages.append({"label": _("Contacted"), "timestamp": inquiry.contacted_at, "color": "bg-indigo-500", "icon": "phone", "performed_by": performed_by}) if inquiry.contacted_nr_at: performed_by = _user_name(inquiry.contacted_nr_by) if hasattr(inquiry, 'contacted_nr_by') else None stages.append({"label": _("Contacted No Response"), "timestamp": inquiry.contacted_nr_at, "color": "bg-orange-500", "icon": "phone-off", "performed_by": performed_by}) if getattr(inquiry, 'dept_response_escalated_at', None): esc_update = _find_update(msg_contains="escalat") performed_by = _user_name(esc_update.created_by) if esc_update else None stages.append({"label": _("Dept Response Escalated"), "timestamp": inquiry.dept_response_escalated_at, "color": "bg-red-500", "icon": "alert-triangle", "performed_by": performed_by}) if inquiry.department_responded_at: performed_by = _user_name(inquiry.department_responded_by) if hasattr(inquiry, 'department_responded_by') else None stages.append({"label": _("Department Responded"), "timestamp": inquiry.department_responded_at, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by}) if inquiry.responded_at: performed_by = _user_name(inquiry.responded_by) if hasattr(inquiry, 'responded_by') else None stages.append({"label": _("Response Sent"), "timestamp": inquiry.responded_at, "color": "bg-cyan-500", "icon": "reply", "performed_by": performed_by}) _resolved_at = getattr(inquiry, 'resolved_at', None) if _resolved_at: resolved_update = _find_update(new_status="resolved") performed_by = _user_name(resolved_update.created_by) if resolved_update else None stages.append({"label": _("Resolved"), "timestamp": _resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by}) _closed_at = getattr(inquiry, 'closed_at', None) if _closed_at: closed_update = _find_update(new_status="closed") performed_by = _user_name(closed_update.created_by) if closed_update else None stages.append({"label": _("Closed"), "timestamp": _closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by}) stages.sort(key=lambda s: s["timestamp"]) for i, stage in enumerate(stages): stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None total_time = None if len(stages) >= 2: total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"]) return {"stages": stages, "total_time": total_time} 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_px_management() or user.is_px_employee(): if 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 user.is_px_admin(): if selected_hospital: departments = departments.filter(hospital=selected_hospital) elif 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, "page_obj": 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" complaint_queryset = ( Complaint.objects.select_related( "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "resolution_survey", "source", "created_by", "domain", "category", "subcategory_obj", "classification_obj", ) .prefetch_related( "attachments", "updates__created_by", "involved_departments__department", "involved_departments__assigned_to", "involved_staff__staff__department", Prefetch( "explanations", queryset=ComplaintExplanation.objects.select_related("staff") .prefetch_related("attachments") .order_by("-created_at"), ), Prefetch( "adverse_actions", queryset=ComplaintAdverseAction.objects.select_related("reported_by").prefetch_related( "involved_staff" ), ), ) .annotate( 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) ComplaintService.ensure_involved_records(complaint) # Self-heal: if the complaint was sent to its primary department but no CID # row exists (legacy data from before the fix), create one now so the primary # department appears in the Involved Departments list. if complaint.sent_to_department and complaint.department_id: if not any(inv.department_id == complaint.department_id for inv in complaint.involved_departments.all()): from .models import ComplaintInvolvedDepartment ComplaintInvolvedDepartment.objects.get_or_create( complaint=complaint, department=complaint.department, defaults={ "role": "primary", "is_primary": True, "added_by": request.user, "forwarded_at": complaint.forwarded_to_dept_at or timezone.now(), "sent": True, "sent_at": complaint.sent_to_department_at or timezone.now(), }, ) if hasattr(complaint, "_prefetched_objects_cache"): complaint._prefetched_objects_cache.pop("involved_departments", None) 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") 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) 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") assignable_users = User.objects.filter( is_active=True, groups__name__in=["PX Employee", "PX Admin", "PX Management"], ) if complaint.hospital: assignable_users = assignable_users.filter(hospital=complaint.hospital) assignable_users = assignable_users.distinct().order_by("first_name", "last_name") hospital_departments = Department.objects.none() if complaint.hospital: from django.db.models import Q hospital_departments = ( Department.objects.filter( hospital=complaint.hospital, status="active", ) .filter(Q(champion__isnull=False) | Q(manager__isnull=False)) .select_related("champion", "manager", "manager__staff_profile") .order_by("name") ) # Departments the complaint hasn't been sent to yet (drives the send-to-dept modal). # Filter to only those the send endpoint can actually deliver to (matches # get_champion_and_manager — closes the manager-with-no-email hole). from apps.organizations.department_contacts import has_contact_target available_departments = hospital_departments.exclude( pk__in=complaint.involved_departments.values_list("department_id", flat=True) ) available_departments = [d for d in available_departments if has_contact_target(d)] if complaint.is_active_status and not complaint.is_overdue: complaint.check_overdue() explanations = complaint.explanations.all() explanation = explanations.first() if explanations else None explanation_attachments = explanation.attachments.all() if explanation else [] escalation_targets = [] default_escalation_target = None # Build escalation list from Manager + Deputy Manager of ALL involved departments involved_depts = list( complaint.involved_departments.select_related("department", "department__manager") .exclude(department__isnull=True) ) dept_label_map = {} departments_to_check = [] if complaint.department: departments_to_check.append(complaint.department) dept_label_map[complaint.department.id] = complaint.department.get_localized_name() for idept in involved_depts: if idept.department and idept.department.id not in dept_label_map: departments_to_check.append(idept.department) dept_label_map[idept.department.id] = idept.department.get_localized_name() seen_staff_ids = set() for dept in departments_to_check: dept_label = dept_label_map.get(dept.id, dept.name) # Deputy Manager (Staff FK) if dept.deputy_manager and dept.deputy_manager.id not in seen_staff_ids: has_user = bool(dept.deputy_manager.user and dept.deputy_manager.user.is_active) if has_user or dept.deputy_manager.email: seen_staff_ids.add(dept.deputy_manager.id) escalation_targets.append({ "staff": dept.deputy_manager, "has_user": has_user, "user_id": str(dept.deputy_manager.user.id) if has_user else "", "role_label": _("Deputy Manager"), "group": dept_label, }) # Primary Manager (User FK — bridge via staff_profile) if dept.manager: manager_staff = getattr(dept.manager, "staff_profile", None) if manager_staff and manager_staff.id not in seen_staff_ids: seen_staff_ids.add(manager_staff.id) escalation_targets.append({ "staff": manager_staff, "has_user": True, "user_id": str(dept.manager.id), "role_label": _("Manager"), "group": dept_label, }) if escalation_targets: default_escalation_target = str(escalation_targets[0]["staff"].id) adverse_actions = complaint.adverse_actions.all() from apps.rca.models import RootCauseAnalysis as RCA linked_rcas = RCA.objects.filter( content_type=complaint_ct, object_id=complaint.pk, is_deleted=False ).select_related("assigned_to", "created_by") stage_timeline = _build_complaint_stage_timeline(complaint) context = { "complaint": complaint, "timeline": timeline, "attachments": attachments, "px_actions": px_actions, "assignable_users": assignable_users, "send_to_users": User.objects.filter( is_active=True, hospital=complaint.hospital ).select_related("department").order_by("first_name", "last_name"), "status_choices": ComplaintStatus.choices, "base_layout": base_layout, "source_user": source_user, "can_edit": can_manage_complaint(user, complaint), "can_manage_actions": ( user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital) or (complaint.assigned_to == user) or ( not complaint.activated_at and (user.is_px_management() or user.is_px_employee()) and user.hospital == complaint.hospital ) ), "can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital), "is_active_status": complaint.is_active_status, "workflow_steps": { "activated": complaint.activated_at is not None, "taxonomy_reviewed": complaint.taxonomy_reviewed_at is not None, "sent_to_department": complaint.sent_to_department or complaint.forwarded_to_dept_at is not None, "department_responded": ( complaint.explanations.filter(is_used=True).exists() or complaint.involved_departments.filter(response_submitted=True).exists() ), "resolved": complaint.status in ("resolved", "closed"), "cancelled": complaint.status == "cancelled", }, "dept_response_progress": { "responded": complaint.involved_departments.filter(sent=True, response_submitted=True).count(), "total": complaint.involved_departments.filter(sent=True).count(), "pending": list( complaint.involved_departments.filter( sent=True, response_submitted=False ).select_related("department").values_list("department__name", flat=True) ), "responded_list": [ { "name": d.department.get_localized_name() if d.department else "—", "at": d.response_submitted_at, } for d in complaint.involved_departments.filter( sent=True, response_submitted=True ).select_related("department") ], }, "satisfaction_history": ComplaintUpdate.objects.filter( complaint=complaint, message__icontains="satisfaction" ).order_by("-created_at")[:5], "satisfaction_changes_remaining": max( 0, Complaint.MAX_SATISFACTION_CHANGES - complaint.satisfaction_change_count ), "can_collect_feedback": bool( complaint.department and complaint.is_active_status and ( (complaint.department.champion_id and complaint.department.champion.user_id == user.id) or complaint.department.manager_id == user.id ) ), "ai_department_suggested": ( bool(complaint.department) and not complaint.involved_departments.filter(department=complaint.department).exists() ), "hospital_departments": hospital_departments, "available_departments": available_departments, "involved_department_form": ComplaintInvolvedDepartmentForm(complaint=complaint, user=user), "workflow_log": build_workflow_log(complaint), "involved_staff_form": ComplaintInvolvedStaffForm(complaint=complaint, user=user), "explanation": explanation, "explanations": explanations, "explanation_attachments": explanation_attachments, "escalation_targets": escalation_targets, "default_escalation_target": default_escalation_target, "escalation_email_subject": f"Complaint Escalated - {complaint.reference_number} - {complaint.title or 'N/A'}", "escalation_email_body": ( f"Dear Manager,\n\n" f"This complaint has been escalated and requires your immediate attention.\n\n" f"Reference: {complaint.reference_number}\n" f"Title: {complaint.title or 'N/A'}\n" f"Severity: {complaint.get_severity_display()}\n" f"Priority: {complaint.get_priority_display()}\n" f"Status: {complaint.get_status_display()}\n\n" f"Please review and take appropriate action.\n\n" f"View: https://{request.get_host()}/organizations/departments/{complaint.department.pk if complaint.department else ''}/" ), "send_to_email_subject": f"Complaint Sent for Response - {complaint.reference_number}", "send_to_email_body": ( f"Dear Team,\n\n" f"Complaint #{complaint.reference_number} requires your response.\n\n" f"Title: {complaint.title or 'N/A'}\n" f"Severity: {complaint.get_severity_display()}\n" f"Priority: {complaint.get_priority_display()}\n\n" f"{complaint.description or 'No description provided.'}\n\n" f"Please review and respond promptly.\n\n" f"View: https://{request.get_host()}/complaints/{complaint.pk}/" ), "current_user": user, "adverse_actions": adverse_actions, "linked_rcas": linked_rcas, "stage_timeline": stage_timeline, "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") ), } from django.contrib.contenttypes.models import ContentType complaint_ct = ContentType.objects.get_for_model(complaint) context["content_type_id"] = complaint_ct.pk context["object_id"] = complaint.pk context["notes"] = complaint.notes.select_related("created_by").all() context["notes_count"] = context["notes"].count() return render(request, "complaints/complaint_detail.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(["GET", "POST"]) def complaint_create(request): """Create new complaint with AI-powered classification""" # 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 # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN) complaint.save() reference_number = complaint.reference_number # Background patient linking (local DB first, then HIS) — avoids blocking the # submit when HIS is slow/unreachable. if not complaint.patient_id: from .tasks import link_complaint_patient link_complaint_patient.delay( str(complaint.id), complaint.national_id, complaint.contact_phone ) 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 collect_feedback_start(request, pk): """Authenticated entry to 'Collect Feedback' for the dept champion or manager. Bootstraps a ComplaintExplanation token for the acting user and redirects to the compose page (same one the emailed token link opens), reusing the per-staff question flow. """ import secrets from .models import ComplaintExplanation complaint = get_object_or_404(Complaint.objects.select_related("department"), pk=pk) dept = complaint.department user = request.user if not dept: messages.error(request, _("This complaint has no department assigned.")) return redirect("complaints:complaint_detail", pk=pk) # Permission: department champion or manager only is_champion = bool(dept.champion_id) and dept.champion.user_id == user.id is_manager = dept.manager_id == user.id if not (is_champion or is_manager): messages.error(request, _("Only the department champion or manager can collect feedback.")) return redirect("complaints:complaint_detail", pk=pk) # Resolve the Staff to attach the explanation to staff = dept.champion if is_champion else getattr(dept.manager, "staff_profile", None) or dept.champion if staff is None: messages.error(request, _("No staff profile available to collect feedback.")) return redirect("complaints:complaint_detail", pk=pk) token = secrets.token_urlsafe(32) ComplaintExplanation.objects.update_or_create( complaint=complaint, staff=staff, defaults={ "token": token, "is_used": False, "requested_by": user, "submitted_via": "direct", }, ) return redirect("complaints:champion_start_investigation", complaint_id=complaint.id, token=token) @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, get_email_header_html 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) # Must be activated (in_progress) before it can be sent/assigned if complaint.status == "open": return JsonResponse({ "success": False, "error": str(_("Activate this complaint before sending it to a department.")), }, status=400) recipient_type = request.POST.get("recipient_type", "department") note = request.POST.get("note", "").strip() email_subject = request.POST.get("email_subject", "").strip() email_body = request.POST.get("email_body", "").strip() notify_targets = [] 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: send_subject = email_subject or f"Complaint Assigned - {complaint.reference_number}" send_body = email_body or f"You have been assigned to complaint #{complaint.reference_number}." NotificationService.send_email( email=person.email, subject=send_subject, message=send_body, html_message=f"""
""", 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.select_related("champion", "manager").get(id=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) # Auto-target the department's champion and manager (no contact-person picker) from apps.organizations.department_contacts import get_champion_and_manager import secrets targets = get_champion_and_manager(department) if not targets: return JsonResponse({ "success": False, "error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")), }, status=400) now = timezone.now() if complaint.department_id == department.pk: # Create/update CID for the primary department too so it appears # in the Involved Departments list and the department-detail queries. involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create( complaint=complaint, department=department, defaults={ "role": "primary", "is_primary": True, "added_by": user, "forwarded_at": now, "sent": True, "sent_at": now, }, ) if not created: involved_dept.forwarded_at = now involved_dept.sent = True involved_dept.sent_at = now involved_dept.routing_status = "sent" involved_dept.rejected_at = None involved_dept.rejected_by_staff = None involved_dept.rejection_reason = "" involved_dept.suggested_department = None involved_dept.save(update_fields=[ "forwarded_at", "sent", "sent_at", "routing_status", "rejected_at", "rejected_by_staff", "rejection_reason", "suggested_department", ]) complaint.sent_to_department = True complaint.sent_to_department_at = now if not complaint.forwarded_to_dept_at: complaint.forwarded_to_dept_at = now else: involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create( complaint=complaint, department=department, defaults={ "role": "secondary", "added_by": user, "forwarded_at": now, "sent": True, "sent_at": now, } ) if not created: involved_dept.forwarded_at = now involved_dept.sent = True involved_dept.sent_at = now # Reset any prior rejection so the champion can act again involved_dept.routing_status = "sent" involved_dept.rejected_at = None involved_dept.rejected_by_staff = None involved_dept.rejection_reason = "" involved_dept.suggested_department = None involved_dept.save(update_fields=[ "forwarded_at", "sent", "sent_at", "routing_status", "rejected_at", "rejected_by_staff", "rejection_reason", "suggested_department", ]) complaint.sent_to_department = True complaint.sent_to_department_at = complaint.sent_to_department_at or now complaint.explanation_requested = True complaint.explanation_requested_at = complaint.explanation_requested_at or now # Notify each recipient (champion + manager): token link + email + SMS notified = [] host = request.get_host() for target in targets: email = target.get("email") or "" phone = target.get("phone") or "" staff = target.get("staff") tuser = target.get("user") label = target.get("label") # Personal no-login explanation link (token) — create for any target with a Staff profile link = None effective_staff = staff if effective_staff is None and tuser is not None: effective_staff = getattr(tuser, "staff_profile", None) if effective_staff is not None: token = secrets.token_urlsafe(32) exp_obj, _ = ComplaintExplanation.objects.update_or_create( complaint=complaint, staff=effective_staff, defaults={ "token": token, "is_used": False, "requested_by": user, "request_message": note, "email_sent_at": timezone.now(), "submitted_via": "email_link", }, ) link = f"https://{host}/complaints/{complaint.id}/explain/{token}/" # Save PX-team attachments against this explanation from .models import ExplanationAttachment for f in request.FILES.getlist("px_attachments"): ExplanationAttachment.objects.create( explanation=exp_obj, file=f, filename=f.name, file_type=f.content_type, file_size=f.size, ) display_name = ( staff.get_full_name() if staff is not None else (tuser.get_full_name() if tuser else label) ) fallback_link = f"https://{host}/complaints/{complaint.pk}/" review_link = link or fallback_link # Collect for async notification — email + SMS are sent by a Celery task # (send_department_notification_task) so this request stays fast. if email or phone: notify_targets.append({ "email": email, "phone": phone, "link": review_link, "label": label, "display_name": display_name, }) notified.append(f"{display_name} ({label})") message = f"Complaint sent to {department.name} — {', '.join(notified)}." # Optionally record a selected staff member as involved (NOT notified; # notification target remains champion/manager only). staff_id = request.POST.get("staff_id", "").strip() if staff_id: from .models import ComplaintInvolvedStaff staff_member = Staff.objects.filter(pk=staff_id, department=department).first() if staff_member: ComplaintInvolvedStaff.objects.get_or_create( complaint=complaint, staff=staff_member, defaults={"added_by": user}, ) # Set forwarded timestamp and update tracking fields if active now = timezone.now() if complaint.is_active_status and complaint.status in ("open", "in_progress"): if not complaint.forwarded_to_dept_at: complaint.forwarded_to_dept_at = now complaint.save(update_fields=[ "forwarded_to_dept_at", "sent_to_department", "sent_to_department_at", "explanation_requested", "explanation_requested_at", ]) ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", message=f"Complaint sent to {recipient_type}", created_by=user, ) else: if not complaint.forwarded_to_dept_at: complaint.forwarded_to_dept_at = now complaint.save(update_fields=[ "forwarded_to_dept_at", "sent_to_department", "sent_to_department_at", "explanation_requested", "explanation_requested_at", ]) # Offload the champion/manager email + SMS to a Celery task (slow network # round-trips) so the HTTP response returns immediately. Best-effort. if notify_targets: try: from .tasks import send_department_notification_task send_department_notification_task.delay({ "complaint_id": str(complaint.id), "reference_number": complaint.reference_number, "title": complaint.title, "department_name": department.name, "note": note, "email_subject": email_subject, "email_body": email_body, "targets": notify_targets, }) except Exception as e: logger.error( f"Failed to dispatch department notification task for " f"complaint #{complaint.reference_number}: {e}" ) 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_send_dept_reminder(request, pk): """Send a manual reminder to a department that hasn't responded yet. Visible in Quick Actions after the complaint has been sent to a department. The user picks which department from a modal; this endpoint sends an email + SMS to that department's champion/manager and logs a ComplaintUpdate. """ from .models import ComplaintInvolvedDepartment, ComplaintUpdate from apps.notifications.services import NotificationService, get_email_header_html from apps.organizations.department_contacts import get_champion_and_manager from apps.core.utils import build_public_track_url complaint = get_object_or_404(Complaint, pk=pk) user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to send reminders.")) return redirect("complaints:complaint_detail", pk=pk) inv_dept_id = request.POST.get("involved_department_id", "").strip() note = request.POST.get("note", "").strip() if not inv_dept_id: messages.error(request, _("Please select a department.")) return redirect("complaints:complaint_detail", pk=pk) involved_dept = get_object_or_404( ComplaintInvolvedDepartment, pk=inv_dept_id, complaint=complaint, sent=True, response_submitted=False, ) department = involved_dept.department reminder_type = request.POST.get("reminder_type", "first") if reminder_type == "first" and involved_dept.first_reminder_sent_at: messages.warning(request, _("First reminder already sent to this department.")) return redirect("complaints:complaint_detail", pk=pk) if reminder_type == "second" and involved_dept.second_reminder_sent_at: messages.warning(request, _("Second reminder already sent to this department.")) return redirect("complaints:complaint_detail", pk=pk) if reminder_type == "second" and not involved_dept.first_reminder_sent_at: messages.warning(request, _("Please send the first reminder before the second.")) return redirect("complaints:complaint_detail", pk=pk) targets = get_champion_and_manager(department) if not targets: messages.error( request, _(f"Cannot send reminder to {department.get_localized_name()}. This department has no champion or manager assigned."), ) return redirect("complaints:complaint_detail", pk=pk) track_url = build_public_track_url("complaint", complaint.reference_number) notified = [] sent_any = False for target in targets: email = target.get("email") or "" phone = target.get("phone") or "" label = target.get("label") or "" display_name = target.get("display_name") or label if email: try: NotificationService.send_email( email=email, subject=f"Reminder: Complaint #{complaint.reference_number} - Response Required", message=( f"This is a reminder that complaint #{complaint.reference_number} " f"is awaiting your department's response.\n\n" f"Track: {track_url}" ), html_message=f""" """, related_object=complaint, ) sent_any = True except Exception: pass if phone: try: NotificationService.send_sms( phone, f"PX360: Reminder - Complaint #{complaint.reference_number} sent to {department.name} is awaiting your response. Track: {track_url}", related_object=complaint, ) sent_any = True except Exception: pass notified.append(f"{display_name} ({label})") if sent_any: if reminder_type == "first": involved_dept.first_reminder_sent_at = timezone.now() else: involved_dept.second_reminder_sent_at = timezone.now() involved_dept.save(update_fields=["first_reminder_sent_at", "second_reminder_sent_at"]) ComplaintUpdate.objects.create( complaint=complaint, update_type="communication", message=f"Manual {reminder_type} reminder sent to {department.get_localized_name()} — {', '.join(notified)}", created_by=user, metadata={ "event_type": "manual_reminder", "department_id": str(department.id), "involved_department_id": str(involved_dept.id), "recipients": notified, "note": note, }, ) messages.success(request, _(f"Reminder sent to {department.get_localized_name()}.")) else: messages.error(request, _(f"Could not send reminder to {department.get_localized_name()}. No valid contact info.")) return redirect("complaints:complaint_detail", pk=pk) @login_required @require_http_methods(["POST"]) def complaint_change_status(request, pk): """Change complaint status""" complaint = get_object_or_404(Complaint, pk=pk) 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) if complaint.satisfaction_locked_by_patient: messages.error( request, _("Satisfaction is locked because the patient has submitted their feedback and cannot be changed."), ) return redirect("complaints:complaint_detail", pk=pk) if complaint.satisfaction_window_expired: messages.error( request, _("Satisfaction can no longer be set. The 5-day window after resolution has expired."), ) 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) old_satisfaction = complaint.satisfaction # Only real satisfaction ratings (satisfied/neutral/dissatisfied) consume # the change budget. "no_response" does NOT count. counts_as_change = satisfaction in ("satisfied", "neutral", "dissatisfied") is_new_rating = satisfaction != old_satisfaction if counts_as_change and is_new_rating and complaint.satisfaction_change_count >= Complaint.MAX_SATISFACTION_CHANGES: messages.error( request, _("Satisfaction has been changed {} times and can no longer be modified by PX-team. " "Only the patient can override it via the tracking page.").format(Complaint.MAX_SATISFACTION_CHANGES), ) return redirect("complaints:complaint_detail", pk=pk) from django.utils import timezone complaint.satisfaction = satisfaction if satisfaction: complaint.satisfaction_set_at = timezone.now() complaint.satisfaction_set_by = request.user else: complaint.satisfaction_set_at = None complaint.satisfaction_set_by = None # Increment counter only for real ratings that represent a change if counts_as_change and is_new_rating: complaint.satisfaction_change_count += 1 complaint.save(update_fields=[ "satisfaction", "satisfaction_set_at", "satisfaction_set_by", "satisfaction_change_count", "updated_at", ]) # Log with transition (from → to) SATISFACTION_LABELS = { "satisfied": _("Satisfied"), "neutral": _("Neutral"), "dissatisfied": _("Dissatisfied"), "no_response": _("No Response"), } if is_new_rating and satisfaction: new_label = SATISFACTION_LABELS.get(satisfaction, satisfaction) if old_satisfaction: old_label = str(SATISFACTION_LABELS.get(old_satisfaction, old_satisfaction)) log_msg = f"Satisfaction changed from '{old_label}' to '{new_label}' by {request.user.get_full_name()}" else: log_msg = f"Satisfaction set to '{new_label}' by {request.user.get_full_name()}" ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=log_msg, created_by=request.user, ) elif not satisfaction and old_satisfaction: ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Satisfaction cleared by {request.user.get_full_name()}", created_by=request.user, ) 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 update_patient_contact_status(request, pk): """Update patient contact status (contacted or contacted no response).""" 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 patient contact status.")) return redirect("complaints:complaint_detail", pk=pk) patient_contact_status = request.POST.get("patient_contact_status", "") valid_choices = ["not_contacted", "contacted", "contacted_no_response"] if patient_contact_status and patient_contact_status not in valid_choices: messages.error(request, _("Invalid patient contact status.")) return redirect("complaints:complaint_detail", pk=pk) from django.utils import timezone old_status = complaint.patient_contact_status complaint.patient_contact_status = patient_contact_status if patient_contact_status and patient_contact_status != "not_contacted": complaint.patient_contact_status_at = timezone.now() complaint.patient_contact_status_by = request.user else: complaint.patient_contact_status_at = None complaint.patient_contact_status_by = None complaint.save(update_fields=["patient_contact_status", "patient_contact_status_at", "patient_contact_status_by", "updated_at"]) ComplaintUpdate.objects.create( complaint=complaint, update_type="communication", message=f"Patient contact status changed from {old_status} to {patient_contact_status}", created_by=request.user, ) messages.success(request, _("Patient contact status updated to: {}").format(complaint.get_patient_contact_status_display())) 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, get_email_header_html 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, html_message=f""" """, ) 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, get_email_header_html 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, html_message=f""" """, ) 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_update_location(request, pk): """Update complaint location details (location_type, area, department, section).""" from apps.organizations.models import Area, Section 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's location.")) return redirect("complaints:complaint_detail", pk=pk) location_type = (request.POST.get("location_type") or "").strip() area_id = (request.POST.get("area") or "").strip() department_id = (request.POST.get("department") or "").strip() section_id = (request.POST.get("section") or "").strip() zone = request.POST.get("zone") if zone is not None: zone = zone.strip() floor = request.POST.get("floor") if floor is not None: floor = floor.strip() area = Area.objects.filter(id=area_id).first() if area_id else None department = Department.objects.filter(id=department_id).first() if department_id else None section = Section.objects.filter(id=section_id).first() if section_id else None try: ComplaintService.update_location( complaint, location_type=location_type, area=area, department=department, section=section, changed_by=request.user, request=request, zone=zone, floor=floor, ) except ComplaintServiceError as e: messages.error(request, str(e)) return redirect("complaints:complaint_detail", pk=pk) messages.success(request, _("Location details updated.")) 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) # Complaint must be activated before it can be escalated if not complaint.activated_at: messages.error(request, "Activate the complaint before escalating it.") return redirect("complaints:complaint_detail", pk=pk) # Check permission user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): 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", "") escalate_to_staff = None escalate_to_user = None escalate_to_email = 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 escalate_to_email = escalate_to_staff.user.email elif escalate_to_staff.email: escalate_to_email = escalate_to_staff.email except Staff.DoesNotExist: pass if not escalate_to_staff: messages.error(request, _("Please select a valid person to escalate to.")) return redirect("complaints:complaint_detail", pk=pk) escalate_to_name = escalate_to_staff.get_full_name() # Mark as escalated — do NOT reassign. The complaint stays with the # PX-team user who activated it. The escalated person is notified via email. complaint.escalated_at = timezone.now() complaint.save(update_fields=["escalated_at"]) # Create update with escalation details escalation_message = f"Complaint escalated. Reason: {reason}" escalation_message += f" Escalated to: {escalate_to_name}" ComplaintUpdate.objects.create( complaint=complaint, update_type="escalation", message=escalation_message, created_by=request.user, metadata={ "reason": reason, "escalated_to_staff_id": str(escalate_to_staff.id), "escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None, "escalated_to_user_name": escalate_to_name, }, ) # Log audit AuditService.log_event( event_type="escalation", description=f"Complaint escalated to {escalate_to_name}", user=request.user, content_object=complaint, metadata={ "reason": reason, "escalated_to_staff_id": str(escalate_to_staff.id), "escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None, "escalated_to_user_name": escalate_to_name, }, ) # Send notification email if escalate_to_email: from apps.notifications.services import NotificationService, get_email_header_html email_subject = request.POST.get("email_subject", f"Complaint Escalated - {complaint.reference_number}") email_body = request.POST.get("email_body", "") complaint_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/complaints/{complaint.pk}/" if complaint.department else "" html_message = f""" """ try: NotificationService.send_email( email=escalate_to_email, subject=email_subject, message=email_body, html_message=html_message, 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 to {escalate_to_name}.", ) 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 @require_http_methods(["POST"]) def confirm_taxonomy(request, pk): """Confirm that the taxonomy classification has been reviewed. Sets taxonomy_reviewed_at/by so the workflow stepper advances past the 'Review Taxonomy' step, unblocking 'Send to Department'. The taxonomy itself remains editable afterwards via the Edit pencil. """ from django.utils import timezone 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 confirm taxonomy for this complaint.")) return redirect("complaints:complaint_detail", pk=pk) complaint.taxonomy_reviewed_at = timezone.now() complaint.taxonomy_reviewed_by = request.user complaint.save(update_fields=["taxonomy_reviewed_at", "taxonomy_reviewed_by", "updated_at"]) messages.success(request, _("Taxonomy confirmed. You can now send to department.")) 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() or request.user.is_px_management() or request.user.is_px_employee() ): 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", "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() or request.user.is_px_management() or request.user.is_px_employee() ): 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() or request.user.is_px_management() or request.user.is_px_employee() ): 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", "outgoing_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_px_management() or user.is_px_employee(): if 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() # Snapshot the RBAC base before direction/filters so we can compute # incoming/outgoing counts for the tabs. base_qs = queryset # Apply non-direction filters (status, category, hospital, department, search) status_filter = request.GET.get("status") if status_filter: base_qs = base_qs.filter(status=status_filter) category_filter = request.GET.get("category") if category_filter: base_qs = base_qs.filter(category=category_filter) hospital_filter = request.GET.get("hospital") if hospital_filter: base_qs = base_qs.filter(hospital_id=hospital_filter) department_filter = request.GET.get("department") if department_filter: base_qs = base_qs.filter(department_id=department_filter) search_query = request.GET.get("search") if search_query: base_qs = base_qs.filter( Q(subject__icontains=search_query) | Q(message__icontains=search_query) | Q(contact_name__icontains=search_query) | Q(contact_email__icontains=search_query) ) # Direction split: incoming vs outgoing direction = request.GET.get("direction", "incoming") dept = getattr(user, "department", None) is_dept_scoped = user.is_department_manager() or user.is_champion() if is_dept_scoped and dept: sent_away = Q(department=dept) & Q(transferred_to_department__isnull=False) & ~Q(transferred_to_department=dept) incoming_qs = base_qs.filter( Q(department=dept) | Q(transferred_to_department=dept) | Q(outgoing_department=dept) ).exclude(sent_away) outgoing_qs = base_qs.filter(sent_away) else: # PX roles: incoming = not forwarded; outgoing = forwarded to a department incoming_qs = base_qs.exclude(transferred_to_department__isnull=False) outgoing_qs = base_qs.filter(transferred_to_department__isnull=False) incoming_count = incoming_qs.count() outgoing_count = outgoing_qs.count() queryset = outgoing_qs if direction == "outgoing" else incoming_qs # 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, "stats": stats, "departments": departments, "filters": request.GET, "direction": direction, "incoming_count": incoming_count, "outgoing_count": outgoing_count, } return render(request, "complaints/inquiry_list.html", context) @login_required def inquiry_detail(request, pk): """ Inquiry detail view with timeline and attachments. Features: - Full inquiry details - Timeline of all updates - Attachments management - Workflow actions (assign, status change, add note, respond) """ from apps.px_sources.models import SourceUser source_user = SourceUser.objects.filter(user=request.user).first() base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" inquiry = get_object_or_404( Inquiry.objects.select_related( "patient", "hospital", "department", "assigned_to", "responded_by", "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_px_management() or user.is_px_employee(): if user.hospital 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_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: from django.db.models import Q hospital_departments = Department.objects.filter( hospital=inquiry.hospital, status="active", ).filter( Q(champion__isnull=False) | Q(manager__isnull=False) ).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" ) escalation_targets = [] if inquiry.hospital: from django.db.models import Q escalation_targets_qs = ( Staff.objects.filter( hospital=inquiry.hospital, status="active", user__isnull=False, user__is_active=True ) .filter(Q(user__groups__name__in=["Hospital Admin", "Department Manager"]) | Q(direct_reports__isnull=False)) .select_related("user", "department", "report_to") .distinct() .order_by("first_name", "last_name") ) for staff in escalation_targets_qs: escalation_targets.append( { "staff": staff, "has_user": True, "user_id": str(staff.user.id), "is_manager": staff.direct_reports.exists(), "is_line_manager": False, "group": "managers", } ) escalation_targets.sort(key=lambda x: (not x["is_manager"], x["staff"].get_full_name())) if inquiry.department: dept = Department.objects.filter(pk=inquiry.department_id).first() if dept: dept_role_holders = [] for holder in dept.get_role_holders(): if holder["staff"].user and holder["staff"].user.is_active: dept_role_holders.append({ "staff": holder["staff"], "has_user": True, "user_id": str(holder["staff"].user.id), "is_manager": False, "is_line_manager": False, "role_label": holder["role_label"], "group": "department_roles", }) if dept_role_holders: escalation_targets = dept_role_holders + escalation_targets context = { "inquiry": inquiry, "timeline": timeline, "stage_timeline": stage_timeline, "attachments": attachments, "assignable_users": assignable_users, "send_to_users": User.objects.filter( is_active=True, hospital=inquiry.hospital ).select_related("department").order_by("first_name", "last_name"), "hospital_departments": hospital_departments, "status_choices": status_choices, "can_edit": ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ), "can_respond": ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or inquiry.assigned_to == user or ( user.is_champion() and user.department and user.department in [inquiry.department, inquiry.outgoing_department] ) ), "can_send_reminder": ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ), "can_admin": user.is_px_admin() or user.is_hospital_admin(), "base_layout": base_layout, "source_user": source_user, "linked_rcas": linked_rcas, "escalation_targets": escalation_targets, "escalation_email_subject": f"Inquiry Escalated - {inquiry.reference_number} - {inquiry.subject or 'N/A'}", "escalation_email_body": ( f"Dear Manager,\n\n" f"This inquiry has been escalated and requires your immediate attention.\n\n" f"Reference: {inquiry.reference_number}\n" f"Subject: {inquiry.subject or 'N/A'}\n" f"Priority: {inquiry.get_priority_display()}\n" f"Status: {inquiry.get_status_display()}\n" f"Department: {inquiry.department.get_localized_name() if inquiry.department else 'N/A'}\n\n" f"Please review and take appropriate action.\n\n" f"View: https://{request.get_host()}/inquiries/{inquiry.pk}/" ), } from django.contrib.contenttypes.models import ContentType inquiry_ct = ContentType.objects.get_for_model(inquiry) context["content_type_id"] = inquiry_ct.pk context["object_id"] = inquiry.pk context["notes"] = inquiry.notes.select_related("created_by").all() context["notes_count"] = context["notes"].count() # Workflow stepper _iq_steps = [ {"label": "Created", "icon": "plus-circle", "done": True}, {"label": "Assigned", "icon": "user-plus", "done": inquiry.assigned_at is not None}, {"label": "Sent to Dept", "icon": "send", "done": inquiry.sent_to_department_at is not None}, {"label": "Dept Response", "icon": "message-square", "done": inquiry.department_responded_at is not None}, {"label": "Resolved", "icon": "check-circle-2", "done": inquiry.status in ("resolved", "closed")}, ] _iq_next = next((i for i, s in enumerate(_iq_steps) if not s["done"]), len(_iq_steps)) if _iq_next < len(_iq_steps): _iq_steps[_iq_next]["current"] = True context["workflow_steps"] = _iq_steps 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() or request.user.is_px_management() or request.user.is_px_employee() ): 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: from django.template.loader import render_to_string email_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, } html_message = render_to_string("emails/inquiry_explanation_request.html", email_context) message = ( f"Dear {staff.first_name} {staff.last_name},\n\n" f"You have been requested to provide a response regarding inquiry #{inquiry.reference_number or inquiry.id}.\n\n" f"Subject: {inquiry.subject}\n\n" f"Please submit your response using the link below:\n{explanation_url}\n" ) NotificationService.send_email( email=staff.email, subject=f"Inquiry Response Requested - #{inquiry.reference_number or inquiry.id}", message=message, html_message=html_message, related_object=explanation, metadata={"notification_type": "inquiry_explanation_request"}, ) 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(["POST"]) def inquiry_quick_create(request): """Quick-create an inquiry from the simple modal (PX staff only). Fields: message, contact name, phone (required), email (optional), location type, category, department, section. The inquiry is auto-activated and assigned to the current user. AI fills subject (from its title) and priority in the background; the user's department/section/category are kept. """ from .models import Inquiry, InquiryUpdate user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): return JsonResponse( {"success": False, "error": str(_("You don't have permission to create inquiries."))}, status=403, ) message = (request.POST.get("message") or "").strip() contact_name = (request.POST.get("contact_name") or "").strip() contact_phone = (request.POST.get("contact_phone") or "").strip() contact_email = (request.POST.get("contact_email") or "").strip() location_type = (request.POST.get("location_type") or "").strip() department_id = (request.POST.get("department") or "").strip() section_id = (request.POST.get("section") or "").strip() # Required-field validation (the inquiry-type `category` is intentionally NOT # collected here — the model field is required at DB level so it is defaulted # to "general" below. The department-category in the modal is a UI filter only.) missing = { "message": message, "contact_name": contact_name, "contact_phone": contact_phone, "location_type": location_type, "department": department_id, } for field, value in missing.items(): if not value: return JsonResponse( {"success": False, "error": str(_("This field is required: {}").format(field))}, status=400, ) hospital = getattr(request, "tenant_hospital", None) or user.hospital if not hospital: return JsonResponse({"success": False, "error": str(_("No hospital assigned to your account."))}, status=400) # Validate location_type from apps.complaints.models import Inquiry as _Inquiry valid_location_types = {c for c, _ in _Inquiry._meta.get_field("location_type").choices or []} if valid_location_types and location_type not in valid_location_types: return JsonResponse({"success": False, "error": str(_("Invalid location type."))}, status=400) # Validate department belongs to the hospital from apps.organizations.models import Department, Section try: department = Department.objects.get(id=department_id, hospital=hospital, status="active") except Department.DoesNotExist: return JsonResponse({"success": False, "error": str(_("Invalid department for this hospital."))}, status=400) # Section is optional — only validate if provided section = None if section_id: try: section = Section.objects.get(id=section_id, department=department) except Section.DoesNotExist: return JsonResponse( {"success": False, "error": str(_("Selected section does not belong to this department."))}, status=400, ) try: inquiry = Inquiry.objects.create( hospital=hospital, created_by=user, message=message, contact_name=contact_name, contact_phone=contact_phone, contact_email=contact_email, location_type=location_type, category="general", department=department, section=section, # The inquiry is routed to this department for handling — mark it as # sent so it appears in the department's incoming queue (not outgoing). sent_to_department=True, sent_to_department_at=timezone.now(), # Defaults — AI refines subject/priority in the background (NOT department) subject=message[:80], priority="medium", # Auto-activate + auto-assign to the creator status="in_progress", assigned_to=user, assigned_at=timezone.now(), activated_at=timezone.now(), metadata={ "subject_pending_ai": True, # AI fills the subject from its generated title "ai_skip_department": True, # user provided department — AI must not override it }, ) # Background AI analysis (fills subject/priority/department) + notifications 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=user, ) roles_display = ", ".join(user.get_role_names()) if hasattr(user, "get_role_names") else "" InquiryUpdate.objects.create( inquiry=inquiry, update_type="assignment", message=f"Inquiry auto-activated and assigned to {user.get_full_name()} ({roles_display})", created_by=user, ) AuditService.log_event( event_type="inquiry_created", description=f"Quick inquiry created: {inquiry.subject}", user=user, content_object=inquiry, metadata={"category": inquiry.category, "quick_create": True}, ) return JsonResponse({ "success": True, "inquiry_id": str(inquiry.id), "reference_number": inquiry.reference_number, }) except Exception as e: logger.error(f"Quick inquiry create failed: {e}") return JsonResponse( {"success": False, "error": str(_("An error occurred while creating the inquiry."))}, status=500, ) @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" communication_request = None 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() # Background patient linking (local DB first, then HIS). if not inquiry.patient_id: from .tasks import link_inquiry_patient link_inquiry_patient.delay(str(inquiry.id), inquiry.contact_phone) 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 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() or user.is_px_management() or user.is_px_employee() ): 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 = {} 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() or user.is_px_management() or user.is_px_employee() ): 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.activated_at = timezone.now() inquiry.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"]) else: inquiry.save(update_fields=["assigned_to", "assigned_at"]) # 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) or user.is_px_management() or user.is_px_employee() ) 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() or user.is_px_management() or user.is_px_employee() or (user.is_department_manager() and user.department_id == inquiry.handling_department_id) or (user.is_champion() and user.department_id == inquiry.handling_department_id) ): messages.error(request, "You don't have permission to change inquiry status.") return _safe_next_redirect(request, 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 _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk)) old_status = inquiry.status inquiry.status = new_status # Handle status-specific logic # Department users (champion / dept manager) must record a patient contact # outcome before closing/resolving an inquiry. PX roles follow their own flow. if new_status in ("resolved", "closed") and (user.is_champion() or user.is_department_manager()): if inquiry.contact_status not in ("contacted", "contacted_no_response"): messages.error(request, _("Please record a patient contact outcome before closing.")) return _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk)) # "Contacted - No Response" is a terminal status set when the department could # not reach the patient. Auto-sync the contact_status tracking so outreach data # stays consistent without double data entry. if new_status == "contact_no_response": inquiry.contact_status = "contacted_no_response" inquiry.contact_status_at = timezone.now() inquiry.contact_status_by = user inquiry._acting_user = request.user 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 _safe_next_redirect(request, redirect("inquiries:inquiry_detail", pk=pk)) @login_required @require_http_methods(["POST"]) def inquiry_set_contact_status(request, pk): """Record the outcome of the department's patient-contact attempt. Sets the existing ``contact_status`` field (not_contacted / contacted / contacted_no_response). Used by the department (champion/manager) that owns the inquiry to log communication outcomes with the patient. """ 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_px_management() or user.is_px_employee() or (user.is_department_manager() and user.department_id == inquiry.handling_department_id) or (user.is_champion() and user.department_id == inquiry.handling_department_id) ): messages.error(request, _("You don't have permission to set the contact status.")) return redirect("inquiries:inquiry_detail", pk=pk) new_contact_status = request.POST.get("contact_status") note = request.POST.get("note", "").strip() valid = {c for c, _ in Inquiry._meta.get_field("contact_status").choices} if new_contact_status not in valid: messages.error(request, _("Please select a valid contact status.")) return redirect("inquiries:inquiry_detail", pk=pk) old_contact_status = inquiry.contact_status inquiry.contact_status = new_contact_status inquiry.contact_status_at = timezone.now() inquiry.contact_status_by = user inquiry.save(update_fields=["contact_status", "contact_status_at", "contact_status_by"]) display = dict(Inquiry._meta.get_field("contact_status").choices).get(new_contact_status, new_contact_status) InquiryUpdate.objects.create( inquiry=inquiry, update_type="communication", message=note or _("Contact status set to: {status}").format(status=display), created_by=user, ) AuditService.log_event( event_type="inquiry_contact_status", description=_("Contact status changed from {old} to {new}").format( old=old_contact_status or "—", new=new_contact_status ), user=user, content_object=inquiry, metadata={"old_contact_status": old_contact_status, "new_contact_status": new_contact_status}, ) messages.success(request, _("Contact status updated to: {status}").format(status=display)) return _safe_next_redirect(request, 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) or user.is_px_management() or user.is_px_employee() ): 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] ) or user.is_px_management() or user.is_px_employee() ) if not can_respond: messages.error(request, "You don't have permission to respond to inquiries.") return redirect("inquiries:inquiry_detail", pk=pk) response = request.POST.get("response", "").strip() if not response: messages.error(request, "Please enter a response.") return redirect("inquiries:inquiry_detail", pk=pk) inquiry.response = response inquiry.response_en = "" inquiry.response_ar = "" inquiry.responded_at = timezone.now() inquiry.responded_by = request.user inquiry.status = "resolved" inquiry._acting_user = request.user 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, get_email_header_html from apps.core.utils import build_public_track_url track_url = build_public_track_url("inquiry", inquiry.reference_number) if inquiry.contact_phone: NotificationService.send_sms( phone=inquiry.contact_phone, message=f"PX360: Your inquiry #{inquiry.reference_number} has been responded to. View details: {track_url}", 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 = ( f"Dear Valued Patient,\n\n" f"Your inquiry #{inquiry.reference_number} has been responded to.\n\n" f"To view the full response, please visit:\n{track_url}\n\n" f"Thank you for your patience.\n\n" f"Reference: {inquiry.reference_number}\n" f"This is an automated message from PX 360." ) NotificationService.send_email( email=inquiry.contact_email, subject=email_subject, message=email_body, html_message=f""" """, 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() and user.department_id == inquiry.handling_department_id) or user.is_px_management() or user.is_px_employee() or (user.is_champion() and user.department_id == inquiry.handling_department_id) ): messages.error(request, _("You don't have permission to transfer inquiries to departments.")) return redirect("inquiries:inquiry_detail", pk=pk) # Must be activated (in_progress) before it can be sent to a department if inquiry.status == "open": messages.error(request, _("Activate this inquiry before sending it to a department.")) 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.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: messages.error(request, _("Department not found.")) return redirect("inquiries:inquiry_detail", pk=pk) if not department.champion and not department.manager: messages.error(request, _(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")) return redirect("inquiries:inquiry_detail", pk=pk) contact_person_id = request.POST.get("contact_person_id") if not contact_person_id: messages.error(request, _("Please select a contact person.")) return redirect("inquiries:inquiry_detail", pk=pk) contact_info = department.is_valid_contact_person(contact_person_id) if not contact_info: messages.error(request, _("Selected person is not a role holder in this department.")) return redirect("inquiries:inquiry_detail", pk=pk) contact_person = contact_info["staff"] 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 # keep the cross-module "sent" signal consistent with observations/complaints inquiry.sent_to_department = True inquiry.sent_to_department_at = inquiry.transferred_at 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.contact_status = "contacted" inquiry.contact_status_at = timezone.now() inquiry.contact_status_by = user 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: import secrets from apps.notifications.settings_service import NotificationServiceWithSettings # Generate token for response link response_token = secrets.token_urlsafe(32) inquiry.response_token = response_token inquiry.response_token_sent_at = timezone.now() inquiry.save(update_fields=["response_token", "response_token_sent_at"]) # Build response link from django.contrib.sites.shortcuts import get_current_site current_site = get_current_site(request) domain = current_site.domain if current_site else request.get_host() response_link = f"https://{domain}/inquiries/{inquiry.pk}/respond/{response_token}/" NotificationServiceWithSettings.send_inquiry_department_assigned( department, inquiry, context_note_en=note_en, context_note_ar=note_ar, recipient_type=recipient_type, ) contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None) if contact_email: from apps.notifications.services import NotificationService, get_email_header_html NotificationService.send_email( email=contact_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.\n\nSubject: {inquiry.subject}\n\nSubmit your response here: {response_link}\n\nDeadline: {inquiry.dept_response_sla_due_at}", html_message=f""" """, 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_escalate(request, pk): from .models import Inquiry, InquiryUpdate from apps.organizations.models import Staff from apps.notifications.services import NotificationService, get_email_header_html 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_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to escalate inquiries.")) return redirect("inquiries:inquiry_detail", pk=pk) if inquiry.status in ("closed", "cancelled"): messages.error(request, _("Cannot escalate a closed or cancelled inquiry.")) return redirect("inquiries:inquiry_detail", pk=pk) escalate_to_id = request.POST.get("escalate_to", "") reason = request.POST.get("reason", "") escalate_to_staff = None escalate_to_user = None if escalate_to_id: try: escalate_to_staff = Staff.objects.get(id=escalate_to_id, status="active") if escalate_to_staff.user and escalate_to_staff.user.is_active: escalate_to_user = escalate_to_staff.user except Staff.DoesNotExist: pass if not escalate_to_user: messages.error(request, _("Please select a valid person to escalate to.")) return redirect("inquiries:inquiry_detail", pk=pk) inquiry.escalated_at = timezone.now() inquiry.save(update_fields=["escalated_at"]) InquiryUpdate.objects.create( inquiry=inquiry, update_type="note", message=f"Inquiry escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}", created_by=user, ) email_subject = request.POST.get("email_subject", f"Inquiry Escalated - {inquiry.reference_number}") email_body = request.POST.get("email_body", "") if escalate_to_user.email: try: NotificationService.send_email( email=escalate_to_user.email, subject=email_subject, message=email_body, html_message=f""" """, related_object=inquiry, metadata={ "notification_type": "inquiry_escalated", "escalated_by": str(user.id), "reason": reason, }, ) except Exception as e: logger.error(f"Failed to send inquiry escalation email: {e}") messages.success(request, _(f"Inquiry escalated to {escalate_to_staff.get_full_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, get_email_header_html 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() and user.department_id == inquiry.handling_department_id) or user.is_px_management() or user.is_px_employee() or (user.is_champion() and user.department_id == inquiry.handling_department_id) ): return JsonResponse({ "success": False, "error": str(_("You don't have permission to send this inquiry.")), }, status=403) # Must be activated (in_progress) before it can be sent/assigned if inquiry.status == "open": return JsonResponse({ "success": False, "error": str(_("Activate this inquiry before sending it to a department.")), }, status=400) 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""" """, 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.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) # Auto-target the department's champion and manager (no contact-person picker) from apps.organizations.department_contacts import get_champion_and_manager from apps.notifications.services import NotificationService targets = get_champion_and_manager(department) if not targets: return JsonResponse({ "success": False, "error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")), }, 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 inquiry.sent_to_department = True inquiry.sent_to_department_at = timezone.now() 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 # Notify champion + manager (email + SMS) with the inquiry link notified = [] link = f"https://{request.get_host()}/inquiries/{inquiry.pk}/" for target in targets: display_name = ( target["staff"].get_full_name() if target.get("staff") else (target["user"].get_full_name() if target.get("user") else target["label"]) ) if target.get("email"): try: NotificationService.send_email( email=target["email"], subject=f"Inquiry Sent to Department - {inquiry.reference_number}", message=f"Inquiry #{inquiry.reference_number} has been sent to your department ({department.name}).\n\n{link}", related_object=inquiry, ) except Exception: pass if target.get("phone"): try: NotificationService.send_sms( target["phone"], f"PX360: Inquiry #{inquiry.reference_number} sent to {department.name}. Review: {link}", related_object=inquiry, ) except Exception: pass notified.append(f"{display_name} ({target['label']})") message = f"Inquiry sent to {department.get_localized_name()} — {', '.join(notified)}." # Set contact_status if open if inquiry.status in ("open",): inquiry.contact_status = "contacted" inquiry.contact_status_at = timezone.now() inquiry.contact_status_by = user 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, ) 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 )) or user.is_px_management() or user.is_px_employee() ): 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.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.") if request.headers.get('X-Requested-With') == 'XMLHttpRequest': from django.http import JsonResponse redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("inquiries:inquiry_detail", kwargs={"pk": pk}) return JsonResponse({"success": True, "redirect_url": redirect_url}) if user.department: return redirect("organizations:department_detail", pk=user.department.pk) 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_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() or user.is_px_management() or user.is_px_employee() ): 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") if reminder_type == "first" and inquiry.dept_response_reminder_sent_at: messages.warning(request, "First reminder already sent.") return redirect("inquiries:inquiry_detail", pk=pk) if reminder_type == "second" and inquiry.dept_response_second_reminder_sent_at: messages.warning(request, "Second reminder already sent.") return redirect("inquiries:inquiry_detail", pk=pk) if reminder_type == "second" and not inquiry.dept_response_reminder_sent_at: messages.warning(request, "Please send the first reminder before the second.") return redirect("inquiries:inquiry_detail", pk=pk) try: from apps.organizations.department_contacts import get_champion_and_manager targets = get_champion_and_manager(dept) if not targets: messages.error(request, f"No champion or manager assigned to {dept.name}.") return redirect("inquiries:inquiry_detail", pk=pk) recipients = [] for target in targets: email = target.get("email") or "" if email: recipients.append({"email": email, "name": target.get("display_name", "")}) if reminder_type == "first": inquiry.dept_response_reminder_sent_at = timezone.now() else: inquiry.dept_response_second_reminder_sent_at = timezone.now() inquiry.save() from .tasks import send_inquiry_reminder_email send_inquiry_reminder_email.delay(str(inquiry.id), reminder_type, recipients) 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 or user.is_px_management() or user.is_px_employee()): 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.contact_status = "contacted_no_response" inquiry.contact_status_at = timezone.now() inquiry.contact_status_by = user 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 == "open": 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"): inquiry.contact_status = "contacted" inquiry.contact_status_at = timezone.now() inquiry.contact_status_by = user 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") category = request.POST.get("category", "") location_type = request.POST.get("location_type", "") department_id = request.POST.get("department") section_id = request.POST.get("section") area_id = request.POST.get("area") 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_type: errors.append(_("Location type is required")) if not department_id: errors.append(_("Department 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) from apps.organizations.models import Area, Department, Section, OrgSubSection department = Department.objects.get(id=department_id) section = Section.objects.filter(id=section_id).first() if section_id else None area = Area.objects.filter(id=area_id).first() if area_id else None # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN) # Create complaint with location hierarchy and all form fields. # Patient linking is backgrounded (local DB first, then HIS) so a slow/unreachable # HIS never blocks the public submission. complaint = Complaint.objects.create( patient=None, hospital=hospital, department=department, section=section, area=area, location_type=location_type if location_type else "", title="Complaint", description=complaint_details, severity="medium", priority="medium", status="open", complaint_source_type=ComplaintSourceType.INTERNAL, source=PXSource.objects.filter(name_en="Public Form").first(), contact_name=complainant_name, contact_phone=mobile_number, contact_email=email, relation_to_patient=relation_to_patient, patient_name=patient_name, national_id=national_id, incident_date=parsed_date if incident_date else None, staff_name=staff_name, expected_result=expected_result, ) reference_number = complaint.reference_number ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message="Complaint submitted via public form. AI analysis running in background.", ) # Trigger AI analysis + patient linking in the background using Celery from .tasks import analyze_complaint_with_ai, link_complaint_patient, notify_staff_new_item analyze_complaint_with_ai.delay(str(complaint.id)) notify_staff_new_item.delay("complaint", str(complaint.id)) link_complaint_patient.delay(str(complaint.id), national_id, mobile_number) # 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_pdf(request, reference_number): """Serve the latest PDF report for a complaint (no auth — scanned from QR code). Finds the most recent ComplaintPdfSummary for the complaint and serves the saved PDF file inline so the browser displays it directly. """ from django.http import Http404, HttpResponse complaint = get_object_or_404(Complaint, reference_number__iexact=reference_number) summary = complaint.pdf_summaries.order_by("-created_at").first() if not summary or not summary.file: raise Http404("No PDF report available for this complaint.") response = HttpResponse(summary.file, content_type="application/pdf") response["Content-Disposition"] = f'inline; filename="{complaint.reference_number}.pdf"' return response 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 - Patient satisfaction submission (locks the field once submitted). The picker is available for 5 days after resolution/closure, matching the unified tracker expiry in core/views.py. """ from datetime import timedelta from django.utils import timezone complaint = None error_message = None info_message = None satisfaction_just_submitted = False reference_number = request.GET.get("reference", "").strip() tracking_expired = False def _compute_tracking_expired(comp): if not comp: return False expiry_base = comp.resolved_at or comp.closed_at if not expiry_base: return False return timezone.now() > expiry_base + timedelta(days=5) 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") .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.") tracking_expired = _compute_tracking_expired(complaint) # Handle patient satisfaction submission (separate from reference search) satisfaction_value = request.POST.get("satisfaction", "").strip() if satisfaction_value and complaint is not None: valid_choices = ["satisfied", "neutral", "dissatisfied"] if satisfaction_value not in valid_choices: error_message = _("Invalid satisfaction value.") elif complaint.status not in ("resolved", "closed"): error_message = _("Satisfaction can only be submitted after the complaint is resolved.") elif tracking_expired: error_message = _("This tracking link has expired. Satisfaction feedback can no longer be submitted.") elif complaint.satisfaction_locked_by_patient: info_message = _("You have already submitted your feedback. Thank you!") else: complaint.satisfaction = satisfaction_value complaint.satisfaction_set_at = timezone.now() complaint.satisfaction_locked_by_patient = True complaint.satisfaction_locked_at = timezone.now() complaint.save( update_fields=[ "satisfaction", "satisfaction_set_at", "satisfaction_locked_by_patient", "satisfaction_locked_at", "updated_at", ] ) satisfaction_just_submitted = True elif reference_number: # GET request with reference parameter try: complaint = ( Complaint.objects.select_related("hospital", "department") .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.") tracking_expired = _compute_tracking_expired(complaint) public_status = None public_updates = [] if complaint: public_status = complaint.public_status public_updates = list( complaint.updates.filter(update_type__in=["status_change", "resolution"]).order_by( "-created_at" ) ) _status_map = { "open": str(_("Received")), "in_progress": str(_("In Progress")), "partially_resolved": str(_("In Progress")), "resolved": str(_("Resolved")), "closed": str(_("Closed")), "cancelled": str(_("Cancelled")), } for update in public_updates: if update.message: for internal, public_label in _status_map.items(): update.message = update.message.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, "info_message": info_message, "satisfaction_just_submitted": satisfaction_just_submitted, "tracking_expired": tracking_expired, "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") national_id = request.POST.get("national_id", "") hospital_id = request.POST.get("hospital") location_type = request.POST.get("location_type", "") department_id = request.POST.get("department", "") section_id = request.POST.get("section", "") 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) from apps.organizations.models import Department, Section department = Department.objects.filter(id=department_id).first() if department_id else None section = Section.objects.filter(id=section_id).first() if section_id else None import uuid from datetime import datetime # Reference number generated by Inquiry.save() (unified INQ-YYYYMM-HOSP-NNNN) # Patient linking is backgrounded (local DB first, then HIS) so a slow HIS # never blocks the public inquiry submission. inquiry = Inquiry.objects.create( patient=None, hospital=hospital, department=department, section=section, location_type=location_type if location_type else "", subject=subject, message=message, category=category, contact_name=name, contact_phone=phone, contact_email=email, national_id=national_id, status="open", is_outgoing=False, ) reference_number = inquiry.reference_number from apps.complaints.tasks import analyze_inquiry_with_ai, link_inquiry_patient, notify_staff_new_item analyze_inquiry_with_ai.delay(str(inquiry.id)) notify_staff_new_item.delay("inquiry", str(inquiry.id)) link_inquiry_patient.delay(str(inquiry.id), phone) 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 inquiry = None 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"], } else: error_message = _("No inquiry found with this reference number. Please check and try again.") has_response = bool(inquiry and (inquiry.department_response_en or inquiry.department_response_ar)) response_en = inquiry.department_response_en if inquiry else "" response_ar = inquiry.department_response_ar if inquiry else "" 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"), "has_response": has_response, "response_en": response_en, "response_ar": response_ar, }) else: return JsonResponse({"success": False, "error": "Inquiry not found"}) return render(request, "complaints/public_inquiry_track.html", { "inquiry": inquiry, "public_status": public_status, "error_message": error_message, "reference_number": reference, "has_response": has_response, "response_en": response_en, "response_ar": response_ar, }) 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) context = { "hospital": hospital, "source_based_configs": source_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 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() or user.is_px_management() or user.is_px_employee() ): 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() or user.is_px_management() or user.is_px_employee() ): 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() or user.is_px_management() or user.is_px_employee() ): 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() or user.is_px_management() or user.is_px_employee() ): 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(), ) 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() 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, }, ) success_msg = _("Department '%(dept)s' added successfully as %(role)s.") % { "dept": involved_dept.department.name, "role": involved_dept.get_role_display(), } if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse({"success": True, "message": str(success_msg)}) messages.success(request, success_msg) return redirect("complaints:complaint_detail", pk=complaint.pk) else: if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse( {"success": False, "errors": form.errors.get_json_data()}, status=400, ) 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) # If the user is removing the complaint's primary department involvement, # record it so ensure_involved_records() does not auto-recreate it on the # next detail-view render. Stored as the dept id; if complaint.department # later changes to a different dept, auto-recreation resumes automatically. if involved_dept.department_id == complaint.department_id and complaint.department_id is not None: complaint.primary_dept_involved_removed_id = complaint.department_id complaint.save(update_fields=["primary_dept_involved_removed"]) 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() response_notes_en = request.POST.get("response_notes_en", "").strip() response_notes_ar = request.POST.get("response_notes_ar", "").strip() if not response_notes and not response_notes_en and not response_notes_ar: 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 or response_notes_en or response_notes_ar involved_dept.response_notes_en = response_notes_en involved_dept.response_notes_ar = response_notes_ar involved_dept.response_submitted = True involved_dept.response_submitted_at = timezone.now() involved_dept.acceptance_status = "acceptable" involved_dept.accepted_at = timezone.now() involved_dept.save() ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Response submitted by {involved_dept.department.name}", created_by=user, ) # Notify complainant that department has responded try: from apps.notifications.services import NotificationService from apps.core.utils import build_public_track_url track_url = build_public_track_url("complaint", complaint.reference_number) if complaint.contact_phone: NotificationService.send_sms( phone=complaint.contact_phone, message=f"PX360: Your complaint {complaint.reference_number} has been responded to. View: {track_url}", related_object=complaint, ) if complaint.contact_email: NotificationService.send_email( email=complaint.contact_email, subject=f"PX360: Response to Your Complaint {complaint.reference_number}", message=f"Your complaint has been responded to.\n\nView: {track_url}", related_object=complaint, ) except Exception as e: logger.warning(f"Failed to send complainant notification: {e}") if request.headers.get('X-Requested-With') == 'XMLHttpRequest': redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("complaints:complaint_detail", kwargs={"pk": complaint.pk}) return JsonResponse({ "success": True, "message": str(_("Department response submitted successfully.")), "redirect_url": redirect_url, }) messages.success(request, _("Department response submitted successfully.")) if user.department: return redirect("organizations:department_detail", pk=user.department.pk) return redirect("complaints:complaint_detail", pk=complaint.pk) @login_required @require_http_methods(["POST"]) def involved_department_review_response(request, pk): """ Review (accept/reject) a department's response to a complaint. On rejection, notifies the champion and resets for resubmission. """ from .models import ComplaintInvolvedDepartment involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to review department responses.")) return redirect("complaints:complaint_detail", pk=complaint.pk) if not involved_dept.response_submitted: messages.error(request, _("No department response to review.")) return redirect("complaints:complaint_detail", pk=complaint.pk) if involved_dept.manager_review_status != "approved": messages.error(request, _("This response has not been approved by the department manager yet.")) return redirect("complaints:complaint_detail", pk=complaint.pk) acceptance = request.POST.get("acceptance_status") if acceptance not in ("acceptable", "not_acceptable"): messages.error(request, _("Invalid acceptance status.")) return redirect("complaints:complaint_detail", pk=complaint.pk) notes = request.POST.get("acceptance_notes", "").strip() involved_dept.acceptance_status = acceptance involved_dept.accepted_by = user involved_dept.accepted_at = timezone.now() involved_dept.acceptance_notes = notes if acceptance == "not_acceptable": involved_dept.response_submitted = False involved_dept.response_submitted_at = None involved_dept.response_notes = "" involved_dept.response_notes_en = "" involved_dept.response_notes_ar = "" involved_dept.acceptance_status = "not_acceptable" involved_dept.save() ComplaintUpdate.objects.create( complaint=complaint, update_type="note", message=f"Department response from {involved_dept.department.name} marked as {acceptance} by {user.get_full_name()}. {notes}", created_by=user, ) AuditService.log_event( event_type="complaint_dept_response_review", description=f"Department response for complaint {complaint.reference_number} marked as {acceptance}", user=user, content_object=complaint, ) if acceptance == "not_acceptable": dept = involved_dept.department recipients = set() if dept.champion and dept.champion.user and dept.champion.user.email: recipients.add(dept.champion.user.email) if dept.manager and dept.manager.email: recipients.add(dept.manager.email) for email in recipients: try: from apps.notifications.services import NotificationService, get_email_header_html NotificationService.send_email( email, subject=f"Response Rejected - {complaint.reference_number}", message=( f"Your department's response for complaint {complaint.reference_number} was not accepted.\n\n" f"Reason: {notes or 'No reason provided.'}\n\n" f"Please submit a new response." ), html_message=f""" """, related_object=complaint, ) except Exception as e: logger.error(f"Failed to send rejection notification: {e}") messages.success(request, _(f"Department response marked as {acceptance}.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @login_required @require_http_methods(["POST"]) def involved_department_reject_routing(request, pk): """ Champion / department manager rejects the routing of a complaint to their department as 'wrong department'. Only the involved department's routing is affected; if it was the primary department, complaint.department is cleared. """ from .models import ComplaintInvolvedDepartment from apps.complaints.services.complaint_service import ( reject_department_routing, RoutingRejectionError, ) from apps.organizations.models import Department involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint department = involved_dept.department user = request.user # Permission: champion or manager of this department, the assigned handler, # or anyone with complaint management rights. is_dept_champion = user.is_champion() and user.department == department is_dept_manager = user.is_department_manager() and user.department == department can_act = ( is_dept_champion or is_dept_manager or involved_dept.assigned_to == user or can_manage_complaint(user, complaint) ) if not can_act: messages.error(request, _("You don't have permission to reject this routing.")) return redirect("complaints:complaint_detail", pk=complaint.pk) reason = request.POST.get("rejection_reason", "").strip() suggested_id = request.POST.get("suggested_department_id", "").strip() suggested = None if suggested_id: suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first() if not reason: messages.error(request, _("Please provide a reason for rejecting this routing.")) return redirect("complaints:complaint_detail", pk=complaint.pk) staff = getattr(user, "staff_profile", None) try: reject_department_routing( involved_dept, staff=staff, user=user, reason=reason, suggested_department=suggested, ) except RoutingRejectionError as exc: messages.error( request, str(exc) if str(exc) else _("This routing can no longer be rejected."), ) return redirect("complaints:complaint_detail", pk=complaint.pk) messages.success( request, _(f"Routing to {department.get_localized_name()} rejected. The PX team has been notified."), ) return redirect("organizations:department_detail", pk=department.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, }, ) success_msg = _("Staff member '%(staff)s' added successfully as %(role)s.") % { "staff": involved_staff.staff, "role": involved_staff.get_role_display(), } if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse({"success": True, "message": str(success_msg)}) messages.success(request, success_msg) return redirect("complaints:complaint_detail", pk=complaint.pk) else: if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse( {"success": False, "errors": form.errors.get_json_data()}, status=400, ) 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) # If the user is removing the complaint's primary staff (accused) involvement, # record it so ensure_involved_records() does not auto-recreate it on the # next detail-view render. Stored as the staff id; if complaint.staff later # changes to a different staff, auto-recreation resumes automatically. if involved_staff.staff_id == complaint.staff_id and complaint.staff_id is not None: complaint.primary_staff_involved_removed_id = complaint.staff_id complaint.save(update_fields=["primary_staff_involved_removed"]) 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, status="active"), }, ) # 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, status="active") if complaint.hospital else Staff.objects.filter(status="active"), } 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, status="active") if complaint.hospital else Staff.objects.filter(status="active"), "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="internal", 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:command-center") # Base queryset queryset = GovernmentTicket.objects.select_related("source", "department", "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(department_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", "department", "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 import urllib.parse params = { "source": ticket.source_id, "complaint_source_type": "external" if ticket.source.code.upper() in ("MOH", "CCHI") else "internal", "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 LegacyLocation, LegacyMainSection, LegacySubSection 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 = LegacyLocation.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 = LegacyMainSection.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 = LegacySubSection.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 "", legacy_location=ticket_location, legacy_main_section=ticket_main_section, legacy_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", "department", "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(department_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.department.name_en if ticket.department else "", "نوع الموقع": ticket.department.location_type if ticket.department and ticket.department.location_type 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() or request.user.is_px_management() or request.user.is_px_employee() ): 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() or request.user.is_px_management() or request.user.is_px_employee() ): 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() or request.user.is_px_management() or request.user.is_px_employee() ): 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() or request.user.is_px_management() or request.user.is_px_employee() ): 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, })