diff --git a/src/dashboard/apps/consent/forms.py b/src/dashboard/apps/consent/forms.py index 3b4b8103..b1f2adba 100644 --- a/src/dashboard/apps/consent/forms.py +++ b/src/dashboard/apps/consent/forms.py @@ -1,5 +1,7 @@ """Dashboard consent app forms.""" +from datetime import datetime + from django import forms from django.forms.widgets import CheckboxInput from django.utils.translation import gettext_lazy as _ @@ -20,7 +22,7 @@ class ConsentForm(forms.Form): """ # Specific authorisation checkbox - is_authorized_signatory = forms.BooleanField( + is_authoritative_signatory = forms.BooleanField( required=True, initial=False, widget=ConsentCheckboxInput( @@ -101,6 +103,12 @@ class ConsentForm(forms.Form): ), ) + signed_at = forms.DateField( + initial=datetime.now().strftime("%d/%m/%Y"), + required=True, + widget=forms.HiddenInput(attrs={"readonly": "readonly"}), + ) + # Global authorisation checkbox - this field must be in last position. consent_agreed = forms.BooleanField( required=True, diff --git a/src/dashboard/apps/consent/templates/consent/includes/_manage_consents_date_place.html b/src/dashboard/apps/consent/templates/consent/includes/_manage_consents_date_place.html index dff5bbd2..bf420359 100644 --- a/src/dashboard/apps/consent/templates/consent/includes/_manage_consents_date_place.html +++ b/src/dashboard/apps/consent/templates/consent/includes/_manage_consents_date_place.html @@ -10,10 +10,3 @@
Le : {% now "d/m/Y" %}

- - diff --git a/src/dashboard/apps/consent/templates/consent/manage.html b/src/dashboard/apps/consent/templates/consent/manage.html index 66b791d6..c1505252 100644 --- a/src/dashboard/apps/consent/templates/consent/manage.html +++ b/src/dashboard/apps/consent/templates/consent/manage.html @@ -19,15 +19,22 @@

Gérer les autorisations

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

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

+
    +
  • + {% trans "The form contains errors" %} +
  • + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} + {% endif %} +
- {% endif %}
+ {% endif %} {% include "consent/includes/_manage_consents.html" %} {% include "consent/includes/_manage_company_informations.html" %} diff --git a/src/dashboard/apps/consent/tests/test_views.py b/src/dashboard/apps/consent/tests/test_views.py index 41dbe678..076acab2 100644 --- a/src/dashboard/apps/consent/tests/test_views.py +++ b/src/dashboard/apps/consent/tests/test_views.py @@ -1,7 +1,9 @@ """Dashboard consent views tests.""" +import datetime import uuid from http import HTTPStatus +from unittest.mock import MagicMock import pytest from django.urls import reverse @@ -13,6 +15,16 @@ from apps.consent.views import ConsentFormView from apps.core.factories import DeliveryPointFactory, EntityFactory +FORM_CLEANED_DATA = { + "is_authoritative_signatory": True, + "allows_measurements": True, + "allows_daily_index_readings": True, + "allows_max_daily_power": True, + "allows_load_curve": True, + "allows_technical_contractual_data": True, + "signed_at": "2025-03-01", +} + @pytest.mark.django_db def test_bulk_update_consent_status_without_ids(rf): @@ -30,14 +42,16 @@ def test_bulk_update_consent_status_without_ids(rf): 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 + mock_form = MagicMock() + mock_form.cleaned_data = FORM_CLEANED_DATA + assert view._bulk_update_consent([], VALIDATED, mock_form) == 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): +def test_bulk_update_consent_status(rf, settings): """Test all consents are correctly updated.""" user = UserFactory() @@ -56,19 +70,51 @@ def test_bulk_update_consent_status(rf): # check data before update assert all(c == AWAITING for c in Consent.objects.values_list("status", flat=True)) + mock_form = MagicMock() + mock_form.cleaned_data = FORM_CLEANED_DATA + # bulk update to VALIDATED, and check all records have been updated. - assert view._bulk_update_consent(ids, VALIDATED) == size + assert view._bulk_update_consent(ids, VALIDATED, mock_form) == 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)) + for c in Consent.objects.all(): + assert c.signed_at == datetime.datetime( + 2025, 3, 1, 0, 0, tzinfo=datetime.timezone.utc + ) + assert c.signature_location == settings.CONSENT_SIGNATURE_LOCATION + + # check company data are present + assert c.company is not None + assert c.company["name"] == entity.name + assert c.company["company_type"] == entity.company_type + assert c.company["siret"] == entity.siret + + # check company representative data are present + assert c.company is not None + assert c.company_representative["email"] == user.email + + # check control authority data are presents + assert c.control_authority is not None + assert c.control_authority["name"] == settings.CONSENT_CONTROL_AUTHORITY["name"] + assert ( + c.control_authority["email"] == settings.CONSENT_CONTROL_AUTHORITY["email"] + ) + + # check authorizations (boolean fields) + assert c.is_authoritative_signatory is True + assert c.allows_measurements is True + assert c.allows_max_daily_power is True + assert c.allows_load_curve is True + assert c.allows_technical_contractual_data is True - # bulk update from VALIDATED to AWAITING: no data must be updated - assert view._bulk_update_consent(ids, AWAITING) == 0 + # bulk update from VALIDATED to AWAITING: no data must be updated. + assert view._bulk_update_consent(ids, AWAITING, mock_form) == 0 # and checks that the status has not changed after the update. assert all(c == VALIDATED for c in Consent.objects.values_list("status", flat=True)) - # bulk update from VALIDATED to REVOKED: no data must be updated - assert view._bulk_update_consent(ids, REVOKED) == 0 + # bulk update from VALIDATED to REVOKED: no data must be updated. + assert view._bulk_update_consent(ids, REVOKED, mock_form) == 0 # and checks that the status has not changed after the update. assert all(c == VALIDATED for c in Consent.objects.values_list("status", flat=True)) @@ -98,7 +144,9 @@ def test_bulk_update_consent_status_with_fake_id(rf): # bulk update to VALIDATED, # and check all records have been updated except the fake id. - assert view._bulk_update_consent(ids, VALIDATED) == size + mock_form = MagicMock() + mock_form.cleaned_data = FORM_CLEANED_DATA + assert view._bulk_update_consent(ids, VALIDATED, mock_form) == 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)) @@ -136,7 +184,9 @@ def test_bulk_update_consent_without_user_perms(rf): # bulk update to VALIDATED, # and check all records have been updated except the wrong ID. - assert view._bulk_update_consent(ids, VALIDATED) == size + mock_form = MagicMock() + mock_form.cleaned_data = FORM_CLEANED_DATA + assert view._bulk_update_consent(ids, VALIDATED, mock_form) == size # and checks that the data has changed to VALIDATED after the update. assert all( diff --git a/src/dashboard/apps/consent/views.py b/src/dashboard/apps/consent/views.py index d8718bd7..da79311d 100644 --- a/src/dashboard/apps/consent/views.py +++ b/src/dashboard/apps/consent/views.py @@ -1,9 +1,11 @@ """Dashboard consent app views.""" +import sentry_sdk from django.contrib import messages -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.db.models import Q -from django.shortcuts import get_object_or_404, redirect +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy as reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -18,6 +20,11 @@ from .mixins import BreadcrumbContextMixin from .models import Consent from .settings import CONSENT_CONTROL_AUTHORITY, CONSENT_SIGNATURE_LOCATION +from .validators import ( + validate_company_schema, + validate_control_authority_schema, + validate_representative_schema, +) class IndexView(BreadcrumbContextMixin, TemplateView): @@ -33,11 +40,102 @@ def get_context_data(self, **kwargs): # noqa: D102 return context +def _get_validate_company_data(consent: dict) -> dict: + """Get the validate company data.""" + company_data = { + "name": consent["delivery_point__entity__name"], + "company_type": consent["delivery_point__entity__company_type"], + "legal_form": consent["delivery_point__entity__legal_form"], + "trade_name": consent["delivery_point__entity__trade_name"], + "siret": consent["delivery_point__entity__siret"], + "naf": consent["delivery_point__entity__naf"], + "address": { + "line_1": consent["delivery_point__entity__address_1"], + "line_2": consent["delivery_point__entity__address_2"], + "zip_code": consent["delivery_point__entity__address_zip_code"], + "city": consent["delivery_point__entity__address_city"], + }, + } + + validate_company_schema(company_data) + + return company_data + + +def _get_validate_control_authority(): + """Get the validate control authority.""" + control_authority = { + "name": CONSENT_CONTROL_AUTHORITY.get("name"), + "represented_by": CONSENT_CONTROL_AUTHORITY.get("represented_by"), + "email": CONSENT_CONTROL_AUTHORITY.get("email"), + "address": { + "line_1": CONSENT_CONTROL_AUTHORITY.get("line_1"), + "line_2": CONSENT_CONTROL_AUTHORITY.get("line_2"), + "zip_code": CONSENT_CONTROL_AUTHORITY.get("zip_code"), + "city": CONSENT_CONTROL_AUTHORITY.get("city"), + }, + } + validate_control_authority_schema(control_authority) + + return control_authority + + +def _get_validate_company_representative(user): + """Get the validate company representative.""" + company_representative = { + "firstname": user.first_name, + "lastname": user.last_name, + "email": user.email, + "phone": None, + } + + validate_representative_schema(company_representative) + + return company_representative + + class ConsentFormView(BreadcrumbContextMixin, FormView): """Updates the status of consents.""" + ERROR_MESSAGE = _( + "An error occurred while validating the form. " + "Our team has been notified and will get back to you shortly." + ) + + CONSENT_FIELDS_FOR_UPDATE = [ + "status", + "created_by", + "updated_at", + "company", + "control_authority", + "company_representative", + "is_authoritative_signatory", + "allows_measurements", + "allows_daily_index_readings", + "allows_max_daily_power", + "allows_load_curve", + "allows_technical_contractual_data", + "signed_at", + "signature_location", + ] + + CONSENT_VALUES_FIELDS = [ + "id", + "delivery_point__entity__name", + "delivery_point__entity__company_type", + "delivery_point__entity__legal_form", + "delivery_point__entity__trade_name", + "delivery_point__entity__siret", + "delivery_point__entity__naf", + "delivery_point__entity__address_1", + "delivery_point__entity__address_2", + "delivery_point__entity__address_zip_code", + "delivery_point__entity__address_city", + ] + template_name = "consent/manage.html" form_class = ConsentForm + success_url = reverse("consent:index") breadcrumb_links = [ {"url": reverse("consent:index"), "title": _("Consent")}, @@ -54,13 +152,17 @@ def form_valid(self, form): """ selected_ids: list[str] = self.request.POST.getlist("status") - 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 + try: + self._bulk_update_consent(selected_ids, VALIDATED, form) # type: ignore + awaiting_ids = self._get_awaiting_ids(selected_ids) + self._bulk_update_consent(awaiting_ids, AWAITING, form) # type: ignore + except ValidationError as e: + sentry_sdk.capture_exception(e) + form.add_error(None, self.ERROR_MESSAGE) + return self.form_invalid(form) messages.success(self.request, _("Consents updated.")) - - return redirect(reverse("consent:index")) + return super().form_valid(form) def get_context_data(self, **kwargs): """Add the user's entities to the context. @@ -88,22 +190,56 @@ def _get_entities(self) -> list: else: return list(user.get_entities()) - def _bulk_update_consent(self, ids: list[str], status: str) -> int: + def _bulk_update_consent( + self, ids: list[str], status: str, form: ConsentForm + ) -> HttpResponse | int: """Bulk update of the consent status for a given status and list of entities. - Only `AWAITING` consents can be updated by users. + This method updates the consent statuses, and their related information: + - company information, + - control authority information, + - company representative information, + - form data, + - date of signature, + - signature location, + + Note: Only `AWAITING` consents can be updated by users. """ - return ( - Consent.objects.filter(id__in=ids, status=AWAITING) + # validate and get control authority and company representative data + control_authority_data = _get_validate_control_authority() + company_representative_data = _get_validate_company_representative( + self.request.user + ) + + # retrieve consents to update, and get related company data for each consent. + consents = ( + Consent.objects.filter( + id__in=ids, + status=AWAITING, + ) .filter( Q(delivery_point__entity__users=self.request.user) | Q(delivery_point__entity__proxies__users=self.request.user) ) - .update( - status=status, - created_by=self.request.user, - updated_at=timezone.now(), + .values(*self.CONSENT_VALUES_FIELDS) + ) + + # build consent object with validated fields to update + update_objects = [ + self._build_consent_object( + consent, + status, + form.cleaned_data, + control_authority_data, + company_representative_data, ) + for consent in consents + ] + + # and finally, bulk update the consents + return Consent.objects.bulk_update( + update_objects, + fields=self.CONSENT_FIELDS_FOR_UPDATE, ) def _get_awaiting_ids(self, validated_ids: list[str]) -> list[str]: @@ -117,3 +253,43 @@ def _get_awaiting_ids(self, validated_ids: list[str]) -> list[str]: for c in e.get_consents() if str(c.id) not in validated_ids ] + + def _build_consent_object( + self, + consent: dict, + status: str, + form_values: dict, + control_authority: dict, + company_representative: dict, + ) -> Consent: + """Builds and returns a `Consent` object with validated data. + + Parameters: + - consent (dict): Consent data, including the identifier, and the related + entity information. + - status (str): Consent status (e.g., "AWAITING", "VALIDATED"). + - form_values (dict): Validated data from the consent form. + - control_authority (dict): Details about the control authority. + - company_representative (dict): Information about the company representative. + """ + company_data = _get_validate_company_data(consent) + + return Consent( + id=consent["id"], + status=status, + created_by=self.request.user, # type: ignore[misc] + updated_at=timezone.now(), + company=company_data, + control_authority=control_authority, + company_representative=company_representative, + is_authoritative_signatory=form_values["is_authoritative_signatory"], + allows_measurements=form_values["allows_measurements"], + allows_daily_index_readings=form_values["allows_daily_index_readings"], + allows_max_daily_power=form_values["allows_max_daily_power"], + allows_load_curve=form_values["allows_load_curve"], + allows_technical_contractual_data=form_values[ + "allows_technical_contractual_data" + ], + signed_at=form_values["signed_at"], + signature_location=CONSENT_SIGNATURE_LOCATION, + )