Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions src/opal/web/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2903,7 +2903,6 @@ def risks_table(
rows = [
{
"risk": r,
"badge": DISPOSITION_BADGES.get(r.disposition, "draft"),
"reviewed_age": _relative_age(r.last_reviewed_at) if r.last_reviewed_at else "—",
"reviewed_iso": r.last_reviewed_at.strftime("%Y-%m-%dT%H:%M:%SZ")
if r.last_reviewed_at
Expand Down Expand Up @@ -2974,8 +2973,11 @@ def risks_new(request: Request, db: DbSession) -> HTMLResponse:


@router.get("/risks/{risk_id}", response_class=HTMLResponse)
def risks_detail(request: Request, db: DbSession, risk_id: int) -> HTMLResponse:
"""Risk detail page — the generated statement is the masthead."""
def risks_detail(
request: Request, db: DbSession, risk_id: int, edit: bool = Query(False)
) -> HTMLResponse:
"""Risk detail page — the generated statement is the masthead. Opens
read-only; ?edit=1 renders the in-place editors, DONE returns to view."""
from opal.risks.lint import lint_risk_row
from opal.risks.readiness import readiness
from opal.web.lint_markup import statement_lint_html
Expand All @@ -2991,15 +2993,14 @@ def risks_detail(request: Request, db: DbSession, risk_id: int) -> HTMLResponse:
findings = lint_risk_row(risk)
linked_issue_ids = [link.issue_id for link in risk.issue_links]

parts = (
db.query(Part).filter(Part.deleted_at.is_(None)).order_by(Part.name).limit(200).all()
)
parts = db.query(Part).filter(Part.deleted_at.is_(None)).order_by(Part.name).limit(200).all()
# The dropdown is capped; the set asset must still render as selected.
if risk.asset_part is not None and risk.asset_part not in parts:
parts.append(risk.asset_part)

context = get_base_context(request, db, f"{risk.risk_number} - OPAL")
context["risk"] = risk
context["editing"] = edit
context["badge"] = DISPOSITION_BADGES.get(risk.disposition, "draft")
context["dispositions"] = [d.value for d in RiskDisposition]
context["roles"] = [r.value for r in RiskIssueRole]
Expand All @@ -3021,7 +3022,9 @@ def risks_detail(request: Request, db: DbSession, risk_id: int) -> HTMLResponse:


@router.get("/risks/{risk_id}/acceptance-panel", response_class=HTMLResponse)
def risks_acceptance_panel(request: Request, db: DbSession, risk_id: int) -> HTMLResponse:
def risks_acceptance_panel(
request: Request, db: DbSession, risk_id: int, edit: bool = Query(False)
) -> HTMLResponse:
"""Acceptance panel partial — re-fetched after field saves."""
from opal.risks.readiness import readiness

Expand All @@ -3033,6 +3036,7 @@ def risks_acceptance_panel(request: Request, db: DbSession, risk_id: int) -> HTM
{
"request": request,
"risk": risk,
"editing": edit,
"readiness": readiness(db, risk),
"current_user": _get_current_user(request, db),
},
Expand Down
3 changes: 2 additions & 1 deletion src/opal/web/static/js/risks.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ async function refreshAcceptancePanel() {
// confirm dialog mid-signature.
const confirmEl = document.getElementById('accept-confirm');
const confirmWasOpen = confirmEl && confirmEl.style.display !== 'none';
const r = await fetch(`/risks/${riskId}/acceptance-panel`);
const editing = typeof EDITING !== 'undefined' && EDITING;
const r = await fetch(`/risks/${riskId}/acceptance-panel${editing ? '?edit=1' : ''}`);
if (r.ok) container.innerHTML = await r.text();
if (confirmWasOpen && document.getElementById('accept-confirm')) openAcceptConfirm();
} catch (e) { /* panel refresh is cosmetic; the next save retries */ }
Expand Down
77 changes: 32 additions & 45 deletions src/opal/web/templates/risks/_acceptance_panel.html
Original file line number Diff line number Diff line change
@@ -1,48 +1,35 @@
{# Acceptance readiness panel — mirrors requirements/_baseline_panel.html and
reuses its CSS. Rendered server-side from opal.risks.readiness, re-fetched
via GET /risks/{id}/acceptance-panel after field saves. All checks are
hard — acceptance is a signature. #}
<div class="baseline-panel mono">
<div class="baseline-panel-title">ACCEPTANCE READINESS</div>
{% for check in readiness.checks %}
<div class="baseline-check {% if not check.passed %}baseline-check-fail{% endif %}">
<span class="baseline-check-box">[{{ 'x' if check.passed else ' ' }}]</span>
<span class="baseline-check-label">{{ check.label }}</span>
{% if not check.passed and check.detail %}
<span class="baseline-check-detail">← {{ check.detail }}</span>
{% endif %}
</div>
{% endfor %}
<div class="baseline-panel-rule">──────────────────────────</div>
{% if risk.disposition == 'accepted' %}
{# Acceptance — the ACCEPT button carries the gate: grayed until every hard
check passes, the tooltip names what is missing (opal.risks.readiness is
the one source; the gate and accept() cannot drift). Re-fetched via
GET /risks/{id}/acceptance-panel?edit=… after field saves. Signature
facts (ACCEPTED · RATIONALE) live in the detail rail once signed. #}
{% if risk.disposition not in ('accepted', 'realized') %}
{% if editing %}
<div class="form-group" style="margin-bottom: var(--space-sm);">
<label class="form-label" for="acceptance-rationale">ACCEPTANCE RATIONALE</label>
<textarea id="acceptance-rationale" class="form-textarea mono" rows="2"
onblur="updateRisk('acceptance_rationale', this.value || null)">{{ risk.acceptance_rationale or '' }}</textarea>
</div>
{% set short = {'scenario_complete': 'scenario', 'owner_assigned': 'owner', 'scored': 'score', 'rationale_recorded': 'rationale', 'lint_clean': 'lint'} %}
{% set failing = readiness.checks | rejectattr('passed') | map(attribute='key') | list %}
<button type="button" class="btn btn-primary" id="accept-btn"
{% if not readiness.ready %}disabled title="missing: {% for k in failing %}{{ short.get(k, k) }}{% if not loop.last %}, {% endif %}{% endfor %}"{% endif %}
onclick="openAcceptConfirm()">
ACCEPT {{ risk.risk_number }}
</button>
<div id="accept-confirm" class="baseline-confirm" style="display: none;">
<div class="baseline-signature">
accepted: <span class="text-green">{{ risk.accepted_by.name if risk.accepted_by else '?' }}</span>
{% if risk.accepted_at %}· <span class="mono">{{ risk.accepted_at.strftime('%Y-%m-%dT%H:%M:%SZ') }}</span>{% endif %}<br>
rationale: {{ risk.acceptance_rationale }}
</div>
{% elif risk.disposition == 'realized' %}
<div class="baseline-signature text-muted">realized — terminal</div>
{% else %}
<div class="form-group" style="margin-bottom: var(--space-sm);">
<label class="form-label" for="acceptance-rationale">ACCEPTANCE RATIONALE</label>
<textarea id="acceptance-rationale" class="form-textarea mono" rows="2"
onblur="updateRisk('acceptance_rationale', this.value || null)">{{ risk.acceptance_rationale or '' }}</textarea>
</div>
<button type="button" class="btn btn-primary" id="accept-btn"
{% if not readiness.ready %}disabled title="clear the blockers above first"{% endif %}
onclick="openAcceptConfirm()">
ACCEPT {{ risk.risk_number }}
</button>
<div id="accept-confirm" class="baseline-confirm" style="display: none;">
<div class="baseline-signature">
accepting <span class="text-green">{{ risk.risk_number }}</span>
at <span class="sev-{{ risk.severity }}">{{ risk.score }}</span>{% if risk.residual_score is not none %} → <span class="sev-{{ risk.residual_severity }}">{{ risk.residual_score }}</span>{% endif %}<br>
rationale: {{ risk.acceptance_rationale or '' }}<br>
signed: <span class="text-green">{{ current_user.name if current_user else '(no session user)' }}</span>
· <span id="accept-sign-time"></span>
</div>
<button type="button" class="btn btn-primary" onclick="commitAccept()" {% if not current_user %}disabled title="select a user first"{% endif %}>CONFIRM</button>
<button type="button" class="btn" onclick="document.getElementById('accept-confirm').style.display='none'">ABORT</button>
accepting <span class="text-green">{{ risk.risk_number }}</span>
at <span class="sev-{{ risk.severity }}">{{ risk.score }}</span>{% if risk.residual_score is not none %} → <span class="sev-{{ risk.residual_severity }}">{{ risk.residual_score }}</span>{% endif %}<br>
rationale: {{ risk.acceptance_rationale or '' }}<br>
signed: <span class="text-green">{{ current_user.name if current_user else '(no session user)' }}</span>
· <span id="accept-sign-time"></span>
</div>
{% endif %}
<button type="button" class="btn btn-primary" onclick="commitAccept()" {% if not current_user %}disabled title="select a user first"{% endif %}>CONFIRM</button>
<button type="button" class="btn" onclick="document.getElementById('accept-confirm').style.display='none'">ABORT</button>
</div>
{% elif risk.acceptance_rationale %}
<div class="form-label">ACCEPTANCE RATIONALE</div>
<div class="mono" style="padding: var(--space-sm); background: var(--bg-tertiary); border: 1px solid var(--border-color); white-space: pre-wrap;">{{ risk.acceptance_rationale }}</div>
{% endif %}
{% endif %}
Loading