From 794d69fe21c18dae3a4b1842b522d12f1b6d4374 Mon Sep 17 00:00:00 2001 From: ssorin Date: Thu, 12 Dec 2024 15:22:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(dashboard)=20add=20the=20global=20con?= =?UTF-8?q?sent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced function-based `consent_form_view` with `ConsentFormView` class-based view, leveraging Django forms for better structure and maintainability. Introduced a `ConsentForm` for managing consent input and added a `BreadcrumbContextMixin` to streamline breadcrumb handling. Updated templates and URLs accordingly for this refactor. Refactored consent views by splitting and improving helper functions for better clarity and reusability. Introduced comprehensive tests for `_bulk_update_consent`, ensuring proper validation of consent status updates with varied scenarios. This enhances maintainability and test coverage. --- .../apps/consent/fixtures/consent.py | 10 +- src/dashboard/apps/consent/forms.py | 26 ++ src/dashboard/apps/consent/mixins.py | 37 ++ .../consent/forms/widgets/checkbox.html | 7 + .../consent/templates/consent/manage.html | 116 +++--- .../apps/consent/tests/test_views.py | 358 ++++++++++++++++++ src/dashboard/apps/consent/urls.py | 6 +- src/dashboard/apps/consent/views.py | 138 +++---- src/dashboard/apps/core/factories.py | 4 +- 9 files changed, 571 insertions(+), 131 deletions(-) create mode 100644 src/dashboard/apps/consent/forms.py create mode 100644 src/dashboard/apps/consent/mixins.py create mode 100644 src/dashboard/apps/consent/templates/consent/forms/widgets/checkbox.html create mode 100644 src/dashboard/apps/consent/tests/test_views.py diff --git a/src/dashboard/apps/consent/fixtures/consent.py b/src/dashboard/apps/consent/fixtures/consent.py index c25f632e..742c2105 100644 --- a/src/dashboard/apps/consent/fixtures/consent.py +++ b/src/dashboard/apps/consent/fixtures/consent.py @@ -29,11 +29,11 @@ def seed_consent(): entity4 = EntityFactory(users=(user5,)) # create delivery points - for i in range(1, 4): - DeliveryPointFactory(provider_assigned_id=f"entity1_{i}", entity=entity1) - DeliveryPointFactory(provider_assigned_id=f"entity2_{i}", entity=entity2) - DeliveryPointFactory(provider_assigned_id=f"entity3_{i}", entity=entity3) - DeliveryPointFactory(provider_assigned_id=f"entity4_{i}", entity=entity4) + for _ in range(1, 4): + DeliveryPointFactory(entity=entity1) + DeliveryPointFactory(entity=entity2) + DeliveryPointFactory(entity=entity3) + DeliveryPointFactory(entity=entity4) # create awaiting consents for delivery_point in DeliveryPoint.objects.all(): diff --git a/src/dashboard/apps/consent/forms.py b/src/dashboard/apps/consent/forms.py new file mode 100644 index 00000000..3e94e7b0 --- /dev/null +++ b/src/dashboard/apps/consent/forms.py @@ -0,0 +1,26 @@ +"""Dashboard consent app forms.""" + +from django import forms +from django.forms.widgets import CheckboxInput +from django.utils.translation import gettext_lazy as _ + + +class ConsentCheckboxInput(CheckboxInput): + """Custom CheckboxInput widget for rendering a checkbox input field.""" + + template_name = "consent/forms/widgets/checkbox.html" + + +class ConsentForm(forms.Form): + """Save user consent through a checkbox field.""" + + consent_agreed = forms.BooleanField( + required=True, + initial=False, + widget=ConsentCheckboxInput( + attrs={ + "label": _("I agree to give my consent"), + "help_text": _("Please confirm your consent by checking this box."), + }, + ), + ) diff --git a/src/dashboard/apps/consent/mixins.py b/src/dashboard/apps/consent/mixins.py new file mode 100644 index 00000000..57008202 --- /dev/null +++ b/src/dashboard/apps/consent/mixins.py @@ -0,0 +1,37 @@ +"""Dashboard consent app mixins.""" + +from django.views.generic.base import ContextMixin +from django_stubs_ext import StrOrPromise + + +class BreadcrumbContextMixin(ContextMixin): + """Mixin to simplify usage of the `dsfr_breadcrumb` in class based views. + + Add the breadcrumb elements in the view context for the dsfr breadcrumb: + https://numerique-gouv.github.io/django-dsfr/components/breadcrumb/. + + ```python + breadcrumb_links = [{"url": "first-url", "title": "First title"}, {...}], + breadcrumb_current: "Current page title", + breadcrumb_root_dir: "the root directory, if the site is not installed at the + root of the domain" + } + ``` + """ + + breadcrumb_links: list[dict[StrOrPromise, StrOrPromise]] | None = None + breadcrumb_current: StrOrPromise | None = None + breadcrumb_root_dir: StrOrPromise | None = None + + def get_context_data(self, **kwargs) -> dict: + """Add breadcrumb context to the view.""" + context = super().get_context_data(**kwargs) + + context["breadcrumb_data"] = { + "links": self.breadcrumb_links, + "current": self.breadcrumb_current, + } + if self.breadcrumb_root_dir: + context["breadcrumb_data"]["root_dir"] = self.breadcrumb_root_dir + + return context diff --git a/src/dashboard/apps/consent/templates/consent/forms/widgets/checkbox.html b/src/dashboard/apps/consent/templates/consent/forms/widgets/checkbox.html new file mode 100644 index 00000000..519a6996 --- /dev/null +++ b/src/dashboard/apps/consent/templates/consent/forms/widgets/checkbox.html @@ -0,0 +1,7 @@ +{% include "django/forms/widgets/input.html" %} + + diff --git a/src/dashboard/apps/consent/templates/consent/manage.html b/src/dashboard/apps/consent/templates/consent/manage.html index aa5e17ad..029fa689 100644 --- a/src/dashboard/apps/consent/templates/consent/manage.html +++ b/src/dashboard/apps/consent/templates/consent/manage.html @@ -3,85 +3,87 @@ {% load i18n static %} {% block dashboard_content %} -

