diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 5d11a8eac..448f86a45 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -52,7 +52,9 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Install dependencies working-directory: ${{ env.working-directory }} - run: npm ci + run: | + npm install + npm ci - name: Install Playwright browser ${{ matrix.playwright-browser }} working-directory: ${{ env.working-directory }} run: npx playwright install --with-deps ${{ matrix.playwright-browser }} diff --git a/backend/core/models.py b/backend/core/models.py index 7b6ef383f..87e3cb929 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -1430,6 +1430,9 @@ def get_selected_implementation_groups(self): if group.get("ref_id") in self.selected_implementation_groups ] + def get_requirement_assessments(self): + return RequirementAssessment.objects.filter(compliance_assessment=self) + def get_requirements_status_count(self): requirements_status_count = [] for st in RequirementAssessment.Status: diff --git a/backend/core/templates/core/action_plan_pdf.html b/backend/core/templates/core/action_plan_pdf.html new file mode 100644 index 000000000..53efae1ba --- /dev/null +++ b/backend/core/templates/core/action_plan_pdf.html @@ -0,0 +1,68 @@ +{% extends 'core/base_pdf.html' %} +{% block content %} +{% load i18n core_extras %} + + + +
+

Action plan

+
+

{% trans "Domain" %}: {{ compliance_assessment.project.folder }}

+

/

+

{% trans "Project" %}: {{ compliance_assessment.project.name }}

+

/

+

{% trans "Audit" %}: {{ compliance_assessment.name }} - {{ compliance_assessment.version }}

+

/

+

{% trans "Framework" %}: {{ compliance_assessment.framework }}

+
+

{% trans "Associated applied controls" %}:

+

{% trans "Separated by status and sorted by eta" %}

+ {% for status, applied_controls in context.items%} + {% for status_color, color in color_map.items %} + {% if status_color == status %} +

{% trans status|title %}:

