diff --git a/server/seed_demo.py b/server/seed_demo.py index 76d67c1..5000b1c 100644 --- a/server/seed_demo.py +++ b/server/seed_demo.py @@ -7,31 +7,59 @@ 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 -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: +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 @@ -41,7 +69,7 @@ def call(method, path, body=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: + 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 @@ -52,6 +80,27 @@ def call(method, path, body=None): 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"] @@ -60,16 +109,36 @@ def constraints(open_names=()): def main(): - global BASE, CTX + 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: @@ -79,6 +148,31 @@ def main(): 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. @@ -90,14 +184,22 @@ def main(): 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: - print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n") + 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})") @@ -109,6 +211,7 @@ def main(): "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})")