Skip to content

Commit 7599096

Browse files
claudefrousselet
authored andcommitted
Add risk treatment flow Sankey chart to dashboard
Render a cash-flow style Sankey diagram above the dashboard risk matrices, visualising how treatment moves each risk from its current severity level (before treatment) to its residual level (after treatment). Each flow's thickness is the number of risks making that transition, so effective treatment reads as a heavy downward flow. - build_risk_treatment_flow() in risks/views.py aggregates risks by (current level, residual level); levels are derived from the likelihood/impact pairs with the same default 5x5 ISO 27005 fallback as the matrices, so the chart stays consistent when no criteria exist. - Columns keep severity order (highest at top, lowest at bottom) via ECharts layoutIterations: 0; nodes use the configured risk-level palette; light/dark mode aware. - Rendered with Apache ECharts (sankey series), loaded from CDN like the other vendored frontend libraries. - French translations, README/CHANGELOG/features docs, and tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 07c958d commit 7599096

8 files changed

Lines changed: 287 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Dashboard risk treatment flow (Sankey)**: a new Sankey (cash-flow style) chart on the home dashboard, displayed above the risk matrices, visualises how treatment moves each risk from its current severity level (before treatment) to its residual level (after treatment). Each column lists the severity levels present (highest at the top, lowest at the bottom), coloured with the configured risk-level palette, and each flow's thickness is the number of risks making that transition - so a heavy downward flow reads as effective treatment and a flat flow as untreated risk. Levels are derived from the likelihood/impact pairs (with the same default 5x5 ISO 27005 fallback as the matrices), so the chart stays consistent with the matrices below even when no risk criteria are configured. The chart honours light/dark mode and is rendered with Apache ECharts. Built from a new `build_risk_treatment_flow()` helper in `risks/views.py`.
1213
- **Ask Cairn: OpenAI and OpenAI-compatible providers**: the assistant gains an `openai` backend (`AI_ASSISTANT_PROVIDER=openai`) that targets OpenAI (ChatGPT, e.g. `gpt-4o-mini`) out of the box and, via `AI_ASSISTANT_BASE_URL`, any other endpoint implementing the OpenAI `/chat/completions` and `/embeddings` API (vLLM, LiteLLM, LocalAI, Together, Groq...). The shared request/response handling was extracted into a generic `OpenAICompatibleClient`; the existing `MistralClient` is now a thin subclass of it (Mistral already exposes an OpenAI-compatible API), so behaviour is unchanged for Mistral users. `AI_ASSISTANT_BASE_URL` now defaults to empty and each provider falls back to its own endpoint (`mistral` -> `api.mistral.ai`, `openai` -> `api.openai.com`, `anthropic` -> `api.anthropic.com`); set it only to target a custom gateway.
1314
- **Ask Cairn: Claude (Anthropic) provider**: a native `anthropic` backend (`AI_ASSISTANT_PROVIDER=anthropic`) talks to Claude through the Messages API (`POST /v1/messages`, `x-api-key` header, top-level `system`, `content` block list) - Claude is not OpenAI-compatible, so it has its own client. Routing uses forced tool use (a `plan` tool whose `input_schema` is the routing schema) and no `temperature`/`thinking` is sent (both 400 on the current Opus family). Set `AI_ASSISTANT_MODEL` to a Claude model id (e.g. `claude-opus-4-8`). Semantic search is not available with this provider, since Anthropic has no embeddings API.
1415
- **Ask Cairn: automatic semantic index maintenance**: the requirement semantic index now stays fresh without a manual command. A `post_delete` signal prunes a deleted requirement's embedding immediately (no provider call); the index is refreshed in a guarded background thread when a server process starts (when `AI_ASSISTANT_SEMANTIC_ENABLED`); and a dedicated **Administration -> Semantic index** page shows an index-status panel (indexed / total requirements, last updated, embedding model) with an **"Update the index now"** button (gated by `system.config.update`) that triggers a background rebuild. Embedding stays off the request path - requirement saves never embed inline; the documented daily `rebuild_semantic_index` cron remains the self-healing backstop. The rebuild logic was extracted into `assistant.semantic.rebuild_index` / `rebuild_index_async` (cache-locked to dedupe overlapping triggers) and reused by the management command.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Manage your organisation's security posture, track compliance with regulatory fr
1212
- **Assets** : essential and support assets with CIA valuation, dependencies, SPOF detection and a supplier registry
1313
- **Risks** : ISO 27005 and EBIOS RM (ANSSI v1.5, workshops 0 to 5) assessments, threat and vulnerability catalogs, treatment plans and formal risk acceptance
1414
- **Compliance** : frameworks, requirements, assessments, findings, action plans and inter-framework mappings, with Excel import
15-
- **Steering** : real-time dashboard, KPI indicators, ISO 27001 management reviews, and PDF/DOCX/PPTX report generation (SoA, audit report, risk register, meeting minutes)
15+
- **Steering** : real-time dashboard (risk matrices and a current-to-residual risk treatment flow chart), KPI indicators, ISO 27001 management reviews, and PDF/DOCX/PPTX report generation (SoA, audit report, risk register, meeting minutes)
1616
- **Ask Cairn (optional)** : natural-language questions in the command palette ("Which decisions were made at the last management review?"), answered by a pluggable LLM provider (Mistral AI by default; OpenAI / any OpenAI-compatible endpoint; Claude; self-hosted Ollama) that cites real records and enforces your permissions, with thumbs up/down feedback that admins can export to improve the assistant
1717

