Skip to content

feat: warn the user during domain export if an object is out of scope #1582

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
53 changes: 52 additions & 1 deletion backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2408,11 +2408,62 @@ def my_assignments(self, request):
}
)

@action(detail=True, methods=["get"])
def check_out_of_scope_objects(self, request, pk=None):
"""Action to check if there are out-of-scope objects for the folder."""
instance = self.get_object()

if instance.content_type == "GL":
return Response(
{
"has_out_of_scope_objects": [],
"out_of_scope_objects": {},
}
)

valid_folder_ids = set(
Folder.objects.filter(
Q(id=instance.id) | Q(id__in=[f.id for f in instance.get_sub_folders()])
).values_list("id", flat=True)
)

objects = get_domain_export_objects(instance)
out_of_scope_types = []
out_of_scope_objects = {}

for model_name, queryset in objects.items():
model = queryset.model
folder_filter = None

if "folder" in [f.name for f in model._meta.get_fields()]:
folder_filter = Q(folder__id__in=valid_folder_ids)

if folder_filter is None:
continue

out_of_scope_qs = queryset.exclude(folder_filter).distinct()
if out_of_scope_qs.exists():
out_of_scope_types.append(model_name)
out_of_scope_objects[model_name] = [
{
"id": obj.id,
"name": getattr(obj, "name", None),
"description": getattr(obj, "description", None),
}
for obj in out_of_scope_qs
]

return Response(
{
"has_out_of_scope_objects": out_of_scope_types,
"out_of_scope_objects": out_of_scope_objects,
}
)

@action(detail=True, methods=["get"])
def export(self, request, pk):
include_attachments = True
instance = self.get_object()

