diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index e083c2e..f1dfc6b 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -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 @@ -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 @@ -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] @@ -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 @@ -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), }, diff --git a/src/opal/web/static/js/risks.js b/src/opal/web/static/js/risks.js index 1240983..51b646c 100644 --- a/src/opal/web/static/js/risks.js +++ b/src/opal/web/static/js/risks.js @@ -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 */ } diff --git a/src/opal/web/templates/risks/_acceptance_panel.html b/src/opal/web/templates/risks/_acceptance_panel.html index bd66b6d..23c7216 100644 --- a/src/opal/web/templates/risks/_acceptance_panel.html +++ b/src/opal/web/templates/risks/_acceptance_panel.html @@ -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. #} -
-
ACCEPTANCE READINESS
- {% for check in readiness.checks %} -
- [{{ 'x' if check.passed else ' ' }}] - {{ check.label }} - {% if not check.passed and check.detail %} - ← {{ check.detail }} - {% endif %} -
- {% endfor %} -
──────────────────────────
- {% 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 %} +
+ + +
+{% 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 %} + + +{% elif risk.acceptance_rationale %} +
ACCEPTANCE RATIONALE
+
{{ risk.acceptance_rationale }}
+{% endif %} +{% endif %} diff --git a/src/opal/web/templates/risks/detail.html b/src/opal/web/templates/risks/detail.html index 225550c..30f0d7f 100644 --- a/src/opal/web/templates/risks/detail.html +++ b/src/opal/web/templates/risks/detail.html @@ -8,7 +8,13 @@ {% endblock %} {% block content %} -{% set actions %}{{ risk.score }}{% if risk.residual_score is not none %} → {{ risk.residual_score }}{% endif %} {{ ok.status(risk.disposition | upper, badge) }}{% endset %} +{# View mode renders facts; ?edit=1 renders the in-place editors. Each + editor saves on blur; DONE returns to view. #} +{% macro fact_block(value, mono=true) %} +
{{ value or '—' }}
+{% endmacro %} + +{% set actions %}{{ risk.score }}{% if risk.residual_score is not none %} → {{ risk.residual_score }}{% endif %} {{ ok.status(risk.disposition | upper, badge) }}{% if editing %}{{ ok.btn("DONE", href="/risks/" ~ risk.id, size="sm", variant="primary") }}{% else %}{{ ok.btn("EDIT", href="/risks/" ~ risk.id ~ "?edit=1", size="sm") }}{% endif %}{% endset %} {% call ok.panel(risk.risk_number ~ " · " ~ risk.title, actions=actions, max_width="900px") %}
@@ -16,20 +22,29 @@
CONDITION — present fact, true now
+ {% if editing %}
+ {% else %} +
{{ fact_block(risk.condition) }}
+ {% endif %}
DEPARTURE — future undesired event; probability scores this
+ {% if editing %}
+ {% else %} +
{{ fact_block(risk.departure) }}
+ {% endif %}
ASSET — what is exposed: a part, or a name (schedule, campaign, ...)
+ {% if editing %}
+ {% else %} +
+ {% if risk.asset_part %} + + {% else %} + {{ fact_block(risk.asset_text) }} + {% endif %} +
+ {% endif %}
CONSEQUENCE — measurable impact; impact scores this
+ {% if editing %}
+ {% else %} +
{{ fact_block(risk.consequence) }}
+ {% endif %} + {% set prob_labels = {1: '1 - Rare', 2: '2 - Unlikely', 3: '3 - Possible', 4: '4 - Likely', 5: '5 - Almost Certain'} %} + {% set impact_labels = {1: '1 - Negligible', 2: '2 - Minor', 3: '3 - Moderate', 4: '4 - Major', 5: '5 - Severe'} %} {% call ok.table() %} + {% if editing %} OWNER @@ -64,7 +95,7 @@ PROBABILITY @@ -74,7 +105,7 @@ IMPACT @@ -98,11 +129,23 @@ + {% else %} + {{ ok.detail_row("OWNER", risk.owner.name if risk.owner else '—', th_width="160px") }} + {{ ok.detail_row("PROBABILITY", prob_labels[risk.probability] if risk.probability in prob_labels else risk.probability, mono=true) }} + {{ ok.detail_row("IMPACT", impact_labels[risk.impact] if risk.impact in impact_labels else risk.impact, mono=true) }} + {{ ok.detail_row("RESIDUAL", (risk.residual_probability ~ ' × ' ~ risk.residual_impact) if risk.residual_probability is not none and risk.residual_impact is not none else '—', mono=true) }} + {% endif %} {% if risk.accepted_at %} ACCEPTED {{ risk.accepted_by.name if risk.accepted_by else '?' }} · {{ ok.timestamp(risk.accepted_at) }} + {% if risk.acceptance_rationale %} + + RATIONALE + {{ risk.acceptance_rationale }} + + {% endif %} {% endif %} {% if risk.realized_issue %} @@ -126,6 +169,7 @@ {% endcall %} + {% if editing %}
DISPOSITION
@@ -185,11 +231,16 @@
+ {% endif %} -
NARRATIVE — context, evidence, suggested responses
+
NARRATIVE — context, evidence, suggested responses
+ {% if editing %} + {% else %} + {{ fact_block(risk.description, mono=false) }} + {% endif %}
@@ -202,6 +253,7 @@ {% endblock %} diff --git a/src/opal/web/templates/risks/table_rows.html b/src/opal/web/templates/risks/table_rows.html index c697762..c7a37d2 100644 --- a/src/opal/web/templates/risks/table_rows.html +++ b/src/opal/web/templates/risks/table_rows.html @@ -4,7 +4,7 @@ {{ risk.risk_number }} {{ risk.title }} - {{ ok.status(risk.disposition | upper, row.badge) }} + {% if risk.disposition == 'realized' %}{{ ok.status("REALIZED", "error") }}{% elif risk.disposition == 'closed' %}CLOSED{% else %}{{ risk.disposition | upper }}{% endif %} {{ risk.score }}{% if risk.residual_score is not none %} → {{ risk.residual_score }}{% endif %} {{ risk.owner.name if risk.owner else '—' }} {{ row.reviewed_age }} diff --git a/tests/unit/test_risks_web.py b/tests/unit/test_risks_web.py index c17c49b..c49fb20 100644 --- a/tests/unit/test_risks_web.py +++ b/tests/unit/test_risks_web.py @@ -69,14 +69,35 @@ def test_detail_incomplete_scenario_masthead_placeholder(web_client): assert f"RISK #{risk['id']}" not in page.text assert "risk-statement-incomplete" in page.text assert "scenario incomplete" in page.text - # The four scenario editors exist with lint overlays + + +def test_detail_view_mode_default(web_client): + """The risk page opens read-only: facts, an EDIT control, no editors, + no acceptance form. ?edit=1 renders the editors and the gated ACCEPT.""" + risk = _create_risk(web_client) + + page = web_client.get(f"/risks/{risk['id']}") + assert page.status_code == 200 + assert "?edit=1" in page.text + for field in ("condition", "departure", "consequence"): + assert f'id="{field}-text"' not in page.text + assert 'id="asset-part-select"' not in page.text + assert 'id="disposition-select"' not in page.text + assert f"ACCEPT {risk['risk_number']}" not in page.text + assert "ACCEPTANCE READINESS" not in page.text + + page = web_client.get(f"/risks/{risk['id']}?edit=1") + assert page.status_code == 200 + assert ">DONE<" in page.text + # The scenario editors exist with lint overlays for field in ("condition", "departure", "consequence"): assert f'id="{field}-text"' in page.text assert f'id="{field}-overlay"' in page.text - # Acceptance panel renders with unmet checks and a disabled accept button - assert "ACCEPTANCE READINESS" in page.text + assert 'id="disposition-select"' in page.text + # No readiness checklist: one gated button, the tooltip names the missing + assert "ACCEPTANCE READINESS" not in page.text assert f"ACCEPT {risk['risk_number']}" in page.text - assert "disabled" in page.text + assert 'disabled title="missing:' in page.text def test_detail_complete_scenario_statement_is_masthead(web_client, test_user): @@ -92,7 +113,7 @@ def test_detail_complete_scenario_statement_is_masthead(web_client, test_user): def test_detail_lint_underline_renders_server_side(web_client): risk = _create_risk(web_client, condition="the valve might stick open") - page = web_client.get(f"/risks/{risk['id']}") + page = web_client.get(f"/risks/{risk['id']}?edit=1") assert page.status_code == 200 assert "lint-block" in page.text # RL-01 is block_accept severity assert "RL-01" in page.text @@ -102,17 +123,20 @@ def test_acceptance_panel_partial_and_accept_flow(web_client, test_user): risk = _create_risk(web_client) _complete_scenario(web_client, risk, test_user.id) - panel = web_client.get(f"/risks/{risk['id']}/acceptance-panel") + panel = web_client.get(f"/risks/{risk['id']}/acceptance-panel?edit=1") assert panel.status_code == 200 - assert "[x]" in panel.text assert "openAcceptConfirm()" in panel.text + assert "ACCEPTANCE READINESS" not in panel.text accepted = web_client.post(f"/api/risks/{risk['id']}/accept", json={}) assert accepted.status_code == 200, accepted.text + # Signature facts live in the rail: ACCEPTED · RATIONALE; the form is gone. page = web_client.get(f"/risks/{risk['id']}") - assert "accepted:" in page.text + assert "ACCEPTED" in page.text assert test_user.name in page.text + assert "heritage hardware, uncrewed, site rated" in page.text + assert f"ACCEPT {risk['risk_number']}" not in web_client.get(f"/risks/{risk['id']}?edit=1").text def test_disposition_panel_fields_only_for_selected_target(web_client):