1818
Everything is bilingual (English/French), audit-ready (full change history, versioning, lifecycle workflows with approvals) and access-controlled (role-based permissions, scope-based tenancy, passkey login).
@@ -47,7 +47,7 @@ To run the published image without cloning the repository, and for production no
4747

4848
## Tech stack
4949

50-
Django 5.2 LTS, PostgreSQL 16, Django REST Framework, Django Channels + Redis (real-time), Bootstrap 5.3 + HTMX (frontend), Docker. Optional: Mistral AI, OpenAI / OpenAI-compatible endpoints, Claude (Anthropic), or self-hosted Ollama (Ask Cairn assistant).
50+
Django 5.2 LTS, PostgreSQL 16, Django REST Framework, Django Channels + Redis (real-time), Bootstrap 5.3 + HTMX + Apache ECharts (frontend), Docker. Optional: Mistral AI, OpenAI / OpenAI-compatible endpoints, Claude (Anthropic), or self-hosted Ollama (Ask Cairn assistant).
5151

5252
## Licence
5353

core/tests/test_dashboard.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,62 @@ def test_matrix_with_risks(self):
630630
assert resp.context["matrix_residual"] is not None
631631

632632

633+
class TestDashboardRiskTreatmentFlow:
634+
"""Sankey flow from current risk level to residual risk level."""
635+
636+
def test_no_flow_without_risks(self):
637+
client, user = _superuser_client()
638+
resp = client.get(reverse("home"))
639+
assert resp.context["risk_treatment_flow"] is None
640+
641+
def test_flow_with_risk(self):
642+
RiskFactory(
643+
current_likelihood=4,
644+
current_impact=5,
645+
residual_likelihood=2,
646+
residual_impact=2,
647+
)
648+
client, user = _superuser_client()
649+
resp = client.get(reverse("home"))
650+
flow = resp.context["risk_treatment_flow"]
651+
assert flow is not None
652+
assert flow["total"] == 1
653+
assert len(flow["links"]) == 1
654+
# One current-level node and one residual-level node.
655+
assert len(flow["nodes"]) == 2
656+
link = flow["links"][0]
657+
assert link["value"] == 1
658+
assert link["source"].startswith("c")
659+
assert link["target"].startswith("r")
660+
661+
def test_flow_aggregates_identical_transitions(self):
662+
for _i in range(3):
663+
RiskFactory(
664+
current_likelihood=4,
665+
current_impact=5,
666+
residual_likelihood=2,
667+
residual_impact=2,
668+
)
669+
client, user = _superuser_client()
670+
resp = client.get(reverse("home"))
671+
flow = resp.context["risk_treatment_flow"]
672+
assert flow["total"] == 3
673+
assert len(flow["links"]) == 1
674+
assert flow["links"][0]["value"] == 3
675+
676+
def test_flow_skips_risks_without_both_levels(self):
677+
# Current evaluated but no residual evaluation -> excluded.
678+
RiskFactory(
679+
current_likelihood=3,
680+
current_impact=3,
681+
residual_likelihood=None,
682+
residual_impact=None,
683+
)
684+
client, user = _superuser_client()
685+
resp = client.get(reverse("home"))
686+
assert resp.context["risk_treatment_flow"] is None
687+
688+
633689
# ── Company identity in the header ──────────────────────────
634690

