Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only credential this app accepts anywhere. Backend: - server/models.py: drop User.password_hash. - server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column, same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds it with server_default=''). - server/auth.py: remove hash_password/verify_password/password_problem/ MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/ RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user untouched. - server/app.py: remove login(), the whole self-service reset-password block (forgot-password/reset-available/reset-password), and change_password() (POST /api/auth/password). Rework create_user() to drop the password field (with a docstring note: the username must exactly match the eventual Okta identity claim, or a later sign-in provisions a second account instead of matching this one). Remove admin_reset_password() outright - nothing left to reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s JIT provisioning (T10.3) was still setting password_hash="", which would have raised TypeError the moment the column was actually dropped. Admin bootstrap (D16): server/manage_users.py moves from creating accounts (create/create-admin/reset-password, all password-based) to a single 'promote <username> --role <role>' command that changes the role on a row Okta's JIT provisioning already created - the documented path for naming the first admin. list/disable/enable unchanged. Frontend: html/users.js drops the password field and validation from createUser(), removes resetPw() and its button (nothing left to reset). html/users.html drops the #nu-password input, adds a tooltip on username explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item that opened it. Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a password to seed fixture rows (and the --keep-server hint now prints a ready-to-use cookie-setting snippet instead of a dead username/password). tests/pipeline_check.py and tests/token_check.py drop an unused PW import. tests/console_dialogs_check.py: the admin password-reset dialog it drove no longer exists, so that scenario is removed - the prompt-with-validate() UI pattern it exercised is still covered via creator_dialogs_check.py's wp-creation-app.js call sites, noted in this file's docstring so the coverage move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in via login" scenario is explicitly marked SKIPPED (not deleted, not faked) - that promise is specific to the login FORM this task removed and can't be honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token cookie now stands in as setup only, so scenarios 3-6 in that file still get a signed-in page to run against. server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login to minting a session the same way okta_callback() does (auth.create_token(), seeded into the cookie jar) rather than waiting on T10.7. This is a real operational change, documented in both files' own AUTHENTICATION sections: they now need to run where AUTH_SECRET_KEY and the database match the target server's (inside the api container, or local dev) - they can no longer sign in to an arbitrary remote URL from an unrelated workstation, because Okta requires a real browser and these are stdlib scripts. The account must already exist; neither script creates or promotes one. server/requirements.txt: bcrypt dropped, nothing imports it anymore. Verified: full Alembic chain (baseline through this migration) upgrades and downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT provisioning re-tested against the post-migration schema (would have thrown before the password_hash="" fix above). create_user() verified via a live HTTP call with no password field. manage_users.py promote verified end to end (seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py and seed_demo.py both run to completion against a live uvicorn instance using the new minted-session path - 25/25 checks, including logout actually invalidating the session (proving the cookie-jar seeding didn't just fake the sign-in, it preserved the real expiry mechanics). wave-10.md T10.4 / D15 / D16
287 lines
14 KiB
Python
287 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 AND its session-minting (not a second implementation).
|
||
|
||
There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own
|
||
AUTHENTICATION section for why and what that means: this needs to run where it can
|
||
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test,
|
||
and the account must already exist (this seeds a project, not a user).
|
||
|
||
export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused
|
||
|
||
…or pass --user. 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__)))
|
||
# So `from server import auth, models` / `from server.db import SessionLocal` also
|
||
# resolve (needed to mint a session — see AUTHENTICATION above).
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
# The session handling is smoketest.py's, imported rather than copied: one cookie
|
||
# jar implementation, one session-minting flow, one place to fix. Importing is
|
||
# safe — that module does its work under `if __name__ == "__main__"`.
|
||
from smoketest import build_opener, seed_session_cookie # noqa: E402
|
||
|
||
BASE = ""
|
||
CTX = None
|
||
# Carries the cookie jar holding the minted session (see AUTHENTICATION above).
|
||
# 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="existing account to sign in as (default: $WP_SEED_USER, then "
|
||
"$WP_SMOKE_USER). Use an admin account.")
|
||
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:
|
||
return abort(
|
||
"no account — $WP_SEED_USER not set.",
|
||
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||
" this can seed without one. Set it and re-run:\n\n"
|
||
" export WP_SEED_USER=<admin-account>\n\n"
|
||
" Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
|
||
" 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" — mint a session directly (see AUTHENTICATION above) and seed it
|
||
# into OPENER's jar, so it rides every request after this one.
|
||
try:
|
||
from server import auth as srv_auth
|
||
from server.db import SessionLocal
|
||
except ImportError as e:
|
||
return abort(f"cannot import the server package to mint a session: {e}",
|
||
" This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
|
||
" DATABASE_URL match the target server's — see AUTHENTICATION above.")
|
||
with SessionLocal() as db:
|
||
user = srv_auth.find_user(db, args.user)
|
||
if not user:
|
||
return abort(f"no account named '{args.user}'.",
|
||
" This signs in as an existing account, it doesn't create one — sign in\n"
|
||
" through Okta once first, or create it from the admin console.")
|
||
if not user.is_active:
|
||
return abort(f"'{args.user}' is disabled.", "")
|
||
token = srv_auth.create_token(user)
|
||
seed_session_cookie(token, BASE)
|
||
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())
|