+ {% endif %} + {% endfor %} +
+
+ + + + + + + + + + + + + + {% for applied_control in applied_controls %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Description" %}{% trans "Category" %}{% trans "ETA" %}{% trans "Expiry date" %}{% trans "Effort" %}{% trans "Matching requirements" %}
{{ applied_control.name }}{{ applied_control.description }}{{ applied_control.category }}{{ applied_control.eta }}{{ applied_control.expiry_date }}{{ applied_control.effort }}{% get_requirements_count applied_control compliance_assessment %}
+

{% trans "No entries found" %}

+
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/backend/core/templatetags/core_extras.py b/backend/core/templatetags/core_extras.py index 0aebc1e5b..8fa6edbc5 100644 --- a/backend/core/templatetags/core_extras.py +++ b/backend/core/templatetags/core_extras.py @@ -2,6 +2,7 @@ from ciso_assistant.settings import VERSION, BUILD, DEBUG from core.utils import COUNTRY_FLAGS, LANGUAGES +from core.models import RequirementAssessment register = template.Library() @@ -16,6 +17,17 @@ def app_build(): return f"{BUILD} (dev)" if DEBUG else BUILD +@register.simple_tag() +def get_requirements_count(applied_control, compliance_assessment): + return ( + RequirementAssessment.objects.filter( + compliance_assessment=compliance_assessment + ) + .filter(applied_controls=applied_control) + .count() + ) + + @register.filter("class") def _class(obj): return obj.__class__.__name__ if obj else "" diff --git a/backend/core/views.py b/backend/core/views.py index 7db5bebe6..ed2c22b18 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1208,6 +1208,92 @@ def selected_implementation_groups(self, request, pk): } return Response(implementation_group_choices) + @action(detail=True, methods=["get"], name="Get action plan data") + def action_plan(self, request, pk): + (viewable_objects, _, _) = RoleAssignment.get_accessible_object_ids( + folder=Folder.get_root_folder(), + user=request.user, + object_type=ComplianceAssessment, + ) + if UUID(pk) in viewable_objects: + response = { + "planned": list(), + "active": list(), + "inactive": list(), + "none": list(), + } + compliance_assessment_object = self.get_object() + requirement_assessments_objects = ( + compliance_assessment_object.get_requirement_assessments() + ) + applied_controls = AppliedControlReadSerializer( + AppliedControl.objects.filter( + requirement_assessments__in=requirement_assessments_objects + ).distinct(), + many=True, + ).data + for applied_control in applied_controls: + applied_control["requirements_count"] = ( + RequirementAssessment.objects.filter( + compliance_assessment=compliance_assessment_object + ) + .filter(applied_controls=applied_control["id"]) + .count() + ) + response[applied_control["status"].lower()].append( + applied_control + ) if applied_control["status"] else response["none"].append( + applied_control + ) + return Response(response) + + @action(detail=True, name="Get action plan PDF") + def action_plan_pdf(self, request, pk): + (object_ids_view, _, _) = RoleAssignment.get_accessible_object_ids( + Folder.get_root_folder(), request.user, ComplianceAssessment + ) + if UUID(pk) in object_ids_view: + context = { + "planned": list(), + "active": list(), + "inactive": list(), + "no status": list(), + } + color_map = { + "planned": "#93c5fd", + "active": "#86efac", + "inactive": "#fca5a5", + "no status": "#e5e7eb", + } + compliance_assessment_object = self.get_object() + requirement_assessments_objects = ( + compliance_assessment_object.get_requirement_assessments() + ) + applied_controls = ( + AppliedControl.objects.filter( + requirement_assessments__in=requirement_assessments_objects + ) + .distinct() + .order_by("eta") + ) + for applied_control in applied_controls: + context[applied_control.status].append( + applied_control + ) if applied_control.status else context["no status"].append( + applied_control + ) + data = { + "color_map": color_map, + "context": context, + "compliance_assessment": compliance_assessment_object, + } + html = render_to_string("core/action_plan_pdf.html", data) + pdf_file = HTML(string=html).write_pdf() + response = HttpResponse(pdf_file, content_type="application/pdf") + return response + else: + return Response({"error": "Permission denied"}) + def perform_create(self, serializer): """ Create RequirementAssessment objects for the newly created ComplianceAssessment diff --git a/frontend/messages/en.json b/frontend/messages/en.json index beaba8953..494babc2f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -517,5 +517,12 @@ "selectedImplementationGroups": "Selected implementation groups", "implementationGroupsDefinition": "Implementation groups definition", "threatRadarChart": "Threat radar", - "noThreatsMapped": "No threats mapped. Consider attaching threats to your risk scenarios for a better overview." + "noThreatsMapped": "No threats mapped. Consider attaching threats to your risk scenarios for a better overview.", + "actionPlan": "Action plan", + "noStatus": "No status", + "actionPlanHelpText": "Separated by status and sorted by eta", + "matchingRequirements": "Matching requirements", + "asZIP": "as ZIP", + "incoming": "Incoming", + "outdated": "Outdated" } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 0ce84cf96..761dcf9df 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -164,8 +164,8 @@ "currentRisk": "Risque courant", "residualRisk": "Risque résiduel", "planned": "Planifié", - "active": "Active", - "inactive": "Inactive", + "active": "Actif", + "inactive": "Inactif", "watchlist": "Liste de surveillance", "watchlistDescription": "Objets qui ont expiré ou expireront dans les 30 prochains jours", "measuresToReview": "Mesures à revoir", @@ -517,5 +517,12 @@ "selectedImplementationGroups": "Groupes d'implémentation sélectionnés", "implementationGroupsDefinition": "Définition des groupes d'implémentation", "threatRadarChart": "Radar des menaces", - "noThreatsMapped": "Aucune menace n'a été attachée. Pensez à lier les menaces à vos scénarios de risque pour une meilleure visibilité." + "noThreatsMapped": "Aucune menace n'a été attachée. Pensez à lier les menaces à vos scénarios de risque pour une meilleure visibilité.", + "actionPlan": "Plan d'action", + "noStatus": "Pas de statut", + "actionPlanHelpText": "Séparé par statut et trié par eta", + "matchingRequirements": "Exigences associées", + "asZIP": "en ZIP", + "incoming": "En approche", + "outdated": "Dépassé" } diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 504bb51e5..d3fae1ab3 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -517,5 +517,12 @@ "selectedImplementationGroups": "Grupos de implementação selecionados", "implementationGroupsDefinition": "Definição de grupos de implementação", "threatRadarChart": "Radar de ameaças", - "noThreatsMapped": "Nenhuma ameaça mapeada. Considere anexar ameaças aos seus cenários de risco para ter uma visão geral melhor." + "noThreatsMapped": "Nenhuma ameaça mapeada. Considere anexar ameaças aos seus cenários de risco para ter uma visão geral melhor.", + "actionPlan": "Plano de ação", + "noStatus": "Sem status", + "actionPlanHelpText": "Separados por status e classificados por eta", + "matchingRequirements": "Requisitos correspondentes", + "asZIP": "em ZIP", + "incoming": "aproximação", + "outdated": "Desatualizado" } diff --git a/frontend/src/lib/components/ModelTable/ModelTable.svelte b/frontend/src/lib/components/ModelTable/ModelTable.svelte index 64eba0549..db711583b 100644 --- a/frontend/src/lib/components/ModelTable/ModelTable.svelte +++ b/frontend/src/lib/components/ModelTable/ModelTable.svelte @@ -36,6 +36,7 @@ export let pagination = true; export let numberRowsPerPage = 10; export let thFiler = false; + export let tags = true; export let orderBy: { identifier: string; direction: 'asc' | 'desc' } | undefined = undefined; @@ -206,7 +207,7 @@ {@const tagList = Array.isArray(_tagList) ? _tagList : [_tagList]} {#each tagList as tag} {@const tagData = tag.values[meta[tag.key]]} - {#if tagData} + {#if tagData && tags} {@const {text, cssClasses} = tagData} {localItems(languageTag())[text]} @@ -245,7 +246,13 @@ {value.str ?? '-'} {/if} {:else if value && value.hexcolor} -

{value.name ?? value.str ?? '-'}

+

+ {#if localItems(languageTag())[toCamelCase(value.name ?? value.str ?? '-')]} + {localItems(languageTag())[toCamelCase(value.name ?? value.str ?? '-')]} + {:else} + {value.name ?? value.str ?? '-'} + {/if} +

{:else if ISO_8601_REGEX.test(value)} {formatDateOrDateTime(value, languageTag())} {:else} diff --git a/frontend/src/lib/utils/locales.ts b/frontend/src/lib/utils/locales.ts index eb01df081..76f10b6f2 100644 --- a/frontend/src/lib/utils/locales.ts +++ b/frontend/src/lib/utils/locales.ts @@ -338,7 +338,13 @@ export function localItems(languageTag: string): LocalItems { }), attemptToRemoveOnlyAdminUserGroup: m.attemptToRemoveOnlyAdminUserGroup({ languageTag: languageTag - }) + }), + actionPlan: m.actionPlan({ languageTag: languageTag }), + matchingRequirements: m.matchingRequirements({ languageTag: languageTag }), + remediationPlan: m.remediationPlan({ languageTag: languageTag }), + incoming: m.incoming({ languageTag: languageTag }), + today: m.today({ languageTag: languageTag }), + outdated: m.outdated({ languageTag: languageTag }) }; return LOCAL_ITEMS; } diff --git a/frontend/src/routes/(app)/analyses-registry/+page.svelte b/frontend/src/routes/(app)/analyses-registry/+page.svelte index d16d60205..7658ab2d5 100644 --- a/frontend/src/routes/(app)/analyses-registry/+page.svelte +++ b/frontend/src/routes/(app)/analyses-registry/+page.svelte @@ -23,7 +23,7 @@ @@ -49,12 +49,12 @@ >

Treatment plan

... as PDF ... as csv diff --git a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.svelte b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.svelte index 3218f48c9..8ac67b039 100644 --- a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.svelte +++ b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.svelte @@ -2,7 +2,8 @@ import { page } from '$app/stores'; import RecursiveTreeView from '$lib/components/TreeView/RecursiveTreeView.svelte'; import { breadcrumbObject } from '$lib/utils/stores'; - import type { TreeViewNode } from '@skeletonlabs/skeleton'; + import type { PopupSettings, TreeViewNode } from '@skeletonlabs/skeleton'; + import { popup } from '@skeletonlabs/skeleton'; import type { PageData } from './$types'; import TreeViewItemContent from './TreeViewItemContent.svelte'; import TreeViewItemLead from './TreeViewItemLead.svelte'; @@ -103,6 +104,12 @@ expandedNodes = $expandedNodesState; $: expandedNodesState.set(expandedNodes); + + const popupDownload: PopupSettings = { + event: 'click', + target: 'popupDownload', + placement: 'bottom' + };
@@ -181,17 +188,37 @@ colors={compliance_assessment_donut_values.values.map((object) => object.itemStyle.color)} />
-
- {m.exportButton()} - {#if canEditObject} - {m.edit()} +
+ - {/if} +
+

{m.complianceAssessment()}

+ ... {m.asZIP()} +

{m.actionPlan()}

+ ... {m.asPDF()} +
+ {#if canEditObject} + {m.edit()} + {/if} +
+ {m.actionPlan()}
diff --git a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/+page.server.ts b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/+page.server.ts new file mode 100644 index 000000000..b625af982 --- /dev/null +++ b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/+page.server.ts @@ -0,0 +1,15 @@ +import { BASE_API_URL } from '$lib/utils/constants'; + +import type { PageServerLoad } from './$types'; + +export const load = (async ({ fetch, params }) => { + const URLModel = 'compliance-assessments'; + const endpoint = `${BASE_API_URL}/${URLModel}/${params.id}/`; + const actionPlanEndpoint = `${BASE_API_URL}/${URLModel}/${params.id}/action_plan/`; + + const res = await fetch(endpoint); + const actionPlanRes = await fetch(actionPlanEndpoint); + const compliance_assessment = await res.json(); + const actionPlan = await actionPlanRes.json(); + return { URLModel, compliance_assessment, actionPlan }; +}) satisfies PageServerLoad; diff --git a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/+page.svelte b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/+page.svelte new file mode 100644 index 000000000..8efd5bde6 --- /dev/null +++ b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/+page.svelte @@ -0,0 +1,206 @@ + + + +
+
+

{m.associatedAppliedControls()}

+

+ {m.actionPlanHelpText()} +

+
+
+ + {m.planned()} + {m.active()} + {m.inactive()} + {m.noStatus()} + +
+ {#if tabSet === 0} + + {/if} + {#if tabSet === 1} + + {/if} + {#if tabSet === 2} + + {/if} + {#if tabSet === 3} + + {/if} +
+
+
+
+
diff --git a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/export/pdf/+server.ts b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/export/pdf/+server.ts new file mode 100644 index 000000000..41a8d50ed --- /dev/null +++ b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/action-plan/export/pdf/+server.ts @@ -0,0 +1,22 @@ +import { BASE_API_URL } from '$lib/utils/constants'; + +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +export const GET: RequestHandler = async ({ fetch, params }) => { + const URLModel = 'compliance-assessments'; + const endpoint = `${BASE_API_URL}/${URLModel}/${params.id}/action_plan_pdf/`; + + const res = await fetch(endpoint); + if (!res.ok) { + error(400, 'Error fetching the PDF file'); + } + + const fileName = `AP-${params.id}-${new Date().toISOString()}.pdf`; + + return new Response(await res.blob(), { + headers: { + 'Content-Type': 'text/pdf', + 'Content-Disposition': `attachment; filename="${fileName}"` + } + }); +}; diff --git a/frontend/src/routes/(app)/risk-assessments/[id=uuid]/+page.svelte b/frontend/src/routes/(app)/risk-assessments/[id=uuid]/+page.svelte index 5c261d3c1..3cc44b1fa 100644 --- a/frontend/src/routes/(app)/risk-assessments/[id=uuid]/+page.svelte +++ b/frontend/src/routes/(app)/risk-assessments/[id=uuid]/+page.svelte @@ -161,7 +161,9 @@
- {m.remediationPlan()}