1"""Synthetic enterprise portal (runs ON THE EXECUTOR NODE, loopback only). Generic: driven by the scenario's portal/portal.json.2Serves: login, document library (downloads from inputs/), record tables (HTML + JSON API), forms whose submissions are appended to3out/portal_submissions.json. It never holds information absent from inputs/ (information constancy across arms).4Usage: python -m wp42.portal --workspace <dir> --port <n> [--token <t>]"""5from __future__ import annotations6import argparse, json, os, secrets, time7from flask import Flask, request, redirect, send_from_directory, session, abort, jsonify, Response89HTML = """<!doctype html><html><head><meta charset="utf-8"><title>{org} — Enterprise Portal</title>10<style>body{{font-family:-apple-system,Helvetica,Arial;margin:2rem;color:#222}}table{{border-collapse:collapse}}td,th{{border:1px solid #ccc;padding:4px 8px;font-size:14px}}nav a{{margin-right:1rem}}</style></head>11<body><h1>{org}</h1><nav><a href="/">Home</a><a href="/documents">Documents</a>{nav}<a href="/forms">Forms</a><a href="/logout">Log out</a></nav><hr>{body}</body></html>"""1213def make_app(workspace: str) -> Flask:14 cfg = json.load(open(os.path.join(workspace, "portal", "portal.json"), encoding="utf-8"))15 app = Flask(__name__); app.secret_key = secrets.token_hex(16)16 inputs = os.path.join(workspace, "inputs"); out_dir = os.path.join(workspace, "out"); os.makedirs(out_dir, exist_ok=True)17 user = cfg.get("user", {"username": "analyst", "password": "analyst"})18 records = cfg.get("records", {}); forms = {f["id"]: f for f in cfg.get("forms", [])}19 docs = cfg.get("documents", [])20 nav = "".join(f'<a href="/records/{k}">{k.title()}</a>' for k in records)21 def page(body): return HTML.format(org=cfg.get("org", "Portal"), nav=nav, body=body)22 def authed(): return session.get("u") == user["username"]23 @app.before_request24 def _gate():25 if request.path.startswith(("/login", "/static", "/health")): return None26 if not authed(): return redirect("/login")27 @app.route("/health")28 def health(): return jsonify({"ok": True, "org": cfg.get("org"), "documents": len(docs), "records": {k: len(v) for k, v in records.items()}, "forms": list(forms)})29 @app.route("/login", methods=["GET", "POST"])30 def login():31 if request.method == "POST":32 if request.form.get("username") == user["username"] and request.form.get("password") == user["password"]:33 session["u"] = user["username"]; return redirect("/")34 return page("<p style='color:red'>Invalid credentials.</p>" + LOGIN), 40135 return page(LOGIN)36 @app.route("/logout")37 def logout(): session.clear(); return redirect("/login")38 @app.route("/")39 def home():40 return page(f"<p>Welcome, {user['username']}. {cfg.get('welcome', '')}</p><ul><li><a href='/documents'>Document library</a> ({len(docs)} documents)</li>" + "".join(f"<li><a href='/records/{k}'>{k}</a> ({len(v)} records)</li>" for k, v in records.items()) + f"<li><a href='/forms'>Forms</a> ({len(forms)})</li></ul>")41 @app.route("/documents")42 def documents():43 rows = "".join(f"<tr><td>{d['id']}</td><td><a href='/documents/{d['id']}/download'>{d['title']}</a></td><td>{d.get('category', '')}</td></tr>" for d in docs)44 return page(f"<h2>Document library</h2><table><tr><th>ID</th><th>Title</th><th>Category</th></tr>{rows}</table>")45 @app.route("/documents/<doc_id>/download")46 def download(doc_id):47 d = next((x for x in docs if x["id"] == doc_id), None)48 if not d: abort(404)49 rel = d["path"]; full = os.path.normpath(os.path.join(inputs, rel))50 if not full.startswith(os.path.normpath(inputs)): abort(403)51 return send_from_directory(os.path.dirname(full), os.path.basename(full), as_attachment=True)52 @app.route("/api/documents")53 def api_docs(): return jsonify(docs)54 @app.route("/records/<entity>")55 def rec_html(entity):56 rows = records.get(entity)57 if rows is None: abort(404)58 cols = list(rows[0].keys()) if rows else []59 body = "".join("<tr>" + "".join(f"<td>{r.get(c, '')}</td>" for c in cols) + "</tr>" for r in rows[:2000])60 return page(f"<h2>{entity}</h2><p>JSON: <a href='/api/{entity}'>/api/{entity}</a></p><table><tr>" + "".join(f"<th>{c}</th>" for c in cols) + f"</tr>{body}</table>")61 @app.route("/api/<entity>")62 def rec_json(entity):63 if entity not in records: abort(404)64 return jsonify(records[entity])65 @app.route("/forms")66 def forms_index():67 return page("<h2>Forms</h2><ul>" + "".join(f"<li><a href='/forms/{k}'>{f['title']}</a></li>" for k, f in forms.items()) + "</ul>")68 @app.route("/forms/<form_id>", methods=["GET", "POST"])69 def form(form_id):70 f = forms.get(form_id)71 if not f: abort(404)72 if request.method == "POST":73 data = request.get_json(silent=True) or {k: v for k, v in request.form.items()}74 rec = {"form_id": form_id, "submitted_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "data": data}75 p = os.path.join(out_dir, "portal_submissions.json")76 cur = json.load(open(p)) if os.path.exists(p) else []77 cur.append(rec); json.dump(cur, open(p, "w"), indent=1)78 if request.is_json: return jsonify({"ok": True, "submission": rec})79 return page(f"<h2>{f['title']}</h2><p style='color:green'>Submission recorded.</p><pre>{json.dumps(rec, indent=1)}</pre>")80 fields = "".join(f"<p><label>{x.get('label', x['name'])}<br><input name='{x['name']}' type='{'number' if x.get('type') in ('number', 'float', 'int') else 'text'}' step='any'></label></p>" for x in f["fields"])81 return page(f"<h2>{f['title']}</h2><form method='post'>{fields}<button type='submit'>Submit</button></form><p>API: POST JSON to this URL.</p>")82 return app8384LOGIN = "<h2>Sign in</h2><form method='post'><p><label>Username<br><input name='username'></label></p><p><label>Password<br><input name='password' type='password'></label></p><button type='submit'>Sign in</button></form>"8586def main():87 ap = argparse.ArgumentParser(); ap.add_argument("--workspace", required=True); ap.add_argument("--port", type=int, required=True)88 a = ap.parse_args()89 app = make_app(a.workspace)90 app.run(host="127.0.0.1", port=a.port, debug=False, use_reloader=False, threaded=True)9192if __name__ == "__main__":93 main()94