diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index 96a0b29..0896ca6 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -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: diff --git a/apps/complaints/ui_views_explanation.py b/apps/complaints/ui_views_explanation.py index 0ef50a9..31f796f 100644 --- a/apps/complaints/ui_views_explanation.py +++ b/apps/complaints/ui_views_explanation.py @@ -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() diff --git a/apps/observations/migrations/0016_observation_observation_status_valid.py b/apps/observations/migrations/0016_observation_observation_status_valid.py new file mode 100644 index 0000000..07604d2 --- /dev/null +++ b/apps/observations/migrations/0016_observation_observation_status_valid.py @@ -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'), + ), + ] diff --git a/apps/observations/models.py b/apps/observations/models.py index 4b4bc7c..74b53ec 100644 --- a/apps/observations/models.py +++ b/apps/observations/models.py @@ -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]}" diff --git a/apps/observations/views.py b/apps/observations/views.py index a08cc8b..cc2323b 100644 --- a/apps/observations/views.py +++ b/apps/observations/views.py @@ -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) diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 0000000..292e23f --- /dev/null +++ b/docs/workflows.md @@ -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//respond//`. + +### 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//respond//`. + +## 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**. diff --git a/e2e/helpers/audit.ts b/e2e/helpers/audit.ts index e6b8c0d..7ea0590 100644 --- a/e2e/helpers/audit.ts +++ b/e2e/helpers/audit.ts @@ -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 }); diff --git a/e2e/tests/workflows/champion-manager-workflow.spec.ts b/e2e/tests/workflows/champion-manager-workflow.spec.ts index 4384bb5..676cc9d 100644 --- a/e2e/tests/workflows/champion-manager-workflow.spec.ts +++ b/e2e/tests/workflows/champion-manager-workflow.spec.ts @@ -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/`, {}); diff --git a/templates/accounts/change_password.html b/templates/accounts/change_password.html index 2778320..a725223 100644 --- a/templates/accounts/change_password.html +++ b/templates/accounts/change_password.html @@ -7,6 +7,7 @@ {% trans "Change Password - PX360" %} + diff --git a/templates/accounts/login.html b/templates/accounts/login.html index 1eb9be1..60d829e 100644 --- a/templates/accounts/login.html +++ b/templates/accounts/login.html @@ -7,6 +7,7 @@ {% trans "Login - PX360" %} + diff --git a/templates/accounts/password_reset.html b/templates/accounts/password_reset.html index 235ac0b..57fc07f 100644 --- a/templates/accounts/password_reset.html +++ b/templates/accounts/password_reset.html @@ -7,6 +7,7 @@ {% trans "Reset Password - PX360" %} + diff --git a/templates/accounts/password_reset_confirm.html b/templates/accounts/password_reset_confirm.html index 6f2de2b..39b3fa7 100644 --- a/templates/accounts/password_reset_confirm.html +++ b/templates/accounts/password_reset_confirm.html @@ -7,6 +7,7 @@ {% trans "Set New Password - PX360" %} + diff --git a/templates/core/select_hospital.html b/templates/core/select_hospital.html index 84b5043..f54fa3e 100644 --- a/templates/core/select_hospital.html +++ b/templates/core/select_hospital.html @@ -6,6 +6,7 @@ {% trans "Select Hospital" %} - PX360 + diff --git a/templates/layouts/base.html b/templates/layouts/base.html index b5cb57d..2be467d 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -5,6 +5,7 @@ + {% block title %}{% trans "PX360 - Patient Experience Management" %}{% endblock %} diff --git a/templates/layouts/public_base.html b/templates/layouts/public_base.html index 6bd1a47..992af21 100644 --- a/templates/layouts/public_base.html +++ b/templates/layouts/public_base.html @@ -7,6 +7,7 @@ + {% block title %}{% trans "PX360 - Patient Experience Management" %}{% endblock %} diff --git a/templates/observations/observation_detail.html b/templates/observations/observation_detail.html index 591ff28..0dee51a 100644 --- a/templates/observations/observation_detail.html +++ b/templates/observations/observation_detail.html @@ -503,6 +503,16 @@ {% trans "Send to Dept" %} {% endif %} + {% if can_convert and observation.status == 'in_progress' %} +
+ {% csrf_token %} + + +
+ {% endif %} {% if can_triage and observation.status != 'closed' and observation.status != 'cancelled' and observation.status != 'rejected' %}