- {% trans "Manage consents" %} -

- - {% if entities %} -
- {% csrf_token %} +

{% trans "Manage consents" %}

+{% if entities %} + + {% csrf_token %} + +
+ {% if form.errors %} +
+

+ {% trans "The form contains errors" %} +

+
+ {% endif %} +
+ {# toggle button #}
- - +
- -
+ {# checkbox to apply the consent globally #} +
+
+ {{ form.consent_agreed }} +
+ {% if form.consent_agreed.errors %} + {% for error in form.consent_agreed.errors %} +

+ {{ error }} +

+ {% endfor %} + {% endif %} +
+
+
+ + + + {% else %} -

- {% trans "No consents to validate" %} -

+

{% trans "No consents to validate" %}

{% endif %} {% endblock dashboard_content %} {% block dashboard_extra_js %} {% if entities %} - + {% endif %} {% endblock dashboard_extra_js %} diff --git a/src/dashboard/apps/consent/tests/test_views.py b/src/dashboard/apps/consent/tests/test_views.py new file mode 100644 index 00000000..9743a9ec --- /dev/null +++ b/src/dashboard/apps/consent/tests/test_views.py @@ -0,0 +1,358 @@ +"""Dashboard consent views tests.""" + +from http import HTTPStatus + +import pytest +from django.urls import reverse + +from apps.auth.factories import UserFactory +from apps.consent import AWAITING, VALIDATED +from apps.consent.factories import ConsentFactory +from apps.consent.models import Consent +from apps.consent.views import ConsentFormView +from apps.core.factories import EntityFactory + + +@pytest.mark.django_db +def test_bulk_update_consent_status_without_ids(rf): + """Test that no status is updated if no id is passed.""" + request = rf.get(reverse("consent:manage")) + request.user = UserFactory() + + view = ConsentFormView() + view.setup(request) + + size = 4 + ConsentFactory.create_batch(size) + + # check data before update + assert all(c == AWAITING for c in Consent.objects.values_list("status", flat=True)) + + # bulk update to VALIDATED of… nothing, and check 0 record have been updated. + assert view._bulk_update_consent([], VALIDATED) == 0 + + # and checks that the data has not changed after the update. + assert all(c == AWAITING for c in Consent.objects.values_list("status", flat=True)) + + +@pytest.mark.django_db +def test_bulk_update_consent_status(rf): + """Test that all consents are correctly updated.""" + user = UserFactory() + + request = rf.get(reverse("consent:manage")) + request.user = user + + view = ConsentFormView() + view.setup(request) + + # create entity for the user and consents for the entity + size = 3 + entity = EntityFactory(users=(user,)) + consents = ConsentFactory.create_batch(size, delivery_point__entity=entity) + ids = [c.id for c in consents] + + # check data before update + assert all(c == AWAITING for c in Consent.objects.values_list("status", flat=True)) + + # bulk update to VALIDATED, and check all records have been updated. + assert view._bulk_update_consent(ids, VALIDATED) == size + + # and checks that the data has changed to VALIDATED after the update. + assert all(c == VALIDATED for c in Consent.objects.values_list("status", flat=True)) + + +@pytest.mark.django_db +def test_bulk_update_consent_status_with_fake_id(rf): + """Test update with wrong ID in list of ids to update.""" + user = UserFactory() + + request = rf.get(reverse("consent:manage")) + request.user = user + + view = ConsentFormView() + view.setup(request) + + # create entity for the user and consents for the entity + size = 3 + entity = EntityFactory(users=(user,)) + consents = ConsentFactory.create_batch(size, delivery_point__entity=entity) + ids = [c.id for c in consents] + + # add a fake id to the ids to update + ids.append("fa62cf1d-c510-498a-b428-fdf72fa35651") + + # check data before update + assert all(c == AWAITING for c in Consent.objects.values_list("status", flat=True)) + + # bulk update to VALIDATED, + # and check all records have been updated except the fake id. + assert view._bulk_update_consent(ids, VALIDATED) == size + + # and checks that the data has changed to VALIDATED after the update. + assert all(c == VALIDATED for c in Consent.objects.values_list("status", flat=True)) + + +@pytest.mark.django_db +def test_bulk_update_consent_without_user_perms(rf): + """Test the update of consents for which the user does not have the rights.""" + user = UserFactory() + + request = rf.get(reverse("consent:manage")) + request.user = user + + view = ConsentFormView() + view.setup(request) + + # create entity for the user and consents for the entity + size = 3 + entity = EntityFactory(users=(user,)) + consents = ConsentFactory.create_batch(size, delivery_point__entity=entity) + ids = [c.id for c in consents] + + # create wrong consent + wrong_user = UserFactory() + wrong_entity = EntityFactory(users=(wrong_user,)) + wrong_consent = ConsentFactory(delivery_point__entity=wrong_entity) + + # add wrong_id to ids + ids.append(wrong_consent.id) + assert len(ids) == size + 1 + assert wrong_consent.id in ids + + # check data before update + assert all(c == AWAITING for c in Consent.objects.values_list("status", flat=True)) + + # bulk update to VALIDATED, + # and check all records have been updated except the wrong id. + assert view._bulk_update_consent(ids, VALIDATED) == size + + # and checks that the data has changed to VALIDATED after the update. + assert all( + c == VALIDATED + for c in Consent.objects.filter(delivery_point__entity=entity).values_list( + "status", flat=True + ) + ) + assert all( + c == AWAITING + for c in Consent.objects.filter( + delivery_point__entity=wrong_entity + ).values_list("status", flat=True) + ) + + +@pytest.mark.django_db +def test_get_awaiting_ids_with_bad_parameters(rf): + """Test get_awaiting_ids() with bad parameters raise exception.""" + user = UserFactory() + + request = rf.get(reverse("consent:manage")) + request.user = user + + view = ConsentFormView() + view.setup(request) + + # create entity for the user and consents for the entity + size = 3 + entity = EntityFactory(users=(user,)) + consents = ConsentFactory.create_batch(size, delivery_point__entity=entity) + # create a list of QuerySet instead of str + ids = [c.id for c in consents] + + # check _get_awaiting_ids() raise exception + # (ids must be a list of string not of QuerySet) + with pytest.raises(ValueError): + view._get_awaiting_ids(validated_ids=ids) + + +@pytest.mark.django_db +def test_get_awaiting_ids(rf): + """Test getting of awaiting ids inferred from validated consents.""" + user = UserFactory() + + request = rf.get(reverse("consent:manage")) + request.user = user + + view = ConsentFormView() + view.setup(request) + + # create entity for the user and consents for the entity + size = 3 + entity = EntityFactory(users=(user,)) + consents = ConsentFactory.create_batch(size, delivery_point__entity=entity) + ids = [str(c.id) for c in consents] + + # removes one `id` from the list `ids`, + # this is the one we must find with _get_awaiting_ids() + id_not_include = ids.pop() + assert len(ids) == size - 1 + + # check awaiting id is the expected + awaiting_ids = view._get_awaiting_ids(validated_ids=ids) + assert len(awaiting_ids) == 1 + assert id_not_include in awaiting_ids + + +@pytest.mark.django_db +def test_templates_render_without_entities(rf): + """Test the templates are rendered without entities.""" + user = UserFactory() + + request = rf.get(reverse("consent:manage")) + request.user = user + + view = ConsentFormView() + view.setup(request) + + # check the context + context = view.get_context_data() + assert context["entities"] == [] + + # Get response object + response = view.dispatch(request) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.django_db +def test_templates_render_html_content_without_entities(rf): + """Test the html content of the templates without entities.""" + user = UserFactory() + + request = rf.get(reverse("consent:manage")) + request.user = user + + view = ConsentFormView() + view.setup(request) + + # Get response object and force template rendering + response = view.dispatch(request) + rendered = response.render() + html = rendered.content.decode() + + # the id of the global consent checkbox shouldn't be present in HTML + not_expected = 'id="id_consent_agreed"' + assert (not_expected not in html) is True + + # checkbox with name “status” shouldn't be present in the HTML + not_expected = '", consent_form_view, name="manage"), + path("manage/", ConsentFormView.as_view(), name="manage"), + path("manage/", ConsentFormView.as_view(), name="manage"), ] diff --git a/src/dashboard/apps/consent/views.py b/src/dashboard/apps/consent/views.py index eea939a6..94d64de6 100644 --- a/src/dashboard/apps/consent/views.py +++ b/src/dashboard/apps/consent/views.py @@ -2,102 +2,110 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied -from django.db.models import TextField -from django.db.models.functions import Cast -from django.shortcuts import get_object_or_404, redirect, render +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy as reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.views.generic import TemplateView +from django.views.generic import FormView, TemplateView from apps.core.models import Entity +from ..auth.models import DashboardUser from . import AWAITING, VALIDATED +from .forms import ConsentForm +from .mixins import BreadcrumbContextMixin from .models import Consent -class IndexView(TemplateView): +class IndexView(BreadcrumbContextMixin, TemplateView): """Index view of the consent app.""" template_name = "consent/index.html" + breadcrumb_current = _("Consent") - def get_context_data(self, **kwargs): - """Add custom context to the view.""" + def get_context_data(self, **kwargs): # noqa: D102 context = super().get_context_data(**kwargs) context["entities"] = self.request.user.get_entities() - context["breadcrumb_data"] = { - "current": _("Consent"), - } return context -def consent_form_view(request, slug=None): - """Manage consent forms. +class ConsentFormView(BreadcrumbContextMixin, FormView): + """Updates the status of consents.""" - This function performs the following actions: - - Retrieves the entity associated with the given slug, if provided. - - Fetches consents awaiting validation for the current user and the specified - entity. - - If a POST request is received, updates the consent status to either VALIDATED - or AWAITING based on user selections and existing data. - """ template_name = "consent/manage.html" + form_class = ConsentForm + + breadcrumb_links = [ + {"url": reverse("consent:index"), "title": _("Consent")}, + ] + breadcrumb_current = _("Manage Consents") + + def form_valid(self, form): + """Update the consent status. - entities = [] - if slug: - entity = get_object_or_404(Entity, slug=slug) - if not request.user.can_validate_entity(entity): - raise PermissionDenied - entities.append(entity) - else: - entities = request.user.get_entities() + Bulk update of the consent status: + - validated consents are set to VALIDATED + - infers AWAITING consents by comparing initial consents + and user VALIDATED consents, and sets them to waiting. + """ + selected_ids: list[str] = self.request.POST.getlist("status") - if request.POST: - selected_ids = request.POST.getlist("status") - update_consent_status(request.user, entities, selected_ids) + self._bulk_update_consent(selected_ids, VALIDATED) # type: ignore + awaiting_ids = self._get_awaiting_ids(selected_ids) + self._bulk_update_consent(awaiting_ids, AWAITING) # type: ignore + + messages.success(self.request, _("Consents updated.")) - messages.success(request, _("Consents updated.")) return redirect(reverse("consent:index")) - breadcrumb_data = { - "links": [ - {"url": reverse("consent:index"), "title": _("Consent")}, - ], - "current": _("Manage Consents"), - } + def get_context_data(self, **kwargs): + """Add the user's entities to the context. - return render( - request=request, - template_name=template_name, - context={"entities": entities, "breadcrumb_data": breadcrumb_data}, - ) + Adds to the context the entities that the user has permission to access. + If a slug is provided, adds the entity corresponding to the slug. + """ + context = super().get_context_data(**kwargs) + context["entities"] = self._get_entities() + return context + def _get_entities(self) -> list: + """Return a list of entities or specific entity if slug is provided.""" + slug: str | None = self.kwargs.get("slug", None) + user: DashboardUser = self.request.user # type: ignore -def update_consent_status(user, entities, selected_ids): - """Updates the status of consents..""" + if slug: + entity: Entity = get_object_or_404(Entity, slug=slug) + if not user.can_validate_entity(entity): + raise PermissionDenied + return [entity] + else: + return list(user.get_entities()) - def _bulk_update_consent(ids: list[str], status: str): + def _bulk_update_consent(self, ids: list[str], status: str) -> int: """Bulk update of the consent status for a given status and list of entities.""" - Consent.objects.filter(id__in=ids).update( - status=status, - created_by=user, - updated_at=timezone.now(), - ) - - def _get_awaiting_ids(entities, ids): - """Get the a list of the non selected ids (awaiting ids).""" - base_ids = [] - for entity in entities: - base_ids.extend( - list( - entity.get_consents() - .annotate(str_id=Cast("id", output_field=TextField())) - .values_list("str_id", flat=True) - ) + return ( + Consent.objects.filter(id__in=ids) + .filter( + Q(delivery_point__entity__users=self.request.user) + | Q(delivery_point__entity__proxies__users=self.request.user) ) - return list(set(base_ids) - set(ids)) + .update( + status=status, + created_by=self.request.user, + updated_at=timezone.now(), + ) + ) - _bulk_update_consent(selected_ids, VALIDATED) - awaiting_ids = _get_awaiting_ids(entities, selected_ids) - _bulk_update_consent(awaiting_ids, AWAITING) + def _get_awaiting_ids(self, validated_ids: list[str]) -> list[str]: + """Get the list of the non-selected IDs (awaiting IDs).""" + if any(not isinstance(item, str) for item in validated_ids): + raise ValueError("validated_ids must be a list of strings") + + return [ + str(c.id) + for e in self._get_entities() + for c in e.get_consents() + if str(c.id) not in validated_ids + ] diff --git a/src/dashboard/apps/core/factories.py b/src/dashboard/apps/core/factories.py index bc4977d6..4d0d0b4b 100644 --- a/src/dashboard/apps/core/factories.py +++ b/src/dashboard/apps/core/factories.py @@ -1,5 +1,7 @@ """Dashboard core factories.""" +import uuid + import factory from .models import DeliveryPoint, Entity @@ -39,5 +41,5 @@ class DeliveryPointFactory(factory.django.DjangoModelFactory): class Meta: # noqa: D106 model = DeliveryPoint - provider_assigned_id = factory.Sequence(lambda n: "dp_%d" % n) + provider_assigned_id = factory.LazyFunction(lambda: str(uuid.uuid4())) entity = factory.SubFactory(EntityFactory)