From d35aaa9323aae509f4a2baf7fe98946930bd8eb7 Mon Sep 17 00:00:00 2001 From: ssorin Date: Mon, 13 Jan 2025 15:22:21 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=94(dashboard)=20enforce=20immutabilit?= =?UTF-8?q?y=20of=20validated=20consents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced a validation mechanism to block any changes to consents with a `VALIDATED` status, raising a `ValidationError` if attempted. This preserves contractual integrity by ensuring such consents remain unchanged. Updated tests to confirm this behavior. --- src/dashboard/CHANGELOG.md | 2 + src/dashboard/apps/consent/models.py | 41 +++++++++- .../apps/consent/tests/test_models.py | 77 +++++++++++++++++-- 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/dashboard/CHANGELOG.md b/src/dashboard/CHANGELOG.md index 6b070c3c..ca35deec 100644 --- a/src/dashboard/CHANGELOG.md +++ b/src/dashboard/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to - add consent form to manage consents of one or many entities - add admin integration for Entity, DeliveryPoint and Consent - add mass admin action (make revoked and make awaiting) for consents +- block the updates of all new data if a consent has the status `VALIDATED` +- block the deletion of consent if it has the status `VALIDATED` - integration of custom 403, 404 and 500 pages - sentry integration - added a signal on the creation of a delivery point. This signal allows the creation diff --git a/src/dashboard/apps/consent/models.py b/src/dashboard/apps/consent/models.py index 61544ad2..424b29db 100644 --- a/src/dashboard/apps/consent/models.py +++ b/src/dashboard/apps/consent/models.py @@ -1,12 +1,13 @@ """Dashboard consent app models.""" +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ from apps.core.abstract_models import DashboardBase -from . import AWAITING, CONSENT_STATUS_CHOICE, REVOKED +from . import AWAITING, CONSENT_STATUS_CHOICE, REVOKED, VALIDATED from .managers import ConsentManager from .utils import consent_end_date @@ -29,6 +30,8 @@ class Consent(DashboardBase): - revoked_at (DateTimeField): recording the revoked date of the consent, if any. """ + VALIDATION_ERROR_MESSAGE = _("Validated consent cannot be modified once defined.") + delivery_point = models.ForeignKey( "qcd_core.DeliveryPoint", on_delete=models.CASCADE, related_name="consents" ) @@ -57,12 +60,48 @@ class Meta: # noqa: D106 def __str__(self): # noqa: D105 return f"{self.delivery_point} - {self.updated_at}: {self.status}" + @classmethod + def from_db(cls, db, field_names, values): + """Store the original values when an instance is loaded from the database.""" + instance = super().from_db(db, field_names, values) + instance._loaded_values = dict(zip(field_names, values, strict=False)) + return instance + + def clean(self): + """Custom validation logic. + + Validates and restricts updates to the Consent object if its status is set + to `VALIDATED`. This ensures that validated consents cannot be modified + after their status are defined to `VALIDATED` (We prevent this update + for contractual reasons). + + Raises: + ------ + ValidationError + If the Consent object's status is `VALIDATED`. + """ + if self._is_validated_and_modified(): + raise ValidationError(message=self.VALIDATION_ERROR_MESSAGE) + def save(self, *args, **kwargs): """Saves with custom logic. If the consent status is `REVOKED`, `revoked_at` is updated to the current time. """ + if self._is_validated_and_modified(): + raise ValidationError(message=self.VALIDATION_ERROR_MESSAGE) + if self.status == REVOKED: self.revoked_at = timezone.now() return super(Consent, self).save(*args, **kwargs) + + def delete(self, *args, **kwargs): + """Restrict the deletion of a consent if its status is `VALIDATED`.""" + if self._loaded_values.get("status") == VALIDATED: + raise ValidationError(message=self.VALIDATION_ERROR_MESSAGE) + super().delete(*args, **kwargs) + + def _is_validated_and_modified(self): + """Checks if the validated 'Consent' object is trying to be modified.""" + return not self._state.adding and self._loaded_values.get("status") == VALIDATED diff --git a/src/dashboard/apps/consent/tests/test_models.py b/src/dashboard/apps/consent/tests/test_models.py index 48cfd8e6..a33329e1 100644 --- a/src/dashboard/apps/consent/tests/test_models.py +++ b/src/dashboard/apps/consent/tests/test_models.py @@ -3,9 +3,11 @@ import datetime import pytest +from django.core.exceptions import ValidationError from django.db.models import signals from apps.consent import AWAITING, REVOKED, VALIDATED +from apps.consent.factories import ConsentFactory from apps.consent.signals import handle_new_delivery_point from apps.consent.utils import consent_end_date from apps.core.factories import DeliveryPointFactory @@ -100,27 +102,86 @@ def test_create_consent_with_custom_period_date(): @pytest.mark.django_db def test_update_consent_status(): - """Tests updating a consent status.""" + """Tests updating a consent status. + + Test that consents can no longer be modified once their status is passed to + `VALIDATED` (raise ValidationError). + """ from apps.consent.models import Consent # create one `delivery_point` and consequently one `consent` + assert Consent.objects.count() == 0 delivery_point = DeliveryPointFactory() + assert Consent.objects.count() == 1 # get the created consent consent = Consent.objects.get(delivery_point=delivery_point) consent_updated_at = consent.updated_at + assert consent.status == AWAITING + assert consent.revoked_at is None - # update status to VALIDATED - consent.status = VALIDATED + # update status to REVOKED + consent.status = REVOKED consent.save() - assert consent.status == VALIDATED + assert consent.status == REVOKED assert consent.updated_at > consent_updated_at + assert consent.revoked_at is not None + new_updated_at = consent.updated_at + # refresh the state in memory + consent = Consent.objects.get(delivery_point=delivery_point) + + # Update the consent to AWAITING + consent.status = AWAITING + consent.revoked_at = None + consent.save() + assert consent.status == AWAITING + assert consent.updated_at > new_updated_at assert consent.revoked_at is None new_updated_at = consent.updated_at + # refresh the state in memory + consent = Consent.objects.get(delivery_point=delivery_point) - # update status to REVOKED - consent.status = REVOKED + # update status to VALIDATED + consent.status = VALIDATED + consent.revoked_at = None consent.save() - assert consent.status == REVOKED + assert consent.status == VALIDATED assert consent.updated_at > new_updated_at - assert consent.revoked_at is not None + assert consent.revoked_at is None + # refresh the state in memory + consent = Consent.objects.get(delivery_point=delivery_point) + + # The consent status is `VALIDATED`, so it cannot be changed anymore. + with pytest.raises(ValidationError): + consent.status = AWAITING + consent.save() + + +@pytest.mark.django_db +def test_delete_consent(): + """Tests deleting a consent. + + Consents can no longer be deleted once their status is passed to + `VALIDATED` (raise ValidationError). + """ + from apps.consent.models import Consent + + # create one `delivery_point` and consequently one `consent` + assert Consent.objects.count() == 0 + delivery_point = DeliveryPointFactory() + assert Consent.objects.count() == 1 + + # get the created consent and delete it + consent = Consent.objects.get(delivery_point=delivery_point) + assert consent.status != VALIDATED + consent.delete() + assert Consent.objects.count() == 0 + + # create a new content with status VALIDATED + ConsentFactory(delivery_point=delivery_point, status=VALIDATED) + consent = Consent.objects.get(delivery_point=delivery_point) + assert consent.status == VALIDATED + # the consent status is `VALIDATED`, so it cannot be deleted. + with pytest.raises(ValidationError): + consent.delete() + assert Consent.objects.count() == 1