diff --git a/CHANGELOG.md b/CHANGELOG.md index e0be1738..39a647b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ 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 @@ -26,11 +27,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/assets/templates/assets/supplier_contact_confirm_delete_modal.html b/assets/templates/assets/supplier_contact_confirm_delete_modal.html index 031e1b6e..bab30a6e 100644 --- a/assets/templates/assets/supplier_contact_confirm_delete_modal.html +++ b/assets/templates/assets/supplier_contact_confirm_delete_modal.html @@ -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; diff --git a/assets/templates/assets/supplier_subprocessor_confirm_delete_modal.html b/assets/templates/assets/supplier_subprocessor_confirm_delete_modal.html index 63f39c13..b05a47a7 100644 --- a/assets/templates/assets/supplier_subprocessor_confirm_delete_modal.html +++ b/assets/templates/assets/supplier_subprocessor_confirm_delete_modal.html @@ -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. diff --git a/assets/views.py b/assets/views.py index 2cc77e31..9e2491c7 100644 --- a/assets/views.py +++ b/assets/views.py @@ -25,6 +25,7 @@ from core.mixins import ( AdvancedFilterMixin, ColumnPreferenceMixin, + ConfirmDeleteShellMixin, HtmxFormMixin, ListSummaryMixin, PredefinedFilterMixin, @@ -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" @@ -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" diff --git a/assistant/templates/assistant/semantic_index.html b/assistant/templates/assistant/semantic_index.html index c86fc18d..fd9d4954 100644 --- a/assistant/templates/assistant/semantic_index.html +++ b/assistant/templates/assistant/semantic_index.html @@ -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 %} diff --git a/compliance/constants.py b/compliance/constants.py index 95e64507..f608a9c4 100644 --- a/compliance/constants.py +++ b/compliance/constants.py @@ -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, diff --git a/compliance/models/assessment.py b/compliance/models/assessment.py index 6f85485b..e1af1672 100644 --- a/compliance/models/assessment.py +++ b/compliance/models/assessment.py @@ -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, @@ -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. diff --git a/compliance/templates/compliance/assessment_results_table_body.html b/compliance/templates/compliance/assessment_results_table_body.html index 6d41e28c..695aa4dc 100644 --- a/compliance/templates/compliance/assessment_results_table_body.html +++ b/compliance/templates/compliance/assessment_results_table_body.html @@ -88,12 +88,12 @@ - {% 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 #} - {% elif assessment.status == 'in_progress' %} + {% elif assessment.is_fieldwork_phase %} {# IN_PROGRESS: toggle EVALUATED ↔ COMPLIANT #}
{% 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`). diff --git a/incidents/templates/incidents/breach_confirm_delete_modal.html b/incidents/templates/incidents/breach_confirm_delete_modal.html index 634357fc..a05690e2 100644 --- a/incidents/templates/incidents/breach_confirm_delete_modal.html +++ b/incidents/templates/incidents/breach_confirm_delete_modal.html @@ -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. diff --git a/incidents/templates/incidents/evidence_confirm_delete_modal.html b/incidents/templates/incidents/evidence_confirm_delete_modal.html index 8aa65aa7..49d74689 100644 --- a/incidents/templates/incidents/evidence_confirm_delete_modal.html +++ b/incidents/templates/incidents/evidence_confirm_delete_modal.html @@ -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 diff --git a/incidents/templates/incidents/incident_confirm_delete_modal.html b/incidents/templates/incidents/incident_confirm_delete_modal.html index 90065e5e..60ffacb6 100644 --- a/incidents/templates/incidents/incident_confirm_delete_modal.html +++ b/incidents/templates/incidents/incident_confirm_delete_modal.html @@ -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 diff --git a/incidents/templates/incidents/notification_confirm_delete_modal.html b/incidents/templates/incidents/notification_confirm_delete_modal.html index c6e110e3..03557032 100644 --- a/incidents/templates/incidents/notification_confirm_delete_modal.html +++ b/incidents/templates/incidents/notification_confirm_delete_modal.html @@ -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 diff --git a/incidents/templates/incidents/obligation_template_confirm_delete_modal.html b/incidents/templates/incidents/obligation_template_confirm_delete_modal.html index 459f1806..3ff10d1f 100644 --- a/incidents/templates/incidents/obligation_template_confirm_delete_modal.html +++ b/incidents/templates/incidents/obligation_template_confirm_delete_modal.html @@ -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 diff --git a/incidents/templates/incidents/reporting_authority_confirm_delete_modal.html b/incidents/templates/incidents/reporting_authority_confirm_delete_modal.html index 8b4e5ea9..fb949e7d 100644 --- a/incidents/templates/incidents/reporting_authority_confirm_delete_modal.html +++ b/incidents/templates/incidents/reporting_authority_confirm_delete_modal.html @@ -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; diff --git a/incidents/templates/incidents/response_action_confirm_delete_modal.html b/incidents/templates/incidents/response_action_confirm_delete_modal.html index aa3e31e3..82ee7b4e 100644 --- a/incidents/templates/incidents/response_action_confirm_delete_modal.html +++ b/incidents/templates/incidents/response_action_confirm_delete_modal.html @@ -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; diff --git a/incidents/templates/incidents/response_plan_confirm_delete_modal.html b/incidents/templates/incidents/response_plan_confirm_delete_modal.html index c33bf81a..de71a576 100644 --- a/incidents/templates/incidents/response_plan_confirm_delete_modal.html +++ b/incidents/templates/incidents/response_plan_confirm_delete_modal.html @@ -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 diff --git a/incidents/templates/incidents/review_confirm_delete_modal.html b/incidents/templates/incidents/review_confirm_delete_modal.html index a903ffe4..ff5f2d34 100644 --- a/incidents/templates/incidents/review_confirm_delete_modal.html +++ b/incidents/templates/incidents/review_confirm_delete_modal.html @@ -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 diff --git a/incidents/templates/incidents/security_event_confirm_delete_modal.html b/incidents/templates/incidents/security_event_confirm_delete_modal.html index 7285f1dc..3c881993 100644 --- a/incidents/templates/incidents/security_event_confirm_delete_modal.html +++ b/incidents/templates/incidents/security_event_confirm_delete_modal.html @@ -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 diff --git a/incidents/views.py b/incidents/views.py index 6637d627..a3892895 100644 --- a/incidents/views.py +++ b/incidents/views.py @@ -61,6 +61,7 @@ from core.mixins import ( AdvancedFilterMixin, ColumnPreferenceMixin, + ConfirmDeleteShellMixin, HtmxFormMixin, ListSummaryMixin, PredefinedFilterMixin, @@ -676,7 +677,7 @@ def get_success_url(self): return reverse("incidents:incident-detail", args=[self.object.pk]) -class IncidentDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): +class IncidentDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): model = Incident permission_required = "incidents.incident.delete" template_name = "incidents/incident_confirm_delete_modal.html" @@ -901,7 +902,7 @@ def get_success_url(self): return reverse("incidents:event-detail", args=[self.object.pk]) -class SecurityEventDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): +class SecurityEventDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): model = SecurityEvent permission_required = "incidents.event.delete" template_name = "incidents/security_event_confirm_delete_modal.html" @@ -1102,7 +1103,7 @@ def get_success_url(self): return reverse("incidents:response-plan-detail", args=[self.object.pk]) -class ResponsePlanDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): +class ResponsePlanDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): model = IncidentResponsePlan permission_required = "incidents.response_plan.delete" template_name = "incidents/response_plan_confirm_delete_modal.html" @@ -1418,7 +1419,7 @@ def get_success_url(self): return reverse("incidents:notification-detail", args=[self.object.pk]) -class NotificationDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): +class NotificationDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): """Delete a hand-added obligation. A generated one refuses deletion from the model layer : it is answered @@ -1546,7 +1547,7 @@ class ResponseActionUpdateView(LoginRequiredMixin, PermissionRequiredMixin, Scop modal_title_update = _l("Edit response action") -class ResponseActionDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): +class ResponseActionDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): model = IncidentResponseAction permission_required = "incidents.incident.update" scope_parent_lookup = "incident__scopes" @@ -1651,7 +1652,7 @@ def get_success_url(self): return reverse("incidents:evidence-detail", args=[self.object.pk]) -class EvidenceDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): +class EvidenceDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): """Remove an item registered in error, while it is still a draft. Destruction of a real artefact is a gated transition with a confirmation and @@ -2060,7 +2061,7 @@ def get_success_url(self): return reverse("incidents:review-detail", args=[self.object.pk]) -class PostIncidentReviewDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): +class PostIncidentReviewDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): """Remove a review opened in error, while its incident is already over. The model refuses to delete a review whose incident is still open : the @@ -2149,7 +2150,7 @@ def get_success_url(self): return reverse("incidents:breach-detail", args=[self.object.pk]) -class PersonalDataBreachDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): +class PersonalDataBreachDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, IncidentChildEditMixin, ProtectedDeleteMixin, DeleteView): model = PersonalDataBreach permission_required = "incidents.notification.delete" scope_parent_lookup = "incident__scopes" @@ -2285,7 +2286,7 @@ def get_success_url(self): return reverse("incidents:reporting-authority-detail", args=[self.object.pk]) -class ReportingAuthorityDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ProtectedDeleteMixin, DeleteView): +class ReportingAuthorityDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ProtectedDeleteMixin, DeleteView): model = ReportingAuthority permission_required = "incidents.response_plan.delete" template_name = "incidents/reporting_authority_confirm_delete_modal.html" @@ -2428,7 +2429,7 @@ def get_success_url(self): return reverse("incidents:obligation-template-detail", args=[self.object.pk]) -class ObligationTemplateDeleteView(LoginRequiredMixin, PermissionRequiredMixin, ProtectedDeleteMixin, DeleteView): +class ObligationTemplateDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ProtectedDeleteMixin, DeleteView): model = ReportingObligationTemplate permission_required = "incidents.response_plan.delete" template_name = "incidents/obligation_template_confirm_delete_modal.html" diff --git a/risks/forms.py b/risks/forms.py index 66ade66b..94d79d68 100644 --- a/risks/forms.py +++ b/risks/forms.py @@ -54,7 +54,9 @@ def get_scale_choices(scale_type, criteria=None): default_c = RiskCriteria.objects.filter(is_default=True).first() if default_c and default_c not in candidates: candidates.append(default_c) - active_c = RiskCriteria.objects.filter(workflow_state="validated").first() + from core.lifecycle import reportable + + active_c = reportable(RiskCriteria.objects.all()).first() if active_c and active_c not in candidates: candidates.append(active_c) @@ -255,6 +257,17 @@ def __init__(self, *args, **kwargs): empty_value=None, label=self.fields[fname].label, widget=forms.Select(attrs=SELECT_ATTRS), ) + # Picking another assessment reloads the four scales from its criteria + # (base.html wires data-scale-source / data-scale-target, in the modal + # and on the page alike). + from django.urls import reverse + + self.fields["assessment"].widget.attrs["data-scale-source"] = reverse("risks:api-scale-choices") + for fname, scale in ( + ("initial_likelihood", "likelihood"), ("current_likelihood", "likelihood"), + ("initial_impact", "impact"), ("current_impact", "impact"), + ): + self.fields[fname].widget.attrs["data-scale-target"] = scale # Link pickers only offer linkable elements (existing links are kept) from core.lifecycle import linkable_or_linked diff --git a/risks/templates/risks/criteria_detail.html b/risks/templates/risks/criteria_detail.html index 9b4580c8..4096c30a 100644 --- a/risks/templates/risks/criteria_detail.html +++ b/risks/templates/risks/criteria_detail.html @@ -12,6 +12,8 @@ {% include "includes/history_panel.html" %} {% help_modal "risks.criteria_detail" %} +{% include "includes/lifecycle_stepper.html" %} + {% detail_layout %} {% slot rail %} {% kpi_tile _("Lifecycle stage") criteria.lifecycle_label icon="signpost-split" %} diff --git a/risks/templates/risks/risk_form.html b/risks/templates/risks/risk_form.html index 00ca307b..f115e6f1 100644 --- a/risks/templates/risks/risk_form.html +++ b/risks/templates/risks/risk_form.html @@ -1,37 +1 @@ {% extends "includes/page_form.html" %} - -{% block extra_js %} - -{% endblock %} diff --git a/risks/tests/test_scale_reload.py b/risks/tests/test_scale_reload.py new file mode 100644 index 00000000..abeef244 --- /dev/null +++ b/risks/tests/test_scale_reload.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Choosing an assessment on the risk form reloads its likelihood / impact scales. + +The behaviour is declared on the form (data-scale-source / data-scale-target) +and wired once in base.html, so it works in the modal and on the page alike; +the endpoint only answers for an assessment the user may see. +""" +import json + +import pytest +from django.test import Client +from django.urls import reverse + +from accounts.tests.factories import GroupFactory, UserFactory +from context.tests.factories import ScopeFactory +from risks.forms import RiskCreateForm +from risks.tests.factories import RiskAssessmentFactory, RiskCriteriaFactory, ScaleLevelFactory + +pytestmark = pytest.mark.django_db + + +def test_the_form_declares_the_dependent_scales(): + form = RiskCreateForm() + assert form.fields["assessment"].widget.attrs["data-scale-source"] == reverse("risks:api-scale-choices") + assert form.fields["initial_likelihood"].widget.attrs["data-scale-target"] == "likelihood" + assert form.fields["current_impact"].widget.attrs["data-scale-target"] == "impact" + + +def _assessment_with_scale(label): + criteria = RiskCriteriaFactory() + ScaleLevelFactory(criteria=criteria, scale_type="likelihood", level=1, name=label) + assessment = RiskAssessmentFactory(risk_criteria=criteria) + return assessment + + +def _labels(client, assessment): + resp = client.get(reverse("risks:api-scale-choices"), {"assessment": assessment.pk}) + return [c["label"] for c in json.loads(resp.content)["likelihood"]] + + +def test_scales_come_from_a_visible_assessment_only(): + mine_scope, other_scope = ScopeFactory(), ScopeFactory() + mine = _assessment_with_scale("Mine") + mine.scopes.add(mine_scope) + other = _assessment_with_scale("Secret") + other.scopes.add(other_scope) + + user = UserFactory() + group = GroupFactory() + group.users.add(user) + group.allowed_scopes.add(mine_scope) + client = Client() + client.force_login(user) + + assert "1 - Mine" in _labels(client, mine) + assert "1 - Secret" not in _labels(client, other) diff --git a/risks/tests/test_views.py b/risks/tests/test_views.py index 8b37ec11..70178753 100644 --- a/risks/tests/test_views.py +++ b/risks/tests/test_views.py @@ -1018,6 +1018,18 @@ def test_detail_loads_200(self): assert "scale_levels" in resp.context assert "risk_levels" in resp.context + def test_detail_renders_the_lifecycle_stepper(self): + """Criteria run the default lifecycle; their page had no stepper, so + a criteria set could never be submitted or validated from the UI.""" + client, user = _superuser_client() + criteria = RiskCriteriaFactory(workflow_state="draft") + resp = client.get(reverse("risks:criteria-detail", args=[criteria.pk])) + assert resp.context["lc_steps"] + assert reverse( + "workflow:transition", + kwargs={"app_label": "risks", "model": "riskcriteria", "pk": criteria.pk}, + ) in resp.content.decode() + class TestRiskCriteriaDeleteView: def test_delete_criteria(self): diff --git a/risks/views.py b/risks/views.py index 03764819..e96c926f 100644 --- a/risks/views.py +++ b/risks/views.py @@ -33,6 +33,7 @@ TableBodyPaginatedMixin, ) from core.query_params import parse_date_param, parse_uuid +from core.scoping import filter_queryset_by_scopes from .constants import ( AcceptanceStatus, AssessmentStatus, @@ -765,7 +766,7 @@ def get_queryset(self): return self.filter_queryset_advanced(qs) -class RiskCriteriaDetailView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, HistoryUrlMixin, DetailView): +class RiskCriteriaDetailView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, HistoryUrlMixin, LifecycleStepperMixin, DetailView): model = RiskCriteria template_name = "risks/criteria_detail.html" context_object_name = "criteria" @@ -1857,13 +1858,15 @@ def scale_choices_api(request): assessment_id = parse_uuid(request.GET.get("assessment")) criteria = None if assessment_id: - try: - assessment = RiskAssessment.objects.select_related( - "risk_criteria" - ).get(pk=assessment_id) + # Only an assessment the user may see: anything else answers with the + # default scales, as for an unknown id. + qs = RiskAssessment.objects.select_related("risk_criteria") + scope_ids = None if request.user.is_superuser else request.user.get_allowed_scope_ids() + if scope_ids is not None: + qs = filter_queryset_by_scopes(qs, scope_ids) + assessment = qs.filter(pk=assessment_id).first() + if assessment is not None: criteria = assessment.risk_criteria - except RiskAssessment.DoesNotExist: - pass l_choices = get_scale_choices("likelihood", criteria) i_choices = get_scale_choices("impact", criteria) return JsonResponse({ diff --git a/templates/base.html b/templates/base.html index 2a31455b..dbd30700 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1129,6 +1129,46 @@