Skip to content

Commit

Permalink
👔(dashboard) enforce immutability of validated consents
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ssorin committed Jan 14, 2025
1 parent ff54629 commit 591e808
Show file tree
Hide file tree
Showing 3 changed files with 76 additions and 9 deletions.
1 change: 1 addition & 0 deletions src/dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 consentement 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
Expand Down
38 changes: 37 additions & 1 deletion src/dashboard/apps/consent/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
)
Expand Down Expand Up @@ -57,12 +60,45 @@ 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 _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
46 changes: 38 additions & 8 deletions src/dashboard/apps/consent/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import datetime

import pytest
from django.core.exceptions import ValidationError
from django.db.models import signals

from apps.consent import AWAITING, REVOKED, VALIDATED
Expand Down Expand Up @@ -100,27 +101,56 @@ 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()

0 comments on commit 591e808

Please sign in to comment.