diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index 0896ca6..12c2bf3 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -691,6 +691,8 @@ def complaint_detail(request, pk): and not complaint.involved_departments.filter(department=complaint.department).exists() ), "hospital_departments": hospital_departments, + "involved_department_form": ComplaintInvolvedDepartmentForm(complaint=complaint, user=user), + "involved_staff_form": ComplaintInvolvedStaffForm(complaint=complaint, user=user), "explanation": explanation, "explanations": explanations, "explanation_attachments": explanation_attachments, @@ -706,7 +708,18 @@ def complaint_detail(request, pk): f"Priority: {complaint.get_priority_display()}\n" f"Status: {complaint.get_status_display()}\n\n" f"Please review and take appropriate action.\n\n" - f"View: https://{request.get_host()}/organizations/departments/{complaint.department.pk}/" + f"View: https://{request.get_host()}/organizations/departments/{complaint.department.pk if complaint.department else ''}/" + ), + "send_to_email_subject": f"Complaint Sent for Response - {complaint.reference_number}", + "send_to_email_body": ( + f"Dear Team,\n\n" + f"Complaint #{complaint.reference_number} requires your response.\n\n" + f"Title: {complaint.title or 'N/A'}\n" + f"Severity: {complaint.get_severity_display()}\n" + f"Priority: {complaint.get_priority_display()}\n\n" + f"{complaint.description or 'No description provided.'}\n\n" + f"Please review and respond promptly.\n\n" + f"View: https://{request.get_host()}/complaints/{complaint.pk}/" ), "current_user": user, "adverse_actions": adverse_actions, @@ -841,6 +854,7 @@ def complaint_create(request): # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN) complaint.save() + reference_number = complaint.reference_number comm_req_id = request.POST.get("comm_req") if comm_req_id: @@ -932,6 +946,8 @@ def complaint_send_to(request, pk): recipient_type = request.POST.get("recipient_type", "department") note = request.POST.get("note", "").strip() + email_subject = request.POST.get("email_subject", "").strip() + email_body = request.POST.get("email_body", "").strip() try: if recipient_type == "person": @@ -955,10 +971,12 @@ def complaint_send_to(request, pk): # Send notification if person.email: + send_subject = email_subject or f"Complaint Assigned - {complaint.reference_number}" + send_body = email_body or f"You have been assigned to complaint #{complaint.reference_number}." NotificationService.send_email( email=person.email, - subject=f"Complaint Assigned - {complaint.reference_number}", - message=f"You have been assigned to complaint #{complaint.reference_number}.", + subject=send_subject, + message=send_body, html_message=f"""
{get_email_header_html()} @@ -1048,10 +1066,12 @@ def complaint_send_to(request, pk): contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None) if complaint.department_id == department.pk and contact_email: + send_subject = email_subject or f"Complaint Sent to Department - {complaint.reference_number}" + send_body = email_body or f"Complaint #{complaint.reference_number} has been sent to your department ({department.name})." NotificationService.send_email( email=contact_email, - subject=f"Complaint Sent to Department - {complaint.reference_number}", - message=f"Complaint #{complaint.reference_number} has been sent to your department ({department.name}).", + subject=send_subject, + message=send_body, html_message=f"""
{get_email_header_html()} @@ -1623,6 +1643,53 @@ def complaint_change_department(request, pk): return redirect("complaints:complaint_detail", pk=pk) +@login_required +@require_http_methods(["POST"]) +def complaint_update_location(request, pk): + """Update complaint location details (location_type, area, department, section).""" + from apps.organizations.models import Area, Section + + complaint = get_object_or_404(Complaint, pk=pk) + + if not can_manage_complaint(request.user, complaint): + messages.error(request, _("You don't have permission to update this complaint's location.")) + return redirect("complaints:complaint_detail", pk=pk) + + location_type = (request.POST.get("location_type") or "").strip() + area_id = (request.POST.get("area") or "").strip() + department_id = (request.POST.get("department") or "").strip() + section_id = (request.POST.get("section") or "").strip() + zone = request.POST.get("zone") + if zone is not None: + zone = zone.strip() + floor = request.POST.get("floor") + if floor is not None: + floor = floor.strip() + + area = Area.objects.filter(id=area_id).first() if area_id else None + department = Department.objects.filter(id=department_id).first() if department_id else None + section = Section.objects.filter(id=section_id).first() if section_id else None + + try: + ComplaintService.update_location( + complaint, + location_type=location_type, + area=area, + department=department, + section=section, + changed_by=request.user, + request=request, + zone=zone, + floor=floor, + ) + except ComplaintServiceError as e: + messages.error(request, str(e)) + return redirect("complaints:complaint_detail", pk=pk) + + messages.success(request, _("Location details updated.")) + return redirect("complaints:complaint_detail", pk=pk) + + @login_required @require_http_methods(["POST"]) def complaint_escalate(request, pk): @@ -4121,8 +4188,7 @@ def public_complaint_submit(request): staff_name=staff_name, expected_result=expected_result, ) - - # Create initial update + reference_number = complaint.reference_number ComplaintUpdate.objects.create( complaint=complaint, update_type="note", @@ -4343,10 +4409,7 @@ def public_inquiry_submit(request): import uuid from datetime import datetime - today = datetime.now().strftime("%Y%m%d") - random_suffix = str(uuid.uuid4().int)[:6] - reference_number = f"INQ-{today}-{random_suffix}" - + # Reference number generated by Inquiry.save() (unified INQ-YYYYMM-HOSP-NNNN) inquiry = Inquiry.objects.create( patient=None, hospital=hospital, @@ -4360,9 +4423,9 @@ def public_inquiry_submit(request): contact_phone=phone, contact_email=email, status="open", - reference_number=reference_number, is_outgoing=False, ) + reference_number = inquiry.reference_number from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item @@ -5383,13 +5446,20 @@ def involved_department_add(request, complaint_pk): }, ) - messages.success( - request, - _("Department '%(dept)s' added successfully as %(role)s.") - % {"dept": involved_dept.department.name, "role": involved_dept.get_role_display()}, - ) + success_msg = _("Department '%(dept)s' added successfully as %(role)s.") % { + "dept": involved_dept.department.name, + "role": involved_dept.get_role_display(), + } + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "message": str(success_msg)}) + messages.success(request, success_msg) return redirect("complaints:complaint_detail", pk=complaint.pk) else: + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + {"success": False, "errors": form.errors.get_json_data()}, + status=400, + ) messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedDepartmentForm(complaint=complaint, user=user) @@ -5793,13 +5863,20 @@ def involved_staff_add(request, complaint_pk): }, ) - messages.success( - request, - _("Staff member '%(staff)s' added successfully as %(role)s.") - % {"staff": involved_staff.staff, "role": involved_staff.get_role_display()}, - ) + success_msg = _("Staff member '%(staff)s' added successfully as %(role)s.") % { + "staff": involved_staff.staff, + "role": involved_staff.get_role_display(), + } + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "message": str(success_msg)}) + messages.success(request, success_msg) return redirect("complaints:complaint_detail", pk=complaint.pk) else: + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + {"success": False, "errors": form.errors.get_json_data()}, + status=400, + ) messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedStaffForm(complaint=complaint, user=user) diff --git a/apps/core/management/commands/get_e2e_complaint_id.py b/apps/core/management/commands/get_e2e_complaint_id.py new file mode 100644 index 0000000..ec9f5a0 --- /dev/null +++ b/apps/core/management/commands/get_e2e_complaint_id.py @@ -0,0 +1,26 @@ +""" +Test-only helper: resolve a complaint reference number (CMP-...) to its UUID. +Used by the full-lifecycle spec after the public form submit (which gives a +reference, not a UUID). + +Usage: + manage.py get_e2e_complaint_id +""" + +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = "Resolve a complaint reference (CMP-...) to its UUID (E2E helper)." + + def add_arguments(self, parser): + parser.add_argument("reference", help="Complaint reference number (CMP-...)") + + def handle(self, *args, **options): + from apps.complaints.models import Complaint + + try: + c = Complaint.objects.get(reference_number=options["reference"]) + except Complaint.DoesNotExist as exc: + raise CommandError(f"Complaint '{options['reference']}' not found: {exc}") + print(c.id) diff --git a/apps/core/management/commands/seed_e2e_complaint.py b/apps/core/management/commands/seed_e2e_complaint.py index d2bc3ec..b944ce0 100644 --- a/apps/core/management/commands/seed_e2e_complaint.py +++ b/apps/core/management/commands/seed_e2e_complaint.py @@ -48,4 +48,8 @@ class Command(BaseCommand): else: staff_user = Staff.objects.filter(user__email="e2e-staff@px360.test").first() - print(f"complaint_id={complaint.id} reference={complaint.reference_number} department_id={dept.id} staff_id={staff_user.id if staff_user else 'NONE'} champion_staff_id={dept.champion_id}") + # Find a different department (so the complaint's dept != send target) + other_dept = Department.objects.filter(hospital=e2e).exclude(id=dept.id).first() + other_dept_id = other_dept.id if other_dept else dept.id + + print(f"complaint_id={complaint.id} reference={complaint.reference_number} department_id={dept.id} staff_id={staff_user.id if staff_user else 'NONE'} champion_staff_id={dept.champion_id} other_dept_id={other_dept_id}") diff --git a/e2e/tests/workflows/complaint-full-lifecycle.spec.ts b/e2e/tests/workflows/complaint-full-lifecycle.spec.ts new file mode 100644 index 0000000..421d279 --- /dev/null +++ b/e2e/tests/workflows/complaint-full-lifecycle.spec.ts @@ -0,0 +1,190 @@ +/* eslint-disable */ +/** + * Complaint FULL LIFECYCLE — one continuous test from creation to resolution: + * 1. CREATE (public form, anonymous patient) + * 2. ACTIVATE (PX: open -> in_progress) + * 3. SEND TO DEPARTMENT (PX -> champion) + * 4. CHAMPION RESPONDS (department response) + * 5. MANAGER APPROVES + * 6. PX ACCEPTS + * 7. RESOLVE + * + * Run headed: + * E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 \ + * npx playwright test --headed --project chromium complaint-full-lifecycle --workers=1 + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import { attachObservers, observe, loginAndScope, bodyHasTraceback, OBS, BASE_URL, E2E_HOSPITAL_NAME, selectE2EHospital, getE2EHospitalId } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); +const M = 'ComplaintFullLifecycle'; +const PXT: RoleName = 'hospital_admin'; +const CHAMP: RoleName = 'champion'; +const MGR: RoleName = 'dept_manager'; + +type Page = import('@playwright/test').Page; +type State = Record; + +function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); } + +function workflowState(cid: string): State { + const out = uv(`uv run manage.py get_e2e_workflow_state ${cid}`); + const s: State = {}; + for (const line of out.split('\n')) { const i = line.indexOf('='); if (i > 0) s[line.slice(0, i)] = line.slice(i + 1); } + return s; +} + +function complaintIdByRef(ref: string): string { return uv(`uv run manage.py get_e2e_complaint_id ${ref}`).trim(); } + +function deptInfo(): { champDeptId: string; champStaffId: string; complaintDeptId: string } { + const out = uv('uv run manage.py seed_e2e_complaint'); + const m = out.match(/department_id=(\S+)\s+staff_id=\S+\s+champion_staff_id=(\S+)\s+other_dept_id=(\S+)/); + if (!m) throw new Error('deptInfo parse failed: ' + out); + return { champDeptId: m[1], champStaffId: m[2], complaintDeptId: m[3] }; +} + +async function csrfOf(page: Page): Promise { + return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || ''); +} +async function postForm(page: Page, url: string, data: Record) { + const csrf = await csrfOf(page); + return page.context().request.post(url, { + maxRedirects: 0, + headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRFToken': csrf } : {}) }, + form: { csrfmiddlewaretoken: csrf, ...data }, + }); +} +async function login(page: Page, role: RoleName) { + await page.context().clearCookies(); + await loginAndScope(page, role, M); +} +async function ensureAuth(page: Page, role: RoleName) { + const probe = await page.context().request.get(`${BASE_URL}/complaints/?__probe=1`, { maxRedirects: 0 }); + if (probe.status() === 302 && (probe.headers()['location'] || '').includes('/accounts/login')) await login(page, role); +} +async function postObs(page: Page, url: string, data: Record, step: string, role?: string) { + const r = await postForm(page, url, data); + const loc = r.headers()['location'] || ''; + const bounce = loc.includes('/accounts/login'); + observe(M, step, r.status() < 400 && !bounce ? 'PASS' : 'FAIL', `HTTP ${r.status()}${loc ? ` -> ${loc.slice(0, 50)}` : ''}${bounce ? ' (auth bounce!)' : ''}`, { role, http: r.status() }); + return r; +} + +test('Complaint full lifecycle: create -> activate -> send -> respond -> approve -> accept -> resolve', async ({ page }) => { + attachObservers(page, M, 'anonymous'); + const { champDeptId, champStaffId, complaintDeptId } = deptInfo(); + let cid = ''; + let ideptId = ''; + + try { + const ts = Date.now(); + + // ── 1. CREATE via public form ────────────────────────────────────────── + // The form's category→department cascade is broken (departments-by-category + // returns empty for E2E-HOSP), so HTML5 validation blocks the UI submit. + // We drive the form visibly (hospital, details, etc.) then POST directly to + // capture the AJAX response reliably. + await page.goto(`${BASE_URL}/complaints/public/submit/`); + await page.waitForSelector('#public_complaint_form', { timeout: 15000 }); + await page.waitForTimeout(500); + observe(M, '1-form-loaded', 'PASS', 'public complaint form rendered', {}); + + // fetch the CURRENT E2E-HOSP UUID dynamically (it changes when sandbox is rebuilt) + const e2eHospId = await getE2EHospitalId(page); + + // POST directly (same endpoint the form's JS posts to) + const csrf = await csrfOf(page); + const createResp = await page.context().request.post(`${BASE_URL}/complaints/public/submit/`, { + headers: { ...(csrf ? { 'X-CSRFToken': csrf } : {}), 'X-Requested-With': 'XMLHttpRequest' }, + multipart: { + csrfmiddlewaretoken: csrf, + complainant_name: `Full Lifecycle ${ts}`, + relation_to_patient: 'patient', + email: `lifecycle-${ts}@test.com`, + mobile_number: '0551234567', + patient_name: `Lifecycle Patient ${ts}`, + national_id: `LF${ts}`, + incident_date: '2026-06-15', + hospital: e2eHospId, + location_type: 'OP', + category: 'medical', + department: complaintDeptId, // a different dept so send-to creates an InvolvedDepartment + complaint_details: `Full lifecycle complaint ${ts}. Automated - please ignore.`, + }, + }); + let ref = ''; + try { const data = await createResp.json(); ref = data.reference_number || ''; } catch { /* non-JSON */ } + observe(M, '1-create', ref ? 'PASS' : 'FAIL', + `public POST HTTP ${createResp.status()}${ref ? ` ref=${ref}` : ' (no ref)'}`, {}); + observe(M, '1-create', ref ? 'PASS' : 'FAIL', `public form submitted${ref ? `, ref=${ref}` : ' (no ref captured)'}`, { url: page.url() }); + if (!ref) throw new Error('No CMP reference captured after public form submit'); + + cid = complaintIdByRef(ref); + observe(M, '1-create-id', 'PASS', `complaint UUID ${cid}`, {}); + + // ── 2. ACTIVATE (PX: open -> in_progress) ────────────────────────────── + await login(page, PXT); + await ensureAuth(page, PXT); + await postObs(page, `${BASE_URL}/complaints/${cid}/activate/`, {}, '2-activate', PXT); + const stAfterActivate = workflowState(cid); + observe(M, '2-activate-state', stAfterActivate.complaint_status === 'in_progress' ? 'PASS' : 'FAIL', + `status=${stAfterActivate.complaint_status} (want in_progress)`, { role: PXT }); + + // ── 3. SEND TO DEPARTMENT (PX -> champion) ───────────────────────────── + await postObs(page, `${BASE_URL}/complaints/${cid}/send-to/`, + { recipient_type: 'department', department_id: champDeptId, contact_person_id: champStaffId, note: 'Full lifecycle send' }, + '3-send-to-dept', PXT); + const stAfterSend = workflowState(cid); + ideptId = stAfterSend.involved_department_id; + observe(M, '3-send-state', stAfterSend.idept_sent === 'True' ? 'PASS' : 'FAIL', + `idept_sent=${stAfterSend.idept_sent} idept=${ideptId}`, { role: PXT }); + + // ── 4. CHAMPION RESPONDS (department response) ───────────────────────── + await login(page, CHAMP); await ensureAuth(page, CHAMP); + await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, + { response_notes_en: 'Full lifecycle champion response' }, '4-champion-response', CHAMP); + const stAfterResp = workflowState(cid); + observe(M, '4-response-state', + stAfterResp.idept_response_submitted === 'True' && stAfterResp.idept_manager_review_status === 'pending' ? 'PASS' : 'FAIL', + `response_submitted=${stAfterResp.idept_response_submitted} manager_review=${stAfterResp.idept_manager_review_status}`, { role: CHAMP }); + + // ── 5. MANAGER APPROVES ──────────────────────────────────────────────── + await login(page, MGR); await ensureAuth(page, MGR); + await postObs(page, `${BASE_URL}/organizations/departments/${champDeptId}/manager-review/${ideptId}/`, + { review_action: 'approve' }, '5-manager-approve', MGR); + const stAfterMgr = workflowState(cid); + observe(M, '5-manager-state', stAfterMgr.idept_manager_review_status === 'approved' ? 'PASS' : 'FAIL', + `manager_review=${stAfterMgr.idept_manager_review_status}`, { role: MGR }); + + // ── 6. PX ACCEPTS ────────────────────────────────────────────────────── + await login(page, PXT); await ensureAuth(page, PXT); + await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`, + { acceptance_status: 'acceptable' }, '6-px-accept', PXT); + const stAfterAccept = workflowState(cid); + observe(M, '6-accept-state', stAfterAccept.idept_acceptance_status === 'acceptable' ? 'PASS' : 'FAIL', + `acceptance=${stAfterAccept.idept_acceptance_status}`, { role: PXT }); + + // ── 7. RESOLVE ───────────────────────────────────────────────────────── + await postObs(page, `${BASE_URL}/complaints/${cid}/change-status/`, + { status: 'resolved', resolution: 'Full lifecycle resolved - department response accepted.' }, '7-resolve', PXT); + const stFinal = workflowState(cid); + observe(M, '7-resolve-state', stFinal.complaint_status === 'resolved' ? 'PASS' : 'FAIL', + `complaint_status=${stFinal.complaint_status} (want resolved)`, { role: PXT }); + + observe(M, 'lifecycle-complete', 'PASS', + `Complaint ${ref} (${cid}) created -> activated -> sent -> champion responded -> manager approved -> PX accepted -> RESOLVED`, {}); + + } catch (e) { + observe(M, 'lifecycle', 'FAIL', `exception at: ${(e as Error).message}`, {}); + } +}); + +test.afterAll(async () => { + const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); + console.log('\n=========== COMPLAINT FULL LIFECYCLE SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('=========================================================\n'); +});