Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Every screen is rebuilt on the component framework : one detail page anatomy, one section title, one empty-value marker, one badge and tag pill, and form footers with a neutral Cancel on the left.
- Dates follow the active locale everywhere, and short numeric dates put the day first in English as in French.
- The lifecycle editor shows its steps, transitions and JSON as stacked cards instead of tabs.
- The assessment result toggles read the planning / fieldwork phase from the assessment instead of comparing a status string in the template.

### Fixed

- Creating or editing a management review, and editing an ISMS change, failed with a server error; their scope pickers now offer the linkable scopes.
- Opening an assessment result's edit page directly failed with a server error, and so did the preview of a finding recorded without an audit.
- Global search results for sites had no link, and the SWOT item and strategy dialogs could not be closed or cancelled.
- The group permission matrix lacked its Validate column, which shifted the labels of the columns after it, and the decision and ISMS change forms never showed field errors.
- Risk criteria pages had no lifecycle stepper, so a criteria set could not be submitted or validated from the interface.
- Choosing another assessment on a new risk now reloads its likelihood and impact scales, in the dialog as on the page (it only ever worked on a fully reloaded page).
- Opening a drawer-only delete link directly (incidents, nonconformities, supplier contacts and sub-processors) rendered a bare dialog fragment instead of a confirmation page.
- The Trust Center hub and the semantic index pages showed a loose eyebrow (the hub's even read "Strategy") instead of their menu breadcrumb.

### Security

- Saving a group's permission matrix silently revoked the permissions it did not display (assess, implement, close, cancel, override dates); they now have a column, and permissions outside the registry are never touched.
- Tag colours are validated before reaching a style attribute, and Cancel / Back links no longer echo an unchecked Referer header.
- The risk scale endpoint no longer answers with the criteria of an assessment outside the user's scopes.


## [0.36.1] - 2026-11-03

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for a supplier contact. Posted via HTMX;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for a supplier sub-processor engagement.
Expand Down
5 changes: 3 additions & 2 deletions assets/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from core.mixins import (
AdvancedFilterMixin,
ColumnPreferenceMixin,
ConfirmDeleteShellMixin,
HtmxFormMixin,
ListSummaryMixin,
PredefinedFilterMixin,
Expand Down Expand Up @@ -1007,7 +1008,7 @@ def get_success_url(self):
return reverse_lazy("assets:supplier-detail", kwargs={"pk": self.object.supplier.pk})


class SupplierContactDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
class SupplierContactDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, DeleteView):
model = SupplierContact
template_name = "assets/supplier_contact_confirm_delete_modal.html"
permission_required = "assets.supplier.update"
Expand Down Expand Up @@ -1083,7 +1084,7 @@ def get_success_url(self):
return reverse_lazy("assets:supplier-detail", kwargs={"pk": self.object.supplier.pk})


class SupplierSubprocessorDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
class SupplierSubprocessorDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, DeleteView):
model = SupplierSubprocessor
template_name = "assets/supplier_subprocessor_confirm_delete_modal.html"
permission_required = "assets.supplier.update"
Expand Down
2 changes: 1 addition & 1 deletion assistant/templates/assistant/semantic_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{% block title %}{% trans "Semantic index - Cairn" %}{% endblock %}

{% block content %}
{% page_header _("Semantic index") eyebrow=_("Administration") accent="accounts" %}{% endpage_header %}
{% page_header _("Semantic index") nav="assistant:semantic-index" accent="accounts" %}{% endpage_header %}

{% detail_layout %}
{% slot rail %}{% if semantic.enabled and semantic.embeddings_supported %}
Expand Down
11 changes: 11 additions & 0 deletions compliance/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ class AssessmentStatus(models.TextChoices):
AssessmentStatus.IN_PROGRESS,
}

# Within the toggleable steps : planning decides what will be evaluated
# (not assessed <-> evaluated), fieldwork records the verdict
# (evaluated <-> compliant). See ComplianceAssessment.is_planning_phase.
ASSESSMENT_PLANNING_STATUSES = {
AssessmentStatus.DRAFT,
AssessmentStatus.PLANNED,
}
ASSESSMENT_FIELDWORK_STATUSES = {
AssessmentStatus.IN_PROGRESS,
}

