From d6cc0c9575c1027e666549155dbc734b2ee1919f Mon Sep 17 00:00:00 2001 From: ssorin Date: Mon, 20 Jan 2025 18:10:39 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F(dashboard)=20add=20admini?= =?UTF-8?q?stration=20and=20company=20fields=20to=20consent=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced new fields in the Consent model to handle company and administration information, including validation for SIRET, NAF code, and zip code. Updated settings with configurable default values for these fields. Added core validators for ensuring data integrity in relevant fields. --- ...nsent_administration_address_1_and_more.py | 284 ++++++++++++++++++ src/dashboard/apps/consent/models.py | 140 +++++++++ src/dashboard/apps/consent/settings.py | 16 + .../apps/core/tests/test_validators.py | 57 ++++ src/dashboard/apps/core/validators.py | 66 ++++ src/dashboard/dashboard/settings.py | 11 + 6 files changed, 574 insertions(+) create mode 100644 src/dashboard/apps/consent/migrations/0004_consent_administration_address_1_and_more.py create mode 100644 src/dashboard/apps/core/tests/test_validators.py create mode 100644 src/dashboard/apps/core/validators.py diff --git a/src/dashboard/apps/consent/migrations/0004_consent_administration_address_1_and_more.py b/src/dashboard/apps/consent/migrations/0004_consent_administration_address_1_and_more.py new file mode 100644 index 00000000..b4dacfdc --- /dev/null +++ b/src/dashboard/apps/consent/migrations/0004_consent_administration_address_1_and_more.py @@ -0,0 +1,284 @@ +# Generated by Django 5.1.5 on 2025-01-21 14:17 + +import apps.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("qcd_consent", "0003_alter_consent_managers_alter_consent_end_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="consent", + name="administration_address_1", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="administration address", + ), + ), + migrations.AddField( + model_name="consent", + name="administration_address_2", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="administration address complement", + ), + ), + migrations.AddField( + model_name="consent", + name="administration_email", + field=models.EmailField( + blank=True, + max_length=254, + null=True, + verbose_name="administration email", + ), + ), + migrations.AddField( + model_name="consent", + name="administration_name", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="administration name", + ), + ), + migrations.AddField( + model_name="consent", + name="administration_represented_by", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="administration represented by", + ), + ), + migrations.AddField( + model_name="consent", + name="administration_town", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="administration town", + ), + ), + migrations.AddField( + model_name="consent", + name="administration_zip_code", + field=models.CharField( + blank=True, + max_length=5, + null=True, + validators=[apps.core.validators.validate_zip_code], + verbose_name="administration zip code", + ), + ), + migrations.AddField( + model_name="consent", + name="allows_daily_index_readings", + field=models.BooleanField( + blank=True, + null=True, + verbose_name="allow history of daily index readings in kWh", + ), + ), + migrations.AddField( + model_name="consent", + name="allows_load_curve", + field=models.BooleanField( + blank=True, + null=True, + verbose_name="allows history of load curve, at steps returned by Enedis", + ), + ), + migrations.AddField( + model_name="consent", + name="allows_max_daily_power", + field=models.BooleanField( + blank=True, + null=True, + verbose_name="allows historical maximum daily power in kVa or kWh ", + ), + ), + migrations.AddField( + model_name="consent", + name="allows_measurements", + field=models.BooleanField( + blank=True, + null=True, + verbose_name="allows historical measurements in kWh", + ), + ), + migrations.AddField( + model_name="consent", + name="allows_technical_contractual_data", + field=models.BooleanField( + blank=True, + null=True, + verbose_name="allows the technical and contractual data available", + ), + ), + migrations.AddField( + model_name="consent", + name="company_address_1", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="company address" + ), + ), + migrations.AddField( + model_name="consent", + name="company_address_2", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="company address complement", + ), + ), + migrations.AddField( + model_name="consent", + name="company_legal_form", + field=models.CharField( + blank=True, + help_text="SA, SARL …", + max_length=50, + null=True, + verbose_name="company legal form", + ), + ), + migrations.AddField( + model_name="consent", + name="company_naf", + field=models.CharField( + blank=True, + max_length=5, + null=True, + validators=[apps.core.validators.validate_naf_code], + verbose_name="company NAF code", + ), + ), + migrations.AddField( + model_name="consent", + name="company_name", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="company name" + ), + ), + migrations.AddField( + model_name="consent", + name="company_siret", + field=models.CharField( + blank=True, + max_length=14, + null=True, + validators=[apps.core.validators.validate_siret], + verbose_name="company SIRET", + ), + ), + migrations.AddField( + model_name="consent", + name="company_town", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="company town" + ), + ), + migrations.AddField( + model_name="consent", + name="company_trade_name", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="company trade name" + ), + ), + migrations.AddField( + model_name="consent", + name="company_type", + field=models.CharField( + blank=True, + help_text="entreprise/ collectivité locale, ECPI, Association, copropriété, ...", + max_length=255, + null=True, + verbose_name="company type", + ), + ), + migrations.AddField( + model_name="consent", + name="company_zip_code", + field=models.CharField( + blank=True, + max_length=5, + null=True, + validators=[apps.core.validators.validate_zip_code], + verbose_name="company zip code", + ), + ), + migrations.AddField( + model_name="consent", + name="done_at", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="done_at" + ), + ), + migrations.AddField( + model_name="consent", + name="is_authorized_signatory", + field=models.BooleanField( + blank=True, null=True, verbose_name="the signatory is authorized" + ), + ), + migrations.AddField( + model_name="consent", + name="representative_email", + field=models.EmailField( + blank=True, + max_length=254, + null=True, + verbose_name="representative email", + ), + ), + migrations.AddField( + model_name="consent", + name="representative_firstname", + field=models.CharField( + blank=True, + max_length=150, + null=True, + verbose_name="representative firstname", + ), + ), + migrations.AddField( + model_name="consent", + name="representative_lastname", + field=models.CharField( + blank=True, + max_length=150, + null=True, + verbose_name="representative lastname", + ), + ), + migrations.AddField( + model_name="consent", + name="representative_phone", + field=models.CharField( + blank=True, + max_length=20, + null=True, + verbose_name="representative phone", + ), + ), + migrations.AddField( + model_name="consent", + name="signature_date", + field=models.DateTimeField( + blank=True, null=True, verbose_name="signature date" + ), + ), + ] diff --git a/src/dashboard/apps/consent/models.py b/src/dashboard/apps/consent/models.py index d011999c..fef105e5 100644 --- a/src/dashboard/apps/consent/models.py +++ b/src/dashboard/apps/consent/models.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from apps.core.abstract_models import DashboardBase +from apps.core.validators import validate_naf_code, validate_siret, validate_zip_code from . import AWAITING, CONSENT_STATUS_CHOICE, REVOKED, VALIDATED from .exceptions import ConsentWorkflowError @@ -15,6 +16,9 @@ class Consent(DashboardBase): """Represents the consent status for a given delivery point and user. + For contractual reason, a consent cannot be modified after it has the status + "VALIDATED" or "REVOKED". + Attributes: - AWAITING: Status indicating that the consent is awaiting validation. - VALIDATED: Status indicating that the consent has been validated. @@ -48,6 +52,142 @@ class Consent(DashboardBase): end = models.DateTimeField(_("end date"), default=consent_end_date) revoked_at = models.DateTimeField(_("revoked at"), null=True, blank=True) + # Contractual fields of the company + # These fields are populated with data from the linked entity. + company_type = models.CharField( + _("company type"), + max_length=255, + help_text=_( + "entreprise/ collectivité locale, ECPI, Association, copropriété, ..." + ), + blank=True, + null=True, + ) + company_name = models.CharField( + _("company name"), max_length=255, blank=True, null=True + ) + company_legal_form = models.CharField( + _("company legal form"), + max_length=50, + help_text=_("SA, SARL …"), + blank=True, + null=True, + ) + company_trade_name = models.CharField( + _("company trade name"), max_length=255, blank=True, null=True + ) + company_siret = models.CharField( + _("company SIRET"), + max_length=14, + validators=[validate_siret], + blank=True, + null=True, + ) + company_naf = models.CharField( + _("company NAF code"), + validators=[validate_naf_code], + max_length=5, + blank=True, + null=True, + ) + company_address_1 = models.CharField( + _("company address"), max_length=255, blank=True, null=True + ) + company_address_2 = models.CharField( + _("company address complement"), max_length=255, blank=True, null=True + ) + company_zip_code = models.CharField( + _("company zip code"), + max_length=5, + validators=[validate_zip_code], + blank=True, + null=True, + ) + company_town = models.CharField( + _("company town"), max_length=255, blank=True, null=True + ) + + # Contractual fields of the company representative + # These fields are populated with the current user data. + # representative_civility = models.CharField( + # _("representative civility"), + # choices=CIVILITY_CHOICES, + # max_length=5, + # blank=True, + # null=True, + # ) + representative_firstname = models.CharField( + _("representative firstname"), max_length=150, blank=True, null=True + ) + representative_lastname = models.CharField( + _("representative lastname"), max_length=150, blank=True, null=True + ) + representative_email = models.EmailField( + _("representative email"), blank=True, null=True + ) + representative_phone = models.CharField( + _("representative phone"), max_length=20, blank=True, null=True + ) + + # Contractual fields of the Administration + # These fields are populated via `settings.CONSENT_ADMINISTRATION_FIELDS` + administration_name = models.CharField( + _("administration name"), max_length=255, blank=True, null=True + ) + administration_address_1 = models.CharField( + _("administration address"), max_length=255, blank=True, null=True + ) + administration_address_2 = models.CharField( + _("administration address complement"), max_length=255, blank=True, null=True + ) + administration_zip_code = models.CharField( + _("administration zip code"), + max_length=5, + validators=[validate_zip_code], + blank=True, + null=True, + ) + administration_town = models.CharField( + _("administration town"), max_length=255, blank=True, null=True + ) + administration_represented_by = models.CharField( + _("administration represented by"), max_length=255, blank=True, null=True + ) + administration_email = models.EmailField( + _("administration email"), blank=True, null=True + ) + + # Fields populated via the consent form + is_authorized_signatory = models.BooleanField( + _("the signatory is authorized"), blank=True, null=True + ) + + allows_measurements = models.BooleanField( + _("allows historical measurements in kWh"), blank=True, null=True + ) + allows_daily_index_readings = models.BooleanField( + _("allow history of daily index readings in kWh"), + blank=True, + null=True, + ) + allows_max_daily_power = models.BooleanField( + _("allows historical maximum daily power in kVa or kWh "), + blank=True, + null=True, + ) + allows_load_curve = models.BooleanField( + _("allows history of load curve, at steps returned by Enedis"), + blank=True, + null=True, + ) + allows_technical_contractual_data = models.BooleanField( + _("allows the technical and contractual data available"), + blank=True, + null=True, + ) + signature_date = models.DateTimeField(_("signature date"), blank=True, null=True) + done_at = models.CharField(_("done_at"), max_length=255, blank=True, null=True) + # models.Manager() must be in first place to ensure django admin expectations. objects = models.Manager() active_objects = ConsentManager() diff --git a/src/dashboard/apps/consent/settings.py b/src/dashboard/apps/consent/settings.py index 2dd4e33e..c278b63c 100644 --- a/src/dashboard/apps/consent/settings.py +++ b/src/dashboard/apps/consent/settings.py @@ -12,3 +12,19 @@ # CONSENT_NUMBER_DAYS_END_DATE = None will return 2024-12-31 23:59:59 (if calculated # during the year 2024). CONSENT_NUMBER_DAYS_END_DATE = getattr(settings, "CONSENT_NUMBER_DAYS_END_DATE", None) + +# Contractual fields that are inserted when consent is validated by a user. +CONSENT_ADMINISTRATION_FIELDS = getattr( + settings, + "CONSENT_ADMINISTRATION_FIELDS", + { + "name": "", + "address_1": "", + "address_2": "", + "zip_code": 92000, + "town": "", + "represented_by": "", + "email": "", + }, +) +CONSENT_DONE_AT_FIELD = getattr(settings, "CONSENT_DONE_AT_FIELD", "") diff --git a/src/dashboard/apps/core/tests/test_validators.py b/src/dashboard/apps/core/tests/test_validators.py new file mode 100644 index 00000000..ccc52e58 --- /dev/null +++ b/src/dashboard/apps/core/tests/test_validators.py @@ -0,0 +1,57 @@ +"""Dashboard core validators tests.""" + +import pytest +from django.core.exceptions import ValidationError + +from apps.core.validators import validate_naf_code, validate_siret, validate_zip_code + + +@pytest.mark.parametrize("value", ["12345678901234", "00000000000000", None]) +def test_validate_siret_valid(value): + """Tests that a valid SIRET does not raise an exception.""" + assert validate_siret(value) is None + + +@pytest.mark.parametrize( + "value", + [ + "1234567890123", # Too short + "123456789012345", # Too long + "1234ABC8901234", # Contains non-numeric characters + 1234, # Number + "", # Empty string + " " * 14, # Only spaces + ], +) +def test_validate_siret_invalid(value): + """Tests that an invalid SIRET raises a ValidationError.""" + with pytest.raises(ValidationError): + validate_siret(value) + + +@pytest.mark.parametrize("value", ["1234A", "0001Z", "9876B", "0000Z", None]) +def test_validate_naf_code_valid(value): + """Test that valid NAF codes does not raise an exception.""" + assert validate_naf_code(value) is None + + +@pytest.mark.parametrize( + "value", ["12345", "12345Z", "123A", "12A45", "ABC1Z", "1234!", "abcdA", "1234", ""] +) +def test_validate_naf_code_invalid(value): + """Test that invalid NAF codes raise a ValidationError.""" + with pytest.raises(ValidationError): + validate_naf_code(value) + + +@pytest.mark.parametrize("value", ["12345", "98765", "00000", None]) +def test_validate_zip_code_valid(value): + """Tests validation of valid zip codes does not raise an exception.""" + assert validate_zip_code(value) is None + + +@pytest.mark.parametrize("value", ["1234", "123456", "12a45", "abcde", "", " ", 12345]) +def test_validate_zip_code_invalid(value): + """Tests validation of invalid zip codes raise a ValidationError.""" + with pytest.raises(ValidationError): + validate_zip_code(value) diff --git a/src/dashboard/apps/core/validators.py b/src/dashboard/apps/core/validators.py new file mode 100644 index 00000000..e0f884e7 --- /dev/null +++ b/src/dashboard/apps/core/validators.py @@ -0,0 +1,66 @@ +"""Dashboard core app validators.""" + +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +DIGITS_NUMBER = 14 + + +def validate_siret(value: str | None) -> None: + """Validate a SIRET number. + + SIRET must be a string that contains only numbers and have a fixed length of 14 + characters. + """ + error_message_numeric = _("The SIRET must be composed only of numbers.") + error_message_length = _("The SIRET must contain exactly 14 digits.") + + if value is None: + return + + if not isinstance(value, str): + raise ValidationError(error_message_numeric) + + if not value.isdigit(): + raise ValidationError(error_message_numeric) + + if len(value) != DIGITS_NUMBER: + raise ValidationError(error_message_length) + + +def validate_naf_code(value: str | None) -> None: + """Validate a NAF code. + + NAF code must respect the format "####A" (4 digits + 1 letter). + """ + if value is None: + return + + if not re.match(r"^\d{4}[A-Za-z]$", value): + raise ValidationError( + _( + "The NAF code must be in the format of 4 digits " + "followed by a letter (e.g.: 6820A)." + ) + ) + + +def validate_zip_code(value: int | None) -> None: + """Validate a zip code. + + Zip code must have only digits and a fixed length of 5 characters. + """ + error_message = _( + "Zip code must be composed of number and a fixed length of 5 characters." + ) + + if value is None: + return + + if not isinstance(value, str): + raise ValidationError(error_message) + + if not re.match(r"^[0-9]{5}$", value): + raise ValidationError(error_message) diff --git a/src/dashboard/dashboard/settings.py b/src/dashboard/dashboard/settings.py index 5f2f21da..29c2cf17 100644 --- a/src/dashboard/dashboard/settings.py +++ b/src/dashboard/dashboard/settings.py @@ -194,6 +194,17 @@ # during the year 2024). CONSENT_NUMBER_DAYS_END_DATE = None +# Contractual fields that are inserted when consent is validated by a user. +CONSENT_ADMINISTRATION_FIELDS = { + "name": "Direction générale de l'énergie et du climat", + "address_1": "Tour Séquoia, 1 place Carpeaux", + "address_2": None, + "zip_code": 92055, + "town": "La Défense CEDEX", + "represented_by": "Mme Laure Courselaud", + "email": "valorisation-recharge@developpement-durable.gouv.fr", +} +CONSENT_DONE_AT_FIELD = "Paris" ## Debug-toolbar