635691

core/views.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@
4444
Threat,
4545
Vulnerability,
4646
)
47-
from risks.views import build_default_risk_matrix, build_risk_matrix
47+
from risks.views import (
48+
build_default_risk_matrix,
49+
build_risk_matrix,
50+
build_risk_treatment_flow,
51+
)
4852

4953

5054
class GeneralDashboardView(LoginRequiredMixin, TemplateView):
@@ -178,6 +182,10 @@ def get_context_data(self, **kwargs):
178182
all_risks, "residual_likelihood", "residual_impact"
179183
)
180184

185+
# Risk treatment flow (current level -> residual level), rendered as a
186+
# Sankey diagram above the matrices.
187+
ctx["risk_treatment_flow"] = build_risk_treatment_flow(all_risks, criteria)
188+
181189
# ── Conformité ───────────────────────────────────
182190
# Per-framework compliance segments and the overall average come
183191
# from compliance.services (shared with the WebSocket refresh and

docs/features.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Detailed feature reference for Cairn. For module-level specifications (business
5353
| Treatment Plans | Structured remediation with ordered actions, progress tracking, cost estimates and linkage to compliance action plans |
5454
| Risk Acceptance | Formal acceptance records with expiry dates, conditions, review tracking and two-step approval workflow |
5555
| Risk Matrices | Visual heatmaps (current vs residual) |
56+
| Risk Treatment Flow | Sankey (cash-flow style) chart on the dashboard showing how treatment moves risks from their current level to their residual level, weighted by the number of risks per transition |
5657

5758
## Compliance
5859

locale/fr/LC_MESSAGES/django.po

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9911,3 +9911,27 @@ msgstr "Index sémantique"
99119911

99129912
msgid "Semantic index - Cairn"
99139913
msgstr "Index sémantique - Cairn"
9914+
9915+
msgid "Risk treatment flow"
9916+
msgstr "Flux de traitement des risques"
9917+
9918+
msgid ""
9919+
"How treatment moves risks from their current level (before treatment) to "
9920+
"their residual level (after treatment)"
9921+
msgstr ""
9922+
"Comment le traitement fait passer les risques de leur niveau actuel (avant "
9923+
"traitement) à leur niveau résiduel (après traitement)"
9924+
9925+
msgid "Sankey diagram of risk treatment flow from current to residual level"
9926+
msgstr ""
9927+
"Diagramme de Sankey du flux de traitement des risques, du niveau actuel au "
9928+
"niveau résiduel"
9929+
9930+
msgid "1 risk"
9931+
msgstr "1 risque"
9932+
9933+
msgid "risks"
9934+
msgstr "risques"
9935+
9936+
msgid "Level %(n)s"
9937+
msgstr "Niveau %(n)s"

risks/views.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,110 @@ def build_default_risk_matrix(risks_qs=None, likelihood_field="current_likelihoo
200200
}
201201

202202

203+
def build_risk_treatment_flow(risks_qs, criteria=None):
204+
"""Build Sankey flow data : current risk level -> residual risk level.
205+
206+
Visualises how treatment moves each risk from its current (before
207+
treatment) severity level to its residual (after treatment) level, in the
208+
spirit of a cash-flow / Sankey diagram. Each link is one transition
209+
(current level -> residual level) weighted by the number of risks.
210+
211+
Returns a JSON-serialisable dict ``{nodes, links, total}`` consumed by the
212+
ECharts sankey on the dashboard, or ``None`` when no risk carries both a
213+
current and a residual evaluation.
214+
"""
215+
# Resolve each severity level -> {name, color} and the (likelihood, impact)
216+
# -> level matrix, from the configured criteria when available, otherwise
217+
# from the default 5-level ISO 27005 scale. Levels are derived from the
218+
# likelihood/impact pairs (mirroring the matrices) so the flow stays
219+
# consistent even when risks were saved without a criteria snapshot.
220+
level_info = {}
221+
matrix = None
222+
if criteria:
223+
for rl in criteria.risk_levels.all():
224+
level_info[rl.level] = {"name": str(rl.name), "color": rl.color}
225+
if criteria.risk_matrix:
226+
matrix = {
227+
key: int(val) for key, val in criteria.risk_matrix.items()
228+
}
229+
if not level_info:
230+
level_info = {
231+
lvl: {"name": str(info["name"]), "color": info["color"]}
232+
for lvl, info in DEFAULT_RISK_LEVELS.items()
233+
}
234+
if not matrix:
235+
matrix = {f"{l},{i}": lvl for (l, i), lvl in DEFAULT_RISK_MATRIX.items()}
236+
237+
def level_of(likelihood, impact):
238+
if likelihood is None or impact is None:
239+
return None
240+
return matrix.get(f"{likelihood},{impact}")
241+
242+
# Count transitions and per-column totals.
243+
flow = {}
244+
current_totals = {}
245+
residual_totals = {}
246+
for risk in risks_qs:
247+
cl = risk.current_risk_level or level_of(
248+
risk.current_likelihood, risk.current_impact
249+
)
250+
rl = risk.residual_risk_level or level_of(
251+
risk.residual_likelihood, risk.residual_impact
252+
)
253+
if cl is None or rl is None:
254+
continue
255+
flow[(cl, rl)] = flow.get((cl, rl), 0) + 1
256+
current_totals[cl] = current_totals.get(cl, 0) + 1
257+
residual_totals[rl] = residual_totals.get(rl, 0) + 1
258+
259+
if not flow:
260+
return None
261+
262+
default_color = "#9ca3af"
263+
264+
def label_for(level):
265+
info = level_info.get(level)
266+
if info:
267+
return info["name"]
268+
return _("Level %(n)s") % {"n": level}
269+
270+
def color_for(level):
271+
info = level_info.get(level)
272+
return info["color"] if info else default_color
273+
274+
# Nodes : current column (depth 0, label on the left) then residual column
275+
# (depth 1, label on the right). Highest severity first so the heavy,
276+
# high-risk flows sit at the top of each column.
277+
nodes = []
278+
for lvl in sorted(current_totals, reverse=True):
279+
nodes.append({
280+
"name": f"c{lvl}",
281+
"displayName": f"{label_for(lvl)} ({current_totals[lvl]})",
282+
"depth": 0,
283+
"itemStyle": {"color": color_for(lvl)},
284+
"label": {"position": "left"},
285+
})
286+
for lvl in sorted(residual_totals, reverse=True):
287+
nodes.append({
288+
"name": f"r{lvl}",
289+
"displayName": f"{label_for(lvl)} ({residual_totals[lvl]})",
290+
"depth": 1,
291+
"itemStyle": {"color": color_for(lvl)},
292+
"label": {"position": "right"},
293+
})
294+
295+
links = [
296+
{"source": f"c{cl}", "target": f"r{rl}", "value": count}
297+
for (cl, rl), count in flow.items()
298+
]
299+
300+
return {
301+
"nodes": nodes,
302+
"links": links,
303+
"total": sum(flow.values()),
304+
}
305+
306+
203307
class CreatedByMixin:
204308
def form_valid(self, form):
205309
form.instance.created_by = self.request.user

templates/home.html

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,26 @@ <h2 class="mb-0">{% trans "Risk management" %}</h2>
757757
</div>
758758
</div>
759759

760+
{# ── Risk treatment flow (current → residual) ───────── #}
761+
{% if risk_treatment_flow %}
762+
<div class="d-flex align-items-center mb-3 flex-wrap gap-2">
763+
<h5 class="mb-0"><i class="bi bi-diagram-3 me-1" style="color:var(--accent)"></i> {% trans "Risk treatment flow" %}</h5>
764+
<span class="text-muted" style="font-size:.8125rem">{% trans "How treatment moves risks from their current level (before treatment) to their residual level (after treatment)" %}</span>
765+
</div>
766+
<div class="row g-3 mb-4">
767+
<div class="col-12">
768+
<div class="card">
769+
<div class="card-body">
770+
{{ risk_treatment_flow|json_script:"risk-flow-data" }}
771+
<div id="risk-treatment-flow" role="img"
772+
aria-label="{% trans 'Sankey diagram of risk treatment flow from current to residual level' %}"
773+
style="width:100%;height:360px"></div>
774+
</div>
775+
</div>
776+
</div>
777+
</div>
778+
{% endif %}
779+
760780
{# ── Risk matrices ─────────────────────────────────── #}
761781
{% if matrix_current or matrix_residual %}
762782
<div class="d-flex align-items-center mb-3 flex-wrap gap-2">
@@ -893,6 +913,76 @@ <h6 class="fw-bold mb-3" style="color:var(--accent)">
893913
{% endblock %}
894914

895915
{% block extra_js %}
916+
917+
{# ── Risk treatment flow (Sankey) ────────────────────── #}
918+
{% if risk_treatment_flow %}
919+
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
920+
<script>
921+
(function() {
922+
var el = document.getElementById('risk-treatment-flow');
923+
var dataEl = document.getElementById('risk-flow-data');
924+
if (!el || !dataEl || typeof echarts === 'undefined') return;
925+
926+
var flow = JSON.parse(dataEl.textContent);
927+
var chart = echarts.init(el, null, {renderer: 'svg'});
928+
929+
function textColor() {
930+
return getComputedStyle(document.documentElement)
931+
.getPropertyValue('--text-secondary').trim() || '#475569';
932+
}
933+
934+
function render() {
935+
var color = textColor();
936+
chart.setOption({
937+
tooltip: {
938+
trigger: 'item',
939+
triggerOn: 'mousemove',
940+
formatter: function(p) {
941+
if (p.dataType === 'edge') {
942+
return (p.data.value === 1
943+
? '{% trans "1 risk" %}'
944+
: p.data.value + ' {% trans "risks" %}');
945+
}
946+
return p.data.displayName;
947+
}
948+
},
949+
series: [{
950+
type: 'sankey',
951+
left: '8%',
952+
right: '8%',
953+
top: 20,
954+
bottom: 20,
955+
nodeGap: 14,
956+
nodeWidth: 16,
957+
draggable: false,
958+
// Keep the column order we sent (highest severity at the top, lowest
959+
// at the bottom) instead of ECharts reordering to reduce crossings.
960+
layoutIterations: 0,
961+
emphasis: {focus: 'adjacency'},
962+
label: {
963+
color: color,
964+
fontFamily: 'Inter, sans-serif',
965+
fontSize: 12,
966+
formatter: function(p) { return p.data.displayName; }
967+
},
968+
lineStyle: {color: 'gradient', opacity: 0.45, curveness: 0.5},
969+
data: flow.nodes,
970+
links: flow.links
971+
}]
972+
});
973+
}
974+
975+
render();
976+
window.addEventListener('resize', function() { chart.resize(); });
977+
978+
// Re-render labels when the colour theme changes.
979+
new MutationObserver(function() { render(); }).observe(
980+
document.documentElement, {attributes: true, attributeFilter: ['data-bs-theme']}
981+
);
982+
})();
983+
</script>
984+
{% endif %}
985+
896986
{% if changelog_entries %}
897987
<script>
898988
(function() {

0 commit comments

Comments
 (0)