Skip to content
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

Ca 178 add score notion to compliance assessment #267

Closed
Closed
6 changes: 6 additions & 0 deletions backend/app_tests/api/test_api_compliance_assessments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions backend/core/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
25 changes: 25 additions & 0 deletions backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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()
Comment on lines +1523 to +1528
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point of getting score choices if they are just a range of integers between min_score and max_score?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It permits to be consistent on the API, to be sure we can only add a score in the range of min_score and max_score.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be done at the validation stage. This way you would only have to check the boundaries instead of iterating over every single integer contained between min_score and max_score.


def __str__(self) -> str:
return self.requirement.display_short
Expand Down
2 changes: 1 addition & 1 deletion backend/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ class Meta:


class ComplianceAssessmentReadSerializer(AssessmentReadSerializer):
framework = FieldsRelatedField()
framework = FieldsRelatedField(["id", "min_score", "max_score"])

class Meta:
model = ComplianceAssessment
Expand Down
6 changes: 6 additions & 0 deletions backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions backend/library/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?
Expand Down
3 changes: 3 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/lib/components/Forms/Score.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script lang="ts">
import { formFieldProxy, type SuperForm } from 'sveltekit-superforms/client';
import { ProgressRadial } from '@skeletonlabs/skeleton';
import { RangeSlider } from '@skeletonlabs/skeleton';
import * as m from '$paraglide/messages';
import type { AnyZodObject } from 'zod';
import { displayScoreColor } from '$lib/utils/helpers';

export let label: string | undefined = undefined;
export let field: string;

export let min_score: number;
export let max_score: number;

export let form: SuperForm<AnyZodObject>;
const { value, errors, constraints } = formFieldProxy(form, field);

$: scoringEnabled = $value === null ? false : true

function preventNull(value: number){
if(value === null){
return 0
}
return value * 100 / max_score

}
Mohamed-Hacene marked this conversation as resolved.
Show resolved Hide resolved

function displayNoValue(value: number){
if(value === null){
return '--'
}
return value
}
</script>

<div>
{#if label !== undefined}
{#if $constraints?.required}
<label class="text-sm font-semibold" for={field}
>{label} <span class="text-red-500">*</span></label
>
{:else}
<label class="text-sm font-semibold" for={field}>{label}</label>
{/if}
{/if}
{#if $errors && $errors.length > 0}
<div>
{#each $errors as error}
<p class="text-error-500 text-xs font-medium">{error}</p>
{/each}
</div>
{/if}
<div class="flex flex-row w-full items-center space-x-4 ml-2">
<input
name={field}
type="checkbox"
class="checkbox"
data-testid="form-input-{field.replaceAll('_', '-')}"
bind:checked={scoringEnabled}
on:change={() => $value = null}
{...$constraints}
{...$$restProps}
{...$constraints}
{...$$restProps}
/>
<div class="flex w-1/2 items-center justify-center">
<RangeSlider disabled={!scoringEnabled} class="w-full" name="range-slider" bind:value={$value} min={min_score} max={max_score} step={1} ticked></RangeSlider>
</div>
<div class="flex w-1/2 items-center justify-center">
{#if scoringEnabled}
<ProgressRadial stroke={100} meter={displayScoreColor($value, max_score)} value={preventNull($value)} font={100} width={'w-32'}>{displayNoValue($value)}</ProgressRadial>
{:else}
<ProgressRadial stroke={100} value={0} font={100} width={'w-32'}>--</ProgressRadial>
{/if}
</div>
</div>
<p class="text-sm text-gray-500">{m.scoringHelpText()}</p>
</div>
16 changes: 15 additions & 1 deletion frontend/src/lib/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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'
}
4 changes: 3 additions & 1 deletion frontend/src/lib/utils/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions frontend/src/lib/utils/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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)) : []
};
Expand All @@ -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<any> = localStorageStore('expandedNodes', expandedNodes, {
storage: 'session'
Expand All @@ -98,8 +101,8 @@
</script>

<div class="flex flex-col space-y-4 whitespace-pre-line">
<div class="card px-6 py-4 bg-white flex flex-row justify-between shadow-lg">
<div class="flex flex-col space-y-2 whitespace-pre-line">
<div class="card px-6 py-4 bg-white flex flex-row justify-between shadow-lg w-full">
<div class="flex flex-col space-y-2 whitespace-pre-line w-1/6">
{#each Object.entries(data.compliance_assessment).filter( ([key, _]) => ['name', 'description', 'project', 'framework', 'authors', 'reviewers', 'status'].includes(key) ) as [key, value]}
<div class="flex flex-col">
<div
Expand Down Expand Up @@ -157,7 +160,12 @@
</div>
{/each}
</div>
<div class="w-full">
{#if data.global_score.score >= 0}
<div class="flex items-center cursor-pointer">
<ProgressRadial stroke={100} meter={displayScoreColor(data.global_score.score, data.global_score.max_score)} font={125} value={data.global_score.score * 100 / data.global_score.max_score} width={'w-52'}>{data.global_score.score}</ProgressRadial>
</div>
{/if}
<div class="w-1/2">
<DonutChart
s_label={m.complianceAssessments()}
values={compliance_assessment_donut_values.values}
Expand Down
Loading
Loading