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

👔(dashboard) enforce immutability of validated consents #332

Merged
merged 1 commit into from
Jan 14, 2025
Merged
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
2 changes: 2 additions & 0 deletions src/dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 40 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,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
77 changes: 69 additions & 8 deletions src/dashboard/apps/consent/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading