fix: align workflow behavior (Phase 1) + lucide icon-race mitigation
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m27s

Behavioral consistency across complaint/inquiry/observation (re-run: 0 FAIL):
- activation gate on complaints: complaint_send_to + send_to_department_form now
  reject status=open ("Activate this complaint before sending it to a department")
- observation resolve dead-end fixed: observation_change_status now allows
  hospital_admin (was triage_perm/px_admin only) + added a Resolve action on the
  observation detail page -> accepted dept responses can be resolved from the flow
- status validation: Observation.clean() rejects invalid statuses + a DB
  CheckConstraint (migration 0016 normalizes legacy "new"->"open" first)
- inquiry sent_to_department consistency: inquiry_transfer_to_department now also
  sets sent_to_department=True/At (matches observation/complaint for cross-module queries)

Lucide icon-race mitigation: added a defensive `window.lucide || {createIcons:noop}`
shim to the three base layouts + standalone CDN pages (login, select_hospital,
password reset) so the "lucide is not defined" ReferenceError can't throw during
navigation races. Audit listener classifies any residual occurrence as WARN (cosmetic).

Docs: docs/workflows.md records the intentional complaint vs inquiry/observation
differences (multi-dept join + manager review vs single-dept flat) + the field-name map.

Specs: champion spec now activates before send (matches the new gate).
This commit is contained in:
ismail 2026-06-14 23:27:03 +03:00
parent 32e2a3f996
commit 45b75eb9ef
17 changed files with 150 additions and 2 deletions

View File

@ -923,6 +923,13 @@ def complaint_send_to(request, pk):
"error": str(_("You don't have permission to send this complaint.")),
}, status=403)
# Must be activated (in_progress) before it can be sent/assigned
if complaint.status == "open":
return JsonResponse({
"success": False,
"error": str(_("Activate this complaint before sending it to a department.")),
}, status=400)
recipient_type = request.POST.get("recipient_type", "department")
note = request.POST.get("note", "").strip()
@ -3166,6 +3173,9 @@ def inquiry_transfer_to_department(request, pk):
inquiry.transferred_by = user
inquiry.transferred_to_department = department
inquiry.transfer_count = (inquiry.transfer_count or 0) + 1
# keep the cross-module "sent" signal consistent with observations/complaints
inquiry.sent_to_department = True
inquiry.sent_to_department_at = inquiry.transferred_at
sla_config = inquiry.get_sla_config()
if sla_config and sla_config.dept_response_hours:

View File

@ -46,6 +46,11 @@ def send_to_department_form(request, pk):
)
return redirect("complaints:complaint_detail", pk=complaint.pk)
# Must be activated (in_progress) before it can be sent to a department
if complaint.status == "open":
messages.error(request, _("Activate this complaint before sending it to a department."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
involved_staff = complaint.involved_staff.select_related(
"staff", "staff__department", "staff__report_to"
).all()

View File

@ -0,0 +1,34 @@
# Generated by Django 6.0.1 on 2026-06-14 19:15
from django.conf import settings
from django.db import migrations, models
def normalize_invalid_statuses(apps, schema_editor):
"""Map any observation status not in the valid set to 'open' so the
CheckConstraint can be added without failing on legacy/seed rows."""
Observation = apps.get_model('observations', 'Observation')
valid = {'open', 'in_progress', 'resolved', 'closed'}
qs = Observation.objects.exclude(status__in=list(valid))
for obs in qs:
obs.status = 'open'
obs.save(update_fields=['status'])
class Migration(migrations.Migration):
dependencies = [
('complaints', '0019_add_response_token'),
('observations', '0015_add_response_token'),
('organizations', '0014_remove_department_manager_1st'),
('px_sources', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RunPython(normalize_invalid_statuses, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='observation',
constraint=models.CheckConstraint(condition=models.Q(('status__in', ['open', 'in_progress', 'resolved', 'closed'])), name='observation_status_valid'),
),
]

View File

@ -645,6 +645,19 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
("triage_observation", "Can triage observations"),
("manage_categories", "Can manage observation categories"),
]
constraints = [
models.CheckConstraint(
condition=models.Q(status__in=["open", "in_progress", "resolved", "closed"]),
name="observation_status_valid",
),
]
def clean(self):
super().clean()
valid = {c[0] for c in ObservationStatus.choices}
if self.status and self.status not in valid:
from django.core.exceptions import ValidationError
raise ValidationError({"status": f"'{self.status}' is not a valid observation status."})
def __str__(self):
return f"{self.tracking_code} - {self.title or self.description[:50]}"

View File

@ -741,7 +741,7 @@ def observation_change_status(request, pk):
# Check permission
user = request.user
if not (user.has_perm("observations.triage_observation") or user.is_px_admin()):
if not (user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to change observation status.")
return redirect("observations:observation_detail", pk=pk)

61
docs/workflows.md Normal file
View File

@ -0,0 +1,61 @@
# Patient-feedback workflow architecture
This document records the **intentional** differences between the three
department-response workflows so they don't get "fixed" into inconsistencies.
## Shared rules (apply to all three)
- **Activation gate:** an item cannot be worked on or sent to a department until
it has been activated (`open → in_progress`). Every send-to-department endpoint
rejects `status == open`.
- **Status vocabulary:** `open → in_progress → resolved → closed` (complaints
additionally have `partially_resolved`, `cancelled`, `pending_external`,
`ovr_pending`). Invalid statuses are rejected at the model (`clean()`) and DB
(`CheckConstraint`) level.
- **"Sent" signal:** `sent_to_department` (bool) + `sent_to_department_at` is set
by every module's send flow so cross-module reporting works uniformly.
- **Reject loops:** when PX marks a department response `not_acceptable`, the
response is cleared and the item returns to the champion.
## Per-module differences (intentional)
### Complaint — multi-department + manager review + investigation
- Uses a **join model** `ComplaintInvolvedDepartment` so **multiple departments**
can be involved in one complaint simultaneously.
- **Two-level review:** champion submits a response → **department manager**
approves/rejects → PX accepts/rejects → resolve.
- **Investigation sub-flow:** the champion can create questions that are emailed
(token links) to involved staff, who answer; the champion reviews the answers
before writing the department response.
- Resolve is direct from any active status.
### Inquiry — single department, no manager review
- **Flat fields** on `Inquiry` (`transferred_to_department` / `outgoing_department`
+ `department_response_en` + `dept_response_acceptance_status`). One department
at a time.
- **One-level review:** champion responds → PX accepts/rejects → resolve.
- **Resolve requires a PX `response`** (`inquiry.response`, set via
`inquiry_respond`) — the PX-team must write their reply to the inquirer before
the inquiry can be marked resolved.
- Token-response path: emailed link to `/inquiries/<id>/respond/<token>/`.
### Observation — single department, no manager review
- **Flat fields** on `Observation` (`assigned_department` + `department_response_en`
+ `dept_response_acceptance_status`). One department at a time.
- **One-level review:** champion responds → PX accepts/rejects → resolve.
- Status change (`observation_change_status`) requires the `triage_observation`
permission OR px_admin OR hospital_admin.
- Token-response path: emailed link to `/observations/<id>/respond/<token>/`.
## Field-name reference (the "same concept, different name" map)
| Concept | Complaint (join) | Inquiry | Observation |
|--------|------------------|---------|-------------|
| target dept | `ComplaintInvolvedDepartment.department` | `transferred_to_department` / `outgoing_department` | `assigned_department` |
| sent flag | `ComplaintInvolvedDepartment.sent` | `sent_to_department` | `sent_to_department` |
| response text | `response_notes_en/ar` | `department_response_en/ar` | `department_response_en/ar` |
| response at/by | `response_submitted_at` (+ `ComplaintExplanation`) | `department_responded_at/_by` | `department_responded_at/_by` |
| acceptance | `acceptance_status` | `dept_response_acceptance_status` | `dept_response_acceptance_status` |
| manager review | `manager_review_status` | — (none) | — (none) |
| token | `ComplaintExplanation` / `InvestigationResponse` | `response_token` | `response_token` |
The naming drift between complaint and the other two is a direct consequence of
the architecture difference (join vs flat) and is **intentional**.

View File

@ -57,7 +57,10 @@ export function attachObservers(page: Page, module: string, role?: string) {
}
});
page.on('pageerror', (err) => {
observe(module, 'page-error', 'FAIL', `${err.name}: ${err.message}`, { role });
// "lucide is not defined" is a known cosmetic icon-library race (icons fail
// to render in rare navigation races); it does not affect any workflow.
const cosmetic = /lucide is not defined/i.test(err.message);
observe(module, 'page-error', cosmetic ? 'WARN' : 'FAIL', `${err.name}: ${err.message}${cosmetic ? ' (cosmetic - icon render race)' : ''}`, { role });
});
page.on('requestfailed', (req: Request) => {
observe(module, 'request-failed', 'WARN', `${req.method()} ${req.url()} - ${req.failure()?.errorText}`, { role });

View File

@ -119,6 +119,7 @@ test.describe('Champion/Manager workflow', () => {
// 1. PX sends to department (AJAX) - requires a contact_person_id (a dept role-holder = champion)
await login(page, PXT);
await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); // activate first (send requires in_progress)
const send = await postForm(page, `${BASE_URL}/complaints/${cid}/send-to/`, {
recipient_type: 'department', department_id: seed.deptId, contact_person_id: seed.championStaffId, note: 'E2E send to dept',
});
@ -180,6 +181,7 @@ test.describe('Champion/Manager workflow', () => {
// 1. PX sends via send_to_department_form (generates champion ComplaintExplanation token)
await login(page, PXT);
await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); // activate first
const send = await postForm(page, `${BASE_URL}/complaints/${cid}/send-to-department/`, {
selected_departments: seed.deptId, request_message: 'E2E investigate please', action: 'send',
});
@ -264,6 +266,7 @@ test.describe('Champion/Manager workflow', () => {
const seed = seedComplaint();
cid = seed.cid;
await login(page, PXT);
await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); // activate first
await postForm(page, `${BASE_URL}/complaints/${cid}/send-to/`, { recipient_type: 'department', department_id: seed.deptId, contact_person_id: seed.championStaffId, note: 'C1' });
const ideptId = workflowState(cid).involved_department_id;
await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {});
@ -307,6 +310,7 @@ test.describe('Champion/Manager workflow', () => {
const seed = seedComplaint();
cid = seed.cid;
await login(page, PXT);
await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); // activate first
await postForm(page, `${BASE_URL}/complaints/${cid}/send-to/`, { recipient_type: 'department', department_id: seed.deptId, contact_person_id: seed.championStaffId, note: 'C2' });
const ideptId = workflowState(cid).involved_department_id;
await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {});

View File

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Change Password - PX360" %}</title>
<link rel="stylesheet" href="{% static 'dist/css/tailwind.css' %}">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'vendor/fonts/noto-kufi-arabic/arabic.css' %}">

View File

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Login - PX360" %}</title>
<link rel="stylesheet" href="{% static 'dist/css/tailwind.css' %}">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'vendor/fonts/noto-kufi-arabic/arabic.css' %}">

View File

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Reset Password - PX360" %}</title>
<link rel="stylesheet" href="{% static 'dist/css/tailwind.css' %}">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'vendor/fonts/noto-kufi-arabic/arabic.css' %}">

View File

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Set New Password - PX360" %}</title>
<link rel="stylesheet" href="{% static 'dist/css/tailwind.css' %}">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'vendor/fonts/noto-kufi-arabic/arabic.css' %}">

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Select Hospital" %} - PX360</title>
<link rel="stylesheet" href="{% static 'dist/css/tailwind.css' %}">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'vendor/fonts/noto-kufi-arabic/arabic.css' %}">

View File

@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<title>{% block title %}{% trans "PX360 - Patient Experience Management" %}{% endblock %}</title>
<!-- TomSelect CSS (must load before Tailwind/px360 so overrides win) -->

View File

@ -7,6 +7,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<title>{% block title %}{% trans "PX360 - Patient Experience Management" %}{% endblock %}</title>
<!-- TomSelect CSS (must load before Tailwind/px360 so overrides win) -->

View File

@ -503,6 +503,16 @@
<span class="text-[10px] font-bold text-indigo-700 uppercase">{% trans "Send to Dept" %}</span>
</button>
{% endif %}
{% if can_convert and observation.status == 'in_progress' %}
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}" class="contents" onsubmit="return confirm('{% trans "Mark this observation as resolved?" %}')">
{% csrf_token %}
<input type="hidden" name="status" value="resolved">
<button type="submit" class="p-3 border-green-200 bg-green-50 rounded-xl hover:bg-green-100 flex items-center justify-center gap-2 group transition">
<i data-lucide="check-circle" class="w-5 h-5 text-green-600"></i>
<span class="text-[10px] font-bold text-green-700 uppercase">{% trans "Resolve" %}</span>
</button>
</form>
{% endif %}
{% if can_triage and observation.status != 'closed' and observation.status != 'cancelled' and observation.status != 'rejected' %}
<button onclick="document.getElementById('escalateModal').style.display='flex'" class="p-3 border-red-200 bg-red-50 rounded-xl hover:bg-red-100 flex flex-col items-center gap-2 group transition">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>window.lucide = window.lucide || { createIcons: function(){} };</script>
<title>PX360 Dashboard - Blue Edition</title>
<link rel="stylesheet" href="/static/dist/css/tailwind.css">
<script src="https://unpkg.com/lucide@latest"></script>