Active user attribute set, latest activities, guarded changes to used values, artefact links, durations and averages; irreversible showcase step
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
parent
6716cee921
commit
8d62c1fbe8
22 changed files with 1025 additions and 35 deletions
59
rtut/activity.py
Normal file
59
rtut/activity.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Latest activities across all runs and reviews, newest first. Derived from run records; nothing extra is stored."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import maturity as M, run as R
|
||||
|
||||
|
||||
def _step_title(run, sid: str) -> str:
|
||||
try:
|
||||
return R.Run.step_def(run, sid).get("title", sid)
|
||||
except Exception:
|
||||
return sid
|
||||
|
||||
|
||||
def events_for(pack, pack_index: int | None) -> list[dict]:
|
||||
out = []
|
||||
for rid in R.list_runs(pack):
|
||||
run = R.load(pack, rid)
|
||||
d = run.data
|
||||
link = f"/run/{pack_index}/{rid}" if pack_index is not None else None
|
||||
who = d["operator"]
|
||||
base = {"pack": pack.data.get("title", pack.id), "run": rid, "link": link, "project": (d.get("context") or {}).get("project")}
|
||||
|
||||
def ev(at, text, who=who):
|
||||
if at:
|
||||
out.append({**base, "at": at, "who": who, "text": text})
|
||||
|
||||
ev(d["created"], f"started a run ({d.get('mode', 'manual')} mode)")
|
||||
for sid, st in d["steps"].items():
|
||||
title = _step_title(run, sid)
|
||||
if st.get("finished"):
|
||||
verb = {"done": "finished", "failed": "failed", "pushed": "pushed through"}.get(st["state"])
|
||||
if verb:
|
||||
ev(st["finished"], f"{verb} step “{title}”")
|
||||
for gid, gs in d.get("gates", {}).items():
|
||||
when = max([r["at"] for r in gs.get("results", {}).values()] or [""])
|
||||
if gs["state"] in ("passed", "failed") and when:
|
||||
ev(when, f"gate “{gid}” {gs['state']}")
|
||||
if gs["state"] == "waived":
|
||||
ev(gs["waiver"]["at"], f"waived gate “{gid}”: {gs['waiver']['reason']}")
|
||||
for dv in d.get("deviations", []):
|
||||
if dv["kind"] in ("override", "changed-after-use"):
|
||||
ev(dv["at"], f"{dv['kind'].replace('-', ' ')}: {dv['item']} ({dv['reason']})", dv.get("who", who))
|
||||
for fb in d.get("feedback", []):
|
||||
ev(fb["at"], f"rated the run {fb['rating']}" + (f": {fb['comment']}" if fb["comment"] else ""))
|
||||
if d.get("outcome"):
|
||||
ev(d.get("finished"), f"finished the run as {d['outcome']}")
|
||||
for rv in M._reviews(pack):
|
||||
out.append({"pack": pack.data.get("title", pack.id), "run": None, "link": f"/pack/{pack_index}" if pack_index is not None else None,
|
||||
"project": None, "at": rv["at"], "who": rv["by"], "text": "reviewed the runbook" + (f": {rv['note']}" if rv.get("note") else "")})
|
||||
return out
|
||||
|
||||
|
||||
def latest(packs: list, limit: int = 15) -> list[dict]:
|
||||
events: list[dict] = []
|
||||
for i, p in enumerate(packs):
|
||||
if p.ok and p.data.get("engine") == "native":
|
||||
events += events_for(p, i)
|
||||
events.sort(key=lambda x: x["at"], reverse=True)
|
||||
return events[:limit]
|
||||
63
rtut/artefacts.py
Normal file
63
rtut/artefacts.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Opening and downloading run artefacts, safely.
|
||||
|
||||
An artefact is a *reference* to something a run produced: a file path or a URL held in a datapoint. The UI may serve a local
|
||||
file only if it is exactly the artefact's resolved location, is a regular file of reasonable size, is not in a sensitive place,
|
||||
and does not look like it contains a secret. Anything else is refused with a reason.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from .pack import SECRET_RE
|
||||
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
SENSITIVE_PARTS = {".ssh", ".gnupg", ".aws", ".kube", ".git", ".password-store", "runbook-runs", "sops", ".docker"}
|
||||
SENSITIVE_SUFFIXES = (".pem", ".key", ".p12", ".pfx", ".kdbx", ".age")
|
||||
SENSITIVE_NAMES = {"shadow", "gshadow", "id_rsa", "id_ed25519", "keys.txt", ".env", ".netrc", ".pgpass"}
|
||||
|
||||
|
||||
def is_url(location: str) -> bool:
|
||||
return location.startswith(("https://", "http://"))
|
||||
|
||||
|
||||
def check(location: str | None) -> tuple[Path | None, str]:
|
||||
"""(path, '') if the file may be served, else (None, reason)."""
|
||||
if not location:
|
||||
return None, "the location is not known yet"
|
||||
if is_url(location):
|
||||
return None, "this artefact is a link, not a local file"
|
||||
p = Path(location).expanduser()
|
||||
if not p.is_absolute():
|
||||
return None, "only absolute file locations can be opened"
|
||||
try:
|
||||
p = p.resolve()
|
||||
except OSError:
|
||||
return None, "the location cannot be resolved"
|
||||
if not p.exists():
|
||||
return None, "the file does not exist (yet)"
|
||||
if not p.is_file():
|
||||
return None, "the location is not a regular file"
|
||||
parts = {x.lower() for x in p.parts}
|
||||
if parts & SENSITIVE_PARTS or p.name.lower() in SENSITIVE_NAMES or p.name.lower().endswith(SENSITIVE_SUFFIXES):
|
||||
return None, "this file is in a sensitive place and is never served"
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
if size > MAX_BYTES:
|
||||
return None, f"the file is larger than {MAX_BYTES // (1024 * 1024)} MB"
|
||||
head = p.read_bytes()[: 256 * 1024]
|
||||
except OSError as e:
|
||||
return None, f"the file cannot be read: {e.strerror}"
|
||||
if SECRET_RE.search(head.decode("utf-8", "ignore")):
|
||||
return None, "the file looks like it contains a secret and is never served"
|
||||
return p, ""
|
||||
|
||||
|
||||
def content(path: Path) -> tuple[bytes, str, bool]:
|
||||
"""(bytes, content type, is_text). Text is served as plain text; everything else only as a download."""
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
data.decode("utf-8")
|
||||
return data, "text/plain; charset=utf-8", True
|
||||
except UnicodeDecodeError:
|
||||
return data, mimetypes.guess_type(path.name)[0] or "application/octet-stream", False
|
||||
|
|
@ -70,7 +70,7 @@ def load_set(path: Path) -> Doc:
|
|||
class Registry:
|
||||
"""Everything found under one or more repository roots."""
|
||||
|
||||
def __init__(self, roots: list[Path]):
|
||||
def __init__(self, roots: list[Path], extra_sets: dict[str, Doc] | None = None):
|
||||
self.roots = [Path(r) for r in roots]
|
||||
self.sets: dict[str, Doc] = {}
|
||||
self.projects: dict[str, Doc] = {}
|
||||
|
|
@ -85,6 +85,8 @@ class Registry:
|
|||
d = _load(f, "project.schema.json"); self.templates[d.id] = d
|
||||
for f in P.discover(r):
|
||||
pk = P.load(f); self.packs[pk.id] = pk
|
||||
for sid, doc in (extra_sets or {}).items(): # app-level sets (e.g. the user): repository sets win
|
||||
self.sets.setdefault(sid, doc)
|
||||
|
||||
|
||||
def set_layers(reg: Registry, set_id: str, _seen: tuple = ()) -> list[tuple[str, dict]]:
|
||||
|
|
|
|||
54
rtut/run.py
54
rtut/run.py
|
|
@ -389,17 +389,67 @@ def effective_outcome(run: Run, now: datetime | None = None, stale_days: int = S
|
|||
return "abandoned" if (now - last).days >= stale_days else "stalled"
|
||||
|
||||
|
||||
def _ts(s: str) -> datetime:
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
def fmt_duration(seconds: float | None) -> str:
|
||||
"""Human duration: '45 s', '3 min 20 s', '1 h 5 min', '2 d 3 h'."""
|
||||
if seconds is None:
|
||||
return "-"
|
||||
s = int(round(seconds))
|
||||
if s < 60:
|
||||
return f"{s} s"
|
||||
if s < 3600:
|
||||
return f"{s // 60} min {s % 60} s" if s % 60 else f"{s // 60} min"
|
||||
if s < 86400:
|
||||
return f"{s // 3600} h {(s % 3600) // 60} min" if (s % 3600) // 60 else f"{s // 3600} h"
|
||||
return f"{s // 86400} d {(s % 86400) // 3600} h" if (s % 86400) // 3600 else f"{s // 86400} d"
|
||||
|
||||
|
||||
def run_times(run: Run, now: datetime | None = None) -> dict:
|
||||
"""Start, finish and durations of one run.
|
||||
|
||||
elapsed: start to finish (to now while unfinished); working: the time steps were actually in progress;
|
||||
step_seconds: per step, from start to finish.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
start = _ts(run.data["created"])
|
||||
fin = run.data.get("finished")
|
||||
end = _ts(fin) if fin else now
|
||||
steps = {}
|
||||
for sid, st in run.data["steps"].items():
|
||||
if st.get("started") and st.get("finished"):
|
||||
steps[sid] = max(0.0, (_ts(st["finished"]) - _ts(st["started"])).total_seconds())
|
||||
return {"started": run.data["created"], "finished": fin, "elapsed": max(0.0, (end - start).total_seconds()),
|
||||
"finished_run": bool(fin), "working": sum(steps.values()), "step_seconds": steps}
|
||||
|
||||
|
||||
def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_DAYS) -> dict:
|
||||
"""How often a runbook was tried and how it went, derived from its run records."""
|
||||
"""How often a runbook was tried and how it went, derived from its run records.
|
||||
|
||||
avg_seconds / median_seconds / fastest_seconds are the elapsed times of *succeeded* runs only:
|
||||
unfinished and failed runs say nothing about how long a working run takes.
|
||||
"""
|
||||
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "stalled": 0,
|
||||
"feedback": {"good": 0, "neutral": 0, "poor": 0}}
|
||||
durations: list[float] = []
|
||||
for rid in list_runs(pack):
|
||||
r = load(pack, rid)
|
||||
card["tried"] += 1
|
||||
card[effective_outcome(r, now, stale_days)] += 1
|
||||
outcome = effective_outcome(r, now, stale_days)
|
||||
card[outcome] += 1
|
||||
if outcome == "succeeded" and r.data.get("finished"):
|
||||
durations.append(run_times(r)["elapsed"])
|
||||
fb = latest_feedback(r)
|
||||
if fb:
|
||||
card["feedback"][fb["rating"]] += 1
|
||||
durations.sort()
|
||||
card["avg_seconds"] = sum(durations) / len(durations) if durations else None
|
||||
card["median_seconds"] = (durations[len(durations) // 2] if len(durations) % 2 else
|
||||
(durations[len(durations) // 2 - 1] + durations[len(durations) // 2]) / 2) if durations else None
|
||||
card["fastest_seconds"] = durations[0] if durations else None
|
||||
card["timed_runs"] = len(durations)
|
||||
return card
|
||||
|
||||
|
||||
|
|
|
|||
42
rtut/run2.py
42
rtut/run2.py
|
|
@ -56,13 +56,49 @@ def _coerce(run: Run, dp_id: str, value: str) -> tuple[str | None, str | None]:
|
|||
return value, None
|
||||
|
||||
|
||||
def set_datapoint(run: Run, dp_id: str, value: str, how: str = "manual") -> str:
|
||||
"""Give a datapoint a value. Invalid input leaves it in state 'err' (never stores it)."""
|
||||
def datapoint_users(run: Run, dp_id: str) -> list[str]:
|
||||
"""Steps that have already started and use this datapoint: they worked with its current value."""
|
||||
return [sid for sid, s in flow(run).steps.items()
|
||||
if dp_id in s.get("uses", []) and run.data["steps"][sid]["state"] in ("running", "done", "failed", "pushed")]
|
||||
|
||||
|
||||
def changes_needing_override(run: Run, changes: dict[str, str]) -> dict[str, list[str]]:
|
||||
"""Of proposed {datapoint: new value}, those that steps already used and that really change the value."""
|
||||
out = {}
|
||||
for dp, v in changes.items():
|
||||
rec = run.data["datapoints"].get(dp)
|
||||
if rec is None or rec.get("value") is None or str(v) == str(rec["value"]):
|
||||
continue
|
||||
users = datapoint_users(run, dp)
|
||||
if users:
|
||||
out[dp] = users
|
||||
return out
|
||||
|
||||
|
||||
def set_datapoint(run: Run, dp_id: str, value: str, how: str = "manual",
|
||||
override_reason: str = "", acknowledge_risk: bool = False) -> str:
|
||||
"""Give a datapoint a value. Invalid input leaves it in state 'err' (never stores it).
|
||||
|
||||
Changing a value that steps have already used is a deviation: it needs the operator's override
|
||||
(reason and acknowledged risk), only in manual mode, and is recorded as 'changed-after-use'.
|
||||
"""
|
||||
_writable(run)
|
||||
if dp_id not in flow(run).datapoints:
|
||||
raise RunError(f"no such datapoint: {dp_id}")
|
||||
clean, err = _coerce(run, dp_id, value)
|
||||
rec = run.data["datapoints"][dp_id]
|
||||
users = datapoint_users(run, dp_id) if how != "initial" else []
|
||||
if users and rec.get("value") is not None and str(value) != str(rec["value"]):
|
||||
risk = "Steps that already ran used the previous value, so their results may no longer match this one."
|
||||
if run.data["mode"] != "manual":
|
||||
raise RunError(f"{dp_id} was already used by {', '.join(users)}; take over (manual mode) to change it")
|
||||
if not override_reason.strip():
|
||||
raise RunError(f"{dp_id} was already used by {', '.join(users)}. Risk: {risk} "
|
||||
"To change it give a reason and acknowledge the risk; this is recorded as a deviation.")
|
||||
if not acknowledge_risk:
|
||||
raise RunError("changing a value that steps already used needs the operator to acknowledge the risk")
|
||||
_deviate(run, "changed-after-use", dp_id, [f"{s} already used {dp_id} = {rec['value']!r}" for s in users],
|
||||
override_reason, risk, True)
|
||||
clean, err = _coerce(run, dp_id, value)
|
||||
if err:
|
||||
rec.update(state="err", error=err, at=_now())
|
||||
rec.pop("value", None)
|
||||
|
|
|
|||
94
rtut/ui.py
94
rtut/ui.py
|
|
@ -13,6 +13,7 @@ from urllib.parse import parse_qs, urlparse
|
|||
from . import pack as P, run as R
|
||||
from . import ui_projects as UP
|
||||
from . import ui_v2 as UI2
|
||||
from . import activity as ACT, user as USER
|
||||
from .attrs import Registry
|
||||
|
||||
CSS = """
|
||||
|
|
@ -65,8 +66,9 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
|
|||
.pill[data-s=done],.pill[data-s=passed],.pill[data-s=ok],.pill[data-s=complete],.pill[data-s=succeeded],.pill[data-s=applied]{color:var(--ok);border-color:var(--ok)}
|
||||
.pill[data-s=running],.pill[data-s="in progress"],.pill[data-s=applying],.pill[data-s=auto],.pill[data-s=auto-confirm]{background:var(--hi);color:#141414;border-color:var(--hi)}
|
||||
.pill[data-s=failed],.pill[data-s=err],.pill[data-s=invalid]{color:var(--bad);border-color:var(--bad)}
|
||||
.pill[data-s=pushed],.pill[data-s=waived],.pill[data-s=stale],.pill[data-s=override],.pill[data-s=push-through],.pill[data-s=waiver],.pill[data-s=modified],.pill[data-s=abandoned]{color:var(--warn);border-color:var(--warn)}
|
||||
.pill[data-s=changed-after-use],.pill[data-s=pushed],.pill[data-s=waived],.pill[data-s=stale],.pill[data-s=override],.pill[data-s=push-through],.pill[data-s=waiver],.pill[data-s=modified],.pill[data-s=abandoned]{color:var(--warn);border-color:var(--warn)}
|
||||
.pill[data-s=nil],.pill[data-s=todo],.pill[data-s=open],.pill[data-s=planned],.pill[data-s="not checked"],.pill[data-s=not-applied]{color:var(--mut);border-style:dashed}
|
||||
.feed{list-style:none;padding:0}.feed li{padding:.3rem 0;border-bottom:1px dashed var(--line)}
|
||||
.checks{list-style:none;padding:0;margin:.4rem 0}.checks li{padding:.35rem 0;border-bottom:1px dashed var(--line)}.checks li:last-child{border:0}
|
||||
#current{scroll-margin-top:1rem}details>summary{list-style-position:outside}
|
||||
@media(max-width:40rem){body{margin:1rem auto}.head{display:block;width:auto}.tabs{overflow:auto}}
|
||||
|
|
@ -98,8 +100,12 @@ SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor",
|
|||
|
||||
|
||||
def score_line(c: dict) -> str:
|
||||
return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
|
||||
line = (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
|
||||
f"abandoned {c['abandoned']} · stalled {c['stalled']}")
|
||||
if c.get("avg_seconds") is not None:
|
||||
line += (f" · average {R.fmt_duration(c['avg_seconds'])} per successful run"
|
||||
f" (fastest {R.fmt_duration(c['fastest_seconds'])}, {c['timed_runs']} timed)")
|
||||
return line
|
||||
|
||||
|
||||
class App:
|
||||
|
|
@ -109,8 +115,44 @@ class App:
|
|||
self.packs = [P.load(p) for r in roots for p in P.discover(r)]
|
||||
|
||||
def registry(self) -> Registry:
|
||||
"""Fresh view of projects, attribute sets, templates and packs (files may have changed)."""
|
||||
return Registry(self.roots)
|
||||
"""Fresh view of projects, attribute sets, templates and packs (files may have changed).
|
||||
|
||||
The active user is available to every project as the app-level attribute set `user-attributes`."""
|
||||
return Registry(self.roots, extra_sets={USER.USER_SET_ID: USER.attribute_set()})
|
||||
|
||||
def operator(self) -> str:
|
||||
return USER.current()["operator"]
|
||||
|
||||
def who(self, name: str = "operator") -> str:
|
||||
"""The active user, as a hidden field and a line, instead of asking for a name again."""
|
||||
u = USER.current()
|
||||
label = f"{e(u['display_name'])} ({e(u['operator'])})" if u["display_name"] else e(u["operator"])
|
||||
return (f"<input type=hidden name={name} value='{e(u['operator'])}'>"
|
||||
f"<p class=note>Running as <b>{label}</b> · <a href='/user'>change</a></p>")
|
||||
|
||||
def activities_html(self, limit: int = 12) -> str:
|
||||
events = ACT.latest(self.packs, limit)
|
||||
if not events:
|
||||
return "<h2>Latest activities</h2><p class=note>Nothing has happened yet. Start a run or a project.</p>"
|
||||
rows = "".join(
|
||||
f"<li><span class=note>{e(x['at'][:16].replace('T', ' '))}</span> · <b>{e(x['who'])}</b> {e(x['text'])} "
|
||||
f"<span class=note>· {e(x['pack'])}{' · project ' + e(x['project']) if x.get('project') else ''}</span>"
|
||||
f"{' · <a href=' + chr(39) + x['link'] + chr(39) + '>open</a>' if x['link'] else ''}</li>" for x in events)
|
||||
return f"<h2>Latest activities</h2><ul class=feed>{rows}</ul>"
|
||||
|
||||
def user_page(self, msg: str = "") -> bytes:
|
||||
u = USER.current()
|
||||
env = ("<p class=note>The operator is set by the <code>RTUT_USER</code> environment variable, which takes precedence over "
|
||||
"anything saved here.</p>" if u["source"] == "environment" else "")
|
||||
src = {"environment": "the environment", "saved": "your saved settings", "login": "your operating-system login"}[u["source"]]
|
||||
return page("You", f"<h1>You</h1>{'<p class=bad>' + e(msg) + '</p>' if msg else ''}"
|
||||
f"<p>Runs, reviews and activity are recorded under <b>{e(u['operator'])}</b>, taken from {src}.</p>{env}"
|
||||
"<p class=note>These are the attributes of the app-level set <code>user-attributes</code>. Every project can reuse it "
|
||||
"(<code>attribute_sets: [user-attributes]</code>). If the UI later sits behind a login, the login fills the same attribute.</p>"
|
||||
f"<form method=post action='/user'>{self.hidden()}"
|
||||
f"<p><label>Operator (one word)<br><input name=operator value='{e(u['operator'])}' required pattern='[A-Za-z0-9][A-Za-z0-9._-]*' maxlength=40></label></p>"
|
||||
f"<p><label>Display name (optional)<br><input name=display_name value='{e(u['display_name'])}' maxlength=80></label></p>"
|
||||
"<button>Save</button></form>")
|
||||
|
||||
def pack_index(self, pack_id: str) -> int | None:
|
||||
return next((i for i, p in enumerate(self.packs) if p.id == pack_id), None)
|
||||
|
|
@ -130,7 +172,7 @@ class App:
|
|||
else:
|
||||
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> {UP.maturity_pill(p)} "
|
||||
f"<span class=note>{e(score_line(R.scorecard(p)))}</span></li>")
|
||||
return page("Runbooks", UP.index_sections(self) + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
|
||||
return page("Runbooks", UP.index_sections(self) + self.activities_html() + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
|
||||
|
||||
def pack_page(self, i: int) -> bytes:
|
||||
p = self.packs[i]; d = p.data
|
||||
|
|
@ -152,15 +194,17 @@ class App:
|
|||
f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>"
|
||||
f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>"
|
||||
f"<form method=post action='/run/new/{i}'>{self.hidden()}{fields}"
|
||||
"<p><label>Your name (one word)<br><input name=operator required></label></p>"
|
||||
"<button>Start run</button></form>")
|
||||
f"{self.who()}<button>Start run</button></form>")
|
||||
|
||||
def runs_list(self, i: int, p) -> str:
|
||||
rows = []
|
||||
for rid in reversed(R.list_runs(p)):
|
||||
r = R.load(p, rid)
|
||||
t = R.run_times(r)
|
||||
took = (f"took {R.fmt_duration(t['elapsed'])}" if t["finished_run"] else f"running {R.fmt_duration(t['elapsed'])}")
|
||||
rows.append(f"<li><a href='/run/{i}/{e(rid)}'>{e(rid)}</a> · {e(r.data['operator'])} · "
|
||||
f"<span class=pill>{e(R.effective_outcome(r))}</span></li>")
|
||||
f"<span class=pill data-s='{e(R.effective_outcome(r))}'>{e(R.effective_outcome(r))}</span> "
|
||||
f"<span class=note>{e(UI2.stamp(t['started']))} · {e(took)}</span></li>")
|
||||
return f"<h2>Runs</h2><ul>{''.join(rows)}</ul>" if rows else ""
|
||||
|
||||
def capture_html(self, url: str, s: dict, st: dict, state: str, finished: bool) -> str:
|
||||
|
|
@ -242,7 +286,9 @@ class App:
|
|||
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
|
||||
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}")
|
||||
out = [f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
|
||||
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"]
|
||||
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"
|
||||
f"<p class=note>{UI2.times_line(r)}</p>"]
|
||||
step_secs = R.run_times(r)["step_seconds"]
|
||||
if msg:
|
||||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
blocked = False
|
||||
|
|
@ -310,7 +356,8 @@ class App:
|
|||
fold = "" if state in ("done", "skipped") else " open" # finished steps start folded; the triangle toggles
|
||||
out.append(f"<details{anchor} class='step {state}'{fold}><summary><span class=head>"
|
||||
f"<span><b>{n}. {e(s['title'])}</b> "
|
||||
f"<span class=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}</span></span>"
|
||||
f"<span class=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}"
|
||||
f"{' · took ' + e(R.fmt_duration(step_secs[s['id']])) if s['id'] in step_secs and state == 'done' else ''}</span></span>"
|
||||
f"<span class=pill data-s='{state}'>{state}</span></span></summary>{block}"
|
||||
f"{self.capture_html(url, s, st, state, finished)}"
|
||||
f"<p class=note>Done when: {e(s['verify']['done_when'])}</p>{rb}{act}</details>")
|
||||
|
|
@ -335,6 +382,17 @@ def make_handler(app: App, port_holder: list):
|
|||
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'")
|
||||
self.end_headers(); self.wfile.write(body)
|
||||
|
||||
def _send_file(self, code: int, body: bytes, ctype: str, download_name: str | None):
|
||||
"""Artefacts: never rendered as a page (plain text or download), never sniffed, sandboxed."""
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
if download_name:
|
||||
safe = "".join(ch for ch in download_name if ch.isalnum() or ch in "._-") or "artefact"
|
||||
self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"")
|
||||
self.end_headers(); self.wfile.write(body)
|
||||
|
||||
def _host_ok(self) -> bool:
|
||||
return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost"))
|
||||
|
||||
|
|
@ -347,6 +405,12 @@ def make_handler(app: App, port_holder: list):
|
|||
return self._send(app.index())
|
||||
if parts[0] == "pack":
|
||||
return self._send(app.pack_page(int(parts[1])))
|
||||
if parts == ["user"]:
|
||||
return self._send(app.user_page())
|
||||
if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact":
|
||||
status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4])
|
||||
want_dl = bool(parse_qs(urlparse(self.path).query).get("download"))
|
||||
return self._send_file(status, body, ctype, dl or ("artefact-" + parts[4] if want_dl and status == 200 else None))
|
||||
if parts[0] == "run":
|
||||
return self._send(app.run_page(int(parts[1]), parts[2], query=parse_qs(urlparse(self.path).query)))
|
||||
if parts[0] == "project" and len(parts) == 2:
|
||||
|
|
@ -369,6 +433,16 @@ def make_handler(app: App, port_holder: list):
|
|||
if form.get("_t") != app.token:
|
||||
return self._send(b"bad token", 403)
|
||||
parts = [x for x in urlparse(self.path).path.split("/") if x]
|
||||
if not form.get("operator", "").strip():
|
||||
form["operator"] = app.operator() # the active user, unless a form names someone
|
||||
if not form.get("by", "").strip():
|
||||
form["by"] = app.operator()
|
||||
if parts == ["user"]:
|
||||
try:
|
||||
USER.save(form.get("operator", ""), form.get("display_name", ""))
|
||||
except ValueError as ex:
|
||||
return self._send(app.user_page(str(ex)), 400)
|
||||
return self._redirect("/user")
|
||||
msg = ""
|
||||
try:
|
||||
if parts[0] == "run" and len(parts) >= 4 and parts[1].isdigit() and app.packs[int(parts[1])].spec == "runbook-pack/v0.2":
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ def maturity_block(app, i: int, pack) -> str:
|
|||
"context, and runs with deviations, do not count.</p>"
|
||||
f"<p>Idempotency: <b>{e(idempotency_text(pack))}</b></p>"
|
||||
f"{'<ul>' + revs + '</ul>' if revs else ''}"
|
||||
f"<form method=post action='/pack/{i}/review'>{app.hidden()}<p class=row>"
|
||||
"<input name=by placeholder='your name (one word)' required> <input name=note class=grow placeholder='review note (optional)'> "
|
||||
f"<form method=post action='/pack/{i}/review'>{app.hidden()}{app.who('by')}<p class=row>"
|
||||
"<input name=note class=grow placeholder='review note (optional)'> "
|
||||
"<button>Record my review of this revision</button></p></form></div>")
|
||||
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
|
|||
if pack.spec == "runbook-pack/v0.2" else "")
|
||||
action = (f"<form method=post action='/project/{e(pid)}/run' class=row>{app.hidden()}"
|
||||
f"<input type=hidden name=pack value='{e(spec['pack'])}'><input type=hidden name=target value='{e(spec.get('target') or '')}'>"
|
||||
f"<input name=operator placeholder='your name' size=10 required> {mode}<button>Start run</button></form>")
|
||||
f"<input type=hidden name=operator value='{e(app.operator())}'> {mode}<button>Start run as {e(app.operator())}</button></form>")
|
||||
link = f" <a href='/run/{i}/{e(ms['run'])}'>latest run</a>" if ms.get("run") and i is not None else ""
|
||||
dev = f" · {ms['deviations']} deviation(s)" if ms.get("deviations") else ""
|
||||
rows += (f"<tr><td>{e(pack.data.get('title', pack.id))}<br><span class=note>{e(spec['pack'])}"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
|||
import html
|
||||
from urllib.parse import quote
|
||||
|
||||
from . import run as R1, run2 as R
|
||||
from . import artefacts as A, run as R1, run2 as R
|
||||
from .flow import Flow
|
||||
|
||||
DONE = ("done", "skipped", "pushed")
|
||||
|
|
@ -55,7 +55,7 @@ def start_form(app, i: int, pack) -> str:
|
|||
"<label><input type=radio name=mode value=manual checked> <b>Manual</b>: you confirm every check and gate.</label><br>"
|
||||
"<label><input type=radio name=mode value=auto-confirm> <b>Auto-confirm</b>: the engine confirms when preconditions hold. "
|
||||
"It does not run commands; you (or another tool) do the work.</label></fieldset>"
|
||||
f"{pre}<p><label>Your name (one word)<br><input name=operator required></label></p><button>Start run</button></form>")
|
||||
f"{pre}{app.who()}<button>Start run</button></form>")
|
||||
|
||||
|
||||
def create_run(app, i: int, form: dict):
|
||||
|
|
@ -102,6 +102,7 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
|
|||
out = [f"<h1>{e(pack.data['title'])}</h1>"
|
||||
f"<p>{sp(d['mode'])} {sp(d['outcome']) if d.get('outcome') else ''} "
|
||||
f"<span class=note>run {e(rid)} · operator {e(d['operator'])}{where}</span></p>"
|
||||
f"<p class=note>{times_line(run)}</p>"
|
||||
f"<p class=eyebrow>{prog['done']} of {prog['total']} done"
|
||||
f"{' · ' + str(prog['pushed']) + ' pushed through' if prog['pushed'] else ''}</p>"
|
||||
f"<progress max={prog['total']} value={prog['done'] + prog['pushed']} style='width:100%'></progress>"]
|
||||
|
|
@ -122,6 +123,20 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
|
|||
return page(pack.data["title"], "".join(out))
|
||||
|
||||
|
||||
def stamp(iso: str | None) -> str:
|
||||
return iso[:16].replace("T", " ") + " UTC" if iso else "-"
|
||||
|
||||
|
||||
def times_line(run) -> str:
|
||||
"""Started, finished and how long: the run's timestamps at a glance."""
|
||||
t = R1.run_times(run)
|
||||
if t["finished_run"]:
|
||||
return (f"Started {e(stamp(t['started']))} · finished {e(stamp(t['finished']))} · took <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
|
||||
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
|
||||
return (f"Started {e(stamp(t['started']))} · running for <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
|
||||
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
|
||||
|
||||
|
||||
# ---- flow view ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _flow_view(c: Ctx, ov: dict) -> str:
|
||||
|
|
@ -222,15 +237,31 @@ def _questions(c: Ctx, st: dict) -> str:
|
|||
if not dps or c.finished:
|
||||
return ""
|
||||
rows = []
|
||||
locked = False
|
||||
for d in dps:
|
||||
rec = c.run.data["datapoints"][d["id"]]
|
||||
rows.append(f"<p>{_input(d, rec)}<br><span class=note>{e(d.get('help', ''))}</span></p>")
|
||||
users = R.datapoint_users(c.run, d["id"])
|
||||
locked = locked or bool(users and rec.get("value") is not None)
|
||||
rows.append(f"<p>{_input(d, rec, users=users)}<br><span class=note>{e(d.get('help', ''))}</span></p>")
|
||||
return (f"<div class=questions><b>Questions for this stage</b> <span class=note>(values used by its steps; never enter secrets)</span>"
|
||||
f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}#stage-{e(st['id'])}'>"
|
||||
f"{''.join(rows)}<button>Save answers</button></form></div>")
|
||||
f"{''.join(rows)}{_override_fields(c, locked)}<button>Save answers</button></form></div>")
|
||||
|
||||
|
||||
def _input(d: dict, rec: dict, editable: bool = True) -> str:
|
||||
def _override_fields(c: Ctx, locked: bool) -> str:
|
||||
"""Only shown when some value is already in use by started steps: changing it is a recorded deviation."""
|
||||
if not locked:
|
||||
return ""
|
||||
if not c.manual:
|
||||
return "<p class=note>Values already in use by steps can only be changed in manual mode: take over first.</p>"
|
||||
return ("<details class=override><summary>Changing a value that steps already used (override)</summary>"
|
||||
"<p class=bad>Risk: steps that already ran used the previous value, so their results may no longer match the new one. "
|
||||
"The change is recorded as a deviation.</p>"
|
||||
"<p><input name=override_reason class=grow style='width:100%' placeholder='why you change it now (required for values in use)'></p>"
|
||||
"<p><label><input type=checkbox name=acknowledge_risk value=1> I acknowledge the risk.</label></p></details>")
|
||||
|
||||
|
||||
def _input(d: dict, rec: dict, editable: bool = True, users: list[str] | None = None) -> str:
|
||||
name = f"d_{d['id']}"
|
||||
val = rec.get("value", "")
|
||||
dis = "" if editable else " disabled"
|
||||
|
|
@ -242,7 +273,9 @@ def _input(d: dict, rec: dict, editable: bool = True) -> str:
|
|||
else:
|
||||
ctl = f"<input name='{name}' value='{e(val)}'{dis}>"
|
||||
err = f" <span class=bad>{e(rec.get('error', ''))}</span>" if rec["state"] == "err" else ""
|
||||
return f"<label>{e(d['label'])} {sp(rec['state'])}<br>{ctl}</label>{err}"
|
||||
use = (f" <span class=chip title='changing it is a recorded deviation'>in use by {e(', '.join(users))}</span>"
|
||||
if users and rec.get("value") is not None else "")
|
||||
return f"<label>{e(d['label'])} {sp(rec['state'])}{use}<br>{ctl}</label>{err}"
|
||||
|
||||
|
||||
# ---- steps ------------------------------------------------------------------------------------------------------------------
|
||||
|
|
@ -262,6 +295,9 @@ def _step(c: Ctx, s: dict) -> str:
|
|||
marks.append("<span class=chip data-s='irreversible'>irreversible</span>")
|
||||
if s.get("auto_confirm") == "manual-only":
|
||||
marks.append("<span class=chip>manual-only</span>")
|
||||
took = R1.run_times(run)["step_seconds"].get(sid)
|
||||
if took is not None and state in DONE:
|
||||
marks.append(f"<span class=note>took {e(R1.fmt_duration(took))}</span>")
|
||||
title = eff.get("title", s["title"]) if st.get("alternative") else s["title"]
|
||||
actor = fl.actors.get(s["actor"], {}).get("role", s["actor"])
|
||||
body = []
|
||||
|
|
@ -461,6 +497,8 @@ def _data_view(c: Ctx) -> str:
|
|||
groups = R.datapoint_rows(run, group=group, flt=flt)
|
||||
out = [f"<p class=note>Every datapoint of this run, independent of the flow. Tags (dimensions) come from the pack; click one to filter.</p>{ctl}"]
|
||||
editable = not c.finished
|
||||
any_locked = any(R.datapoint_users(run, x["id"]) and x["value"] is not None and x["editable"]
|
||||
for rows_ in groups.values() for x in rows_)
|
||||
out.append(f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}?view=data"
|
||||
f"{'&group=' + quote(group) if group else ''}{'&filter=' + quote(flt) if flt else ''}'>")
|
||||
for gname, rows in groups.items():
|
||||
|
|
@ -473,19 +511,20 @@ def _data_view(c: Ctx) -> str:
|
|||
dims = " ".join(link(group, f"{k}:{v}", f"{k}: {v}") for k, v in r["dimensions"].items())
|
||||
src = " · ".join(x for x in (f"asked in {e(r['ask_in'])}" if r["ask_in"] else "", f"produced by {e(r['produced_by'])}" if r["produced_by"] else "",
|
||||
f"confirmed by {e(r['confirmed_by'])}" if r["confirmed_by"] else "", f"how: {e(r['how'])}" if r["how"] else "") if x)
|
||||
ctl = _input(d, rec, editable and r["editable"]) if (editable and r["editable"]) else (
|
||||
ctl = _input(d, rec, editable and r["editable"], R.datapoint_users(run, r["id"])) if (editable and r["editable"]) else (
|
||||
f"{e(r['label'])} {sp(r['state'])}<br><code>{e(r['value'] if r['value'] is not None else '')}</code>")
|
||||
trs += f"<tr><td>{ctl}</td><td>{dims}</td><td class=note>{src}</td></tr>"
|
||||
out.append(f"<table class=data><tr><th>Datapoint</th><th>Dimensions</th><th>Provenance</th></tr>{trs}</table>")
|
||||
if not groups:
|
||||
out.append("<p class=note>No datapoints match.</p>")
|
||||
if editable:
|
||||
out.append("<button>Save changed values</button>")
|
||||
out.append(_override_fields(c, any_locked) + "<button>Save changed values</button>")
|
||||
out.append("</form>")
|
||||
arts = R.artefact_rows(run)
|
||||
if arts:
|
||||
out.append("<h2>Artefacts</h2><table><tr><th>Artefact</th><th>Where it lives</th><th>Produced by</th><th>Until</th></tr>" + "".join(
|
||||
out.append("<h2>Artefacts</h2><table><tr><th>Artefact</th><th>Where it lives</th><th>Open</th><th>Produced by</th><th>Until</th></tr>" + "".join(
|
||||
f"<tr><td>{e(a['title'])}</td><td><code>{e(a['location'] or '(not known yet)')}</code></td>"
|
||||
f"<td>{_artefact_links(c, a)}</td>"
|
||||
f"<td>{e(a['produced_by'])} {sp(a['produced_state'])}</td><td>{e(a['lives_until'] or '-')}</td></tr>" for a in arts) + "</table>")
|
||||
if fl.actors:
|
||||
counts: dict[str, int] = {}
|
||||
|
|
@ -497,6 +536,32 @@ def _data_view(c: Ctx) -> str:
|
|||
return "".join(out)
|
||||
|
||||
|
||||
def _artefact_links(c: Ctx, a: dict) -> str:
|
||||
loc = a["location"]
|
||||
if loc and A.is_url(loc):
|
||||
return f"<a href='{e(loc)}' target=_blank rel='noopener noreferrer'>Open link</a>"
|
||||
path, why = A.check(loc)
|
||||
if path is None:
|
||||
return f"<span class=note>{e(why)}</span>"
|
||||
url = f"{c.base}/artefact/{e(a['id'])}"
|
||||
return f"<a href='{url}' target=_blank rel=noopener>Open</a> · <a href='{url}?download=1'>Download</a>"
|
||||
|
||||
|
||||
def serve_artefact(app, i: int, rid: str, aid: str) -> tuple[int, bytes, str, str | None]:
|
||||
"""(status, body, content type, download filename or None). Refusals come back as short plain text."""
|
||||
run = R1.load(app.packs[i], rid)
|
||||
if run.pack.spec != "runbook-pack/v0.2":
|
||||
return 404, b"no artefacts", "text/plain; charset=utf-8", None
|
||||
row = next((a for a in R.artefact_rows(run) if a["id"] == aid), None)
|
||||
if row is None:
|
||||
return 404, b"no such artefact", "text/plain; charset=utf-8", None
|
||||
path, why = A.check(row["location"])
|
||||
if path is None:
|
||||
return 403, f"not served: {why}".encode(), "text/plain; charset=utf-8", None
|
||||
body, ctype, is_text = A.content(path)
|
||||
return 200, body, ctype, (None if is_text else path.name)
|
||||
|
||||
|
||||
# ---- overview ------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _overview_view(c: Ctx, ov: dict) -> str:
|
||||
|
|
@ -590,6 +655,7 @@ def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
|
|||
return f"{base}#current"
|
||||
if parts == ["data"]:
|
||||
bad = []
|
||||
changes = {}
|
||||
for k, v in form.items():
|
||||
if not k.startswith("d_"):
|
||||
continue
|
||||
|
|
@ -597,7 +663,14 @@ def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
|
|||
cur = run.data["datapoints"].get(dp)
|
||||
if cur is None or v == "" or (cur["state"] in ("set", "ok") and cur.get("value") == v):
|
||||
continue # unchanged or left empty
|
||||
if R.set_datapoint(run, dp, v) == "err":
|
||||
changes[dp] = v
|
||||
need = R.changes_needing_override(run, changes) # all or nothing: check before applying any
|
||||
reason, ack = form.get("override_reason", ""), bool(form.get("acknowledge_risk"))
|
||||
if need and not (reason.strip() and ack):
|
||||
raise R1.RunError("already in use by started steps: " + "; ".join(f"{d} ({', '.join(u)})" for d, u in need.items())
|
||||
+ ". To change them give a reason and acknowledge the risk; each change is recorded as a deviation.")
|
||||
for dp, v in changes.items():
|
||||
if R.set_datapoint(run, dp, v, override_reason=reason, acknowledge_risk=ack) == "err":
|
||||
bad.append(f"{dp} ({run.data['datapoints'][dp]['error']})")
|
||||
if bad:
|
||||
raise ValueError("invalid value for: " + ", ".join(bad))
|
||||
|
|
|
|||
89
rtut/user.py
Normal file
89
rtut/user.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""The active user: who is running things. Lives in the app, outside any project, as the attribute set `user-attributes`.
|
||||
|
||||
Order of derivation: the RTUT_USER environment variable (for a wrapper that knows the login), the saved
|
||||
`user-attributes.yaml` in the app's config folder, then the operating-system login. If the UI later sits behind a
|
||||
login, that login is what should fill the same attribute; forms already read only from here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .attrs import Doc
|
||||
from .pack import SECRET_RE
|
||||
|
||||
USER_SET_ID = "user-attributes"
|
||||
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,39}")
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
return Path(os.environ.get("RTUT_CONFIG_DIR") or (Path.home() / ".config" / "runbook-tutorials"))
|
||||
|
||||
|
||||
def user_file() -> Path:
|
||||
return config_dir() / "user-attributes.yaml"
|
||||
|
||||
|
||||
def _one_word(text: str) -> str:
|
||||
word = re.sub(r"[^A-Za-z0-9._-]+", "-", text.strip()).strip("-._")
|
||||
return word[:40] or "operator"
|
||||
|
||||
|
||||
def _saved() -> dict:
|
||||
f = user_file()
|
||||
if not f.exists():
|
||||
return {}
|
||||
try:
|
||||
data = yaml.safe_load(f.read_text()) or {}
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
out = {}
|
||||
for k, v in (data.get("attributes") or {}).items():
|
||||
out[k] = v.get("value") if isinstance(v, dict) else v
|
||||
return {k: str(v) for k, v in out.items() if v is not None}
|
||||
|
||||
|
||||
def current() -> dict:
|
||||
"""{'operator': one word, 'display_name': str, 'source': 'environment'|'saved'|'login'}"""
|
||||
saved = _saved()
|
||||
env = os.environ.get("RTUT_USER", "").strip()
|
||||
if env:
|
||||
return {"operator": _one_word(env), "display_name": saved.get("display_name", ""), "source": "environment"}
|
||||
if saved.get("operator") and NAME.fullmatch(saved["operator"]):
|
||||
return {"operator": saved["operator"], "display_name": saved.get("display_name", ""), "source": "saved"}
|
||||
try:
|
||||
login = getpass.getuser()
|
||||
except Exception:
|
||||
login = "operator"
|
||||
return {"operator": _one_word(login), "display_name": saved.get("display_name", ""), "source": "login"}
|
||||
|
||||
|
||||
def save(operator: str, display_name: str = "") -> Path:
|
||||
operator = operator.strip()
|
||||
if not NAME.fullmatch(operator):
|
||||
raise ValueError("the operator name is one word: letters, digits, dot, dash or underscore (max 40)")
|
||||
if SECRET_RE.search(operator) or SECRET_RE.search(display_name):
|
||||
raise ValueError("names are never secret")
|
||||
attrs: dict = {"operator": {"value": operator, "requirement": "required",
|
||||
"note": "One word that identifies you in runs, reviews and activity."}}
|
||||
if display_name.strip():
|
||||
attrs["display_name"] = {"value": display_name.strip()[:80], "requirement": "optional"}
|
||||
f = user_file()
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text(yaml.safe_dump({"spec": "attribute-set/v0.1", "id": USER_SET_ID, "title": "User attributes",
|
||||
"attributes": attrs}, sort_keys=False))
|
||||
return f
|
||||
|
||||
|
||||
def attribute_set() -> Doc:
|
||||
"""The active user as an attribute set, for projects and forms to reuse (`attribute_sets: [user-attributes]`)."""
|
||||
u = current()
|
||||
attrs = {"operator": {"value": u["operator"], "requirement": "required"}}
|
||||
if u["display_name"]:
|
||||
attrs["display_name"] = {"value": u["display_name"], "requirement": "optional"}
|
||||
return Doc(user_file(), {"spec": "attribute-set/v0.1", "id": USER_SET_ID, "title": "User attributes",
|
||||
"attributes": attrs}, [])
|
||||
Loading…
Add table
Add a link
Reference in a new issue