# 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//respond//` (`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//respond//` (`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.