From e3ef3b00236aa2fa5803e8cb7dda2524103634bf Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Tue, 16 Jun 2026 11:04:46 -0700 Subject: [PATCH 1/2] Round-1 test feedback + smoke-test script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API smoke test (server/smoketest.py): stdlib end-to-end check of health, projects, SOPs, WPs, the AWP issue gate (409 → 200), status, metrics, comments, and cascade delete. Referenced from DEPLOYMENT.md. SOP config: - Constraints: fix custom constraints never appearing — renderStandardConstraints no longer clobbers state.constraints; customs render in their own list with remove buttons; modal gains a free-text "Add" field. - Sources: add column headers (Data Type / Location-Platform / URL / Notes); preset data types are now fixed labels, "Add Source" creates an editable custom row. - Issuance strategy: add a tooltip + worked examples for each option. - Remove the "Comment submitted" acknowledgement popup (home + suite); keep the commenter name between comments. WP creator: - Clearing the last open constraint now offers to mark the package Issued and scrolls to the status control. - Form sections are collapsible (click a section heading to fold it). Co-Authored-By: Claude Opus 4.8 (1M context) --- DEPLOYMENT.md | 21 ++++ html/index.html | 2 - html/work-package-suite-app.js | 78 ++++++++++--- html/work-package-suite.html | 22 +++- html/wp-creation-app.js | 33 +++++- html/wp-creation-styles.css | 5 + server/smoketest.py | 196 +++++++++++++++++++++++++++++++++ 7 files changed, 334 insertions(+), 23 deletions(-) create mode 100644 server/smoketest.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e5e71c6..ba9958f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -106,6 +106,27 @@ create a project**. Create one, complete an SOP, and confirm a row appears: docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;" ``` +### Automated smoke test + +`server/smoketest.py` exercises the whole stack end-to-end (health → project → +SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade +cleanup). Stdlib only — no pip/jq. + +```bash +# Through the proxy (use --insecure for a self-signed internal cert): +python3 server/smoketest.py https://wp-suite.company.local --insecure + +# Or from inside the api container (hits FastAPI directly): +docker compose exec api python /app/server/smoketest.py http://localhost:8000 + +# Add --keep to leave a demo project in the DB so you can open it in the UI. +``` + +Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all +working. It cleans up after itself (the test project and its SOP/WPs are +deleted via cascade); a single tagged test comment remains (there's no comment +delete endpoint). + --- ## What is stored in SQL today diff --git a/html/index.html b/html/index.html index d258f44..d3da0bf 100644 --- a/html/index.html +++ b/html/index.html @@ -664,9 +664,7 @@ if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment }); document.getElementById('comment-text').value = ''; - document.getElementById('commenter-name').value = ''; loadComments(); - alert('Thank you! Feedback submitted.'); } function exportFeedback() { diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index bc64b85..69cf65a 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -477,18 +477,45 @@ function removeRole(i){ renderOptionalRoles(); } +// Seed the standard 10 once; after that, render reflects state.constraints +// (checkbox = whether each standard one is active) and never clobbers customs. +let _constraintsSeeded = false; function renderStandardConstraints(){ const container = document.getElementById('standard-constraints'); + if(!_constraintsSeeded){ + if(!state.constraints || !state.constraints.length){ + state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c})); + } + _constraintsSeeded = true; + } + const active = name => state.constraints.some(c=>c.name===name); container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
- +
${c.description}
`).join(''); - state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c})); + renderCustomConstraints(); +} + +// Render the custom (non-standard) constraints into their own list with remove buttons. +function renderCustomConstraints(){ + const el = document.getElementById('custom-constraints-list'); if(!el) return; + const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name); + const customs = state.constraints.filter(c=>!stdNames.includes(c.name)); + el.innerHTML = customs.length ? customs.map(c=>` +
+ ${escAttr(c.name)} + +
`).join('') : `
No custom constraints added yet.
`; +} + +function removeCustomConstraint(name){ + state.constraints = state.constraints.filter(c=>c.name!==name); + renderCustomConstraints(); } function toggleConstraint(name){ @@ -514,13 +541,24 @@ function closeConstraintModal(){ } function addCustomConstraint(name){ - if(!state.constraints.find(c=>c.name===name)){ + if(name && !state.constraints.find(c=>c.name===name)){ state.constraints.push({name,description:''}); } closeConstraintModal(); renderStandardConstraints(); } +// Free-text custom constraint from the modal's input. +function addCustomConstraintText(){ + const inp = document.getElementById('custom-constraint-input'); + const name = (inp && inp.value || '').trim(); + if(!name){ if(inp) inp.focus(); return; } + if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; } + state.constraints.push({name, description:''}); + if(inp) inp.value=''; + renderStandardConstraints(); +} + const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning']; let seqDragIndex = null; @@ -600,20 +638,30 @@ const DEFAULT_SOURCES = [ function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function renderSources(){ const container = document.getElementById('sources-list'); - if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph})); - container.innerHTML = state.sources.map((s,i)=>` -
- - - - - -
- `).join(''); + if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true})); + const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;"; + const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"; + const header = `
+
Data Type
Location / Platform
URL
Notes
+
`; + container.innerHTML = header + state.sources.map((s,i)=>{ + // Preset data types are fixed labels; custom rows (Add Source) get an editable name. + const dataType = s.preset + ? `
${escAttr(s.label)}
` + : ``; + return `
+ ${dataType} + + + + +
`; + }).join(''); } function addSource(){ - state.sources.push({label:'',system:'',notes:'',link:''}); + // Added rows are custom — the user types their own data type here. + state.sources.push({label:'', system:'', notes:'', link:'', preset:false}); renderSources(); } @@ -823,9 +871,7 @@ function submitComment(){ if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment}); document.getElementById('comment-text').value = ''; - document.getElementById('commenter-name').value = ''; loadStepComments(); - alert('✓ Comment submitted!'); } function exportComments(){ diff --git a/html/work-package-suite.html b/html/work-package-suite.html index 32796df..aa1c214 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -172,14 +172,23 @@ Use ## for counter, [Sector] [TYPE] as variables
- - - Hold Ctrl to select multiple + Hold Ctrl (Cmd on Mac) to select multiple. +
+ Examples: +
    +
  • By Sector / Area — one package per physical area, e.g. all work in Sector 1P, Level 2 chase.
  • +
  • By Discipline — separate packages per trade, e.g. Electrical wire-pull vs Mechanical install.
  • +
  • By Phase / Sequence — follow the build order, e.g. rough-in → wire pull → terminations.
  • +
  • By Resource Availability — size to a crew/equipment window, e.g. one boom-lift crew's week.
  • +
+
@@ -374,7 +383,12 @@

Add Custom Constraint

-
+
+ + +
+
…or pick from the library:
+
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index f98b895..6367548 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -529,6 +529,20 @@ function setConstraint(i,val){ if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){ prevStatus=getRadio('status'); holdContext={index:i, before}; openHoldModal(pkgConstraints[i].name, true); + return; + } + // Clearing the LAST open constraint makes the package release-ready — offer to + // issue it and scroll up to the status control so the change is visible. + if(before==='open' && val!=='open' && readiness().open===0){ + const st=getRadio('status'); + if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){ + if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){ + setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); + track('status_change',{status:'Issued',via:'constraint_clear'}); + } + const sg=document.getElementById('status-group'); + if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'}); + } } } function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; } @@ -771,7 +785,24 @@ function setFormChrome(on){ if(nav) nav.style.display = on ? '' : 'none'; if(save) save.style.display = on ? 'flex' : 'none'; document.body.classList.toggle('has-sticky-save', !!on); - if(on){ buildSectionNav(); updateStickyStatus(); } + if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); } +} +// Make each form card collapsible by clicking its heading (idempotent). +function makeCollapsible(){ + document.querySelectorAll('.main > .card').forEach(card=>{ + if(card.id==='saved-card') return; + const head=card.querySelector('.section-header, .sub-heading'); + if(!head || head.dataset.collapsible) return; + head.dataset.collapsible='1'; + head.style.cursor='pointer'; + const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾'; + head.insertBefore(chev, head.firstChild); + head.addEventListener('click', e=>{ + if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return; + const collapsed=card.classList.toggle('collapsed'); + chev.textContent = collapsed ? '▸' : '▾'; + }); + }); } function buildSectionNav(){ const nav=document.getElementById('section-nav'); if(!nav) return; diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index 9c84d05..9c5b475 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -564,6 +564,11 @@ .so-date { font-size:13px; font-variant-numeric:tabular-nums; } .so-ovr { margin-left:8px; font-size:11px; } + /* Collapsible form sections */ + .collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; } + .card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; } + .card.collapsed .section-desc { display:none; } + /* Section nav (jump chips) */ .section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px; padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px); diff --git a/server/smoketest.py b/server/smoketest.py new file mode 100644 index 0000000..c4eb2cb --- /dev/null +++ b/server/smoketest.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test for the Work Package Suite API + PostgreSQL. + +Exercises the real HTTP endpoints the way the front end does, proving that +NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP +release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq. + +USAGE + # Against the deployed site (through the NGINX proxy): + python3 server/smoketest.py https://wp-suite.company.local + + # Self-signed / internal TLS cert? skip verification: + python3 server/smoketest.py https://wp-suite.company.local --insecure + + # From inside the api container (hits FastAPI directly): + docker compose exec api python /app/server/smoketest.py http://localhost:8000 + + # Leave the demo project in the database so you can open it in the UI: + python3 server/smoketest.py https://wp-suite.company.local --keep + +The base URL is the SITE root (no /api). Default: http://localhost:8000 +Exit code 0 = all checks passed, 1 = one or more failed. +""" +import argparse +import json +import ssl +import sys +import urllib.error +import urllib.request + +# ── tiny colored reporter ───────────────────────────────────────────────────── +_PASS, _FAIL = [], [] +def _c(s, code): # color if a TTY + return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s +def ok(msg): _PASS.append(msg); print(" " + _c("PASS", "32") + " " + msg) +def bad(msg): _FAIL.append(msg); print(" " + _c("FAIL", "31") + " " + msg) +def check(name, cond, detail=""): + (ok if cond else bad)(name + (f" ({detail})" if detail and not cond else "")) + return cond + +BASE = "" +CTX = None + +def call(method, path, body=None): + """Returns (status_code, parsed_body). Never raises on HTTP status.""" + url = BASE + path + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + url, data=data, method=method, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(req, context=CTX, timeout=20) as r: + raw = r.read().decode(); status = r.status + except urllib.error.HTTPError as e: + raw = e.read().decode(); status = e.code + try: + parsed = json.loads(raw) if raw else None + except ValueError: + parsed = raw + return status, parsed + + +def main(): + global BASE, CTX + ap = argparse.ArgumentParser(description="Work Package Suite API smoke test") + ap.add_argument("base_url", nargs="?", default="http://localhost:8000", + help="Site root, no /api (default: http://localhost:8000)") + ap.add_argument("--insecure", action="store_true", help="skip TLS verification") + ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)") + args = ap.parse_args() + BASE = args.base_url.rstrip("/") + if args.insecure: + CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE + + print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n") + + project_id = None + try: + # 1) Health — API is up and reachable through the proxy. + try: + st, body = call("GET", "/api/health") + except urllib.error.URLError as e: + print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n" + " Is the stack up (docker compose ps) and the URL correct?\n") + return 1 + check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True, + f"status={st} body={body}") + + # 2) Create a project (writes to the projects table). + st, proj = call("POST", "/api/projects", { + "name": "ZZ Smoke Test Project", "number": "SMOKE-001", + "client": "Internal QA", "division": "Controls", "site": "Test Host", + "created_by": "smoketest", + }) + project_id = proj.get("id") if isinstance(proj, dict) else None + check("create project", st == 200 and bool(project_id), f"status={st}") + + # 3) Read it back + confirm it's in the list (SQL round-trip). + st, got = call("GET", f"/api/projects/{project_id}") + check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}") + st, lst = call("GET", "/api/projects") + check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst), + f"status={st} count={len(lst) if isinstance(lst, list) else '?'}") + + # 4) Create a SOP linked to the project. + st, sop = call("POST", "/api/sops", { + "project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001", + "complete": True, "created_by": "smoketest", + "data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]", + "disciplines": ["Mechanical", "Electrical", "Tech"]}}, + }) + sop_id = sop.get("id") if isinstance(sop, dict) else None + check("create SOP linked to project", st == 200 and bool(sop_id) and sop.get("project_id") == project_id, + f"status={st}") + st, latest = call("GET", f"/api/sops/latest?project_id={project_id}") + check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}") + + # 5) Create a Work Package with one OPEN constraint (not release-ready). + st, wp = call("POST", "/api/wps", { + "project_id": project_id, "sop_id": sop_id, + "number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install", + "status": "Scheduled", "created_by": "smoketest", + "data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "", + "constraints": [{"name": "Materials", "status": "open", "comment": "awaiting delivery"}, + {"name": "Safety & Permitting", "status": "cleared", "comment": ""}]}, + }) + wp_id = wp.get("id") if isinstance(wp, dict) else None + check("create work package", st == 200 and bool(wp_id), f"status={st}") + + # 6) The AWP release gate: issuing with an open constraint must be REFUSED (409). + st, refused = call("POST", f"/api/wps/{wp_id}/issue") + check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}") + + # 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued). + call("POST", "/api/wps", { + "id": wp_id, "project_id": project_id, "sop_id": sop_id, + "number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install", + "status": "Scheduled", + "data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "", + "constraints": [{"name": "Materials", "status": "cleared", "comment": ""}, + {"name": "Safety & Permitting", "status": "cleared", "comment": ""}]}, + }) + st, issued = call("POST", f"/api/wps/{wp_id}/issue") + check("issue succeeds once constraints clear", st == 200 and issued.get("status") == "Issued", + f"status={st}") + check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at"))) + + # 8) Status transition endpoint. + st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"}) + check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}") + + # 9) Metrics aggregate for the project (Python aggregation over SQL rows). + st, m = call("GET", f"/api/wps/metrics?project_id={project_id}") + check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1, + f"status={st} metrics={m}") + + # 10) Comment / feedback write + read. + st, c = call("POST", "/api/feedback", { + "type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id, + "text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"}) + check("post comment/feedback", st == 200 and isinstance(c, dict) and bool(c.get("id")), f"status={st}") + st, comments = call("GET", f"/api/comments?wp_id={wp_id}") + check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments), + f"status={st}") + + # 11) WPs filter by project. + st, wps = call("GET", f"/api/wps?project_id={project_id}") + check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}") + + finally: + # 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE). + if project_id and not args.keep: + st, _ = call("DELETE", f"/api/projects/{project_id}") + check("delete project (cascades SOP + WPs)", st == 200, f"status={st}") + st, after = call("GET", f"/api/wps?project_id={project_id}") + check("WPs removed by cascade", st == 200 and isinstance(after, list) and len(after) == 0, + f"status={st} remaining={after}") + elif project_id and args.keep: + print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.") + + # ── summary ──────────────────────────────────────────────────────────────── + total = len(_PASS) + len(_FAIL) + print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.") + if _FAIL: + print(_c(f"FAILED ({len(_FAIL)}):", "31")) + for f in _FAIL: + print(" - " + f) + print("\nResult: " + _c("FAIL", "31") + "\n") + return 1 + print("\nResult: " + _c("ALL PASS — API, Python logic, and SQL are working.", "32") + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 2bdb65e5801ada226c9dadc49a2be5c9e6d5c68d Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Tue, 16 Jun 2026 11:28:11 -0700 Subject: [PATCH 2/2] Add seed_demo.py loadable demo project + document both test scripts server/seed_demo.py seeds a realistic DEMO project (complete SOP + a spread of Work Packages: issued, gated, multi-discipline master with split instances, overdue, over-threshold draft) via the API. --clean removes it. DEPLOYMENT.md documents both smoketest.py and seed_demo.py, including the localStorage caveat (seeded project shows in the UI picker; seeded SOP/WPs are SQL-only until Phase 2). Co-Authored-By: Claude Opus 4.8 (1M context) --- DEPLOYMENT.md | 22 ++++++ server/seed_demo.py | 175 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 server/seed_demo.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index ba9958f..6c2313b 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -127,6 +127,28 @@ working. It cleans up after itself (the test project and its SOP/WPs are deleted via cascade); a single tagged test comment remains (there's no comment delete endpoint). +### Loadable demo project + +`server/seed_demo.py` populates a realistic **DEMO** project (a complete SOP plus +a spread of Work Packages: issued, gated, a multi-discipline master with split +instances, an overdue one, an over-threshold draft) so there's data to look at. + +```bash +python3 server/seed_demo.py https://wp-suite.company.local --insecure +python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later +``` + +> **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in +> the home-page project picker right away (this is the visible proof that the +> projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are +> written to SQL too, but the current front end still reads SOPs/WPs from the +> browser, so they won't render in the Creator/Dashboard until the Phase 2 +> wiring. Inspect them at the SQL layer with `smoketest.py` or: +> ```bash +> docker compose exec db psql -U wpsuite -d wpsuite \ +> -c "select number, subject, status from work_packages order by number;" +> ``` + --- ## What is stored in SQL today diff --git a/server/seed_demo.py b/server/seed_demo.py new file mode 100644 index 0000000..82ca6e2 --- /dev/null +++ b/server/seed_demo.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Seed a realistic DEMO project into the Work Package Suite database via the API. + +Creates one project, a complete SOP, and a spread of Work Packages that exercise +the features and dashboard: an issued package, a gated (open-constraint) package, +a multi-discipline master with its split instances (A/B/C), an overdue package, +and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end +and to have data to inspect. + +USAGE + python3 server/seed_demo.py https://wp-suite.company.local --insecure + docker compose exec api python /app/server/seed_demo.py http://localhost:8000 + python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects + +IMPORTANT — what shows where: + * The DEMO **project** is API/SQL-backed, so it appears in the home-page + project picker immediately (proves the projects → SQL path in the UI). + * The DEMO **SOP and Work Packages** are written to SQL too, but the current + front end still reads SOPs/WPs from the browser (localStorage), so they will + NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2 + wiring. Verify them at the SQL/API layer instead: + python3 server/smoketest.py # automated end-to-end check + docker compose exec db psql -U wpsuite -d wpsuite \ + -c "select number,subject,status from work_packages order by number;" +""" +import argparse +import json +import ssl +import sys +import urllib.error +import urllib.request + +BASE = "" +CTX = None +DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data + + +def call(method, path, body=None): + url = BASE + path + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, + headers={"Content-Type": "application/json", "Accept": "application/json"}) + try: + with urllib.request.urlopen(req, context=CTX, timeout=20) as r: + raw = r.read().decode(); status = r.status + except urllib.error.HTTPError as e: + raw = e.read().decode(); status = e.code + try: + parsed = json.loads(raw) if raw else None + except ValueError: + parsed = raw + return status, parsed + + +def constraints(open_names=()): + base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs", + "Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"] + return [{"name": n, "status": ("open" if n in open_names else "cleared"), + "comment": ("awaiting delivery" if n in open_names else "")} for n in base] + + +def main(): + global BASE, CTX + ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite") + ap.add_argument("base_url", nargs="?", default="http://localhost:8000", + help="Site root, no /api (default: http://localhost:8000)") + ap.add_argument("--insecure", action="store_true", help="skip TLS verification") + ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit") + args = ap.parse_args() + BASE = args.base_url.rstrip("/") + if args.insecure: + CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE + + # health gate + try: + st, _ = call("GET", "/api/health") + except urllib.error.URLError as e: + print(f"ABORT: cannot reach {BASE}/api/health — {e}"); return 1 + if st != 200: + print(f"ABORT: /api/health returned {st}"); return 1 + + # --clean: remove any prior demo projects (cascade removes their SOP + WPs) + st, projects = call("GET", "/api/projects") + demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")] + if args.clean: + for p in demos: + call("DELETE", f"/api/projects/{p['id']}") + print(f"Removed {len(demos)} DEMO project(s).") + return 0 + if demos: + print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n") + + # 1) Project + st, proj = call("POST", "/api/projects", { + "name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER, + "client": "Micron Technology, Inc.", "division": "Semiconductor", + "site": "Boise, ID — Fab", "created_by": "seed_demo"}) + pid = proj["id"] + print(f"Project: {proj['name']} ({pid})") + + # 2) SOP (complete) + st, sop = call("POST", "/api/sops", { + "project_id": pid, "name": "DEMO SOP", "number": DEMO_NUMBER, "complete": True, + "created_by": "seed_demo", + "data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]", + "disciplines": ["Mechanical", "Electrical", "Tech"], + "discMode": "choice", "instanceSuffix": "letter", + "woSize": "Standard — 3–5 days (≈40–80 hrs)", "sizeHoursMax": "80"}}}) + sid = sop["id"] + print(f"SOP: complete ({sid})") + + # 3) Work packages + def wp(number, subject, typ, status, data, parent_id=None): + body = {"project_id": pid, "sop_id": sid, "number": number, "subject": subject, + "type": typ, "status": status, "created_by": "seed_demo", "data": data} + if parent_id: + body["parent_id"] = parent_id + st, w = call("POST", "/api/wps", body) + print(f" WP {number:<16} {status:<12} {subject}") + return w + + # a) issued, all clear + wp("WP01-1P-CONDUIT", "1P horn/strobe conduit", "Conduit Install", "Issued", + {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "", + "constraints": constraints(), "due": "2026-06-30"}) + # b) gated — one open constraint, still Scheduled + wp("WP02-1P-WIRE", "1P wire pull", "Wire Pull", "Scheduled", + {"disciplines": ["Electrical"], "hours": "60", "actualHrs": "", + "constraints": constraints(open_names=["Materials (on site, bagged & tagged)"]), "due": "2026-07-04"}) + # c) multi-discipline master + split instances (master excluded from metrics) + master_id = "wp_demo_master_chiller" + instances = [("WP03-CHILLER_Mech", "Mechanical", "A", "Mechanical Install", "In Progress"), + ("WP03-CHILLER_Elec", "Electrical", "B", "Wire Pull", "Scheduled"), + ("WP03-CHILLER_Tech", "Tech", "C", "Terminations", "Draft")] + child_ids = [] + for num, disc, label, typ, status in instances: + cid = f"wp_demo_{label.lower()}" + child_ids.append(cid) + body = {"project_id": pid, "sop_id": sid, "parent_id": master_id, "id": cid, + "number": num, "subject": "Chiller skid — " + disc, "type": typ, "status": status, + "created_by": "seed_demo", + "data": {"disciplines": [disc], "instanceOf": master_id, "instanceLabel": label, + "parentNumber": "WP03-CHILLER", "hours": "50", "actualHrs": "", + "constraints": constraints(), "due": "2026-07-10"}} + call("POST", "/api/wps", body) + print(f" WP {num:<16} {status:<12} (instance {label})") + wp("WP03-CHILLER", "Chiller skid (multi-discipline master)", "Mechanical Install", "Scheduled", + {"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True, "children": child_ids, + "hours": "150", "constraints": constraints(), "due": "2026-07-10"}) + call("POST", "/api/wps", {"project_id": pid, "sop_id": sid, "id": master_id, + "number": "WP03-CHILLER", "subject": "Chiller skid (multi-discipline master)", + "type": "Mechanical Install", "status": "Scheduled", "created_by": "seed_demo", + "data": {"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True, + "children": child_ids, "hours": "150", "constraints": constraints(), + "due": "2026-07-10"}}) + # d) overdue, in progress + wp("WP04-2P-TERM", "2P terminations", "Terminations", "In Progress", + {"disciplines": ["Tech"], "hours": "30", "actualHrs": "20", + "constraints": constraints(), "due": "2026-06-10"}) # past today (2026-06-16) → overdue + # e) over-threshold draft (hours > 80) + wp("WP05-3P-PANEL", "3P panel install", "Panel Install", "Draft", + {"disciplines": ["Electrical"], "hours": "120", "actualHrs": "", + "constraints": constraints(open_names=["Schedule"]), "due": "2026-07-20"}) + + # metrics readback + st, m = call("GET", f"/api/wps/metrics?project_id={pid}") + print(f"\nMetrics (masters excluded): {m}") + print(f"\nDone. The DEMO project '{proj['name']}' now appears in the home-page picker.") + print("SOP/WPs are in SQL (see header note) — verify with smoketest.py or psql.") + print("Remove later with: python3 server/seed_demo.py --clean") + return 0 + + +if __name__ == "__main__": + sys.exit(main())