Files
Project-SDE-WP-Suite/server/seed_demo.py
n.siegfried 928ab8c900 Archive projects, auto-add default members, rebuild the admin console
Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).

ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.

DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.

ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.

Found in review and fixed here rather than later:

- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
  an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
  in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
  stored name containing \' closes the JS string literal and the rest executes.
  jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
  through it. The same bug, unescaped entirely, was in the SOP builder's custom
  constraint names (escHandlerArg there). Three of seven test payloads escaped the
  literal under the old idiom — one of them a plain name ending in a backslash, so
  it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
  payload naming a WP you may touch and a SOP you may not was authorised on the WP
  alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
  ready to take effect again on demotion — cleared, as set_user_auto_add already
  does for the role.

smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:48:43 -07:00

179 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.
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:
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 ssl
import sys
import urllib.error
import urllib.request
BASE = ""
CTX = 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 urllib.request.urlopen(req, context=CTX, 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 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
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")
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
# 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
# --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
if demos:
print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n")
# 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"})
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 — 35 days (≈4080 hrs)", "sizeHoursMax": "80"}}})
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())