diff --git a/apps/surveys/models.py b/apps/surveys/models.py index eacc7da..d09077f 100644 --- a/apps/surveys/models.py +++ b/apps/surveys/models.py @@ -393,10 +393,14 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel): return None def save(self, *args, **kwargs): - """Generate access token on creation""" + """Generate access token on creation + auto-set hospital from template""" if not self.access_token: self.access_token = secrets.token_urlsafe(32) + # Auto-set hospital from the survey template if not provided + if not self.hospital_id and self.survey_template_id: + self.hospital = self.survey_template.hospital + # Set token expiration if not self.token_expires_at: from datetime import timedelta diff --git a/e2e/tests/workflows/deep-workflow-v3.spec.ts b/e2e/tests/workflows/deep-workflow-v3.spec.ts new file mode 100644 index 0000000..fba1fcf --- /dev/null +++ b/e2e/tests/workflows/deep-workflow-v3.spec.ts @@ -0,0 +1,283 @@ +/* eslint-disable */ +/** + * DEEP WORKFLOW V3 — remaining module workflows: + * Survey public form (token), PX Source user CRUD, Callcenter complaint create, + * Adverse actions, SLA config, Escalation rules, Thresholds, + * Journey templates, Reference folders. + * + * Run headed: + * E2E_MAXIMIZED=1 npx playwright test --headed --project chromium deep-workflow-v3 --workers=1 + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { attachObservers, observe, loginAndScope, OBS, BASE_URL, getE2EHospitalId } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); +const M = 'DeepWorkflowV3'; +const ADMIN: RoleName = 'hospital_admin'; + +type Page = import('@playwright/test').Page; + +async function login(page: Page, role: RoleName = ADMIN) { await page.context().clearCookies(); await loginAndScope(page, role, M); } +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 }, + }); +} +function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); } +function shellPy(code: string): string { + const tmpFile = `/tmp/e2e_v3_${Date.now()}_${Math.random().toString(36).slice(2,6)}.py`; + fs.writeFileSync(tmpFile, code); + const out = uv(`uv run manage.py shell < ${tmpFile}`); + try { fs.unlinkSync(tmpFile); } catch {} + return out; +} + +test.describe('Deep Workflow V3', () => { + test.describe.configure({ mode: 'serial' }); + + // ── 1. Survey instance + public form (token) ──────────────────────────── + test('Survey: instance + public form token flow', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + // create template + instance via ORM + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.surveys.models import SurveyTemplate, SurveyQuestion, SurveyInstance +from apps.organizations.models import Hospital +h = Hospital.objects.get(id='${hospId}') +t = SurveyTemplate.objects.filter(hospital=h, is_active=True).first() +if not t: + t = SurveyTemplate.objects.create(name='E2E V3 Survey', hospital=h, survey_type='general', scoring_method='average', negative_threshold=3.0, is_active=True) + SurveyQuestion.objects.create(survey_template=t, text='How was your experience?', question_type='rating', order=1, is_required=True, is_base=True) +i = SurveyInstance.objects.create(survey_template=t, status='sent') +print(f'TOKEN={i.access_token}') +`); + const m = out.match(/TOKEN=(\S+)/); + if (!m) { observe(M, 'survey-token', 'FAIL', 'no token', {}); return; } + const token = m[1]; + observe(M, 'survey-token', 'PASS', `token=${token.slice(0,8)}...`, {}); + + // visit the public survey form (anonymous, token-based) + await page.context().clearCookies(); + await page.goto(`${BASE_URL}/surveys/s/${token}/`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(1000); + const body = (await page.textContent('body')) || ''; + const renders = !page.url().includes('invalid') && body.length > 200; + observe(M, 'survey-public', renders ? 'PASS' : 'WARN', `public survey form: ${renders ? 'renders' : 'failed'} (${body.length} chars)`, { url: page.url() }); + + // try submitting (might need question answers) + if (renders) { + // find any rating/answer inputs and submit + const forms = page.locator('form#survey-form, form').first(); + if (await forms.count()) { + // fill any visible input/select with a valid value + const inputs = page.locator('input[type="radio"], input[type="number"], select'); + const cnt = await inputs.count(); + for (let i = 0; i < cnt; i++) { + const inp = inputs.nth(i); + const type = await inp.getAttribute('type'); + if (type === 'radio') { await inp.check({ force: true }).catch(() => {}); } + else if (type === 'number') { await inp.fill('4').catch(() => {}); } + } + await page.locator('button[type="submit"], #submitBtn').first().click({ force: true }).catch(() => {}); + await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); + await page.waitForTimeout(1500); + observe(M, 'survey-submit', page.url().includes('thank') || page.url().includes('complete') ? 'PASS' : 'WARN', + `survey submitted, url=${page.url().slice(-50)}`, { url: page.url() }); + } + } + } catch (e) { observe(M, 'survey-flow', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 2. PX Source user: create complaint ───────────────────────────────── + test('PX Source user: create complaint + inquiry', async ({ page }) => { + attachObservers(page, M, 'source_user'); + await login(page, 'source_user'); + try { + // complaint create page + await page.goto(`${BASE_URL}/px-sources/complaints/new/`); + await page.waitForLoadState('domcontentloaded'); + const cRenders = !page.url().includes('login'); + observe(M, 'source-complaint-page', cRenders ? 'PASS' : 'WARN', `source user complaint form: ${cRenders}`, { url: page.url() }); + + // inquiry create page + await page.goto(`${BASE_URL}/px-sources/inquiries/new/`); + await page.waitForLoadState('domcontentloaded'); + const iRenders = !page.url().includes('login'); + observe(M, 'source-inquiry-page', iRenders ? 'PASS' : 'WARN', `source user inquiry form: ${iRenders}`, { url: page.url() }); + } catch (e) { observe(M, 'source-flow', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 3. Callcenter complaint create ────────────────────────────────────── + test('Callcenter: complaint create page', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + await page.goto(`${BASE_URL}/callcenter/complaints/create/`); + await page.waitForLoadState('domcontentloaded'); + const renders = !page.url().includes('login') && (await page.textContent('body') || '').length > 200; + observe(M, 'callcenter-create', renders ? 'PASS' : 'WARN', `callcenter complaint form: ${renders}`, { url: page.url() }); + } catch (e) { observe(M, 'callcenter', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 4. Adverse action create (linked to a complaint) ──────────────────── + test('Adverse action: create on a complaint', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import Complaint +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +c = Complaint.objects.filter(hospital=h).first() +print(f'CID={c.id if c else "NONE"}') +`); + const m = out.match(/CID=(\S+)/); + if (!m || m[1] === 'NONE') { observe(M, 'adverse', 'WARN', 'no complaint for adverse action', {}); return; } + const cid = m[1]; + const r = await postForm(page, `${BASE_URL}/complaints/${cid}/adverse-actions/add/`, { + action_type: 'medication_error', description: 'E2E adverse action test', + severity: 'medium', action_taken: 'Reported to supervisor', + }); + observe(M, 'adverse-create', r.status() < 400 ? 'PASS' : 'WARN', `adverse action HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'adverse', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 5. SLA config + escalation rule + threshold create ────────────────── + test('Admin config: SLA + escalation + threshold', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + // SLA config create (px_admin only — hospital_admin may get 403) + const sl = await postForm(page, `${BASE_URL}/complaints/settings/sla-management/new/`, { + hospital: hospId, category: 'medical', severity: 'medium', sla_hours: '48', + }); + observe(M, 'sla-config', sl.status() < 400 ? 'PASS' : 'WARN', `SLA config HTTP ${sl.status()}`, { http: sl.status() }); + + // escalation rule create + const er = await postForm(page, `${BASE_URL}/complaints/settings/escalation-rules/new/`, { + hospital: hospId, priority: 'medium', escalation_hours: '24', escalation_level: '1', + }); + observe(M, 'escalation-rule', er.status() < 400 ? 'PASS' : 'WARN', `escalation rule HTTP ${er.status()}`, { http: er.status() }); + + // threshold create + const th = await postForm(page, `${BASE_URL}/complaints/settings/thresholds/new/`, { + hospital: hospId, category: 'medical', warning_threshold: '5', critical_threshold: '10', + }); + observe(M, 'threshold', th.status() < 400 ? 'PASS' : 'WARN', `threshold HTTP ${th.status()}`, { http: th.status() }); + } catch (e) { observe(M, 'config-flow', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 6. Journey template create ────────────────────────────────────────── + test('Journey: template list + create page', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + // Journey uses DRF — list + create via API + const csrf = await csrfOf(page); + const r = await page.context().request.post(`${BASE_URL}/journeys/templates/`, { + headers: { 'Content-Type': 'application/json', ...(csrf ? { 'X-CSRFToken': csrf } : {}) }, + data: { name: `E2E Journey ${Date.now()}`, description: 'E2E test journey' }, + }); + observe(M, 'journey-create', r.status() < 400 ? 'PASS' : 'WARN', `journey template HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'journey', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 7. Reference folder create ────────────────────────────────────────── + test('References: folder + document create', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + const r = await postForm(page, `${BASE_URL}/references/folders/new/`, { + name: `E2E Folder ${Date.now()}`, hospital: hospId, + }); + observe(M, 'ref-folder', r.status() < 400 ? 'PASS' : 'WARN', `folder HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'ref', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 8. Complaint template use ─────────────────────────────────────────── + test('Complaint template: list + detail', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + // list templates + await page.goto(`${BASE_URL}/complaints/templates/`); + await page.waitForLoadState('domcontentloaded'); + observe(M, 'tpl-list', 'PASS', 'template list loads', { url: page.url() }); + + // get first template id + visit detail + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import ComplaintTemplate +t = ComplaintTemplate.objects.first() +print(f'TID={t.id if t else "NONE"}') +`); + const m = out.match(/TID=(\S+)/); + if (m && m[1] !== 'NONE') { + await page.goto(`${BASE_URL}/complaints/templates/${m[1]}/`); + await page.waitForLoadState('domcontentloaded'); + observe(M, 'tpl-detail', 'PASS', `template detail loads`, { url: page.url() }); + } else { + observe(M, 'tpl-detail', 'WARN', 'no templates exist', {}); + } + } catch (e) { observe(M, 'tpl', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 9. Physician detail ───────────────────────────────────────────────── + test('Physician: detail page', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.organizations.models import Staff +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +s = Staff.objects.filter(hospital=h, staff_type='physician').first() +if not s: + s = Staff.objects.filter(hospital=h).first() +print(f'PID={s.id if s else "NONE"}') +`); + const m = out.match(/PID=(\S+)/); + if (m && m[1] !== 'NONE') { + await page.goto(`${BASE_URL}/physicians/${m[1]}/`); + await page.waitForLoadState('domcontentloaded'); + const renders = (await page.textContent('body') || '').length > 200; + observe(M, 'phys-detail', renders ? 'PASS' : 'WARN', `physician detail: ${renders}`, { url: page.url() }); + } else { + observe(M, 'phys-detail', 'WARN', 'no physicians in E2E-HOSP', {}); + } + } catch (e) { observe(M, 'phys', 'FAIL', `exception: ${(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=========== DEEP WORKFLOW V3 SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('=================================================\n'); +});