40 KiB
Complaints — Workflow & Lifecycle
Source of truth: current code in
apps/complaints/(+apps/organizations/ui_views.pyfor the manager-review tier). This document describes the current implementation only. Every claim is cited asfile:line. It is not a redesign. Where code disagrees withdocs/workflows.md, the code wins and the gap is flagged as ⚠ Gap.
1. Purpose
The Complaints module is the case-management engine for patient complaints. Per the model docstring (apps/complaints/models.py:1-10, :197-207) it:
- Tracks complaints with SLA deadlines (
models.py:501due_at). - Manages the workflow
open → in progress → resolved → closed(plus extra states). - Triggers a resolution-satisfaction survey on closure (
models.py:570-573; tasktasks.py:386). - Auto-creates PX Actions from negative resolution satisfaction (
tasks.py:477). - Maintains a complaint timeline (
ComplaintUpdate,models.py:1177) and attachments (ComplaintAttachment,models.py:1131).
The same Complaint model also handles appreciations via ComplaintType.APPRECIATION (models.py:72-76) and the sibling Inquiry model (models.py:1528) shares the app — both documented in their own files.
2. How a Case Starts
2.1 Creation channels
| Channel | Entry point | View function | Source recorded |
|---|---|---|---|
| Public portal (no login) | POST /complaints/public/submit/ → complaints:public_complaint_submit (urls.py:123) |
ui_views.public_complaint_submit (ui_views.py:4369) |
complaint_source_type=INTERNAL (hardcoded ui_views.py:4463); source=PXSource("Public Form") |
| Authenticated staff/internal form | GET/POST /complaints/new/ → complaints:complaint_create (urls.py:39) |
ui_views.complaint_create (ui_views.py:897) |
complaint_source_type default INTERNAL (forms.py:374-380); source from form/user, else PXSource("staff") (ui_views.py:930-946) |
| Patient SMS portal (token-auth session) | POST /complaints/patient/<token>/visit/<visit_id>/ → complaints:patient_complaint_visit_form (urls.py:154) |
ui_views.patient_complaint_visit_form (ui_views.py:6686) via PatientComplaintSession (models.py:3449) |
complaint_source_type="internal"; metadata submitted_via:"patient_link" |
| DRF API (any authenticated user) | POST /complaints/api/complaints/ → ComplaintViewSet (views.py:117) |
ComplaintViewSet.perform_create (views.py:229) |
via serializer |
| Government ticket conversion (MOH/CHI/CCHI) | /complaints/government-tickets/<pk>/convert/ → complaints:convert_to_complaint (urls.py:214) |
ui_views.convert_to_complaint (ui_views.py:6887) |
complaint_source_type=external for MOH/CCHI (ui_views.py:6905); moh_reference/chi_reference (ui_views.py:6912-6913) |
| Call center / Survey / Social media / MOH / CHI | No dedicated endpoint — these are ComplaintSource taxonomy values (models.py:86-97) selected via the source FK (models.py:443) on whichever channel is used. MOH/CHI typically arrive as GovernmentTickets then convert. |
2.2 Who can create
- Public form & patient SMS portal: no auth.
- Internal staff form (
complaint_create):@login_requiredonly (ui_views.py:895). - Government ticket create/import/convert:
is_px_admin() or is_px_management()(ui_views.py:6839, 6865, 6892, 6925). - DRF API:
IsAuthenticated(views.py:127).
2.3 Information required before submission
Public form (PublicComplaintForm, forms.py:57, Meta.fields forms.py:228-247). Required:
complainant_name, relation_to_patient, mobile_number, patient_name, national_id, incident_date, hospital, location_type, complaint_details. Optional: email, department, area, section, staff_name, expected_result, attachments (≤5 files, ≤10 MB, jpg/png/gif/pdf/doc/docx — forms.py:319-339).
Validators: Saudi mobile 05xxxxxxxx (forms.py:282-293); national_id 10 digits (forms.py:295-306); no future incident date (forms.py:308-317).
⚠ Gap: The view re-validates inline and additionally requires
department+location_type+hospitaleven though the form marksdepartmentoptional — the view is stricter than the form (ui_views.py:4401-4422, department required atui_views.py:4411).
Internal form (ComplaintForm, forms.py:351, Meta.fields forms.py:483-501). Required: relation_to_patient, patient_name, national_id, incident_date, hospital, location_type, department, description. Optional: complaint_type, complaint_source_type, source, area, section, staff, expected_result.
2.4 What happens immediately after submission
Complaint.save() (models.py:737-772) runs on every save and:
- Records previous status into
_status_wasfor the signal (models.py:740-745). - Generates the reference number if absent:
generate_reference("CMP", hospital)(models.py:748-751) → formatCMP-{YYYYMM}-{SEQ:04d}(global monthly sequence,apps/core/reference.py:32-43). - Computes the SLA deadline
due_atviacalculate_sla_due_date()(models.py:753-754, 784-830). - Hashes
national_id→national_id_hash(models.py:765-768). - Syncs department send/forward timestamps (
models.py:770, 774-782). - Default
status = ComplaintStatus.OPEN(models.py:463-465). Cases are created OPEN, not auto-activated —activated_atis null until the activation action (§6).
Then ComplaintService.post_create_hooks (complaint_service.py:1049-1086):
- Writes a
ComplaintUpdate"Complaint created. AI analysis running in background." - Dispatches
analyze_complaint_with_ai(tasks.py:668) andnotify_admins_new_complaint(tasks.py:2253). - Logs audit event
complaint_created.
Public form additionally dispatches link_complaint_patient and notify_staff_new_item (ui_views.py:4482-4486).
Signals (signals.py):
pre_savesync_department_from_staff(signals.py:21-39): auto-setscomplaint.departmentfromstaff.department.post_savesend_complaint_creation_sms(signals.py:42-56): dispatches creation SMS only ifcontact_phone/contact_emailpresent.post_savesend_complaint_status_change_sms(signals.py:59-89): SMS when status becomesresolved/closed.post_saveonComplaintInvolvedDepartmentnotify_champion_on_department_assignment(signals.py:104-126): notifies the champion when a sent dept row is created.
3. Complete Lifecycle
3.1 The 8 statuses (ComplaintStatus, models.py:25-35)
OPEN, IN_PROGRESS, PARTIALLY_RESOLVED, RESOLVED, CLOSED, CANCELLED, PENDING_EXTERNAL, OVR_PENDING.
3.2 The transition map
ComplaintService.VALID_STATUS_TRANSITIONS (complaint_service.py:292-301):
open -> [in_progress, cancelled]
in_progress -> [partially_resolved, resolved, cancelled, pending_external, ovr_pending]
partially_resolved -> [resolved, in_progress, cancelled, pending_external]
resolved -> [closed, in_progress]
closed -> [in_progress]
cancelled -> [open, in_progress]
pending_external -> [resolved, in_progress, cancelled, closed]
ovr_pending -> [in_progress, resolved, cancelled]
3.3 Enforcement layers
⚠ Gap vs
docs/workflows.md:12: that doc claims invalid statuses are "rejected at the model (clean()) and DB (CheckConstraint) level." This is not true for Complaints. There is noclean()method onComplaintand no DBCheckConstraintin any migration. The only enforcement isComplaintService.change_status(complaint_service.py:386-498), which:
- requires
activated_atbefore any change except → CLOSED (the activation gate,complaint_service.py:406-407);- looks up
valid_nextforold_status; raisesComplaintServiceErrorifnew_statusnot allowed unless the useris_px_admin()(complaint_service.py:412-417) — PX Admin can force any transition;- permission:
is_px_admin() or is_hospital_admin() or is_px_management() or is_px_employee()(complaint_service.py:398-400).
⚠ Gap: Several writers bypass change_status and set status directly: toggle_escalated_ovr (ui_views.py:1560), approve_ovr_escalation/reject_ovr_escalation (ui_views.py:1650, 1680, 1762), and ComplaintService.assign (complaint_service.py:228-237) which reopens resolved/closed/cancelled by flipping back to IN_PROGRESS.
3.4 All paths
┌─────────────────────────── cancelled ────────────┐ (reopen→open/in_progress)
│ │
[create]→ OPEN ──┼─(activate)→ IN_PROGRESS ──┬→ partially_resolved ──┬→ resolved ──┬→ closed ──(reopen)→ IN_PROGRESS
│ │ │ │
│ ├→ pending_external ────┘ └→ IN_PROGRESS (reopen)
│ ├→ ovr_pending ──(approve/reject)→ IN_PROGRESS
│ └→ cancelled
└─(cancel)→ cancelled
is_active_status property (models.py:880-892): active = OPEN, IN_PROGRESS, PARTIALLY_RESOLVED, PENDING_EXTERNAL. Note OVR_PENDING is not active.
4. Status Definitions
| Status | Purpose | When entered | Who moves it forward | Next statuses |
|---|---|---|---|---|
| OPEN | "Received" (public label, progress 15%, amber, models.py:938-950). Logged + AI-classified but not being worked; activated_at null. |
On creation (default, models.py:464) |
Activation (ComplaintService.activate complaint_service.py:106) |
IN_PROGRESS, CANCELLED |
| IN_PROGRESS | "In Progress" (50%, blue). Owned/being worked. | From OPEN (activate), PARTIALLY_RESOLVED, RESOLVED, CLOSED, CANCELLED, OVR_PENDING, PENDING_EXTERNAL | PX/hospital/management/employee roles (complaint_service.py:398) — department managers cannot change status |
PARTIALLY_RESOLVED, RESOLVED, CANCELLED, PENDING_EXTERNAL, OVR_PENDING |
| PARTIALLY_RESOLVED | "In Progress" (75%, blue). Some aspects resolved. Stamps partially_resolved_at/by (complaint_service.py:451). |
From IN_PROGRESS | PX/hospital/management/employee | RESOLVED, IN_PROGRESS, CANCELLED, PENDING_EXTERNAL |
| RESOLVED | "Resolved" (100%, emerald). Solution provided. Stamps resolved_at/by + optional resolution/resolution_category/resolution_outcome (complaint_service.py:420-434). |
From IN_PROGRESS, PARTIALLY_RESOLVED, PENDING_EXTERNAL, OVR_PENDING | Same roles | CLOSED, IN_PROGRESS (reopen) |
| CLOSED | "Closed" (100%, slate). Stamps closed_at/by; dispatches resolution-satisfaction survey (complaint_service.py:436-441). |
From RESOLVED | Same roles | IN_PROGRESS (reopen) |
| CANCELLED | "Cancelled" (0%, rose). Withdrawn/invalid. Stamps cancelled_at/by. |
From OPEN, IN_PROGRESS, PARTIALLY_RESOLVED, PENDING_EXTERNAL, OVR_PENDING | Same roles | OPEN, IN_PROGRESS |
| PENDING_EXTERNAL | (no public label). Awaiting outside party (MOH/CHI/insurance). Sets pending_external_set_at + was_pending_external=True (complaint_service.py:443-445). |
From IN_PROGRESS, PARTIALLY_RESOLVED | Same roles | RESOLVED, IN_PROGRESS, CANCELLED, CLOSED |
| OVR_PENDING | "OVR Pending Approval". Escalation/oversight awaiting PX-Admin/Hospital-Admin approval. | toggle_escalated_ovr (ui_views.py:1560) |
approve_ovr_escalation/reject_ovr_escalation (PX/Hospital admin only, ui_views.py:1650, 1680) |
IN_PROGRESS, RESOLVED, CANCELLED |
"OVR" is never expanded anywhere in code/docs. From fields
is_escalated_ovr,escalated_ovr_by(models.py:676-685) it functions as an admin-approved oversight/escalation tier.
5. Workflow Actions
Decorators are consistently @login_required + @require_http_methods(["POST"]) for mutations. Permissions are enforced inside the function (no @permission_required decorators). Shared helper: can_manage_complaint (ui_views.py:291 → ComplaintService.can_manage complaint_service.py:78-94).
5.1 Lifecycle / status actions
| Action | URL name | View (file:line) | Permission / gate | Effect |
|---|---|---|---|---|
| Activate | complaint_activate (urls.py:63) |
ui_views.complaint_activate (ui_views.py:2141) |
can_activate (complaint_service.py:96-104) |
Assigns to current user; OPEN→IN_PROGRESS; sets activated_at; audit complaint_activated |
| Change status (resolve/close/cancel) | complaint_change_status (urls.py:42) |
ui_views.complaint_change_status (ui_views.py:1376) |
PX/Hosp-Admin/PX-Mgmt/PX-Employee; activation gate (except→closed); transition map (PX-Admin bypasses) | §3.2 |
| Reopen | complaint_reopen (urls.py:48) |
ui_views.complaint_reopen (ui_views.py:1788) |
PX/Hosp-Admin/assignee/dept-manager; only from resolved/closed (view) | Creates a NEW OPEN complaint linked via reopened_from; original keeps terminal status |
| Request/cancel OVR | toggle_escalated_ovr (urls.py:45) |
ui_views.toggle_escalated_ovr (ui_views.py:1560) |
can_manage_complaint |
Toggles status ↔ OVR_PENDING; emails PX admins (ui_views.py:1591) |
| Approve OVR | approve_ovr_escalation (urls.py:46) |
ui_views.approve_ovr_escalation (ui_views.py:1650) |
PX-Admin/Hosp-Admin; must be OVR_PENDING | → IN_PROGRESS + is_escalated_ovr=True |
| Reject OVR | reject_ovr_escalation (urls.py:47) |
ui_views.reject_ovr_escalation (ui_views.py:1680) + duplicate at ui_views.py:1762 |
PX-Admin/Hosp-Admin; must be OVR_PENDING | → IN_PROGRESS, not escalated |
| Escalate (to a person) | complaint_escalate (urls.py:62) |
ui_views.complaint_escalate (ui_views.py:2002) |
PX/Hosp-Admin/PX-Mgmt/PX-Employee; active status; must be activated (ui_views.py:2015) |
Selects a Staff to email; sets escalated_at; does NOT reassign (ui_views.py:2052-2054) |
| Update patient-contact status | update_patient_contact_status (urls.py:44) |
ui_views.update_patient_contact_status (ui_views.py:1520) |
can_manage_complaint |
Sets patient_contact_status (not_contacted/contacted/contacted_no_response) |
| Update satisfaction | update_satisfaction (urls.py:43) |
ui_views.update_satisfaction (ui_views.py:1418) |
can_manage_complaint; resolved/closed only; blocked if patient-locked or >5 days |
Sets satisfaction; max 3 changes (models.py:626-627) |
| Update closure delay reason | update_delay_reason_closure (urls.py:50) |
ui_views.update_delay_reason_closure (ui_views.py:1864) |
can_manage_complaint; only if is_overdue or >72h; not on closed/resolved |
Sets delay_reason_closure (72h rule) |
| Add note | complaint_add_note (urls.py:61) |
ui_views.complaint_add_note (ui_views.py:1913) |
active status only | Writes a "note" ComplaintUpdate |
| Change department | complaint_change_department (urls.py:59) |
ui_views.complaint_change_department (ui_views.py:1930) |
active status; PX/Hosp-Admin; same hospital | Changes complaint.department |
| Update location | complaint_update_location (urls.py:60) |
ui_views.complaint_update_location (ui_views.py:1955) |
active status | Updates location_type/area/department/section/zone/floor |
| Confirm taxonomy gate | confirm_taxonomy (urls.py:64) |
ui_views.confirm_taxonomy (ui_views.py:2157) |
can_manage_complaint |
Sets taxonomy_reviewed_at/by to unblock Send-to-Department |
| Soft delete / restore / trash | complaint_soft_delete/restore/trash_list (urls.py:94,95,93) |
ui_views.py:7199,7213,7240 |
@login_required |
Soft-delete |
5.2 Assignment actions
| Action | URL name | View | Permission |
|---|---|---|---|
| Assign case manager (UI) | complaint_assign (urls.py:41) |
ui_views.complaint_assign (ui_views.py:862) |
assigner PX/Hosp-Admin/assignee; target in groups PX Employee/PX Admin/PX Management (complaint_service.py:213-219); reopens resolved/closed/cancelled when reassigned |
| Assign case manager (API) | complaint-api:assign |
ComplaintViewSet.assign (views.py:257) |
same |
| Assign staff (the subject) | complaint-api:assign_staff |
ComplaintViewSet.assign_staff (views.py:485) |
PX Admin only; active status; sets complaint.staff; auto-syncs department; clears needs_staff_review |
| Send to person OR dept (AJAX) | complaint_send_to (urls.py:182) |
ui_views.complaint_send_to (ui_views.py:1092) |
can_manage_complaint; rejects status=="open" |
| Confirm AI dept suggestion | confirm_ai_department_suggestion (urls.py:172) |
ui_views.confirm_ai_department_suggestion (ui_views.py:5500) |
can_manage_complaint |
| Add/edit/remove involved department | involved_department_add/edit/remove (urls.py:170,176,177) |
ui_views.py:5562,5645,5701 |
can_manage_complaint |
| Add/edit/remove involved staff | involved_staff_add/edit/remove (urls.py:186-188) |
ui_views.py:6022,6101,6148 |
can_manage_complaint |
| Bulk assign/status/escalate | complaint_bulk_* (urls.py:89-91) |
ui_views.py:2498,2526,2555 |
⚠ @require_http_methods only (no @login_required) — possible auth gap |
5.3 Department-response & investigation (token, no-login)
| Action | URL name | View |
|---|---|---|
| Champion/manager submits response | involved_department_response (urls.py:178) |
ui_views.involved_department_response (ui_views.py:5749) |
| PX accepts/rejects response | involved_department_review_response (urls.py:179) |
ui_views.involved_department_review_response (ui_views.py:5853) |
| Champion rejects routing (wrong dept) | involved_department_reject_routing (urls.py:180) |
ui_views.involved_department_reject_routing (ui_views.py:5955) |
| Manager review (approve/reject) | organizations:department_manager_review |
apps/organizations/ui_views.py:4101 |
| Staff explanation form (token) | complaint_explanation_form (urls.py:140) |
views.complaint_explanation_form (views.py:3554) |
| Champion starts investigation | champion_start_investigation (urls.py:143) |
views.champion_start_investigation (views.py:4009) |
| Staff investigation response (token) | staff_investigation_form (urls.py:145) |
views.staff_investigation_form (views.py:4338) |
| Champion reviews answers | champion_review_answers (urls.py:146) |
views.champion_review_answers (views.py:4489) |
5.4 Other actions
Convert to appreciation (ComplaintViewSet.convert_to_appreciation views.py:2076); generate AI resolution (views.py:1323); create PX action from AI (views.py:602); adverse action CRUD (adverse_action_* ui_views.py:6245-6622); government ticket create/import/convert (ui_views.py:6836,6922,6887); PDF (views.py:4951); public tracking (ui_views.public_complaint_track ui_views.py:4555).
6. Decision Points
- Activation gate — complaint cannot be worked/sent/escalated until activated. Enforced at:
ComplaintService.activate(complaint_service.py:108);complaint_send_torejectsstatus=="open"(ui_views.py:1111);complaint_escalaterequiresactivated_at(ui_views.py:2015);change_statusrequiresactivated_atexcept →closed (complaint_service.py:406). - AI classification —
analyze_complaint_with_ai(tasks.py:668) sets severity, priority, taxonomy, emotion,complaint_type(complaint vs appreciation), staff matches. - Taxonomy review gate —
taxonomy_reviewed_atmust be set viaconfirm_taxonomybefore Send-to-Department is unblocked (ui_views.py:2157). - Manager approval tier —
ComplaintInvolvedDepartment.manager_review_status(models.py:2687-2707); set only inapps/organizations/ui_views.py:4200/4255. - PX acceptance tier —
acceptance_status(models.py:2653-2662); set ininvolved_department_review_response(ui_views.py:5886). - Resolution category —
ResolutionCategory(models.py:54-61): FULL_ACTION_TAKEN, PARTIAL_ACTION_TAKEN, NO_ACTION_NEEDED, CANNOT_RESOLVE, PATIENT_WITHDRAWN. Set at resolve (complaint_service.py:429). - Resolution outcome —
ResolutionOutcome(models.py:64-69): PATIENT / HOSPITAL / OTHER. - Delay reasons —
DelayReasonChoices(models.py:46-51): DEPARTMENT_NO_RESPONSE, ESCALATED, PATIENT_NOT_SATISFIED. Only settable if overdue or >72h (ui_views.py:1877-1880). - Duplicate detection —
services/duplicate_detection.py(weights: patient 0.30, date 0.20, description 0.35, category 0.15; threshold 0.75, "likely duplicate" ≥0.85; date window ±3 days). ⚠ Gap: no call site inapps/complaintscreate views — advisory library only, not wired to block creation. - Patient confirmation — satisfaction, set/locked via public tracker (
ui_views.py:4626-4638) or PXupdate_satisfaction.
7. Assignment Flow
On Complaint:
assigned_to(User,models.py:487) — the case manager. Set byactivate(activator becomes assignee) orassign.staff(Staff,models.py:270) — the subject of the complaint. PX-Admin-only viaassign_staff(views.py:485). Syncsdepartmentviapre_savesignal (signals.py:21-39).department(models.py:267),section(models.py:387).
ComplaintInvolvedDepartment (multi-department join, models.py:2593): department FK; role (PRIMARY/SECONDARY/COORDINATION/INVESTIGATING, models.py:2601-2605); is_primary (only one per complaint — enforced in save() models.py:2757-2764); per-department assigned_to (models.py:2629); response, acceptance, manager-review, routing, reminder/delay fields; unique_together=[complaint, department] (models.py:2744).
ComplaintInvolvedStaff (multi-staff join, models.py:2792): staff FK; role (ACCUSED/WITNESS/RESPONSIBLE/INVESTIGATOR/SUPPORT/PX_MANAGEMENT); per-staff explanation tracking. Auto-created for the primary staff via ComplaintService.ensure_involved_records (complaint_service.py:1020-1047) unless primary_staff_involved_removed.
Dual assignment — assigned_to (case manager) and staff (subject) are distinct and independently assignable (docs/COMPLAINT_DUAL_ASSIGNMENT_FEATURE.md).
Owner cascade — Complaint.get_owner() (models.py:711-732): section(champion→supervisor→deputy_supervisor) → department(champion→deputy_manager→supervisor→deputy_supervisor→manager_2nd→manager_3rd).
Reassignment / transfer — ComplaintService.assign (complaint_service.py:194) reassigns the case manager; change_department (complaint_service.py:546) transfers; complaint_escalate notifies a person without reassigning. Final owner is assigned_to until resolved/closed.
8. Investigation Process
Two parallel mechanisms:
8.1 Legacy ComplaintExplanation direct-response
ComplaintExplanation (models.py:2251): one per (complaint, staff); token (models.py:2273), is_used, SLA tracking (sla_due_at, is_overdue), and ExplanationAttachment (models.py:2359). Token link emailed by ComplaintService.send_to_department (complaint_service.py:796) or complaint_send_to (ui_views.py:1252). Champion opens complaint_explanation_form (views.py:3554) and either submits a direct OTP-verified reply (views.py:3620-3889) or follows the "investigate" link.
8.2 ChampionInvestigation per-staff question flow (main investigation)
champion_start_investigation(views.py:4009): champion selects involved staff, writes per-staffInvestigationQuestions (models.py:3793); creates aChampionInvestigation(models.py:3740, statusQUESTIONS_SENT) + anInvestigationResponseper staff (models.py:3824) with its own token; emails/SMSes each staff a no-login link/complaints/<id>/investigate/respond/<token>/.staff_investigation_form(views.py:4338): staff answer;InvestigationAnswer(models.py:3849) +InvestigationResponseAttachment(models.py:3902).champion_review_answers(views.py:4489): champion reviews, writes afinal_reply, sets assessment flags (negligence_finding,policy_issue_finding,requires_improvement_project), OTP-verifies (6-digit, 10-min expiry,views.py:4613), setsInvestigationStatus.REPLY_SUBMITTED, marksComplaintExplanation.is_used=True, writes theComplaintInvolvedDepartmentresponse fields (first-responder-wins within the same department,views.py:4743-4750).
8.3 Other investigation models
ComplaintAdverseAction(models.py:3047): corrective/adverse-action.ActionType(models.py:3063),SeverityLevel(models.py:3077),VerificationStatus(models.py:3085: reported→under_investigation→verified/unfounded/resolved).ComplaintPRInteraction(models.py:2480): PR/Patient-Relations contact log.ComplaintMeeting(models.py:2543): meeting record (management_intervention/pr_follow_up/department_review).
When investigation starts: implicitly when sent to a department. Who investigates: the department champion/manager (token-authenticated, no login). Review tiers: (1) champion composes → (2) department manager approves/rejects → (3) PX accepts/rejects.
9. Communication Flow
ComplaintCommunication (models.py:3375) with ComplaintCommunicationType (models.py:3364: PHONE_CALL/EMAIL/SMS/MEETING/LETTER/OTHER), direction (inbound/outbound). Exposed via DRF only.
ComplaintUpdate (models.py:1177) is the unified timeline; update_type: status_change/assignment/note/resolution/escalation/communication (models.py:1187-1198).
| Patient touchpoint | When | Where |
|---|---|---|
| Acknowledgement/creation | On create | send_complaint_creation_sms_task (tasks.py:3331) via signal (signals.py:42); notify_admins_new_complaint (tasks.py:2253) |
| Need more info | Manual | update_patient_contact_status (ui_views.py:1520) — records status, no auto-SMS |
| Progress update | Manual | notes/communications |
| Resolution sent | On resolve | resolution_sent_at set (complaint_service.py:426); SMS/email via send_complaint_status_change_task (tasks.py:3418, signal signals.py:59) |
| Department responded | Champion response | SMS+email to complainant (ui_views.py:5816-5835) |
| Closure | On close | resolution survey dispatched (complaint_service.py:439 → tasks.py:386) |
| Routing rejected | Wrong dept | emails handler + PX admins (complaint_service.py:1181-1221) |
| OVR requested/decided | OVR flow | emails PX admins/managers (ui_views.py:1591, 1704) |
| Champion notified | Dept assigned | notify_champion_on_dept_assignment_task (tasks.py:3509) |
| Satisfaction lock | Public tracker | patient sets + locks satisfaction for 5 days (ui_views.py:4626; models.py:894-908) |
Delivery via apps.notifications.services.NotificationService, often offloaded to Celery.
10. Escalation Flow
10.1 SLA configuration
ComplaintSLAConfig(models.py:1224): per (hospital, source, severity, priority)sla_hours; reminder timings.unique_together=[hospital, source, severity, priority](models.py:1295).ComplaintThreshold(models.py:1309): threshold breaches (resolution_survey_score, response_time, resolution_time) withaction_type(create_px_action/send_notification/escalate).check_threshold()atmodels.py:1367.- Defaults (
config/settings/base.py:355-368): low=72h, medium=48h, high=24h, critical=12h. calculate_sla_due_date(models.py:784-830) precedence: source-based config → severity/priority config → severity-only config → settings defaults.
10.2 Automatic tasks (tasks.py)
check_overdue_complaints(tasks.py:359, every 15 min) →Complaint.check_overdue()(models.py:867-878). ⚠ Gap: only checks OPEN, IN_PROGRESS, RESOLVED — not partially_resolved/pending_external/ovr_pending.send_sla_reminders(tasks.py:1781, hourly) — first + second reminders; emails assigned user or dept manager; uses on-call schedule.send_explanation_reminders(tasks.py:1616) +check_overdue_explanation_requests(tasks.py:1593) — forComplaintExplanationSLA.check_resolution_survey_threshold(tasks.py:478) — auto-creates aPXActionif a closed complaint's survey breachesComplaintThreshold.
10.3 Manual escalation
complaint_escalate(ui_views.py:2002): pick aStaff; setsescalated_at; emails them; does not reassign.- OVR escalation (
toggle_escalated_ovrui_views.py:1560): two-step approval flow (request → admin approve/reject). When approved,is_escalated_ovr=True.
10.4 Escalation hierarchy
ComplaintService.get_escalation_target (complaint_service.py:36-76): for a staff explanation → staff.report_to → staff.department.manager → complaint.department.manager → hospital admins & PX staff.
11. Resolution Process
- Who can mark resolved: any PX/Hosp-Admin/PX-Mgmt/PX-Employee via
change_status(complaint_service.py:398). - Approval required? No separate approval to resolve (PX Admin can force). But the department response must pass manager-review + PX-acceptance tiers before a complaint is typically resolved (§14).
- Patient confirmation required? Not to resolve; satisfaction is captured afterwards (PX sets it or patient submits/locks on the tracker).
- Fields set on resolve (
complaint_service.py:420-434):resolved_at/by;resolution+resolution_sent_at;resolution_category;resolution_outcome+resolution_outcome_other. Special: ifwas_pending_external,resolved_atis back-dated topending_external_set_at(complaint_service.py:421-422). - Patient-contact status is tracked separately and is not a hard precondition.
12. Closure Process
- Who closes: same as resolve, via
change_status→ CLOSED. - What happens on close:
closed_at/by; dispatchessend_complaint_resolution_survey(tasks.py:386→ createsSurveyInstance). - 72-hour closure rule:
DelayReasonChoicesdocstring "Delay reason for 72h closure" (models.py:46-47).delay_reason_closureonly settable whenis_overdueor >72h (ui_views.py:1877-1880); not on closed/resolved. This explains why a complaint wasn't closed within the target window. - Reopen conditions:
complaint_reopen(ui_views.py:1788) only from resolved/closed (view checkui_views.py:1804).ComplaintService.reopen(complaint_service.py:304) requires resolved/closed/cancelled, and creates a brand-new OPEN complaint linked viareopened_from. The original keeps its terminal status (its status is not changed by reopen). - Permanently completed: once closed, terminal unless explicitly reopened. Patient satisfaction window expires after 5 days (
models.py:894-908).
13. Exception Flows
- Duplicate detection —
ComplaintDuplicateDetector(threshold 0.75, likely ≥0.85). ⚠ Gap: no call site in create views — advisory only. - Withdrawn by patient —
ResolutionCategory.PATIENT_WITHDRAWN(models.py:61); chosen at resolve time. - Invalid submission / wrong department —
involved_department_reject_routing(ui_views.py:5955) + token equivalent (views.py:3892).reject_department_routing(complaint_service.py:1093) setsrouting_status=REJECTED, clearscomplaint.department/sectionif it was primary, emails handler + PX admins. - Missing info / no patient response —
PatientContactStatus.CONTACTED_NO_RESPONSE(models.py:43) +DelayReasonChoices.PATIENT_NOT_SATISFIED/DEPARTMENT_NO_RESPONSE. - Complaint → Appreciation conversion —
ComplaintViewSet.convert_to_appreciation(views.py:2076); only forcomplaint_type=="appreciation"; creates anAppreciation, storesmetadata.appreciation_id, optionally closes the complaint (docs/COMPLAINT_TO_APPRECIATION_CONVERSION.md). - Merged cases — ⚠ no "merge" action exists in
ui_views.py/urls.py. Closest is duplicate-detection (advisory) and reopen-as-new. - Reopened cases — creates a new complaint, doesn't mutate the original.
- Government ticket conversion —
convert_to_complaint(ui_views.py:6887) prefillscomplaint_create; sets references and external source type.
14. Department-Response Sub-Flow
A two-tier review with an embedded investigation loop. State lives on ComplaintInvolvedDepartment (models.py:2593). Shared rules in docs/workflows.md:6-18.
14.1 Activation gate
A complaint cannot be sent to a department until activated. complaint_send_to rejects status == "open" (ui_views.py:1111). The taxonomy gate (taxonomy_reviewed_at, confirm_taxonomy ui_views.py:2157) must also be passed.
14.2 Sending to a department
- Endpoint
complaint_send_to(ui_views.py:1092), URLcomplaints:complaint_send_to. - Primary department (
complaint.department): setscomplaint.sent_to_department=True,sent_to_department_at,forwarded_to_dept_at(ui_views.py:1197-1201). - Other departments:
get_or_creates aComplaintInvolvedDepartmentwithsent=True,sent_at,forwarded_at(ui_views.py:1203-1213); resets prior rejection (ui_views.py:1219-1229). - Resolves recipients via
get_champion_and_manager(ui_views.py:1188); creates aComplaintExplanationtoken per recipient (ui_views.py:1253-1265); offloads email/SMS tosend_department_notification_task(ui_views.py:1342-1359). - A parallel implementation
ComplaintService.send_to_department(complaint_service.py:737) exists (champion-only, contact-person picker). ⚠ Gap: two overlapping "send to department" implementations.
14.3 Who receives it
The department champion (and manager) — resolved by get_champion_and_manager (ui_views.py:1188). Signal notify_champion_on_department_assignment (signals.py:104) dispatches notify_champion_on_dept_assignment_task (tasks.py:3509) when a sent dept row is created.
14.4 Champion's response submission
Two paths to populate response_notes/response_notes_en/response_notes_ar, response_submitted=True, response_submitted_at (models.py:2641-2650):
- Logged-in —
involved_department_response(ui_views.py:5749). Permission: champion OR manager ORinvolved_dept.assigned_toORcan_manage_complaint. Sets fields +acceptance_status="acceptable"+accepted_atimmediately, notifies complainant. ⚠ Note: this path self-accepts, skipping the manager-review tier. - Token (no-login) — direct reply (
views.py:3620-3889) orchampion_review_answersafter investigation (views.py:4680-4778). OTP-verified; apply first-responder-wins within the same department (views.py:3803-3808, 4743-4750); email the complaint assignee.
14.5 Manager review tier (tier 1)
- View:
apps/organizations/ui_views.py:4101 department_manager_review(in the organizations app, not complaints — easy to miss). - Permission: PX/Hosp-Admin OR dept manager of that department.
- Preconditions:
involved_dept.response_submittedTrue and not already approved. - Shows configurable
ManagerReviewQuestions (models.py:3623); createsDepartmentManagerReview(models.py:3671) +ManagerReviewAnswers (models.py:3707). - Approve (
organizations/ui_views.py:4199):manager_review_status="approved"; emails assignee. - Reject (
organizations/ui_views.py:4254):manager_review_status="rejected"; clears the response (response_submitted=False,response_submitted_at=None,response_notes*=""); emails champion+assignee → reject loop back to champion.
14.6 PX acceptance tier (tier 2)
- View:
involved_department_review_response(ui_views.py:5853). - Permission: PX/Hosp-Admin/PX-Mgmt/PX-Employee.
- Precondition:
manager_review_status == "approved"(ui_views.py:5875) — manager tier must run first. - Accept (
acceptance_status="acceptable"): setsaccepted_by/at/notes. - Reject (
acceptance_status="not_acceptable"): clears the response (response_submitted=False,response_submitted_at=None,response_notes*="") (ui_views.py:5891-5898); emails champion+manager → reject loop back to champion.
14.7 Reject loops summary
Both manager-reject and PX-reject clear response_submitted, response_submitted_at, and all response_notes* fields, returning the involved department to the champion for a fresh response. ⚠ Gap: PX-reject leaves manager_review_status="approved" (doesn't reset it), so re-approval semantics are unclear after a PX-reject.
14.8 Investigation sub-flow (token questions to involved staff)
The champion, instead of a direct reply, follows "investigate" → champion_start_investigation (views.py:4009): selects ComplaintInvolvedStaff, writes per-staff questions, each staff gets a no-login token emailed/SMSed. Staff answer via staff_investigation_form (views.py:4338). Champion reviews via champion_review_answers (views.py:4489), writes a final_reply, OTP-verifies — that final reply becomes the ComplaintInvolvedDepartment response, feeding back into the manager→PX review tiers.
14.9 Cross-cutting helper properties
Complaint.sent_to_any_department(models.py:1109).Complaint.all_departments_responded(models.py:1113).ComplaintExplanation.linked_involved_department(models.py:2343).ComplaintInvolvedDepartment.can_reject_routing(models.py:2780): only if not responded, routing_status SENT, complaint not closed/cancelled.ComplaintInvolvedDepartment.sla_remaining(models.py:2766): hours left (default 48).
15. End-to-End Example
Patient submits via public form
↓ (ui_views.py:4369) → status=OPEN, ref CMP-202607-0001, due_at computed,
AI analysis + admin notify dispatched
Complaint auto-classified by AI (severity high, department X)
↓ (tasks.py:668)
PX staff activates + confirms taxonomy
↓ (ui_views.py:2141, 2157) → status=IN_PROGRESS, activated_at set,
assigned_to = activator, taxonomy_reviewed_at set
PX sends to Department X (primary)
↓ (ui_views.py:1092) → ComplaintInvolvedDepartment(primary, sent=True),
ComplaintExplanation token emailed to champion
[DECISION: champion investigates vs direct reply]
Champion starts investigation (token questions to involved staff)
↓ (views.py:4009) → InvestigationResponse tokens emailed to staff
Staff answer via no-login token link
↓ (views.py:4338) → InvestigationAnswer stored
Champion reviews answers, writes final_reply, OTP-verifies
↓ (views.py:4489) → ComplaintInvolvedDepartment.response_submitted=True,
first-responder-wins; assignee emailed
[DECISION POINT: Manager review tier]
Department manager approves
↓ (organizations/ui_views.py:4199) → manager_review_status="approved",
assignee emailed
[DECISION POINT: PX acceptance tier]
PX accepts the response
↓ (ui_views.py:5853) → acceptance_status="acceptable"
[DECISION POINT: resolution category]
PX resolves (category=FULL_ACTION_TAKEN, outcome=HOSPITAL)
↓ (ui_views.py:1376) → status=RESOLVED, resolved_at/by, resolution_sent_at,
patient notified (SMS/email)
↓ patient satisfaction window opens (5 days)
PX closes
↓ (ui_views.py:1376) → status=CLOSED, closed_at/by,
resolution-satisfaction survey dispatched
[PERMANENT unless reopened → creates NEW complaint]
Appendix — Flagged gaps vs docs/workflows.md
- No model-level transition enforcement (
clean()/CheckConstraintabsent); onlyComplaintService.VALID_STATUS_TRANSITIONS, bypassed by PX Admin. - Manager review lives in
apps/organizations/ui_views.py:4101, notapps/complaints/. - Duplicate
reject_ovr_escalationview definitions (ui_views.py:1680and1762) registered to one URL name. - Public form view stricter than
PublicComplaintForm(department required). check_overdue_complaintsignores partially_resolved/pending_external/ovr_pending.- Public submissions hardcoded
complaint_source_type=INTERNALdespite an EXTERNAL enum existing. - "Merge cases" action does not exist.
- PX-reject leaves
manager_review_status="approved". - Bulk actions lack
@login_required. - "OVR" acronym never expanded.
- Duplicate-detection library not wired into create views.