diff --git a/src/dashboard/apps/consent/apps.py b/src/dashboard/apps/consent/apps.py index e3494bd8..ec8b9463 100644 --- a/src/dashboard/apps/consent/apps.py +++ b/src/dashboard/apps/consent/apps.py @@ -13,5 +13,8 @@ class ConsentConfig(AppConfig): verbose_name = _("Consent") def ready(self): - """Register signals.""" + """Register signals and validate CONSENT_CONTROL_AUTHORITY on ready.""" from .signals import handle_new_delivery_point # noqa: F401 + from .validators import validate_configured_control_authority + + validate_configured_control_authority() 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/models.py b/src/dashboard/apps/consent/models.py index dd8830d4..8176f673 100644 --- a/src/dashboard/apps/consent/models.py +++ b/src/dashboard/apps/consent/models.py @@ -33,20 +33,14 @@ "maxLength": 5, "pattern": r"^\d{4}[A-Z]$", }, - "address": { - "type": "object", - "properties": { - "line_1": {"type": ["string", "null"], "maxLength": 255}, - "line_2": {"type": ["string", "null"], "maxLength": 255}, - "zip_code": { - "type": ["string", "null"], - "maxLength": 5, - "pattern": "^[0-9]{1,5}$", - }, - "city": {"type": ["string", "null"], "maxLength": 255}, - }, - "required": ["line_1", "zip_code", "city"], + "address_1": {"type": ["string", "null"], "maxLength": 255}, + "address_2": {"type": ["string", "null"], "maxLength": 255}, + "zip_code": { + "type": ["string", "null"], + "maxLength": 5, + "pattern": "^[0-9]{1,5}$", }, + "city": {"type": ["string", "null"], "maxLength": 255}, }, "required": [ "company_type", @@ -55,7 +49,9 @@ "trade_name", "siret", "naf", - "address", + "address_1", + "zip_code", + "city", ], "additionalProperties": False, } @@ -78,22 +74,16 @@ "name": {"type": ["string", "null"], "maxLength": 255}, "represented_by": {"type": ["string", "null"], "maxLength": 255}, "email": {"type": ["string", "null"], "format": "email"}, - "address": { - "type": "object", - "properties": { - "line_1": {"type": ["string", "null"], "maxLength": 255}, - "line_2": {"type": ["string", "null"], "maxLength": 255}, - "zip_code": { - "type": ["string", "null"], - "maxLength": 5, - "pattern": "^[0-9]{1,5}$", - }, - "city": {"type": ["string", "null"], "maxLength": 255}, - }, - "required": ["line_1", "zip_code", "city"], + "address_1": {"type": ["string", "null"], "maxLength": 255}, + "address_2": {"type": ["string", "null"], "maxLength": 255}, + "zip_code": { + "type": ["string", "null"], + "maxLength": 5, + "pattern": "^[0-9]{1,5}$", }, + "city": {"type": ["string", "null"], "maxLength": 255}, }, - "required": ["name", "represented_by", "email", "address"], + "required": ["name", "represented_by", "email", "address_1", "zip_code", "city"], "additionalProperties": False, } 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_validators.py b/src/dashboard/apps/consent/tests/test_validators.py index 7eaaec3b..716b9bfc 100644 --- a/src/dashboard/apps/consent/tests/test_validators.py +++ b/src/dashboard/apps/consent/tests/test_validators.py @@ -1,10 +1,11 @@ """Dashboard consent validators tests.""" import pytest -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from apps.consent.validators import ( validate_company_schema, + validate_configured_control_authority, validate_control_authority_schema, validate_representative_schema, ) @@ -16,12 +17,10 @@ "trade_name": "The test company", "siret": "12345678901234", "naf": "1234A", - "address": { - "line_1": "1 rue Exemple", - "line_2": None, - "zip_code": "75000", - "city": "Paris", - }, + "address_1": "1 rue Exemple", + "address_2": None, + "zip_code": "75000", + "city": "Paris", } VALID_REPRESENTATIVE_DATA = { @@ -35,12 +34,10 @@ "name": "QualiCharge", "represented_by": "John Doe", "email": "mail@test.com", - "address": { - "line_1": "1 Rue Exemple", - "line_2": None, - "zip_code": "75000", - "city": "Paris", - }, + "address_1": "1 Rue Exemple", + "address_2": None, + "zip_code": "75000", + "city": "Paris", } @@ -111,11 +108,11 @@ def test_validate_compnay_naf_code_invalid(value): def test_validate_zip_code_valid(value): """Tests validation of valid zip codes does not raise an exception.""" valid_company_data = VALID_COMPANY_DATA - valid_company_data["address"]["zip_code"] = value + valid_company_data["zip_code"] = value assert validate_company_schema(valid_company_data) is None valid_authority_data = VALID_CONTROL_AUTHORITY_DATA - valid_authority_data["address"]["zip_code"] = value + valid_authority_data["zip_code"] = value assert validate_control_authority_schema(valid_authority_data) is None @@ -123,18 +120,18 @@ def test_validate_zip_code_valid(value): def test_validate_zip_code_invalid(value): """Tests validation of invalid zip codes raise a ValidationError.""" valid_company_data = VALID_COMPANY_DATA - valid_company_data["address"]["zip_code"] = value + valid_company_data["zip_code"] = value with pytest.raises(ValidationError): validate_company_schema(valid_company_data) # reset with valid zip - VALID_COMPANY_DATA["address"]["zip_code"] = "12345" + VALID_COMPANY_DATA["zip_code"] = "12345" valid_authority_data = VALID_CONTROL_AUTHORITY_DATA - valid_authority_data["address"]["zip_code"] = value + valid_authority_data["zip_code"] = value with pytest.raises(ValidationError): validate_control_authority_schema(valid_authority_data) # reset with valid zip - VALID_CONTROL_AUTHORITY_DATA["address"]["zip_code"] = "12345" + VALID_CONTROL_AUTHORITY_DATA["zip_code"] = "12345" def test_validate_company_schema_valid(): @@ -142,7 +139,7 @@ def test_validate_company_schema_valid(): assert validate_company_schema(VALID_COMPANY_DATA) is None # valid with specific zip code - VALID_COMPANY_DATA["address"]["zip_code"] = "978" + VALID_COMPANY_DATA["zip_code"] = "978" assert validate_company_schema(VALID_COMPANY_DATA) is None # test with null values @@ -153,11 +150,9 @@ def test_validate_company_schema_valid(): "trade_name": None, "siret": None, "naf": None, - "address": { - "line_1": None, - "zip_code": None, - "city": None, - }, + "address_1": None, + "zip_code": None, + "city": None, } assert validate_company_schema(valid_company_data) is None @@ -218,11 +213,9 @@ def test_validate_control_authority_schema_valid(): "name": None, "represented_by": None, "email": None, - "address": { - "line_1": None, - "zip_code": None, - "city": None, - }, + "address_1": None, + "zip_code": None, + "city": None, } assert validate_control_authority_schema(validate_control_authority_data) is None @@ -245,3 +238,42 @@ def test_validate_control_authority_schema_invalid(): invalid_value["additional_property"] = "" with pytest.raises(ValidationError): validate_control_authority_schema(invalid_value) + + +def test_validate_configured_control_authority_is_valid(settings): + """Test validate_configured_control_authority with valid data.""" + # Change temporally settings.CONSENT_CONTROL_AUTHORITY. + settings.CONSENT_CONTROL_AUTHORITY = { + "name": "Control Authority Name", + "represented_by": "John Doe", + "email": "test@example.com", + "address_1": "123 Street Name", + "address_2": "", + "zip_code": "12345", + "city": "City Name", + } + + # ImproperlyConfigured should not be raised + try: + validate_configured_control_authority() + except ImproperlyConfigured as e: + pytest.fail(f"settings.CONSENT_CONTROL_AUTHORITY validation error: {e.message}") + + +def test_validate_configured_control_authority_raise_error(settings): + """Test validate_configured_control_authority with invalid data.""" + # Change temporally settings.CONSENT_CONTROL_AUTHORITY with invalid data. + # invalid: the key 'name' is expected, not 'firstname' + settings.CONSENT_CONTROL_AUTHORITY = { + "firstname": "Control Authority Name", + "represented_by": "John Doe", + "email": "jdoe@example.com", + "address_1": "", + "address_2": "", + "zip_code": "12345", + "city": "City Name", + } + + # must raise ImproperlyConfigured + with pytest.raises(ImproperlyConfigured): + validate_configured_control_authority() diff --git a/src/dashboard/apps/consent/tests/test_views.py b/src/dashboard/apps/consent/tests/test_views.py index 41dbe678..ab1580cf 100644 --- a/src/dashboard/apps/consent/tests/test_views.py +++ b/src/dashboard/apps/consent/tests/test_views.py @@ -1,9 +1,12 @@ """Dashboard consent views tests.""" +import datetime import uuid from http import HTTPStatus +from unittest.mock import MagicMock import pytest +from django.conf import settings from django.urls import reverse from apps.auth.factories import UserFactory @@ -13,6 +16,17 @@ 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, + "consent_agreed": True, + "signed_at": "2025-03-01", +} + @pytest.mark.django_db def test_bulk_update_consent_status_without_ids(rf): @@ -30,7 +44,9 @@ 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)) @@ -56,19 +72,66 @@ 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)) - - # bulk update from VALIDATED to AWAITING: no data must be updated - assert view._bulk_update_consent(ids, AWAITING) == 0 + 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 in company json field + assert c.company is not None + assert c.company["company_type"] == entity.company_type + assert c.company["name"] == entity.name + assert c.company["legal_form"] == entity.legal_form + assert c.company["trade_name"] == entity.trade_name + assert c.company["siret"] == entity.siret + assert c.company["naf"] == entity.naf + assert c.company["address_1"] == entity.address_1 + assert c.company["address_2"] == entity.address_2 + assert c.company["zip_code"] == entity.address_zip_code + assert c.company["city"] == entity.address_city + + # check company representative data are present + assert c.company is not None + assert c.company_representative["firstname"] == user.first_name + assert c.company_representative["lastname"] == user.last_name + assert c.company_representative["email"] == user.email + + # check control authority data are presents + assert c.control_authority is not None + control_authority = settings.CONSENT_CONTROL_AUTHORITY + assert c.control_authority["name"] == control_authority["name"] + assert ( + c.control_authority["represented_by"] == control_authority["represented_by"] + ) + assert c.control_authority["email"] == control_authority["email"] + assert c.control_authority["address_1"] == control_authority["address_1"] + assert c.control_authority["address_2"] == control_authority["address_2"] + assert c.control_authority["zip_code"] == control_authority["zip_code"] + assert c.control_authority["city"] == control_authority["city"] + + # 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, 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 +161,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 +201,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/validators.py b/src/dashboard/apps/consent/validators.py index 023ec942..f03b9855 100644 --- a/src/dashboard/apps/consent/validators.py +++ b/src/dashboard/apps/consent/validators.py @@ -1,6 +1,7 @@ """Dashboard consent app validators.""" -from django.core.exceptions import ValidationError +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, ValidationError from jsonschema import ValidationError as JSONSchemaValidationError from jsonschema import validate @@ -40,3 +41,17 @@ def validate_control_authority_schema(value): validator = json_schema_validator(CONTROL_AUTHORITY_SCHEMA) return validator(value) + + +def validate_configured_control_authority(): + """Validates the `settings.CONSENT_CONTROL_AUTHORITY`. + + Check if settings.CONSENT_CONTROL_AUTHORITY is valid, + raise ImproperlyConfigured otherwise. + """ + try: + validate_control_authority_schema(settings.CONSENT_CONTROL_AUTHORITY) + except ValidationError as e: + raise ImproperlyConfigured( + f"settings.CONSENT_CONTROL_AUTHORITY validation error: {e.message}" + ) from e diff --git a/src/dashboard/apps/consent/views.py b/src/dashboard/apps/consent/views.py index d8718bd7..66a27a31 100644 --- a/src/dashboard/apps/consent/views.py +++ b/src/dashboard/apps/consent/views.py @@ -1,23 +1,24 @@ """Dashboard consent app views.""" +import sentry_sdk +from django.conf import settings 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 _ from django.views.generic import FormView, TemplateView from apps.core.models import Entity -from dashboard.settings import CONTACT_EMAIL from ..auth.models import DashboardUser from . import AWAITING, VALIDATED from .forms import ConsentForm from .mixins import BreadcrumbContextMixin from .models import Consent -from .settings import CONSENT_CONTROL_AUTHORITY, CONSENT_SIGNATURE_LOCATION class IndexView(BreadcrumbContextMixin, TemplateView): @@ -36,8 +37,14 @@ def get_context_data(self, **kwargs): # noqa: D102 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." + ) + template_name = "consent/manage.html" form_class = ConsentForm + success_url = reverse("consent:index") breadcrumb_links = [ {"url": reverse("consent:index"), "title": _("Consent")}, @@ -54,13 +61,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. @@ -69,10 +80,10 @@ def get_context_data(self, **kwargs): If a slug is provided, adds the entity corresponding to the slug. """ context = super().get_context_data(**kwargs) - context["control_authority"] = CONSENT_CONTROL_AUTHORITY + context["control_authority"] = settings.CONSENT_CONTROL_AUTHORITY context["entities"] = self._get_entities() - context["signature_location"] = CONSENT_SIGNATURE_LOCATION - context["mailto"] = CONTACT_EMAIL + context["signature_location"] = settings.CONSENT_SIGNATURE_LOCATION + context["mailto"] = settings.CONTACT_EMAIL return context def _get_entities(self) -> list: @@ -88,22 +99,59 @@ 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) + # retrieve consents to update + 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(), - ) + .select_related("delivery_point__entity") + ) + + # build consent object with validated fields to update + update_objects = [ + self._build_consent_object(status, form.cleaned_data, consent) + for consent in consents + ] + + # and finally, bulk update the consents + return Consent.objects.bulk_update( + update_objects, + fields=[ + "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", + ], ) def _get_awaiting_ids(self, validated_ids: list[str]) -> list[str]: @@ -117,3 +165,57 @@ 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, + status: str, + form_values: dict, + consent: Consent, + ) -> Consent: + """Builds and returns a `Consent` object with validated data. + + Parameters: + - 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. + - consent (dict): Consent data, including the identifier, and the related + entity information. + """ + # Get related company data from consent + consent_entity = consent.delivery_point.entity + company_data = { + "name": consent_entity.name, + "company_type": consent_entity.company_type, + "legal_form": consent_entity.legal_form, + "trade_name": consent_entity.trade_name, + "siret": consent_entity.siret, + "naf": consent_entity.naf, + "address_1": consent_entity.address_1, + "address_2": consent_entity.address_2, + "zip_code": consent_entity.address_zip_code, + "city": consent_entity.address_city, + } + + company_representative = { + "firstname": self.request.user.first_name, # type: ignore[union-attr] + "lastname": self.request.user.last_name, # type: ignore[union-attr] + "email": self.request.user.email, # type: ignore[union-attr] + } + + # remove `consent_agreed` from `form_values`, which is not stored in db + form_values_copy = form_values.copy() + form_values_copy.pop("consent_agreed", None) + + # build a Consent object with all validated data + return Consent( + id=consent.id, + status=status, + created_by=self.request.user, # type: ignore[misc] + updated_at=timezone.now(), + company=company_data, + control_authority=settings.CONSENT_CONTROL_AUTHORITY, + company_representative=company_representative, + **form_values_copy, + signature_location=settings.CONSENT_SIGNATURE_LOCATION, + )