T1.6 - S13: seed_demo.py can seed a running instance again

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>
This commit is contained in:
2026-08-14 18:52:31 -05:00
parent 5f3141e2a3
commit 357712e93e

View File

@@ -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 an over-threshold draft. Use it to prove the SQL + Python layer end-to-end
and to have data to inspect. 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 USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure python3 server/seed_demo.py https://wp-suite.company.local --insecure
docker compose exec api python /app/server/seed_demo.py http://localhost:8000 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 python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects
IMPORTANT — what shows where: WHAT SHOWS WHERE
* The DEMO **project** is API/SQL-backed, so it appears in the home-page Everything it writes is API/SQL-backed and renders in the UI: the project appears
project picker immediately (proves the projects → SQL path in the UI). in the home-page picker, and selecting it shows its Work Packages in the Field
* The DEMO **SOP and Work Packages** are written to SQL too, but the current View. Verified at T1.6 — 7 cards from a fresh seed.
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 (This block used to warn that SOPs and Work Packages would NOT render because the
wiring. Verify them at the SQL/API layer instead: 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 python3 server/smoketest.py <url> # automated end-to-end check
docker compose exec db psql -U wpsuite -d wpsuite \ docker compose exec db psql -U wpsuite -d wpsuite \
-c "select number,subject,status from work_packages order by number;" -c "select number,subject,status from work_packages order by number;"
""" """
import argparse import argparse
import json import json
import os
import ssl import ssl
import sys import sys
import urllib.error import urllib.error
import urllib.request 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 = "" BASE = ""
CTX = None 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 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, req = urllib.request.Request(url, data=data, method=method,
headers={"Content-Type": "application/json", "Accept": "application/json"}) headers={"Content-Type": "application/json", "Accept": "application/json"})
try: 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 raw = r.read().decode(); status = r.status
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
raw = e.read().decode(); status = e.code raw = e.read().decode(); status = e.code
@@ -52,6 +80,27 @@ def call(method, path, body=None):
return status, parsed 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=()): def constraints(open_names=()):
base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs", base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs",
"Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"] "Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"]
@@ -60,16 +109,36 @@ def constraints(open_names=()):
def main(): def main():
global BASE, CTX global BASE, CTX, OPENER
ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite") ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite")
ap.add_argument("base_url", nargs="?", default="http://localhost:8000", ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
help="Site root, no /api (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("--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("--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() args = ap.parse_args()
BASE = args.base_url.rstrip("/") BASE = args.base_url.rstrip("/")
if args.insecure: if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE 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 # health gate
try: try:
@@ -79,6 +148,31 @@ def main():
if st != 200: if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1 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). # --clean: remove any prior demo projects (cascade removes their SOP + WPs).
# archived=all because /api/projects hides archived projects by default — an # 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. # 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']}") call("DELETE", f"/api/projects/{p['id']}")
print(f"Removed {len(demos)} DEMO project(s).") print(f"Removed {len(demos)} DEMO project(s).")
return 0 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: 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 # 1) Project
st, proj = call("POST", "/api/projects", { st, proj = call("POST", "/api/projects", {
"name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER, "name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER,
"client": "Micron Technology, Inc.", "division": "Semiconductor", "client": "Micron Technology, Inc.", "division": "Semiconductor",
"site": "Boise, ID — Fab", "created_by": "seed_demo"}) "site": "Boise, ID — Fab", "created_by": "seed_demo"})
expect(st, proj, "creating the DEMO project")
pid = proj["id"] pid = proj["id"]
print(f"Project: {proj['name']} ({pid})") print(f"Project: {proj['name']} ({pid})")
@@ -109,6 +211,7 @@ def main():
"disciplines": ["Mechanical", "Electrical", "Tech"], "disciplines": ["Mechanical", "Electrical", "Tech"],
"discMode": "choice", "instanceSuffix": "letter", "discMode": "choice", "instanceSuffix": "letter",
"woSize": "Standard — 35 days (≈4080 hrs)", "sizeHoursMax": "80"}}}) "woSize": "Standard — 35 days (≈4080 hrs)", "sizeHoursMax": "80"}}})
expect(st, sop, "creating the DEMO SOP")
sid = sop["id"] sid = sop["id"]
print(f"SOP: complete ({sid})") print(f"SOP: complete ({sid})")