diff --git a/src/dashboard/CHANGELOG.md b/src/dashboard/CHANGELOG.md index 01c7141f..672aaaba 100644 --- a/src/dashboard/CHANGELOG.md +++ b/src/dashboard/CHANGELOG.md @@ -21,7 +21,9 @@ and this project adheres to - 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` +- Allow consent updates if status changes from `VALIDATED` to `REVOKED` +and updated fields are allowed to be updated +- block the deletion of consent if it has the status `VALIDATED` or `REVOKED` - block consent updates (via the consent form) if the consent status is not `AWAITING` - integration of custom 403, 404 and 500 pages - sentry integration diff --git a/src/dashboard/apps/consent/exceptions.py b/src/dashboard/apps/consent/exceptions.py new file mode 100644 index 00000000..d77d206b --- /dev/null +++ b/src/dashboard/apps/consent/exceptions.py @@ -0,0 +1,14 @@ +"""Dashboard consent exceptions.""" + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + + +class ConsentWorkflowError(ValidationError): + """Exception for consent workflow validation errors.""" + + DEFAULT_MESSAGE = _("Consent workflow error.") + + def __init__(self, custom_message=None): + """Initialize the exception with an optional custom message.""" + super().__init__(custom_message or self.DEFAULT_MESSAGE) diff --git a/src/dashboard/apps/consent/models.py b/src/dashboard/apps/consent/models.py index 424b29db..0fed7600 100644 --- a/src/dashboard/apps/consent/models.py +++ b/src/dashboard/apps/consent/models.py @@ -1,6 +1,5 @@ """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 _ @@ -8,6 +7,7 @@ from apps.core.abstract_models import DashboardBase from . import AWAITING, CONSENT_STATUS_CHOICE, REVOKED, VALIDATED +from .exceptions import ConsentWorkflowError from .managers import ConsentManager from .utils import consent_end_date @@ -30,8 +30,6 @@ 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" ) @@ -80,16 +78,18 @@ def clean(self): ValidationError If the Consent object's status is `VALIDATED`. """ - if self._is_validated_and_modified(): - raise ValidationError(message=self.VALIDATION_ERROR_MESSAGE) + if self._is_update_allowed(): + return def save(self, *args, **kwargs): """Saves with custom logic. - If the consent status is `REVOKED`, `revoked_at` is updated to the current time. + - Validates and restricts updates to the consent based on its status and + modified fields. + - Updates `revoked_at` with the current date if the consent status is `REVOKED`. """ - if self._is_validated_and_modified(): - raise ValidationError(message=self.VALIDATION_ERROR_MESSAGE) + if not self._is_update_allowed(): + return if self.status == REVOKED: self.revoked_at = timezone.now() @@ -97,11 +97,71 @@ def save(self, *args, **kwargs): 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 + """Restrict the deletion of a consent. + + Consents cannot be deleted if status is `VALIDATED` or `REVOKED`. + """ + if self._is_deletion_allowed(): + super().delete(*args, **kwargs) + + def _is_update_allowed(self) -> bool: + """Check if consent can be updated. + + Workflow according to consent status: + - AWAITING: + - can be updated without restriction + + - VALIDATED + - if the status is updated to something other than REVOKED, an exception is + raised, + - if the status is updated to REVOKED, we check the updated fields are + allowed to be updated. + + - REVOKED + - can be updated without restriction + todo: add restriction: REVOKED consent cannot be modified + """ + ALLOWED_UPDATE_FIELDS = {"status", "revoked_at", "updated_at"} + + if self._state.adding: + return True + + loaded_status = self._loaded_values.get("status") # type: ignore[attr-defined] + updated_status = self.status + + if loaded_status == VALIDATED: + if updated_status != REVOKED: + raise ConsentWorkflowError( + _('Validated consent can only be changed to the status "revoked".') + ) + + # Update the consent status from VALIDATED to REVOKED + # we check the updated fields are allowed to be updated. + updated_fields = { + field + for field, loaded_value in self._loaded_values.items() # type: ignore[attr-defined] + if getattr(self, field) != loaded_value + } + + if not updated_fields.issubset(ALLOWED_UPDATE_FIELDS): + raise ConsentWorkflowError( + _( + 'Only the authorized fields ("status", "revoked_at" and ' + '"updated_at") can be modified.' + ) + ) + + return True + + def _is_deletion_allowed(self) -> bool: + """Check if a consent can be deleted. + + Consent cannot be deleted if his loaded status is `VALIDATED` or `REVOKED`. + """ + if self._loaded_values.get("status") == VALIDATED: # type: ignore[attr-defined] + raise ConsentWorkflowError(_("Validated consent cannot be deleted.")) + + elif self._loaded_values.get("status") == REVOKED: # type: ignore[attr-defined] + raise ConsentWorkflowError(_("Revoked consent cannot be deleted.")) + + return True diff --git a/src/dashboard/apps/consent/tests/test_models.py b/src/dashboard/apps/consent/tests/test_models.py index a33329e1..f5d18204 100644 --- a/src/dashboard/apps/consent/tests/test_models.py +++ b/src/dashboard/apps/consent/tests/test_models.py @@ -3,12 +3,14 @@ import datetime import pytest -from django.core.exceptions import ValidationError from django.db.models import signals +from django.utils.translation import gettext_lazy as _ from apps.consent import AWAITING, REVOKED, VALIDATED +from apps.consent.exceptions import ConsentWorkflowError from apps.consent.factories import ConsentFactory from apps.consent.signals import handle_new_delivery_point +from apps.consent.tests.conftest import FAKE_TIME from apps.consent.utils import consent_end_date from apps.core.factories import DeliveryPointFactory from apps.core.models import DeliveryPoint @@ -101,87 +103,383 @@ def test_create_consent_with_custom_period_date(): @pytest.mark.django_db -def test_update_consent_status(): - """Tests updating a consent status. +def test_is_update_allowed(): + """Tests the `_is_update_allowed` method of a consent. + + - AWAITING to VALIDATED is allowed. + - AWAITING to REVOKED is allowed. + - VALIDATED to AWAITING is not allowed. + - VALIDATED to REVOKED with not-allowed fields is not allowed. + - VALIDATED to REVOKED with allowed fields is allowed. + - REVOKED to AWAITING is allowed. + - REVOKED to VALIDATED is allowed. + - Create new consent is allowed. + """ + from apps.consent.models import Consent + + # update from AWAITING to VALIDATED + consent = ConsentFactory(status=AWAITING) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = VALIDATED + assert consent._is_update_allowed() is True + + # update from AWAITING to REVOKED + consent = ConsentFactory(status=AWAITING) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + assert consent._is_update_allowed() is True + + # update from VALIDATED to AWAITING, raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = AWAITING + with pytest.raises(ConsentWorkflowError) as exc_info: + consent._is_update_allowed() + assert exc_info.value.message == _( + 'Validated consent can only be changed to the status "revoked".' + ) - Test that consents can no longer be modified once their status is passed to - `VALIDATED` (raise ValidationError). + # update from VALIDATED to REVOKED + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + assert consent._is_update_allowed() is True + + # update from VALIDATED to REVOKED with not-allowed fields raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + consent.end = FAKE_TIME + with pytest.raises(ConsentWorkflowError) as exc_info: + consent._is_update_allowed() + assert exc_info.value.message == _( + 'Only the authorized fields ("status", "revoked_at" and "updated_at") ' + "can be modified." + ) + + # update from VALIDATED to REVOKED with allowed fields. + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + consent.revoked_at = consent.revoked_at or consent.start + assert consent._is_update_allowed() is True + + # update from REVOKED to AWAITING + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = AWAITING + assert consent._is_update_allowed() is True + + # update from REVOKED to VALIDATED + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = VALIDATED + assert consent._is_update_allowed() is True + + # create a new consent + dl = DeliveryPointFactory() + consent = Consent(delivery_point=dl, status=AWAITING) + assert consent._state.adding is True + assert consent._is_update_allowed() is True + + +@pytest.mark.django_db +def test_clean_and_update_awaiting_consent_status(): + """Tests clean and update an awaiting consent status. + + - AWAITING to VALIDATED is authorized. + - AWAITING to REVOKED is authorized. + - AWAITING with mixed fields is authorized. """ 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 + # update the status from AWAITING to VALIDATED + consent = ConsentFactory(status=AWAITING) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = VALIDATED + consent.end = FAKE_TIME + consent.clean() + consent.save() + assert consent.status == VALIDATED + assert consent.end == FAKE_TIME + + # update the status from AWAITING to REVOKED + consent = ConsentFactory(status=AWAITING) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + consent.end = FAKE_TIME + consent.clean() + consent.save() + assert consent.status == REVOKED + assert consent.end == FAKE_TIME + assert consent.revoked_at is not None + + # update values + consent = ConsentFactory(status=AWAITING) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.end = FAKE_TIME + consent.clean() + consent.save() + assert consent.end == FAKE_TIME + + +@pytest.mark.django_db +def test_update_validated_consent_status(): + """Tests updating a validated consent status. + + - VALIDATED to AWAITING is not authorized. + - VALIDATED to REVOKED with allowed fields is authorized. + - VALIDATED to REVOKED with not-allowed fields is not authorized. + - VALIDATED with mixed fields is not authorized. + """ + from apps.consent.models import Consent + + # update the status from VALIDATED to AWAITING raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = AWAITING + with pytest.raises(ConsentWorkflowError) as exc_info: + consent.save() + assert exc_info.value.message == _( + 'Validated consent can only be changed to the status "revoked".' + ) + + # update the status from VALIDATED to REVOKED with allowed fields is authorized. + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + consent.revoked_at = FAKE_TIME + consent.save() + assert consent.status == REVOKED + assert consent.revoked_at is not None + + # update status VALIDATED to REVOKED with not-allowed field raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + # update of an unauthorized field + consent.end = FAKE_TIME + with pytest.raises(ConsentWorkflowError) as exc_info: + consent.save() + assert exc_info.value.message == _( + 'Only the authorized fields ("status", "revoked_at" and "updated_at") ' + "can be modified." + ) + + # update status with mixed fields raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + # update of an unauthorized field + consent.end = FAKE_TIME + with pytest.raises(ConsentWorkflowError) as exc_info: + consent.save() + assert exc_info.value.message == _( + 'Validated consent can only be changed to the status "revoked".' + ) + + +@pytest.mark.django_db +def test_update_revoked_consent_status(): + """Tests updating a revoked consent status. + + - REVOKED to AWAITING is authorized. + - REVOKED to VALIDATED is authorized. + - REVOKED with mixed fields is authorized. + """ + from apps.consent.models import Consent - # get the created consent - consent = Consent.objects.get(delivery_point=delivery_point) - consent_updated_at = consent.updated_at + # update the status from REVOKED to AWAITING + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = AWAITING + consent.save() assert consent.status == AWAITING - assert consent.revoked_at is None - # update status to REVOKED + # update the status from REVOKED to VALIDATED + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = VALIDATED + consent.save() + assert consent.status == VALIDATED + + # update status with mixed fields + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.end = FAKE_TIME + consent.save() + assert consent.status == REVOKED + assert consent.end == FAKE_TIME + + +@pytest.mark.django_db +def test_clean_validated_consent_status(): + """Tests the `clean` method of a validated consent status. + + - VALIDATED to AWAITING is not authorized. + - VALIDATED to REVOKED with allowed fields is authorized. + - VALIDATED to REVOKED with not-allowed fields is not authorized. + - VALIDATED with mixed fields is not authorized. + """ + from apps.consent.models import Consent + + # update the status from VALIDATED to AWAITING raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = AWAITING + with pytest.raises(ConsentWorkflowError) as exc_info: + consent.clean() + assert exc_info.value.message == _( + 'Validated consent can only be changed to the status "revoked".' + ) + + # update the status from VALIDATED to REVOKED with allowed fields is authorized. + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory consent.status = REVOKED + consent.revoked_at = FAKE_TIME + consent.clean() consent.save() 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 + # update status VALIDATED to REVOKED with not-allowed field raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.status = REVOKED + # update of an unauthorized field + consent.end = FAKE_TIME + with pytest.raises(ConsentWorkflowError) as exc_info: + consent.clean() + assert exc_info.value.message == _( + 'Only the authorized fields ("status", "revoked_at" and "updated_at") ' + "can be modified." + ) + + # update status with mixed fields raise exception + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + # update of an unauthorized field + consent.end = FAKE_TIME + with pytest.raises(ConsentWorkflowError) as exc_info: + consent.clean() + assert exc_info.value.message == _( + 'Validated consent can only be changed to the status "revoked".' + ) + + +@pytest.mark.django_db +def test_clean_revoked_consent_status(): + """Tests the `clean` method of a revoked consent status. + + - REVOKED to AWAITING is authorized. + - REVOKED to VALIDATED is authorized. + - REVOKED with mixed fields is authorized. + """ + from apps.consent.models import Consent + + # update the status from REVOKED to AWAITING + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory consent.status = AWAITING - consent.revoked_at = None + consent.clean() 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 VALIDATED + # update the status from REVOKED to VALIDATED + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory consent.status = VALIDATED - consent.revoked_at = None + consent.clean() consent.save() assert consent.status == VALIDATED - assert consent.updated_at > new_updated_at - 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() + # update status with mixed fields + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + consent.end = FAKE_TIME + consent.clean() + consent.save() + assert consent.status == REVOKED + assert consent.end == FAKE_TIME + + +@pytest.mark.django_db +def test_is_deletion_allowed(): + """Tests the `_is_deletion_allowed` method of a validated consent. + + - AWAITING can be deleted. + - VALIDATED cannot be deleted. + - REVOKED cannot be deleted. + """ + from apps.consent.models import Consent + + # Delete AWAITING consent + consent = ConsentFactory(status=AWAITING) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + assert consent._is_deletion_allowed() is True + + # Delete VALIDATED consent + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + with pytest.raises(ConsentWorkflowError) as exc_info: + consent._is_deletion_allowed() + assert exc_info.value.message == _("Validated consent cannot be deleted.") + + # Delete REVOKED consent + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + with pytest.raises(ConsentWorkflowError) as exc_info: + consent._is_deletion_allowed() + assert exc_info.value.message == _("Revoked consent cannot be deleted.") @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). + - AWAITING can be deleted. + - VALIDATED cannot be deleted. + - REVOKED cannot be deleted. """ 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 + signals.post_save.disconnect( + receiver=handle_new_delivery_point, + sender=DeliveryPoint, + dispatch_uid="handle_new_delivery_point", + ) - # get the created consent and delete it - consent = Consent.objects.get(delivery_point=delivery_point) - assert consent.status != VALIDATED + # delete an AWAITING consent + consent = ConsentFactory(status=AWAITING) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + assert Consent.objects.count() == 1 + assert consent.status == AWAITING 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) + # delete an VALIDATED consent it is not allowed. + consent = ConsentFactory(status=VALIDATED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + assert Consent.objects.count() == 1 assert consent.status == VALIDATED - # the consent status is `VALIDATED`, so it cannot be deleted. - with pytest.raises(ValidationError): + with pytest.raises(ConsentWorkflowError) as exc_info: consent.delete() + assert exc_info.value.message == _("Validated consent cannot be deleted.") assert Consent.objects.count() == 1 + + # delete an REVOKED consent it is not allowed. + consent = ConsentFactory(status=REVOKED) + consent = Consent.objects.get(id=consent.id) # refresh the state in memory + expected_consent_number = 2 + assert Consent.objects.count() == expected_consent_number + assert consent.status == REVOKED + with pytest.raises(ConsentWorkflowError) as exc_info: + consent.delete() + assert exc_info.value.message == _("Revoked consent cannot be deleted.") + assert Consent.objects.count() == expected_consent_number + + signals.post_save.connect( + receiver=handle_new_delivery_point, + sender=DeliveryPoint, + dispatch_uid="handle_new_delivery_point", + ) diff --git a/src/dashboard/readme.md b/src/dashboard/readme.md index dcbce264..035887b2 100644 --- a/src/dashboard/readme.md +++ b/src/dashboard/readme.md @@ -72,6 +72,35 @@ There is a signal on the creation of a `delivery point` (`apps.core.models.Deliv This signal allows the creation of a `consent` (`apps.consent.models.Consent`) corresponding to the `delivery_point`. +## Business logic + +### Consent management + +3 different status types exist for consents with different management rules: + +#### AWAITING + +Consent awaiting validation by the user. +- [x] Users can change consent without restriction. + +#### VALIDATED: + +Consent validated by the user. +It can only be modified under conditions: +- [x] users cannot modify validated consents, +- [x] administrators can change a validated consent to `REVOKED`, +- [x] the updated values are restricted to the `status`, `revoked_date` and + `updated_at` + fields, +- [x] validated consent cannot be deleted. + +#### REVOKED: + +Consent revoked. +- [ ] It cannot be modified. +- [x] It cannot be deleted. + ## License This work is released under the MIT License (see LICENSE). +