informed-decision/informed_decision/ui.py

123 lines
10 KiB
Python
Raw Permalink Normal View History

"""Server-rendered review; no scripts, telemetry or browser-held bearer token."""
from html import escape
from urllib.parse import quote
import uuid
from .disposition import Verb, legal_verbs
STYLES = """
:root{color-scheme:light;font-family:system-ui,sans-serif;color:#172d35;background:#f4f5ef}
*{box-sizing:border-box}body{margin:0}a{color:#145c65}header{background:#173d43;color:white;padding:1.25rem max(1.25rem,calc((100vw - 1080px)/2));display:flex;justify-content:space-between;align-items:center;gap:1rem}header a{color:inherit;text-decoration:none;font-weight:700}header small{color:#d2e8df}main{max-width:1080px;margin:2.5rem auto;padding:0 1.25rem}h1{font-size:clamp(1.7rem,3vw,2.6rem);line-height:1.2;max-width:850px}h2{font-size:1.2rem;margin-top:0}h3{font-size:1rem}p,li{line-height:1.6}.eyebrow{font-size:.75rem;letter-spacing:.1em;text-transform:uppercase;color:#486a6f}.grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(230px,1fr);gap:1.25rem;align-items:start}.card{background:white;border:1px solid #d9dfd7;border-radius:12px;padding:1.5rem;margin-bottom:1.25rem}.notice{padding:1rem 1.25rem;border-left:4px solid #356f63;background:#e3eee7;margin:1.25rem 0}.warning{border-color:#a26814;background:#fff1d6}dl{margin:0}dt{font-size:.8rem;color:#5e7375;margin-top:1rem}dt:first-child{margin-top:0}dd{margin:.3rem 0;overflow-wrap:anywhere}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.88rem;line-height:1.5;background:#f3f5f0;padding:1rem;border-radius:6px}button{font:inherit;font-weight:600;border:1px solid #1f6460;border-radius:7px;padding:.7rem 1.1rem;background:#23665d;color:white;cursor:pointer}button.secondary{background:white;color:#244d50}button:disabled{opacity:.45;cursor:not-allowed}input,select,textarea{font:inherit;max-width:100%;padding:.65rem;border:1px solid #9badaa;border-radius:5px}input[type=checkbox]{width:1.2rem;height:1.2rem;vertical-align:middle;margin-right:.6rem}label{display:block;line-height:1.5;margin:.7rem 0}textarea{display:block;width:100%;min-height:90px}.muted{font-size:.9rem;color:#567074}.brief{white-space:pre-wrap}.actions{display:flex;flex-wrap:wrap;gap:.7rem}.highlight{padding:.9rem 0;border-bottom:1px solid #e3e8df}.highlight:last-of-type{border-bottom:0}details{margin:1rem 0}summary{cursor:pointer;font-weight:600}.hash{overflow-wrap:anywhere;font-family:monospace;font-size:.8rem}.record{padding:.7rem 0;border-bottom:1px solid #e3e8df}:focus-visible{outline:3px solid #b67e19;outline-offset:3px}footer{padding:1.5rem 0;color:#567074;font-size:.85rem}@media(max-width:740px){.grid{grid-template-columns:1fr}main{margin:1.5rem auto}header{align-items:start;flex-direction:column}}
"""
def text(value):
return escape(str(value), quote=True)
def document(title, content, subject=None):
return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
f'<title>{text(title)} · Informed Decision</title><link rel="stylesheet" href="/assets/review.css">'
'</head><body><header><a href="/">Informed Decision</a>'
f'<small>{text(subject) if subject else "Human review"}</small></header><main>{content}'
'<footer>An approval records a human decision. Execution has its own permission checks.</footer>'
'</main></body></html>')
def hidden(name, value):
return f'<input type="hidden" name="{text(name)}" value="{text(value)}">'
def error_page(message, subject=None):
return document("Review unavailable", '<h1>Review unavailable</h1><p>'+text(message)
+'</p><p><a href="/">Return to Informed Decision</a></p>', subject)
def review_page(page, session):
memo, p = page.memo, page.presentation
base = "/presentations/" + quote(p.id, safe="")
csrf = hidden("csrf", session.csrf)
missing = memo.required_ack_ids - p.acked_highlight_ids
legal = legal_verbs(memo.step_kind)
intent = page.intent
blocked = page.stale or page.engine_status not in ("requested", "approved")
accepted = intent and intent["state"] == "confirmed"
uncertain = intent and intent["state"] in ("in_flight", "unresolved")
if accepted:
notice = '<div class="notice" role="status"><strong>Approval entry recorded.</strong> This record retains the original review and acknowledgments. It does not authorize execution.</div>'
elif uncertain:
notice = '<div class="notice warning" role="status"><strong>Submission outcome is not confirmed.</strong> The entry may have reached Approval Engine. Do not submit another approval. This original record is retained for operator recovery.</div>'
elif page.stale:
notice = '<div class="notice warning" role="status">This is an earlier memo version. It cannot be used for a new action.</div>'
elif blocked:
notice = '<div class="notice warning" role="status">This approval is no longer open for a new entry.</div>'
else:
notice = '<div class="notice">Review the complete request and packet. Acknowledging highlights does not narrow what you accept.</div>'
binding = page.binding
facts = ''.join(f'<dt>{label}</dt><dd>{text(binding.get(key, "Not supplied"))}</dd>' for key, label in
(("action", "Action"), ("target", "Act scope"), ("actor", "Executing identity"),
("principal", "Requesting party"), ("reason", "Reason")))
highlights = ''
for index, h in enumerate(memo.highlights):
acked = h.id in p.acked_highlight_ids
control = (f'<span>✓ Acknowledged</span>' if acked else
f'<label><input type="checkbox" name="h{index}" value="{text(h.id)}">I have reviewed this highlight</label>')
highlights += (f'<div class="highlight"><p><strong>{text(h.note)}</strong></p>'
f'<p class="muted">Document: {text(h.item_id)} · {"Required acknowledgment" if h.required_ack else "Optional acknowledgment"}</p>'
+ (control if not blocked and not accepted and not uncertain else ('<p>Acknowledged</p>' if acked else '<p>Not acknowledged</p>')) + '</div>')
ack_button = '' if blocked or accepted or uncertain else '<button class="secondary" type="submit">Record acknowledgments</button>'
highlights = (f'<section class="card"><h2>Highlights</h2><form method="post" action="{base}/ack">'
+ csrf + highlights + ack_button + '</form></section>') if memo.highlights else ''
packets = ''
for index, item in enumerate(memo.packet):
body = page.documents[item.item_id]
# Browser display is escaped text; every original byte remains available
# through an entitled attachment response, never as executable HTML.
try:
preview = body.decode("utf-8") if len(body) <= 65536 else None
except UnicodeDecodeError:
preview = None
packets += (f'<details><summary>{text(item.label)}</summary>'
+ (f'<pre>{text(preview)}</pre>' if preview is not None else '<p>Open the complete attachment to review this document.</p>')
+ f'<a href="{base}/packet/{index}">Download complete document</a><p class="hash">{text(item.hash)}</p></details>')
packet = '<section class="card"><h2>Complete packet</h2>'+ (packets or '<p>No attachments.</p>') + '</section>'
terms = ''.join(f'<h3>{label}</h3><p class="brief">{text(value)}</p>' for label, value in
(("Terms", memo.binding.terms), ("Justification", memo.binding.justification)) if value)
forms = ''
if not blocked and not accepted and not uncertain:
for verb, label in ((Verb.ACCEPT, "Accept the complete request"), (Verb.RETURN, "Return for improvement"),
(Verb.DISCUSS, "Request discussion"), (Verb.DECLINE, "Decline")):
if verb not in legal:
continue
operation = intent["operation_id"] if intent and verb is Verb.ACCEPT else str(uuid.uuid4())
fields = csrf + hidden("verb", verb.value) + hidden("operation_id", operation)
if verb is Verb.RETURN:
fields += '<label>Reason <select name="reason" required><option value="">Choose a reason</option><option value="clarification_needed">Clarification needed</option><option value="wrong_scope">Scope needs correction</option><option value="missing_information">Information missing</option></select></label>'
if verb in (Verb.RETURN, Verb.DISCUSS):
fields += '<label>Your note <textarea name="note" maxlength="8192"></textarea></label>'
disabled = ' disabled' if missing and verb in (Verb.ACCEPT, Verb.DECLINE) else ''
forms += (f'<details{" open" if verb is Verb.ACCEPT else ""}><summary>{label}</summary>'
f'<form method="post" action="{base}/act">{fields}<p>'
+ ('Acceptance covers the entire request, including the complete packet.' if verb is Verb.ACCEPT else
'This records your response on the memo.')
+ f'</p><button type="submit"{disabled}>{label}</button></form></details>')
if missing:
forms = '<p class="muted">Record all required acknowledgments before accepting or declining.</p>' + forms
records = ''.join(f'<div class="record"><strong>{text(d.verb.value.capitalize())}</strong> · {text(d.at)}'
+ (f'<p>{text(d.note)}</p>' if d.note else '') + '</div>' for d in page.dispositions)
evidence = (f'<details><summary>Evidence details</summary><p>Memo {text(memo.id)} · version {memo.version}</p>'
f'<p>Presentation {text(p.id)}</p><p class="hash">View: {text(p.view_hash)}</p>'
f'<p class="hash">Act: {text(memo.approval_binding_digest)}</p>'
f'<p>Account zone: {text(session.tenant.value)} ({text(session.tenant.route.value)}). This is separate from the act scope.</p></details>')
content = (f'<p class="eyebrow">Decision review · version {memo.version}</p><h1>{text(memo.question)}</h1>'
+ notice + '<div class="grid"><div><section class="card"><h2>The request</h2>'
f'<p><strong>{text(memo.requested_act)}</strong></p><p class="brief">{text(memo.brief)}</p>{terms}</section>'
+ highlights + packet + '<section class="card"><h2>Your response</h2>' + (forms or '<p>No new approval submission is available from this record.</p>')
+ (f'<h3>Recorded responses</h3>{records}' if records else '') + '</section></div>'
+ f'<aside class="card"><h2>What this act covers</h2><dl>{facts}<dt>Person being bound</dt>'
f'<dd>{text(memo.binding.principal.display_name)} · {text(session.subject)}</dd></dl>{evidence}</aside></div>')
return document(memo.question, content, session.subject)