diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index e5e71c6..6c2313b 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -106,6 +106,49 @@ 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).
+
+### 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/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=>`
`).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
-
Issuance Strategy
-
+ Issuance Strategyi
+
By Sector / Area
By Discipline
By Phase / Sequence
By Resource Availability
- 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
✕
-
+
+
+ Add
+
+ …or pick from the library:
+
Done
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/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())
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())