#!/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. Every /api/ route except /api/health requires a session, so this signs in first and keeps the session cookie for the rest of the run — the same way server/smoketest.py does, reusing its opener rather than growing a second implementation of it. Credentials come from the environment so the password never has to appear in a command line or shell history: export WP_SEED_USER= # or WP_SMOKE_USER, which is reused export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD …or pass --user / --password. Use an admin account: seeding creates a project, and --clean deletes one, which needs Project Admin on it. 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 WHAT SHOWS WHERE Everything it writes is API/SQL-backed and renders in the UI: the project appears in the home-page picker, and selecting it shows its Work Packages in the Field View. Verified at T1.6 — 7 cards from a fresh seed. (This block used to warn that SOPs and Work Packages would NOT render because the front end still read them from localStorage, pending "Phase 2 wiring". That stopped being true when the sync layer landed, and the warning outlived it. If you are checking whether seeding worked, the UI is now a fair test.) To check 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 os import ssl import sys import urllib.error import urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # The session handling is smoketest.py's, imported rather than copied: one cookie # jar implementation, one login flow, one place to fix. Importing is safe — that # module does its work under `if __name__ == "__main__"`. from smoketest import build_opener # noqa: E402 BASE = "" CTX = None # Carries the cookie jar holding the session issued by /api/auth/login. This # script used to call urllib.request.urlopen() directly, which has no cookie # support, so the session was dropped and every data route answered 401 (S13). OPENER = 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 OPENER.open(req, 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 abort(msg, hint=""): """Could not run, as distinct from ran and failed.""" print("\nABORT " + msg) if hint: print(hint) print() return 2 def expect(status, body, what): """Stop on the first refused write with the status, instead of dying on a KeyError three lines later. A 401 here used to surface as `TypeError: 'NoneType' object is not subscriptable`, which reads like a broken stack rather than a missing session.""" if status not in (200, 201): detail = body.get("detail") if isinstance(body, dict) else body raise SystemExit(abort(f"{what} failed (HTTP {status}): {detail}", " The account needs Project Admin to create and delete projects.")) return body 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, OPENER 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") ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""), help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). " "Use an admin account.") ap.add_argument("--password", default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""), help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — " "preferred, so it stays out of shell history)") 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 OPENER = build_opener(CTX) if not args.user or not args.password: missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user), ("WP_SEED_PASSWORD", args.password)) if not v) return abort( f"no credentials — {missing} not set.", " Every /api/ route except /api/health needs a session, so there is nothing\n" " this can seed without one. Set them and re-run:\n\n" " export WP_SEED_USER=\n" " export WP_SEED_PASSWORD='…'\n\n" " Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n" " too, so one set of credentials serves this and smoketest.py.") # 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 # Sign in. The cookie the response sets is held by OPENER's jar and rides every # request after this one. st, body = call("POST", "/api/auth/login", {"username": args.user, "password": args.password}) if st != 200: detail = body.get("detail") if isinstance(body, dict) else body hint = (" The account may be locked: the API locks an account for a while after a\n" " few consecutive failures, so retrying with the wrong password makes this\n" " worse. Check the password, then wait out the lockout window." if st in (401, 403, 423, 429) else " Unexpected status from the login endpoint — check the API logs.") return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint) logged_in = True print(f"Signed in as {args.user}.") try: return seed(args) finally: if logged_in: try: call("POST", "/api/auth/logout") except Exception: pass def seed(args): # --clean: remove any prior demo projects (cascade removes their SOP + WPs). # archived=all because /api/projects hides archived projects by default — an # archived DEMO project is still a DEMO project, and --clean has to find it. # (Deleting one is still allowed; only writes to its contents are frozen.) st, projects = call("GET", "/api/projects?archived=all") 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 # Refuse rather than pile up a second identical DEMO project. This used to be a # note that scrolled past, and running the script twice left two of everything # with no way to tell them apart. if demos: names = ", ".join(f"{p.get('number','?')} ({p.get('id','?')})" for p in demos[:5]) print(f"\n{len(demos)} DEMO project(s) already exist: {names}") print("Nothing was created. Remove them first, then re-run:\n") print(f" python3 server/seed_demo.py {BASE} --clean\n") return 1 # 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"}) expect(st, proj, "creating the DEMO project") 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"}}}) expect(st, sop, "creating the DEMO SOP") 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())