diff --git a/backend/app_tests/api/test_api_compliance_assessments.py b/backend/app_tests/api/test_api_compliance_assessments.py index 0ff2003d7..543bf048e 100644 --- a/backend/app_tests/api/test_api_compliance_assessments.py +++ b/backend/app_tests/api/test_api_compliance_assessments.py @@ -122,6 +122,8 @@ def test_get_compliance_assessments(self, test): "framework": { "id": str(Framework.objects.all()[0].id), "str": str(Framework.objects.all()[0]), + "min_score": 0, + "max_score": 100 }, }, user_group=test.user_group, @@ -150,6 +152,8 @@ def test_create_compliance_assessments(self, test): "framework": { "id": str(Framework.objects.all()[0].id), "str": str(Framework.objects.all()[0]), + "min_score": Framework.objects.all()[0].min_score, + "max_score": Framework.objects.all()[0].max_score }, }, user_group=test.user_group, @@ -190,6 +194,8 @@ def test_update_compliance_assessments(self, test): "framework": { "id": str(Framework.objects.all()[0].id), "str": str(Framework.objects.all()[0]), + "min_score": Framework.objects.all()[0].min_score, + "max_score": Framework.objects.all()[0].max_score }, }, user_group=test.user_group, diff --git a/backend/core/helpers.py b/backend/core/helpers.py index 34a63f808..4f3785134 100644 --- a/backend/core/helpers.py +++ b/backend/core/helpers.py @@ -238,6 +238,8 @@ def get_sorted_requirement_nodes_rec( "name": node.name, "ra_id": str(req_as.id) if requirements_assessed else None, "status": req_as.status if requirements_assessed else None, + "score": req_as.score if requirements_assessed else None, + "max_score": req_as.compliance_assessment.framework.max_score if requirements_assessed else None, "status_display": req_as.get_status_display() if requirements_assessed else None, "status_i18n": camel_case(req_as.status) if requirements_assessed else None, "node_content": node.display_long, @@ -266,6 +268,8 @@ def get_sorted_requirement_nodes_rec( "description": req.description, "ra_id": str(req_as.id), "status": req_as.status, + "score": req_as.score, + "max_score": req_as.compliance_assessment.framework.max_score, "status_display": req_as.get_status_display(), "status_i18n": camel_case(req_as.status), "style": "leaf", diff --git a/backend/core/migrations/0009_framework_max_score_framework_min_score_and_more.py b/backend/core/migrations/0009_framework_max_score_framework_min_score_and_more.py new file mode 100644 index 000000000..a605812dc --- /dev/null +++ b/backend/core/migrations/0009_framework_max_score_framework_min_score_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.4 on 2024-04-16 14:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_alter_complianceassessment_status_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='framework', + name='max_score', + field=models.IntegerField(default=100, verbose_name='Maximum score'), + ), + migrations.AddField( + model_name='framework', + name='min_score', + field=models.IntegerField(default=0, verbose_name='Minimum score'), + ), + migrations.AddField( + model_name='requirementassessment', + name='score', + field=models.IntegerField(blank=True, null=True, verbose_name='Score'), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 04762a1ad..4cd054f10 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -328,6 +328,12 @@ def __str__(self) -> str: class Framework(ReferentialObjectMixin): + min_score = models.IntegerField( + default=0, verbose_name=_("Minimum score") + ) + max_score = models.IntegerField( + default=100, verbose_name=_("Maximum score") + ) library = models.ForeignKey( Library, on_delete=models.CASCADE, @@ -1288,6 +1294,11 @@ class Result(models.TextChoices): class Meta: verbose_name = _("Compliance assessment") verbose_name_plural = _("Compliance assessments") + + def get_global_score(self): + requirement_assessments_scored = RequirementAssessment.objects.filter(compliance_assessment=self).exclude(score=None) + score = requirement_assessments_scored.aggregate(models.Avg('score')) + return score['score__avg'] or -1 def get_requirements_status_count(self): requirements_status_count = [] @@ -1465,6 +1476,7 @@ def quality_check(self) -> dict: class RequirementAssessment(AbstractBaseModel, FolderMixin): + class Status(models.TextChoices): TODO = "to_do", _("To do") IN_PROGRESS = "in_progress", _("In progress") @@ -1473,12 +1485,18 @@ class Status(models.TextChoices): COMPLIANT = "compliant", _("Compliant") NOT_APPLICABLE = "not_applicable", _("Not applicable") + status = models.CharField( max_length=100, choices=Status.choices, default=Status.TODO, verbose_name=_("Status"), ) + score = models.IntegerField( + blank=True, + null=True, + verbose_name=_("Score"), + ) evidences = models.ManyToManyField( Evidence, blank=True, @@ -1501,6 +1519,13 @@ class Status(models.TextChoices): verbose_name=_("Applied controls"), related_name="requirement_assessments", ) + + def get_score_choices(self): + return [(i, i) for i in range(self.compliance_assessment.framework.min_score, self.compliance_assessment.framework.max_score + 1)] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._meta.get_field('score').choices = self.get_score_choices() def __str__(self) -> str: return self.requirement.display_short diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 805eab409..863dab9df 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -476,7 +476,7 @@ class Meta: class ComplianceAssessmentReadSerializer(AssessmentReadSerializer): - framework = FieldsRelatedField() + framework = FieldsRelatedField(["id", "min_score", "max_score"]) class Meta: model = ComplianceAssessment diff --git a/backend/core/views.py b/backend/core/views.py index b53230040..b49a85c90 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1223,6 +1223,12 @@ def quality_check(self, request): for a in compliance_assessments ] return Response({"results": res}) + + @action(detail=True, methods=["get"]) + def global_score(self, request, pk): + """Returns the global score of the compliance assessment""" + return Response({"score": self.get_object().get_global_score(), + "max_score": self.get_object().framework.max_score}) @action(detail=True, methods=["get"], url_path="quality_check") def quality_check_detail(self, request, pk): diff --git a/backend/library/utils.py b/backend/library/utils.py index b06b74878..23cef7234 100644 --- a/backend/library/utils.py +++ b/backend/library/utils.py @@ -290,6 +290,8 @@ def import_framework(self, library_object: Library): ref_id=self.framework_data["ref_id"], name=self.framework_data.get("name"), description=self.framework_data.get("description"), + min_score=self.framework_data.get("min_score", 0), + max_score=self.framework_data.get("max_score", 100), provider=library_object.provider, locale=library_object.locale, default_locale=library_object.default_locale, # Change this in the future ? diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 416d05a1c..a6c0c89cb 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -501,6 +501,9 @@ "missingMandatoyObjects2": "Please add them before proceeding", "attemptToDeleteOnlyAdminAccountError": "You can't delete the only admin account of your application.", "attemptToRemoveOnlyAdminUserGroup": "You can't remove the only admin user of the application from the admin user group.", + "scoringHelpText": "Check to enable scoring", + "minScore": "Minimum score", + "maxScore": "Maximum score", "setTemporaryPassword1": "In case the user cannot set their own password, you can", "setTemporaryPassword": "set a temporary password", "setTemporaryPassword2": "Please use a strong one and make sure to inform the user to change it as soon as possible", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 1d605e939..e116b72f3 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -501,6 +501,9 @@ "missingMandatoyObjects2": "Veuillez les ajouter avant de continuer", "attemptToDeleteOnlyAdminAccountError": "Vous ne pouvez pas supprimer votre unique compte administrateur de l'application.", "attemptToRemoveOnlyAdminUserGroup": "Vous ne pouvez pas retirer le seul compte administrateur de l'application du groupe des administrateurs.", + "scoringHelpText": "Cocher pour activer le score", + "minScore": "Score minimum", + "maxScore": "Score maximum", "setTemporaryPassword1": "Si l'utilisateur ne peut pas définir son propre mot de passe, vous pouvez", "setTemporaryPassword": "définir un mot de passe temporaire", "setTemporaryPassword2": "Veuillez en utiliser un solide et assurez-vous d'informer l'utilisateur de le modifier dès que possible.", diff --git a/frontend/src/lib/components/Forms/Score.svelte b/frontend/src/lib/components/Forms/Score.svelte new file mode 100644 index 000000000..ce300b529 --- /dev/null +++ b/frontend/src/lib/components/Forms/Score.svelte @@ -0,0 +1,77 @@ + + +
+ {#if label !== undefined} + {#if $constraints?.required} + + {:else} + + {/if} + {/if} + {#if $errors && $errors.length > 0} +
+ {#each $errors as error} +

{error}

+ {/each} +
+ {/if} +
+ $value = null} + {...$constraints} + {...$$restProps} + {...$constraints} + {...$$restProps} + /> +
+ +
+
+ {#if scoringEnabled} + {displayNoValue($value)} + {:else} + -- + {/if} +
+
+

{m.scoringHelpText()}

+
diff --git a/frontend/src/lib/utils/helpers.ts b/frontend/src/lib/utils/helpers.ts index 50f259b3e..567f02896 100644 --- a/frontend/src/lib/utils/helpers.ts +++ b/frontend/src/lib/utils/helpers.ts @@ -1,4 +1,4 @@ -export function formatStringToDate(inputString: string,locale: string="en") { +export function formatStringToDate(inputString: string, locale: string="en") { const date = new Date(inputString); return date.toLocaleDateString(locale, { year: 'numeric', @@ -23,4 +23,18 @@ export function getRequirementTitle(ref_id: string, name: string) { pattern == 2 ? ref_id : pattern == 1 ? name : ''; return title; +} + +export function displayScoreColor(value: number, max_score: number){ + value = value * 100 / max_score + if(value < 25){ + return 'stroke-red-500' + } + if(value < 50){ + return 'stroke-orange-500' + } + if(value < 75){ + return 'stroke-yellow-500' + } + return 'stroke-green-500' } \ No newline at end of file diff --git a/frontend/src/lib/utils/locales.ts b/frontend/src/lib/utils/locales.ts index 22b921956..8102dc9f0 100644 --- a/frontend/src/lib/utils/locales.ts +++ b/frontend/src/lib/utils/locales.ts @@ -315,7 +315,9 @@ export function localItems(languageTag: string): LocalItems { libraryAlreadyExistsError: m.libraryAlreadyImportedError({ languageTag: languageTag }), invalidLibraryFileError: m.invalidLibraryFileError({ languageTag: languageTag }), attemptToDeleteOnlyAdminAccountError: m.attemptToDeleteOnlyAdminAccountError({ languageTag: languageTag }), - attemptToRemoveOnlyAdminUserGroup: m.attemptToRemoveOnlyAdminUserGroup({ languageTag: languageTag }) + attemptToRemoveOnlyAdminUserGroup: m.attemptToRemoveOnlyAdminUserGroup({ languageTag: languageTag }), + minScore: m.minScore({ languageTag: languageTag }), + maxScore: m.maxScore({ languageTag: languageTag }) }; return LOCAL_ITEMS; } diff --git a/frontend/src/lib/utils/schemas.ts b/frontend/src/lib/utils/schemas.ts index 90fdc861e..fa4208243 100644 --- a/frontend/src/lib/utils/schemas.ts +++ b/frontend/src/lib/utils/schemas.ts @@ -151,6 +151,7 @@ export const AssetSchema = baseNamedObject({ export const RequirementAssessmentSchema = z.object({ status: z.string(), + score: z.number().optional().nullable(), comment: z.string().optional().nullable(), folder: z.string(), requirement: z.string(), diff --git a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.server.ts b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.server.ts index 619db72be..fa5c16324 100644 --- a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.server.ts +++ b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.server.ts @@ -17,5 +17,9 @@ export const load = (async ({ fetch, params }) => { `${BASE_API_URL}/${URLModel}/${params.id}/donut_data/` ).then((res) => res.json()); - return { URLModel, compliance_assessment, object, tree, compliance_assessment_donut_values }; + const global_score = await fetch( + `${BASE_API_URL}/${URLModel}/${params.id}/global_score/` + ).then((res) => res.json()); + + return { URLModel, compliance_assessment, object, tree, compliance_assessment_donut_values, global_score }; }) satisfies PageServerLoad; 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 a2d75073e..0a0fb9e90 100644 --- a/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.svelte +++ b/frontend/src/routes/(app)/compliance-assessments/[id=uuid]/+page.svelte @@ -63,7 +63,9 @@ statusI18n: node.status_i18n, assessable: node.assessable, statusDisplay: node.status_display, - statusColor: complianceColorMap[node.status] + statusColor: complianceColorMap[node.status], + score: node.score, + max_score: node.max_score }, children: node.children ? transformToTreeView(Object.entries(node.children)) : [] }; @@ -86,8 +88,9 @@ let expandedNodes: TreeViewNode[] = []; - import { localStorageStore } from '@skeletonlabs/skeleton'; + import { ProgressRadial, localStorageStore } from '@skeletonlabs/skeleton'; import type { Writable } from 'svelte/store'; + import { displayScoreColor } from '$lib/utils/helpers'; const expandedNodesState: Writable = localStorageStore('expandedNodes', expandedNodes, { storage: 'session' @@ -98,8 +101,8 @@
-
-
+
+
{#each Object.entries(data.compliance_assessment).filter( ([key, _]) => ['name', 'description', 'project', 'framework', 'authors', 'reviewers', 'status'].includes(key) ) as [key, value]}
{/each}
-
+ {#if data.global_score.score >= 0} +
+ {data.global_score.score} +
+ {/if} +
+ import { displayScoreColor } from '$lib/utils/helpers'; import { localItems } from '$lib/utils/locales'; import { languageTag } from '$paraglide/runtime'; + import { ProgressRadial } from '@skeletonlabs/skeleton'; export let statusI18n: string; export let statusDisplay: string; export let statusColor: string; export let assessable: boolean; + export let score: number; + export let max_score: number; const lead = localItems(languageTag())[statusI18n] ?? statusDisplay ?? ''; @@ -13,7 +17,14 @@ {#if assessable} - +
+ {lead} + {#if score !== null} + + {score} + + {/if} +
{/if} diff --git a/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.svelte b/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.svelte index bc03d3bf4..083e235ab 100644 --- a/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.svelte +++ b/frontend/src/routes/(app)/requirement-assessments/[id=uuid]/+page.svelte @@ -11,6 +11,7 @@ import SuperForm from '$lib/components/Forms/Form.svelte'; import HiddenInput from '$lib/components/Forms/HiddenInput.svelte'; import Select from '$lib/components/Forms/Select.svelte'; + import Score from '$lib/components/Forms/Score.svelte'; import TextArea from '$lib/components/Forms/TextArea.svelte'; import CreateModal from '$lib/components/Modals/CreateModal.svelte'; import ModelTable from '$lib/components/ModelTable/ModelTable.svelte'; @@ -266,6 +267,12 @@