Every /api/ route but /api/health requires a session and the script sent none, so it could not seed anything. It predates the commit that taught the smoke test to sign in. It now signs in the same way, reusing smoketest.py's build_opener rather than growing a second cookie-jar implementation - one login flow, one place to fix. Credentials come from WP_SEED_USER / WP_SEED_PASSWORD, falling back to WP_SMOKE_USER / WP_SMOKE_PASSWORD so one set serves both scripts, and it signs out in a finally. No bypass, no debug flag, no unauthenticated seeding route: the diff touches server/seed_demo.py and nothing else, adds no route decorator anywhere, and the 33 get_current_user dependencies in app.py are untouched. The script authenticates like a client; the server is not weaker than it was. Two things found while fixing it: The failure mode was worse than a refusal. call() swallowed the HTTPError and returned the error body, so a 401 surfaced as a KeyError on proj["id"] three lines later - which reads like a broken stack rather than a missing session. Writes now go through expect(), which stops on the first refusal and prints the status and detail. Running it twice used to print a note that scrolled past and then create a second identical DEMO project, leaving two of everything with no way to tell them apart. It now refuses, names what exists, and prints the --clean command. Also corrected the header's own instructions, which said the seeded SOP and Work Packages would NOT render in the UI because the front end still read them from localStorage "pending Phase 2 wiring". That stopped being true when the sync layer landed. Selecting the seeded project now shows 7 Work Package cards in the Field View, so anyone using the UI to check whether seeding worked is no longer told to expect nothing. Verified against a freshly started instance: no credentials aborts cleanly with exit 2 and no traceback; a first run exits 0 and seeds a project, a complete SOP and 9 packages; a second run exits 1 without duplicating; the data is visible in the picker, the hero, the app bar and the Field View; --clean removes it and exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
14 KiB
Python
282 lines
14 KiB
Python
#!/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=<admin-account> # 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 <url> # 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=<admin-account>\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 <url> --clean")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|