logger.info(
"Starting domain export",
domain_id=instance.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { getModelInfo } from '$lib/utils/crud';
import { loadDetail } from '$lib/utils/load';
import {
nestedDeleteFormAction,
nestedWriteFormAction,
} from '$lib/utils/actions';
import { nestedDeleteFormAction, nestedWriteFormAction } from '$lib/utils/actions';

import type { Actions, PageServerLoad } from './$types';
import { BASE_API_URL } from '$lib/utils/constants';

export const load: PageServerLoad = async (event) => {
return loadDetail({ event, model: getModelInfo('folders'), id: event.params.id });
const detailData = await loadDetail({
event,
model: getModelInfo('folders'),
id: event.params.id
});

// Fetch has_out_of_scope_objects
const outOfScopeRes = await event.fetch(`${BASE_API_URL}/folders/${event.params.id}/check_out_of_scope_objects/`);
if (!outOfScopeRes.ok) {
throw new Error('Failed to check out-of-scope objects');
}
const { has_out_of_scope_objects, out_of_scope_objects } = await outOfScopeRes.json();
return {
...detailData,
has_out_of_scope_objects,
out_of_scope_objects
};
};

export const actions: Actions = {
Expand All @@ -20,4 +34,3 @@ export const actions: Actions = {
return nestedDeleteFormAction({ event });
},
};

Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,67 @@
import DetailView from '$lib/components/DetailView/DetailView.svelte';
import type { PageData, ActionData } from './$types';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/stores';
import { getSecureRedirect } from '$lib/utils/helpers';
import * as m from '$paraglide/messages';
import * as m from '$paraglide/messages';
import { safeTranslate} from '$lib/utils/i18n';
import ConfirmExportBody from '$lib/components/Modals/ConfirmExportBody.svelte';


import type { ModalSettings, ModalComponent, ModalStore } from '@skeletonlabs/skeleton';
import { getModalStore } from '@skeletonlabs/skeleton';
import ConfirmExportScopeModal from '$lib/components/Modals/ConfirmExportScopeModal.svelte';

export let data: PageData;
export let form: ActionData;

const modalStore: ModalStore = getModalStore();

$: if (form && form.redirect) {
goto(getSecureRedirect(form.redirect));
}

function confirmExport(event: Event) {
event.preventDefault();

if (data.has_out_of_scope_objects?.length) {
const modalComponent: ModalComponent = {
ref: ConfirmExportScopeModal,
props: {
outOfScopeObjects: data.out_of_scope_objects || {},
bodyComponent: ConfirmExportBody,
bodyProps: {
types: data.has_out_of_scope_objects
}
}
};
const modal: ModalSettings = {
type: 'component',
component: modalComponent,
title: m.confirmModalTitleWarning(),
response: (r: boolean) => {
if (r) (event.target as HTMLFormElement).submit();
}
};
modalStore.trigger(modal);
} else {
(event.target as HTMLFormElement).submit();
}
}
</script>

<DetailView {data}>
<div slot="actions" class="flex flex-col space-y-2 justify-end">
<form class="flex justify-end" action={`${$page.url.pathname}/export`}>
<button type="submit" class="btn variant-filled-primary h-fit" >
<i class="fa-solid fa-download mr-2" /> {m.exportButton()}
</button>
</form>
</div>
<div slot="actions" class="flex flex-col space-y-2 justify-end">
<form
class="flex justify-end"
action={`${$page.url.pathname}/export`}
method="GET"
on:submit={confirmExport}
>
<button type="submit" class="btn variant-filled-primary h-fit">
<i class="fa-solid fa-download mr-2" />
{m.exportButton()}
</button>
</form>
</div>
</DetailView>
6 changes: 6 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,8 @@
"revoke": "Revoke",
"riskAcceptanceValidatedMessage": "This risk acceptance is currently validated. It can be revoked at any time, but this will be irrevocable. You will need to duplicate it with a different version if necessary.",
"confirmModalTitle": "Confirm",
"confirmModalTitleWarning": "Be careful",
"bodyModalExportFolder": "Some objects out of this domain will also be exported. Here is the list of Object types concerned: ",
"confirmModalMessage": "Are you sure? This action will permanently affect the following object",
"submit": "Submit",
"requirementAssessment": "Requirement assessment",
Expand Down Expand Up @@ -1384,6 +1386,10 @@
"requirementsProgressionSemiColon": "Requirements progression:",
"noRequirementFound": "No requirement assessment found",
"noAuditForTheFramework": "There is no audit using this framework",
"detailedListOfOutOfScopeObjects": "Detailed list of out-of-scope objects",
"loadedlibrary": "Loaded libraries",
"riskmatrix": "Risk matrix",
"referencecontrol": "Reference control",
"entryType": "Type",
"entry": "Entry",
"incident": "Incident",
Expand Down
6 changes: 6 additions & 0 deletions frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,8 @@
"identified": "Identifié",
"confirmed": "Confirmé",
"dismissed": "Rejeté",
"confirmModalTitleWarning": "Faites attention",
"bodyModalExportFolder": "Certains objets en dehors de ce domaine vont aussi être exportés. Voici la liste des catégories d'objets concernés: ",
"assigned": "Assigné",
"selectEvidence": "Sélectionner une preuve",
"infrastructure": "Infrastructure",
Expand All @@ -1384,6 +1386,10 @@
"requirementsProgressionSemiColon": "Progression des exigences :",
"noRequirementFound": "Aucune revue de cette exigence trouvée",
"noAuditForTheFramework": "Il n'y a pas d'audit utilisant ce référentiel",
"detailedListOfOutOfScopeObjects": "Liste détaillée des objets en dehors du domaine",
"loadedlibrary": "Bibliothèques chargées",
"riskmatrix": "Matrice de risques",
"referencecontrol": "Mesures de référence",
"entryType": "Type",
"entry": "Entrée",
"incident": "Incident",
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/components/Modals/ConfirmExportBody.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- ConfirmExportBody.svelte -->
<script>
import * as m from '$paraglide/messages';
import { safeTranslate } from '$lib/utils/i18n';
export let types = [];
</script>

<div class="space-y-4">
<div class="text-lg">
{m.bodyModalExportFolder()}
<div class="mt-2 flex flex-wrap gap-2">
{#each types as type}
<span class="badge variant-filled-warning">{safeTranslate(type)}</span>
{/each}
</div>
</div>
</div>
98 changes: 98 additions & 0 deletions frontend/src/lib/components/Modals/ConfirmExportScopeModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script lang="ts">
import type { urlModel } from '$lib/utils/types';

// Props
/** Exposes parent props to this component. */
export let parent: any;

// Stores
import { getModalStore } from '@skeletonlabs/skeleton';
import type { ModalStore } from '@skeletonlabs/skeleton';

import * as m from '$paraglide/messages';
import { Accordion, AccordionItem } from '@skeletonlabs/skeleton';
import { safeTranslate } from '$lib/utils/i18n';
const modalStore: ModalStore = getModalStore();

export let _form = {};
export let URLModel: urlModel | '' = '';
export let id: string = '';
export let formAction: string;
export let bodyComponent: ComponentType | undefined;
export let bodyProps: Record<string, unknown> = {};

import { superForm } from 'sveltekit-superforms';

import SuperForm from '$lib/components/Forms/Form.svelte';

const { form } = superForm(_form, {
dataType: 'json',
id: `confirm-modal-form-${crypto.randomUUID()}`
});

// Base Classes
const cBase = 'card p-4 w-modal shadow-xl space-y-4';
const cHeader = 'text-2xl font-bold';
const cForm = 'p-4 space-y-4 rounded-container-token';

import SuperDebug from 'sveltekit-superforms';
import type { ComponentType } from 'svelte';
export let debug = false;

export let outOfScopeObjects: Record<string, Array<{ name: string; description?: string }>> = {};
</script>

{#if $modalStore[0]}
<div class="modal-example-form {cBase}">
<header class={cHeader}>{$modalStore[0].title ?? '(title missing)'}</header>
<article class="max-h-[60vh] overflow-y-auto">
{#if bodyComponent}
<div class="prose prose-sm dark:prose-invert max-w-none">
<svelte:component this={bodyComponent} {...bodyProps} />
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none whitespace-pre-line">
{$modalStore[0].body ?? '(body missing)'}
</div>
{/if}
<div class="card p-4 bg-surface-100-800-token mt-4">
<h3 class="font-semibold text-lg mb-4">{m.detailedListOfOutOfScopeObjects()}</h3>
<Accordion>
{#each Object.entries(outOfScopeObjects) as [type, objects]}
<AccordionItem>
<div slot="summary" class="flex items-center gap-2">
<span class="font-medium">{safeTranslate(type)}</span>
<span class="badge variant-filled-warning">{objects.length}</span>
</div>
<div slot="content">
<ul class="list-disc pl-6 space-y-1">
{#each objects as obj}
<li>
<span class="font-medium">{obj.name}</span>
{#if obj.description}
<span class="text-surface-600-400-token">({obj.description})</span>
{/if}
</li>
{/each}
</ul>
</div>
</AccordionItem>
{/each}
</Accordion>
</div>
</article>
<!-- Enable for debugging: -->
<SuperForm dataType="json" action={formAction} data={_form} class="modal-form {cForm}">
<!-- prettier-ignore -->
<footer class="modal-footer {parent.regionFooter}">
<button type="button" class="btn {parent.buttonNeutral}" on:click={parent.onClose}>{m.cancel()}</button>
<input type="hidden" name="urlmodel" value={URLModel} />
<input type="hidden" name="id" value={id} />
<button class="btn variant-filled-error" type="submit" on:click={parent.onConfirm}>{m.submit()}</button>
</footer>
</SuperForm>
{#if debug === true}
<SuperDebug data={$form} />
{/if}
</div>
{/if}
Loading