- {% if form.errors %}
+ {% if form.errors %}
+
+ {% 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,
+ )