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
170 lines
9.2 KiB
Python
170 lines
9.2 KiB
Python
"""UI pages for attribute sets: list, inspect (with what they resolve to and who reuses them), create and edit."""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
from pathlib import Path
|
|
|
|
from . import user as USER
|
|
from .attrs import fold, save_set, set_layers, used_by
|
|
|
|
EXTRA_ROWS = 3
|
|
|
|
|
|
def e(s) -> str:
|
|
return html.escape(str(s))
|
|
|
|
|
|
def pill(text: str) -> str:
|
|
return f"<span class=pill data-s='{e(text)}'>{e(text)}</span>"
|
|
|
|
|
|
def _is_app_level(doc) -> bool:
|
|
return Path(doc.path) == USER.user_file()
|
|
|
|
|
|
def _where(app, doc) -> str:
|
|
if _is_app_level(doc):
|
|
return "app (you)"
|
|
for r in app.roots:
|
|
try:
|
|
return f"{r.name}/{Path(doc.path).relative_to(r)}"
|
|
except ValueError:
|
|
continue
|
|
return str(doc.path)
|
|
|
|
|
|
def _usage(app, reg, sid: str) -> str:
|
|
u = used_by(reg, sid)
|
|
parts = []
|
|
parts += [f"<a href='/project/{e(p)}'>{e(p)}</a>" for p in u["projects"]]
|
|
parts += [f"<a href='/template/{e(t)}'>{e(t)}</a> <span class=note>(template)</span>" for t in u["templates"]]
|
|
parts += [f"<a href='/attribute-set/{e(x)}'>{e(x)}</a> <span class=note>(extends it)</span>" for x in u["sets"]]
|
|
return ", ".join(parts) or "<span class=note>nothing yet</span>"
|
|
|
|
|
|
def sets_page(app, msg: str = "") -> bytes:
|
|
from .ui import page
|
|
reg = app.registry()
|
|
rows = ""
|
|
for sid, doc in sorted(reg.sets.items()):
|
|
n = len(doc.data.get("attributes", {})) if doc.ok else 0
|
|
state = "" if doc.ok else f" <span class=bad>invalid: {e(doc.errors[0])}</span>"
|
|
ext = ", ".join(f"<a href='/attribute-set/{e(x)}'>{e(x)}</a>" for x in doc.data.get("extends", [])) or "-"
|
|
rows += (f"<tr><td><a href='/attribute-set/{e(sid)}'>{e(doc.data.get('title', sid))}</a>{state}<br><span class=note>{e(sid)}</span></td>"
|
|
f"<td>{e(_where(app, doc))}</td><td>{ext}</td><td>{n}</td><td>{_usage(app, reg, sid)}</td></tr>")
|
|
roots = "".join(f"<option value='{i}'>{e(r.name)} ({e(r)})</option>" for i, r in enumerate(app.roots))
|
|
extends_help = ", ".join(sorted(reg.sets)) or "none yet"
|
|
body = ("<h1>Attribute sets</h1>"
|
|
"<p class=note>Named, reusable, non-secret facts. Runbooks receive them as inputs, projects and templates compose them, and one set "
|
|
"can extend another (later layers win, and every value keeps its source). Nothing here may be a secret.</p>"
|
|
+ (f"<p class=bad>{e(msg)}</p>" if msg else "")
|
|
+ ("<table><tr><th>Set</th><th>Where it lives</th><th>Extends</th><th>Attributes</th><th>Reused by</th></tr>" + rows + "</table>"
|
|
if rows else "<p class=note>No attribute sets yet.</p>")
|
|
+ "<details class=override><summary>New attribute set</summary>"
|
|
f"<form method=post action='/attribute-sets/new'>{app.hidden()}"
|
|
"<p><label>Id<br><input name=set_id required pattern='[a-z0-9]+([.-][a-z0-9]+)*' placeholder='lower-case, digits, dots, hyphens'></label></p>"
|
|
"<p><label>Title<br><input name=title required minlength=3 class=grow style='width:100%'></label></p>"
|
|
f"<p><label>Stored in repository<br><select name=root>{roots}</select></label></p>"
|
|
f"<p><label>Extends (ids, comma separated)<br><input name=extends class=grow style='width:100%' placeholder='available: {e(extends_help)}'></label></p>"
|
|
"<button>Create</button></form></details>")
|
|
return page("Attribute sets", body)
|
|
|
|
|
|
def set_page(app, sid: str, msg: str = "") -> bytes | None:
|
|
from .ui import page
|
|
reg = app.registry()
|
|
doc = reg.sets.get(sid)
|
|
if doc is None:
|
|
return None
|
|
d = doc.data
|
|
out = [f"<h1>{e(d.get('title', sid))}</h1><p>{pill('app' if _is_app_level(doc) else 'repository')} <code>{e(sid)}</code> "
|
|
f"<span class=note>{e(_where(app, doc))}</span></p>"]
|
|
if msg:
|
|
out.append(f"<p class=bad>{e(msg)}</p>")
|
|
if not doc.ok:
|
|
out.append("<p class=bad>This set is invalid:</p><ul>" + "".join(f"<li>{e(x)}</li>" for x in doc.errors) + "</ul>")
|
|
return page(d.get("title", sid), "".join(out))
|
|
out.append(f"<p><b>Reused by:</b> {_usage(app, reg, sid)}</p>")
|
|
try:
|
|
folded = fold(set_layers(reg, sid))
|
|
except ValueError as ex:
|
|
out.append(f"<p class=bad>{e(ex)}</p>")
|
|
folded = {}
|
|
rows = ""
|
|
for name, a in folded.items():
|
|
val = "" if a["value"] is None else a["value"]
|
|
rows += (f"<tr><td><code>{e(name)}</code></td><td>{e(val) if val != '' else '<span class=note>(no value)</span>'}</td>"
|
|
f"<td>{e(a['requirement'] or '-')}</td><td>{e(a.get('note', ''))}</td><td class=note>{e(a['source'] or '-')}"
|
|
f"{' ' + pill('modified') if a['modified'] else ''}</td></tr>")
|
|
out.append("<h2>What this set resolves to</h2><p class=note>Including everything it extends; the source shows which set gave each value.</p>"
|
|
"<table><tr><th>Attribute</th><th>Value</th><th>Requirement</th><th>Note</th><th>Source</th></tr>" + rows + "</table>")
|
|
if _is_app_level(doc):
|
|
out.append("<p class=note>This is <b>your</b> set: it follows you across every project. Change it on "
|
|
"<a href='/user'>your settings page</a>. A repository may define its own set with the same id, which then wins.</p>")
|
|
return page(d.get("title", sid), "".join(out))
|
|
own = d.get("attributes", {})
|
|
trs = ""
|
|
from .attrs import normalize
|
|
for i, (name, a) in enumerate(normalize(own).items()):
|
|
req = a.get("requirement") or ""
|
|
opts = "".join(f"<option value='{o}'{' selected' if o == req else ''}>{o or '(none)'}</option>" for o in ("", "required", "optional"))
|
|
trs += (f"<tr><td><input name=n{i} value='{e(name)}' readonly size=14></td>"
|
|
f"<td><input name=v{i} value='{e('' if a['value'] is None else a['value'])}'></td><td><select name=r{i}>{opts}</select></td>"
|
|
f"<td><input name=t{i} value='{e(a.get('note') or '')}'></td><td><label><input type=checkbox name=x{i} value=1> remove</label></td></tr>")
|
|
base = len(own)
|
|
for j in range(EXTRA_ROWS):
|
|
i = base + j
|
|
trs += (f"<tr><td><input name=n{i} placeholder='new_attribute' size=14 pattern='[a-z][a-z0-9_]*'></td><td><input name=v{i}></td>"
|
|
f"<td><select name=r{i}><option value=''>(none)</option><option>required</option><option>optional</option></select></td>"
|
|
f"<td><input name=t{i}></td><td></td></tr>")
|
|
warn = ""
|
|
u = used_by(reg, sid)
|
|
if u["projects"] or u["templates"] or u["sets"]:
|
|
warn = ("<p class=note>Other things reuse this set. Changing a value changes what their runbooks receive from now on; "
|
|
"runs that already started keep the values they started with.</p>")
|
|
out.append("<h2>Edit</h2>" + warn +
|
|
f"<form method=post action='/attribute-set/{e(sid)}'>{app.hidden()}"
|
|
f"<p><label>Title<br><input name=title value='{e(d.get('title', ''))}' required minlength=3 class=grow style='width:100%'></label></p>"
|
|
f"<p><label>Extends (ids, comma separated)<br><input name=extends value='{e(', '.join(d.get('extends', [])))}' class=grow style='width:100%'></label></p>"
|
|
f"<input type=hidden name=rows value={base + EXTRA_ROWS}>"
|
|
"<table class=edit><tr><th>Attribute</th><th>Value</th><th>Requirement</th><th>Note</th><th></th></tr>" + trs + "</table>"
|
|
"<p class=note>A value can be left empty for a required attribute: it then has to be given by whoever uses the set. "
|
|
"Never enter secrets.</p><button>Save</button></form>")
|
|
return page(d.get("title", sid), "".join(out))
|
|
|
|
|
|
def _split(text: str) -> list[str]:
|
|
return [x for x in text.replace(",", " ").split() if x]
|
|
|
|
|
|
def handle_post(app, parts: list[str], form: dict) -> str:
|
|
"""Create or edit an attribute set. Returns the URL to go to; raises ValueError with the reason if refused."""
|
|
reg = app.registry()
|
|
if parts == ["attribute-sets", "new"]:
|
|
try:
|
|
root = app.roots[int(form.get("root", "0"))]
|
|
except (ValueError, IndexError):
|
|
raise ValueError("choose a repository")
|
|
sid = form.get("set_id", "").strip()
|
|
if sid in reg.sets:
|
|
raise ValueError(f"an attribute set {sid} already exists")
|
|
save_set(reg, root, sid, form.get("title", ""), _split(form.get("extends", "")), {})
|
|
return f"/attribute-set/{sid}"
|
|
if len(parts) == 2 and parts[0] == "attribute-set":
|
|
sid = parts[1]
|
|
doc = reg.sets.get(sid)
|
|
if doc is None:
|
|
raise KeyError(sid)
|
|
if _is_app_level(doc):
|
|
raise ValueError("this is your app-level set: change it on your settings page")
|
|
attrs: dict = {}
|
|
for i in range(int(form.get("rows", "0") or 0)):
|
|
name = form.get(f"n{i}", "").strip()
|
|
if not name or form.get(f"x{i}"):
|
|
continue
|
|
if name in attrs:
|
|
raise ValueError(f"{name} appears twice")
|
|
attrs[name] = {"value": form.get(f"v{i}", ""), "requirement": form.get(f"r{i}", ""), "note": form.get(f"t{i}", "")}
|
|
save_set(reg, Path(doc.path).parent.parent, sid, form.get("title", ""), _split(form.get("extends", "")), attrs, existing_path=Path(doc.path))
|
|
return f"/attribute-set/{sid}"
|
|
raise ValueError("unknown action")
|