# Statuses that allow full editing of findings and result details
ASSESSMENT_EDITABLE_STATUSES = {
AssessmentStatus.IN_PROGRESS,
Expand Down
12 changes: 12 additions & 0 deletions compliance/models/assessment.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from simple_history.models import HistoricalRecords

from compliance.constants import (
ASSESSMENT_FIELDWORK_STATUSES,
ASSESSMENT_PLANNING_STATUSES,
AssessmentStatus,
ComplianceStatus,
FINDING_SEVERITY_ORDER,
Expand Down Expand Up @@ -137,6 +139,16 @@ def compliance_pct(self):
def workflow_perm_namespace(self):
return "compliance.assessment"

@property
def is_planning_phase(self):
"""Planning: choosing what will be evaluated (not assessed <-> evaluated)."""
return self.status in ASSESSMENT_PLANNING_STATUSES

@property
def is_fieldwork_phase(self):
"""Fieldwork: recording verdicts (evaluated <-> compliant)."""
return self.status in ASSESSMENT_FIELDWORK_STATUSES


def transition_to(self, target, user=None, comment=None, *, enforce_permission=False, save=True):
"""Validate and perform a status transition.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@
<span class="btn btn-sm btn-outline-secondary disabled" title="{% trans 'Status determined by findings' %}" aria-label="{% trans 'Status determined by findings' %}">
<i class="bi bi-lock" aria-hidden="true"></i>
</span>
{% elif assessment.status == 'in_progress' and entry.result and entry.result.compliance_status == 'not_assessed' %}
{% elif assessment.is_fieldwork_phase and entry.result and entry.result.compliance_status == 'not_assessed' %}
{# IN_PROGRESS: NOT_ASSESSED is frozen #}
<span class="btn btn-sm btn-outline-secondary disabled" title="{% trans 'Not planned for evaluation' %}" aria-label="{% trans 'Not planned for evaluation' %}">
<i class="bi bi-circle" aria-hidden="true"></i>
</span>
{% elif assessment.status == 'in_progress' %}
{% elif assessment.is_fieldwork_phase %}
{# IN_PROGRESS: toggle EVALUATED ↔ COMPLIANT #}
<button type="button" class="btn btn-sm {% if entry.result and entry.result.compliance_status == 'compliant' %}btn-success{% else %}btn-outline-primary{% endif %}"
hx-post="{% url 'compliance:assessment-result-toggle' assessment.pk entry.requirement.pk %}"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for a nonconformity register entry.
Expand Down
16 changes: 16 additions & 0 deletions compliance/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,19 @@ def test_framework_recalc_on_requirement_delete(self):
bad.delete()
fw.refresh_from_db()
assert fw.compliance_level == 100


@pytest.mark.django_db
class TestAssessmentPhases:
"""The result toggles read the phase from the model, not a status literal."""

@pytest.mark.parametrize("state,planning,fieldwork", [
("draft", True, False), ("planned", True, False), ("in_progress", False, True),
("completed", False, False), ("closed", False, False),
])
def test_phase(self, state, planning, fieldwork):
from compliance.tests.factories import ComplianceAssessmentFactory

assessment = ComplianceAssessmentFactory(status=state)
assert assessment.is_planning_phase is planning
assert assessment.is_fieldwork_phase is fieldwork
8 changes: 3 additions & 5 deletions compliance/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from core.mixins import (
AdvancedFilterMixin,
ColumnPreferenceMixin,
ConfirmDeleteShellMixin,
HtmxFormMixin,
ListSummaryMixin,
PredefinedFilterMixin,
Expand Down Expand Up @@ -1187,10 +1188,7 @@ def post(self, request, assessment_pk, requirement_pk):
if assessment.findings.filter(requirements=requirement).exists():
return HttpResponse(status=409)

is_planning = assessment.status in (
AssessmentStatus.DRAFT,
AssessmentStatus.PLANNED,
)
is_planning = assessment.is_planning_phase
now = timezone.now()

result, created = AssessmentResult.objects.get_or_create(
Expand Down Expand Up @@ -1605,7 +1603,7 @@ def get_success_url(self):
return reverse("compliance:finding-detail", args=[self.object.pk])


class FindingRegisterDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, DeleteView):
class FindingRegisterDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, DeleteView):
model = Finding
permission_required = "compliance.finding.delete"
scope_parent_lookup = "assessment__scopes"
Expand Down
50 changes: 37 additions & 13 deletions core/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,40 @@ def nav_name_for(url: str):
return match.view_name if match.view_name in NAV_TREE else None


def is_drawer_request(request) -> bool:
"""True for an HTMX request that targets the drawer (``#drawer-form-content``).

A boosted navigation also sends ``HX-Request: true``, but with
``HX-Boosted: true`` and the page shell as its target: it wants a page.
"""
if request.headers.get("HX-Request") != "true":
return False
if request.headers.get("HX-Boosted") == "true":
return False
return request.headers.get("HX-Target") == "drawer-form-content"


class ConfirmDeleteShellMixin:
"""Serve a drawer delete confirmation as a page on a direct visit.

The entity template extends ``delete_shell``
(``{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}``):
the modal shell in the drawer, ``includes/confirm_delete_page.html`` when
the ``/delete/`` URL is opened directly, so a bookmarked or shared link
renders a full page instead of a bare fragment. Both shells read the same
blocks.
"""

def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["delete_shell"] = (
"includes/confirm_delete_modal.html"
if is_drawer_request(self.request)
else "includes/confirm_delete_page.html"
)
return ctx


class HtmxFormMixin:
"""Mixin for CreateView/UpdateView to serve modal forms via HTMX.

Expand All @@ -67,19 +101,9 @@ class HtmxFormMixin:
modal_title_update = ""

def _is_htmx(self):
# The drawer flow uses hx-target="#drawer-form-content"; HTMX puts
# the target id into the HX-Target header. The body carries
# `hx-boost="true"` for soft-nav, which also sends HX-Request=true
# but with HX-Boosted=true and HX-Target=page-shell. Distinguishing
# the two prevents the modal partial (designed to be swapped into
# the drawer container) from being mistakenly returned for a
# boosted full-page navigation, which would otherwise render the
# bare drawer markup full-bleed without the page chrome.
if self.request.headers.get("HX-Request") != "true":
return False
if self.request.headers.get("HX-Boosted") == "true":
return False
return self.request.headers.get("HX-Target") == "drawer-form-content"
# The drawer flow targets #drawer-form-content; a boosted navigation
# (HX-Boosted, target page-shell) wants the full page instead.
return is_drawer_request(self.request)

def get_template_names(self):
if self._is_htmx() and self.modal_template_name:
Expand Down
2 changes: 2 additions & 0 deletions core/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,13 @@
"core:lifecycle-list": ("Administration", "General", "Lifecycles"),
"core:lifecycle-edit": ("Administration", "General", "Lifecycles"),
"accounts:calendar-subscription-list": ("Administration", "General", "Calendar subscriptions"),
"trust_center_manage:hub": ("Administration", "General", "Trust Center"),
"trust_center_manage:settings": ("Administration", "General", "Trust Center"),
"trust_center_manage:request-list": ("Administration", "Trust Center", "Document requests"),
"accounts:user-list": ("Administration", "Access", "Users"),
"accounts:group-list": ("Administration", "Access", "Groups"),
"accounts:permission-list": ("Administration", "Access", "Permissions"),
"assistant:semantic-index": ("Administration", "Ask Cairn", "Semantic index"),
"assistant:feedback-list": ("Administration", "Ask Cairn", "Feedback"),
"accounts:access-log-list": ("Administration", "Logs", "Access log"),
"accounts:action-log-list": ("Administration", "Logs", "Action log"),
Expand Down
46 changes: 46 additions & 0 deletions core/tests/test_delete_shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 François Rousselet
"""A drawer delete confirmation renders as a page on a direct visit.

Opening a drawer-only ``/delete/`` URL directly (a bookmark, a shared link)
used to return the bare modal fragment, unstyled and outside the page.
"""
import pytest
from django.test import Client
from django.urls import reverse

from accounts.tests.factories import UserFactory
from incidents.models import Incident
from incidents.tests.factories import IncidentFactory

pytestmark = pytest.mark.django_db

DRAWER = {"HTTP_HX_REQUEST": "true", "HTTP_HX_TARGET": "drawer-form-content"}


@pytest.fixture
def client():
c = Client()
c.force_login(UserFactory(is_superuser=True))
return c


def test_direct_visit_renders_the_page_shell(client):
incident = IncidentFactory()
html = client.get(reverse("incidents:incident-delete", args=[incident.pk])).content.decode()
assert "<html" in html and 'class="card page-form page-form--danger" hx-boost="false"' in html
assert "modal-header" not in html.split('class="card page-form', 1)[1].split("</form>", 1)[0]


def test_drawer_request_renders_the_modal(client):
incident = IncidentFactory()
html = client.get(reverse("incidents:incident-delete", args=[incident.pk]), **DRAWER).content.decode()
assert "<html" not in html
assert 'class="modal-header"' in html and 'hx-post=' in html


def test_page_post_deletes_and_redirects(client):
incident = IncidentFactory(workflow_state="draft")
resp = client.post(reverse("incidents:incident-delete", args=[incident.pk]))
assert resp.status_code == 302
assert not Incident.objects.filter(pk=incident.pk).exists()
5 changes: 5 additions & 0 deletions docs/sdk/ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ the hidden fields), which is also what a non-stepped modal puts in its
{% block delete_details %}<p class="text-muted small">{% trans "The 4 linked actions will become orphaned." %}</p>{% endblock %}
```

A drawer-served confirmation that can also be opened directly extends
`delete_shell` instead (`{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}`)
and its view mixes in `core.mixins.ConfirmDeleteShellMixin`: the same blocks
then render in the modal from the drawer and as a full page on a direct visit.

`delete_button_attrs` adds attributes to the Delete button, for example
`disabled` while a protection rule forbids the deletion (say why in
`delete_details`).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for the GDPR qualification of an incident.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for an evidence item. Posted via HTMX; the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for an incident file. Posted via HTMX; the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n ui %}
{% comment %}
Drawer-served delete confirmation for a notification obligation. Posted via
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for a reporting obligation template. Posted
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for a reporting authority. Posted via HTMX;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for one response action. Posted via HTMX;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for an incident response plan. Posted via
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for a post-incident review. Posted via
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extends "includes/confirm_delete_modal.html" %}
{% extends delete_shell|default:"includes/confirm_delete_modal.html" %}
{% load i18n %}
{% comment %}
Drawer-served delete confirmation for a security event report. Posted via
Expand Down
Loading
Loading