350 lines
31 KiB
Markdown
350 lines
31 KiB
Markdown
# Observations — Workflow & Lifecycle
|
|
|
|
> Source of truth: `apps/observations/`. An "observation" is a **staff safety/quality observation** (not a patient complaint). This document describes the **current implementation only**. Every claim is cited as `file:line`.
|
|
|
|
> **⚠ Read first — several stale-doc/latent-bug findings contradict `docs/workflows.md` and `README.md`.** Most important: the PX "accept/reject" department-response review step documented in `docs/workflows.md:44-47` **no longer exists in code** — the fields were removed (migration `0020`). Full list at the end.
|
|
|
|
---
|
|
|
|
## 1. Purpose
|
|
|
|
The Observations app is the **staff observation / safety-quality reporting module** (`apps/observations/README.md:3`). Any staff member can report issues they notice; submission may be **anonymous** (no login) with optional Staff ID + Name (`README.md:7,13`; model docstring `models.py:237-246`). PX360 staff then **triage** and route to a responsible department and/or create a PX Action.
|
|
|
|
It is explicitly **not** a complaint: no patient complainant, no multi-department join model, no manager-review tier (`docs/workflows.md:41-47`). The reporter is a *staff* observer; "patient-facing response" in code (`models.py:565`) actually means the response shown back to the staff reporter via the public track page.
|
|
|
|
Anonymous detection: `is_anonymous` returns True when neither `reporter_staff_id` nor `reporter_name` is set (`models.py:666`).
|
|
|
|
---
|
|
|
|
## 2. How a Case Starts
|
|
|
|
### 2.1 Creation channels (two entry points, both calling `ObservationService.create_observation` `services.py:43`)
|
|
|
|
| Channel | View | URL name | Auth | Form |
|
|
|---|---|---|---|---|
|
|
| Public (anonymous-allowed) | `observation_create_public` (`views.py:162`) | `observations:observation_create_public` (`urls.py:30`) → `/observations/new/` | None | `ObservationPublicForm` (`forms.py:29`) |
|
|
| Internal (staff) | `observation_create` (`views.py:301`) | `observations:observation_create` (`urls.py:41`) → `/observations/create/` | `@login_required` | `ObservationInternalForm` (`forms.py:181`) |
|
|
|
|
> **Note:** `PublicObservationForm` (`forms.py:717`) also exists but is **not wired to any URL**; only `ObservationPublicForm` is used by the view (`views.py:42`).
|
|
|
|
### 2.2 Public form — required fields (`ObservationPublicForm`, `forms.py:29`)
|
|
`Meta.fields` (`forms.py:68`): `hospital, location_type, area, title, description, location_text, incident_datetime, reporter_staff_id, reporter_name, reporter_phone, reporter_email`.
|
|
- **Required:** `description` (min length 10, `forms.py:173`); `location_type` (forced required `forms.py:147`).
|
|
- **Optional:** `area`, `title`, `location_text`, `incident_datetime` (defaulted to now `forms.py:163`), `reporter_*` (optional → enables anonymity).
|
|
- **No severity/category field on the public form** → public submissions always come in as **MEDIUM severity, no category** until triage/AI assigns them (`views.py:188`).
|
|
- Spam protection: honeypot `website` field (`forms.py:41`, validated `forms.py:166`). Attachments: `ALLOWED_EXTENSIONS`/`MAX_FILE_SIZE` 10 MB (`forms.py:25`).
|
|
|
|
### 2.3 Internal form — required fields (`ObservationInternalForm`, `forms.py:181`)
|
|
`Meta.fields` (`forms.py:196`): `hospital, location_type, area, description, section, incident_datetime, patient_file_number, assigned_department, assigned_to, px_source`.
|
|
- **Required:** `hospital`, `location_type`, `description` (min 10).
|
|
- Auto-fills reporter from logged-in user (`views.py:322`) — internal observations are never anonymous. Sets `source_legacy="staff_portal"` (vs public `"public_form"`, `views.py:204`).
|
|
- If `assigned_to` chosen at creation, the view **immediately** moves to IN_PROGRESS + writes a status log "Auto-assigned during creation" (`views.py:339`).
|
|
|
|
### 2.4 What happens immediately after submission (`ObservationService.create_observation`, `services.py:43`)
|
|
Inside one `@transaction.atomic` (`services.py:44`):
|
|
1. `Observation.objects.create(...)` with default `status=OPEN` (`models.py:375`).
|
|
2. **Tracking code** generated by the model's `save()` override (`models.py:650`): if adding and `hospital_id` set → `generate_reference("OBS", hospital)` → **`OBS-YYYYMM-NNNN`** (`apps/core/reference.py:32`); else random `OBS-XXXXXX` (`models.py:26`). Uniqueness loop retries.
|
|
3. **Initial status log:** `ObservationStatusLog(from_status="", to_status=OPEN, comment="Observation submitted")` (`services.py:113`).
|
|
4. **Attachments** created (`services.py:118`); metadata auto-extracted in `ObservationAttachment.save()` (`models.py:865`).
|
|
5. **New-observation notification** queued to `notify_staff_new_item.delay("observation", id)` (`services.py:126`) → `send_new_observation_notification` (`tasks.py:266`) → `ObservationService.notify_new_observation` (`services.py:430`) emails every user in the **"PX Admin"** group (`services.py:440`).
|
|
6. **AI analysis** queued: `analyze_observation_with_ai.delay(id)` (`services.py:134`; task `tasks.py:283`) — overwrites `severity`, matches `category`, sets `title`, stores result in `metadata["ai_analysis"]` (`tasks.py:347`); also drops a bilingual `ObservationNote` (`tasks.py:410`).
|
|
7. Public path redirects to success page showing tracking code (`views.py:207,225`).
|
|
|
|
**Signal side:** `signals.py` only `logger.info`s creation and status-log events (`signals.py:14`) — no signal-driven status change.
|
|
|
|
**Token fields** (`response_token`/`response_token_used`/`response_token_sent_at`, added `0015`) are **empty at creation** — generated only when sent to a department (`views.py:1269`).
|
|
|
|
---
|
|
|
|
## 3. Complete Lifecycle
|
|
|
|
### 3.1 The four statuses (`models.py:43-49`)
|
|
`OPEN`, `IN_PROGRESS`, `RESOLVED`, `CLOSED`.
|
|
|
|
### 3.2 `VALID_OBSERVATION_TRANSITIONS` (`models.py:52-57`) — verified
|
|
```
|
|
OPEN → {IN_PROGRESS}
|
|
IN_PROGRESS → {RESOLVED}
|
|
RESOLVED → {CLOSED, IN_PROGRESS} # IN_PROGRESS = reopen
|
|
CLOSED → {IN_PROGRESS} # reopen
|
|
```
|
|
Note the two reopen edges: RESOLVED→IN_PROGRESS and CLOSED→IN_PROGRESS.
|
|
|
|
### 3.3 Enforcement layers
|
|
- **Service:** `ObservationService.change_status` (`services.py:144`) checks `VALID_OBSERVATION_TRANSITIONS` and raises `ValueError` on illegal moves (`services.py:166`).
|
|
- **Model `clean()`:** `Observation.clean()` (`models.py:640`) raises `ValidationError` if `status` not in the four valid choices. (Validates the *value*, not the *transition*.)
|
|
- **DB `CheckConstraint`:** `observation_status_valid` restricting `status ∈ {open, in_progress, resolved, closed}` (`models.py:633`), added in `0016:30`.
|
|
- **⚠ Bypass:** `observation_respond` (`views.py:1001`) sets `status="resolved"` directly, skipping both the service transition check AND the status-log creation (Finding #7).
|
|
|
|
### 3.4 Original statuses & the simplification mapping
|
|
Original choices (`0001_initial.py:151`): `new, triaged, assigned, in_progress, resolved, closed, rejected, duplicate, contacted` (+ `contacted_no_response` on log fields).
|
|
|
|
**`0014_map_statuses.py`** (data migration):
|
|
| Old | → New `status` | `contact_status` |
|
|
|---|---|---|
|
|
| `new` | `open` | `not_contacted` |
|
|
| `triaged`/`assigned`/`contacted` | `in_progress` | `not_contacted` / `contacted` |
|
|
| `rejected`/`duplicate` | `closed` | `not_contacted` |
|
|
|
|
`0013_simplify_statuses.py` moved "contacted" out of `status` into a separate `contact_status` enum (`not_contacted / contacted / contacted_no_response`, `models.py:379-389`).
|
|
|
|
> **⚠ Stale:** `tests.py` references `ObservationStatus.NEW`/`.TRIAGED`/`.ASSIGNED` which no longer exist — would fail to import. `README.md:78` lists the old statuses; `README.md:70` the old `OBS-ABC123` format.
|
|
|
|
---
|
|
|
|
## 4. Status Definitions
|
|
|
|
| Status | Purpose | When entered | Who moves forward | Next |
|
|
|---|---|---|---|---|
|
|
| **OPEN** | Newly submitted, not picked up. Default on create. | At creation (`services.py:114`) | Anyone who activates/triages/assigns it (`observation_activate` `views.py:854`, `observation_triage` `views.py:729`, `observation_assign` `views.py:799`) — all flip OPEN→IN_PROGRESS. Send endpoints **reject OPEN** (activation gate). | IN_PROGRESS only |
|
|
| **IN_PROGRESS** | Being worked. `activated_at` stamped on first entry (`services.py:177`); `due_at` computed from SLA. | On activate/triage/assign; on reopen from RESOLVED/CLOSED; on assign from resolved/closed | PX/Hosp-Admin/PX-Mgmt/PX-Employee/Dept-Manager (varies by view) | RESOLVED only |
|
|
| **RESOLVED** | Work complete; awaiting closure. `resolved_at`/`resolved_by` stamped (`services.py:186`). Triggers `notify_resolution` (`services.py:205`). | Via `change_status` to RESOLVED; **or** via `observation_respond` which auto-resolves when a PX response is sent (`views.py:1000`) | `triage_observation` perm OR px_admin OR hospital_admin (`views.py:773`); also anyone allowed to `observation_respond` (`views.py:978`) | CLOSED, or reopen to IN_PROGRESS |
|
|
| **CLOSED** | Terminal. `closed_at`/`closed_by` stamped (`services.py:189`). | Via `change_status` to CLOSED (same gate) | Same | Reopen to IN_PROGRESS |
|
|
|
|
Helper: `is_active_status` True for open/in_progress (`models.py:771`). `check_overdue` treats resolved/closed/rejected/duplicate as inactive (`models.py:757`) — `rejected`/`duplicate` literals are harmless leftovers.
|
|
|
|
---
|
|
|
|
## 5. Workflow Actions
|
|
|
|
### Public (no login)
|
|
| Action | View (`views.py`) | URL name | Effect |
|
|
|---|---|---|---|
|
|
| Submit (public) | `observation_create_public` `:162` | `observation_create_public` | Creates OPEN; redirects to success page |
|
|
| Success page | `observation_submitted` `:225` | `observation_submitted` | Shows tracking code |
|
|
| Track by code | `observation_track` `:241` | `observation_track` | Public status + dept response view (no internal notes) |
|
|
| **Token response** (champion, no login) | `observation_respond_with_token` `:1987` | `observation_respond_with_token` | Validates token; champion submits dept response |
|
|
|
|
### Internal (login required)
|
|
| Action | View (`views.py`) | URL name | Permission gate | Effect |
|
|
|---|---|---|---|---|
|
|
| Create (internal) | `observation_create` `:301` | `observation_create` | `@login_required` | Creates OPEN; if `assigned_to` → IN_PROGRESS |
|
|
| **Triage** | `observation_triage` `:729` | `observation_triage` | `triage_observation` perm OR px_admin | `ObservationService.triage_observation` → sets dept/assignee, → IN_PROGRESS |
|
|
| **Change status** | `observation_change_status` `:765` | `observation_change_status` | `triage_observation` perm OR px_admin OR hospital_admin (`:773`) — **the gate the brief quotes** | Uses `ObservationService.change_status` → enforces transitions |
|
|
| **Assign/Reassign** | `observation_assign` `:799` | `observation_assign` | px_admin/hospital_admin/px_management/px_employee | Sets `assigned_to`; if resolved/closed → reopen to IN_PROGRESS + clear stamps; if open → IN_PROGRESS |
|
|
| **Activate (take)** | `observation_activate` `:854` | `observation_activate` | px_admin/hospital_admin/px_management/px_employee | Assigns to self; OPEN→IN_PROGRESS |
|
|
| **Reopen** | `observation_reopen` `:908` | `observation_reopen` | px_admin/hospital_admin/px_management/px_employee | Only from resolved/closed; → IN_PROGRESS; clears stamps |
|
|
| Add note | `observation_add_note` `:945` | `observation_add_note` | `@login_required` | `ObservationService.add_note` |
|
|
| **PX outward response** | `observation_respond` `:973` | `observation_respond` | px_admin/hospital_admin/px_management/px_employee OR `assigned_to` (`:978`) | Sets `response/responded_at/responded_by/response_sent_at`; auto-resolves if not already resolved/closed (`:994`). **⚠ Bypasses transition validator & status log.** |
|
|
| Generate AI response | `observation_generate_ai_response` `:1020` | `observation_generate_ai_response` | same as respond | Returns bilingual JSON draft; does not persist |
|
|
| **Send to department (legacy)** | `observation_send_to_department` `:1179` | `observation_send_to_department` | px_admin/hospital_admin/department_manager/px_management/px_employee | **Activation gate:** rejects OPEN. Sets `assigned_department`, `sent_to_department=True`, dept-response SLA, `response_token`, emails champion token link (§14) |
|
|
| **Escalate (manual)** | `observation_escalate` `:1335` | `observation_escalate` | px_admin/hospital_admin/px_management/px_employee | Refuses closed/cancelled/rejected; sets `escalated_at`, emails target |
|
|
| **Send-to (AJAX)** | `observation_send_to` `:1433` | `observation_send_to` | px_admin/hospital_admin/department_manager/px_management/px_employee | Activation gate. Branch: person (assigns User) or department (emails+SMS champion+manager). **⚠ Reads nonexistent `observation.reference_number` (`:1564`) → AttributeError.** |
|
|
| **Dept response (logged-in)** | `observation_department_response` `:1612` | `observation_department_response` | px_admin OR hospital_admin OR (is_champion AND `assigned_department==user.department`) | Stores `department_response_en/ar`, stamps `department_responded_at/_by`, AI summary, notifies reporter |
|
|
| Send dept-response reminder | `observation_send_dept_response_reminder` `:1752` | `observation_send_dept_response_reminder` | px_admin/hospital_admin/px_management/px_employee | Refuses if already responded; emails champion; stamps reminder |
|
|
| Convert to PX Action | `observation_convert_to_action` `:1113` | `observation_convert_to_action` | px_admin/hospital_admin/px_management/px_employee | One-shot guard on `action_id`; creates `PXAction` |
|
|
| Soft delete/Restore | `observation_soft_delete`/`observation_restore` `:1961/1975` | — | px_admin/hospital_admin/px_management/px_employee | |
|
|
| PDF | `observation_pdf` `:2066` | `observation_pdf` | `@login_required` | |
|
|
| Category CRUD | — | `urls.py:77-83` | `observations.manage_categories` via `@permission_required` | Delete blocked if in use |
|
|
|
|
---
|
|
|
|
## 6. Decision Points
|
|
|
|
- **Activation gate** (shared rule): OPEN observations **cannot be sent to a department** — both send endpoints reject `status == OPEN` (`views.py:1196, 1457`). Must first reach IN_PROGRESS via activate/triage/assign.
|
|
- **Triage needed?** New observations arrive OPEN with no dept/category (public) or optional dept (internal). Triage sets dept/owner/category (`services.py:215`).
|
|
- **AI classification** (`tasks.py:283`): proposes severity/category/title post-create.
|
|
- **Send to person vs department** (`observation_send_to`): `recipient_type` branch (`views.py:1463`).
|
|
- **Department has a contact target?** `has_contact_target(dept)` gates the dept list; legacy send requires champion **or** manager (`views.py:1212`).
|
|
- **Contact person valid?** `department.is_valid_contact_person(contact_person_id)` (`views.py:1221`).
|
|
- **Already responded?** Reminder short-circuits if `department_responded_at` set (`views.py:1763`).
|
|
- **Already converted?** Convert short-circuits on `action_id` (`views.py:1129`).
|
|
- **Convert-to-action vs send-to-dept** — mutually exclusive paths chosen by PX staff.
|
|
- **⚠ No formal PX accept/reject decision point exists anymore** (Finding #1). The implicit "review" is `observation_respond`.
|
|
|
|
---
|
|
|
|
## 7. Assignment Flow
|
|
|
|
- **`assigned_department`** (`models.py:445`) — the single responsible department (`SET_NULL`). This is the "outgoing department" field reused by the send flow; there is **no separate outgoing-department field**, unlike Inquiry.
|
|
- **`assigned_to`** (`models.py:453`) — individual user assignee.
|
|
- **`assigned_at`** (`models.py:461`).
|
|
- **Section** (`models.py:353`) — finer-grained.
|
|
- **Reassignment:** `observation_assign` logs old→new in the comment (`views.py:841`); `observation_activate` reassigns to self (`views.py:873`).
|
|
- **Transfer to department:** send endpoints overwrite `assigned_department` (`views.py:1240, 1538`) — effectively reassignment + send.
|
|
- **Owner cascade** `get_owner()` (`models.py:685`): section(champion→supervisor→deputy_supervisor) → department(champion→deputy_manager→supervisor→deputy_supervisor→manager_2nd→manager_3rd).
|
|
- **Escalation targets** computed in detail view from `dept.get_role_holders()` + Hospital Admin/Department Manager groups (`views.py:665`).
|
|
- **Final owner** = whoever resolves/closes (`resolved_by`/`closed_by`).
|
|
- **SLA config** per (hospital, severity): `ObservationSLAConfig` (`models.py:138`); `Observation.get_sla_config()` (`models.py:733`). `dept_response_hours` (default 48, `models.py:183`) drives dept-response SLA.
|
|
|
|
---
|
|
|
|
## 8. Investigation Process
|
|
|
|
**No separate investigation sub-flow** for observations (contrast with Complaints, `docs/workflows.md:25-28`). No Investigation/Question/Explanation models.
|
|
|
|
- **Notes** = `ObservationNote` (`models.py:879`): `note`, `created_by`, `is_internal` (default True). Plus generic `notes = GenericRelation("core.Note")` (`models.py:619`).
|
|
- **Audit trail** = `ObservationStatusLog` (`models.py:904`): every status change recorded by `change_status` (`services.py:196`). The champion-response path writes a pseudo-log with `from_status == to_status` (`views.py:1668`); the token path does **not** create a status log — small inconsistency.
|
|
- **Stage timeline** built by `_build_observation_stage_timeline` (`views.py:83`) from status logs + timestamp fields.
|
|
|
|
---
|
|
|
|
## 9. Communication Flow
|
|
|
|
All notifications via `apps.notifications.services.NotificationService`.
|
|
|
|
| Event | Trigger | Recipient | Channel | Code |
|
|
|---|---|---|---|---|
|
|
| New observation | create | "PX Admin" group | email | `services.py:430` |
|
|
| Assignment | triage/assign | `assigned_to` | email | `services.py:496` (⚠ `notify_assignment` defined but never called) |
|
|
| Sent to department | send endpoints | champion (+manager) | email (+SMS in send_to) | `views.py:1268, 1552` |
|
|
| Token link emailed | send-to-department | contact person | email w/ one-time link | `views.py:1282` |
|
|
| Department responded | dept response / token | reporter (`reporter_phone/email`) | SMS + email w/ track URL | `views.py:1684, 2039` |
|
|
| SLA reminder (resolution) | `send_observation_sla_reminders` | `assigned_to` | email | `tasks.py:53` |
|
|
| Dept-response reminders (auto) | `send_observation_dept_response_reminders` | dept champion | email | `tasks.py:511` |
|
|
| Dept-response reminder (manual) | `observation_send_dept_response_reminder` | dept champion | email | `views.py:1752` |
|
|
| Resolution/Closure | `change_status` → RESOLVED/CLOSED | `assigned_to` + dept manager | email | `services.py:559` |
|
|
| Escalation | `observation_escalate` | chosen escalate-to user | email | `views.py:1393` |
|
|
| Monthly follow-up | **(disabled)** | `assigned_to` | email | `tasks.py:211` (beat commented out, `config/celery.py:193,198`) |
|
|
| Public track page | observer enters code | observer (self-serve) | web | `views.py:241` |
|
|
|
|
**Acknowledgement/"need more info"/"no observer response"** tracked by `contact_status` (`not_contacted/contacted/contacted_no_response`, `models.py:379`), set to `contacted` on send-to-department (`views.py:1255, 1584`). **No automated "no response" flip** — no view currently writes `contacted_no_response`.
|
|
|
|
**Token-response path:** `/observations/<uuid:pk>/respond/<str:token>/` (`urls.py:36`) → `observation_respond_with_token` (`views.py:1987`). Validates token, refuses if used, one-shot.
|
|
|
|
---
|
|
|
|
## 10. Escalation Flow
|
|
|
|
### Manual
|
|
`observation_escalate` (`views.py:1335`): PX/admin/management/employee picks a `Staff` target + reason + email subject/body. Refuses closed/cancelled/rejected (`views.py:1350`). Stamps `escalated_at`, writes status log + note, emails target.
|
|
|
|
### "Automatic" — present in config but DISABLED in code
|
|
- Config fields on `ObservationSLAConfig`: `dept_response_auto_escalate_enabled` (default True, `models.py:195`), `dept_response_escalation_hours_overdue` (default 0 = immediately, `models.py:198`).
|
|
- Seed defaults (`seed_observation_dept_response_sla.py:39`): `sla_hours=72`, `dept_response_hours=48`, first reminder 12h, second 4h, auto-escalate enabled, escalation 0h.
|
|
- Task `check_overdue_observation_dept_responses` (`tasks.py:447`) flags overdue but the escalation branch is **dead code**: always logs "Auto-escalation skipped (disabled)" and `continue`s (`tasks.py:499`). **So escalation is manual only.**
|
|
- **SLA breach detection:** `Observation.check_overdue` (`models.py:757`) sets `is_overdue`/`breached_at`. Driven by `check_overdue_observations` (`tasks.py:19`) every 15 min.
|
|
- **Manager involvement:** only via manual escalation or the escalation-targets list. No manager-approval tier in the response review.
|
|
|
|
> **⚠ Celery beat contradicts task docstrings:** all four observation tasks run at `crontab(minute="*/15")` (`config/celery.py:86-114`), but `tasks.py:56` and `:517` claim "every hour". Monthly follow-up beats are **commented out** (`config/celery.py:193,198`) — never fire.
|
|
|
|
---
|
|
|
|
## 11. Resolution Process
|
|
|
|
- **Who can resolve:** the `observation_change_status` gate (`views.py:773`) = `triage_observation` perm **OR** `is_px_admin()` **OR** `is_hospital_admin()` — as `docs/workflows.md:45-46` states. Additionally `observation_respond` (`views.py:978`) lets px_admin/hospital_admin/px_management/px_employee **or the current assignee** resolve by sending a response.
|
|
- **How resolved:** `ObservationService.change_status(..., RESOLVED)` stamps `resolved_at`/`resolved_by` (`services.py:186`), writes `ObservationStatusLog`, fires `notify_resolution`. The alternate `observation_respond` sets fields inline (`views.py:997`) and additionally records `responded_at/responded_by/response_sent_at` (`models.py:570`, added `0017`).
|
|
- **Approval required:** **⚠ No separate approval step exists anymore.** The historical `dept_response_acceptance_status` (pending/acceptable/not_acceptable) was removed in `0020`. Today the champion's department response is **not formally accepted/rejected** — PX simply decides whether to write a patient-facing `response` (which resolves the case) or to reopen/re-ask via notes.
|
|
- **Observer confirmation:** **no observer satisfaction step.** `0018` added `satisfaction`/`satisfaction_set_at`, but **`0019` removed** both (`0019:13`) — so satisfaction is no longer captured for observations.
|
|
|
|
---
|
|
|
|
## 12. Closure Process
|
|
|
|
- **When closeable:** from RESOLVED only (`models.py:55`).
|
|
- **Who closes:** same `triage_observation` OR px_admin OR hospital_admin gate (`views.py:773`), via `observation_change_status`.
|
|
- **Side effects:** `closed_at`/`closed_by` (`services.py:189`); `notify_resolution` emailed.
|
|
- **Reopen:** CLOSED → IN_PROGRESS is legal (`models.py:56`). Two entry points: `observation_reopen` (`views.py:908`, clears resolved/closed stamps) and `observation_assign` (`views.py:825`, implicit reopen-on-reassign).
|
|
- **Permanently completed:** no "archived"/"locked" state — CLOSED is terminal but always reopenable. Soft-delete (`views.py:1961`) is the only "removal" path, reversible via restore.
|
|
|
|
---
|
|
|
|
## 13. Exception Flows
|
|
|
|
- **Duplicate/Invalid/Rejected:** **former statuses**, now collapsed to `closed` by `0014_map_statuses.py:19-20`. Today no dedicated "mark duplicate/invalid" action; PX closes + notes. (UI color maps for `rejected`/`duplicate` linger in `admin.py:171`, `models.py:728` but are unreachable.)
|
|
- **Wrong department:** re-send via send endpoints (overwrites `assigned_department`) or reassign.
|
|
- **Missing info:** triage note / `ObservationNote`; `contact_status` settable to `contacted` (no view sets `contacted_no_response`).
|
|
- **No observer response:** tracked conceptually via `contact_status="contacted_no_response"` but **no automated transition** writes it.
|
|
- **Converted to PX Action:** `observation_convert_to_action` (`views.py:1113`); one-shot guard on `action_id`; the observation is **not** auto-closed on conversion.
|
|
- **Withdrawn:** no dedicated status/action. Anonymous/identified reporter cannot retract; only PX can soft-delete.
|
|
- **Merged:** no merge feature.
|
|
- **Reopened:** `observation_reopen` (`views.py:908`) from resolved/closed → IN_PROGRESS.
|
|
|
|
---
|
|
|
|
## 14. Department-Response Sub-Flow
|
|
|
|
> ⚠ The documented "champion responds → PX accepts/rejects → resolve" loop (`docs/workflows.md:44`) is **only partially implemented** today. The accept/reject tier was removed (`0020`). What remains is "champion responds → PX writes outward response (resolve)". Both the intended design and the actual code are documented below.
|
|
|
|
### 14.1 Migration history of the sub-flow
|
|
- `0004_add_sent_to_department_fields.py:13` — added `sent_to_department` (bool) + `sent_to_department_at`.
|
|
- `0005_data_sent_to_department_backfill.py:5` — backfilled `sent_to_department=True` for any observation with an `assigned_department`.
|
|
- `0001_initial.py:165-175` — original dept-response fields.
|
|
- `0001_initial.py:176-178` + `0020:13-28` — `dept_response_acceptance_status` (+`_at`/`_by`/`_notes`) **added then removed**. Net effect: no acceptance fields today.
|
|
- `0015_add_response_token.py:13` — `response_token`/`response_token_used`/`response_token_sent_at`.
|
|
- `0017_observation_responded_at...py:15` — `responded_at`/`responded_by`/`response`/`response_en/ar`/`response_sent_at` (the PX outward response).
|
|
|
|
### 14.2 The activation gate (shared rule)
|
|
Both send endpoints refuse `status == OPEN`:
|
|
- `observation_send_to_department`: `views.py:1195` → "Activate this observation before sending it to a department."
|
|
- `observation_send_to`: `views.py:1456` → HTTP 400 JSON.
|
|
So an observation must first reach IN_PROGRESS (via triage/activate/assign) before forwarding.
|
|
|
|
### 14.3 Send → receive
|
|
1. **Send** (`observation_send_to_department` `views.py:1179` or `observation_send_to` `views.py:1433`):
|
|
- Sets `assigned_department = department` (`views.py:1240, 1538`).
|
|
- Sets `sent_to_department = True`, `sent_to_department_at = now`, `forwarded_to_dept_at = now` (`views.py:1241-1243, 1539-1541`) — the uniform cross-module "sent" signal.
|
|
- Computes `dept_response_sla_due_at = now + dept_response_hours`; resets overdue/reminder/escalation stamps (`views.py:1245-1252, 1543-1550`).
|
|
- Sets `contact_status="contacted"` (`views.py:1255, 1584`).
|
|
- **Legacy path** generates `response_token` (`views.py:1273`) and emails the chosen contact person a one-time link `https://{domain}/observations/{pk}/respond/{token}/` (`views.py:1282`).
|
|
- **Unified path** emails+SMSes champion **and** manager via `get_champion_and_manager` — but **⚠ crashes on `observation.reference_number`** (`views.py:1564-1574`, Finding #2 — `reference_number` doesn't exist on Observation, only `tracking_code`).
|
|
- Writes an internal `ObservationNote` (`views.py:1261, 1590`).
|
|
|
|
2. **Who receives it:** the department **champion** primarily (reminder recipient is `dept.champion.user`, `tasks.py:542`); the dept manager is a secondary recipient in the unified send path.
|
|
|
|
### 14.4 Champion's response submission (two surfaces, same fields)
|
|
- **Logged-in champion:** `observation_department_response` (`views.py:1612`), gated by `is_champion()` AND `observation.assigned_department == user.department` (`views.py:1619`). Form: `ObservationDepartmentResponseForm` (`forms.py:654`) — requires `response_en` **or** `response_ar` (`forms.py:682`).
|
|
- **Token (no login):** `observation_respond_with_token` (`views.py:1987`), validates `response_token` (`:1998`) and `response_token_used` (`:2001`).
|
|
|
|
Both store `department_response_en/ar`, stamp `department_responded_at` + `department_responded_by` (logged-in path only), clear `dept_response_is_overdue`, mark `response_token_used=True` (token path), generate AI summary into `department_response_summary_en/ar` (`views.py:1644, 2022`), write a pseudo `ObservationStatusLog` (logged-in path only, `views.py:1668`), and notify the reporter via SMS/email with the public track URL (`views.py:1684, 2039`).
|
|
|
|
### 14.5 ONE-level review — what actually exists
|
|
- **Per `docs/workflows.md:41-47` (intended):** "champion responds → PX accepts/rejects → resolve" using `dept_response_acceptance_status`.
|
|
- **In current code:** `dept_response_acceptance_status` and friends were removed (`0020`). There is **no** `px_accept`/`px_reject` URL or view. The de-facto "review" is the PX user running `observation_respond` (`views.py:973`) to write `response/response_en/ar`, `responded_at/_by`, `response_sent_at` (`models.py:565`), which also flips status to `resolved` (`views.py:1000`).
|
|
- **⚠ Reject loop / "response cleared, returns to champion":** described in `docs/workflows.md:16-17` as a shared rule, but there is **no implementation** for observations — no view clears `department_response_en/ar` or resets `department_responded_at`. If PX is unhappy, the only options today are to reopen (`observation_reopen`) and re-send, or to add a note. **Document as a gap.**
|
|
|
|
### 14.6 Token-response path (summary)
|
|
- URL: `/observations/<uuid:pk>/respond/<str:token>/` (`urls.py:36`).
|
|
- Token generated only at send-time (`views.py:1273`), stored in `response_token` (`models.py:551`), uniqueness-indexed.
|
|
- Validation: token equality + non-empty (`views.py:1998`); not already used (`views.py:2001`, field `response_token_used` `models.py:555`).
|
|
- One-shot: sets `response_token_used=True` on submit (`views.py:2019`); subsequent visits render `response_already_submitted.html`.
|
|
- No-auth requirement explicit (`views.py:1987`).
|
|
|
|
---
|
|
|
|
## 15. End-to-End Example
|
|
|
|
```
|
|
Staff member submits observation via public form (anonymous allowed)
|
|
↓ (views.py:162) → status=OPEN, tracking OBS-202607-0001,
|
|
initial status log written, AI analysis dispatched,
|
|
PX admins notified
|
|
PX triages (sets department, assignee, category, severity)
|
|
↓ (views.py:729) → status=IN_PROGRESS, activated_at, due_at computed
|
|
[DECISION: send to department or convert to action]
|
|
PX sends to Department X
|
|
↓ (views.py:1179) → assigned_department=X, sent_to_department=True,
|
|
dept_response_sla_due_at set, response_token generated,
|
|
token link emailed to champion, contact_status=contacted
|
|
[DECISION: champion responds via token or logged-in]
|
|
Champion submits dept response via token link
|
|
↓ (views.py:1987) → department_response_en set, department_responded_at,
|
|
response_token_used=True, AI summary generated,
|
|
reporter notified SMS+email
|
|
[⚠ NO PX accept/reject — field removed]
|
|
[DECISION POINT: PX must write outward response]
|
|
PX writes outward response
|
|
↓ (views.py:973) → response set, responded_at/by, response_sent_at,
|
|
status=resolved (auto), resolved_at/by
|
|
[⚠ bypasses transition validator & status log]
|
|
[DECISION POINT: close or reopen]
|
|
PX closes
|
|
↓ (views.py:765) → status=closed, closed_at/by, notify_resolution
|
|
[PERMANENT unless reopened → IN_PROGRESS]
|
|
```
|
|
|
|
---
|
|
|
|
## Appendix — Flagged gaps vs `docs/workflows.md` / `README.md`
|
|
|
|
1. **`dept_response_acceptance_status` removed** (`0020`) — PX accept/reject no longer implemented; no `px_accept`/`px_reject` view.
|
|
2. **`observation_send_to` AttributeError** (`views.py:1564-1574`) — reads nonexistent `observation.reference_number`; should be `tracking_code`.
|
|
3. **Stale test suite** — `tests.py` references `ObservationStatus.NEW`/`.TRIAGED`/`.ASSIGNED`.
|
|
4. **Celery beat contradicts task docstrings** — all run `*/15`, not "every hour"; monthly follow-up beats commented out.
|
|
5. **Auto dept-response escalation disabled** (`tasks.py:499`) despite config flag defaulting True.
|
|
6. **`README.md` outdated** — old statuses, old tracking format.
|
|
7. **`observation_respond` bypasses transition validator & status log** (`views.py:1001`) — inconsistency in audit trail.
|
|
8. **No reject loop** for observations (no view clears dept response) — gap vs `docs/workflows.md:16-17`.
|
|
9. **`satisfaction` removed** (`0019`) — no observer satisfaction step.
|
|
10. **Stale literals** — `check_overdue`/escalate reference `cancelled`/`rejected`/`duplicate` which can never occur.
|