From 61da83b0da2b2c422b2b73f04c6bd34fc3535062 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. --- src/dashboard/CHANGELOG.md | 1 + src/dashboard/Pipfile | 1 + src/dashboard/Pipfile.lock | 144 +++++++++++++++++- .../0004_consent_contractual_fields.py | 104 +++++++++++++ src/dashboard/apps/consent/models.py | 59 ++++++- src/dashboard/apps/consent/schemas.py | 80 ++++++++++ src/dashboard/apps/consent/settings.py | 16 ++ .../apps/consent/tests/test_validators.py | 129 ++++++++++++++++ src/dashboard/apps/consent/validators.py | 42 +++++ .../apps/core/tests/test_validators.py | 58 +++++++ src/dashboard/apps/core/validators.py | 66 ++++++++ src/dashboard/dashboard/settings.py | 11 ++ 12 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 src/dashboard/apps/consent/migrations/0004_consent_contractual_fields.py create mode 100644 src/dashboard/apps/consent/schemas.py create mode 100644 src/dashboard/apps/consent/tests/test_validators.py create mode 100644 src/dashboard/apps/consent/validators.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/CHANGELOG.md b/src/dashboard/CHANGELOG.md index c8452f08..09aefc0e 100644 --- a/src/dashboard/CHANGELOG.md +++ b/src/dashboard/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to - add consent form to manage consents of one or many entities - add admin integration for Entity, DeliveryPoint and Consent - add mass admin action (make revoked) for consents +- add validators for SIRET, NAF code and Zip code - disallow mass action "delete" for consents in admin - block the updates of all new data if a consent has the status `REVOKED` - block the updates of all new data if a consent has the status `VALIDATED` diff --git a/src/dashboard/Pipfile b/src/dashboard/Pipfile index cb7eddad..25977d58 100644 --- a/src/dashboard/Pipfile +++ b/src/dashboard/Pipfile @@ -10,6 +10,7 @@ django-environ = "==0.12.0" django-extensions = "==3.2.3" django-stubs = {extras = ["compatible-mypy"], version = "==5.1.1"} gunicorn = "==23.0.0" +jsonschema = "==4.23.0" psycopg = {extras = ["pool", "binary"], version = "==3.2.3"} sentry-sdk = {extras = ["django"], version = "==2.20.0"} whitenoise = "==6.8.2" diff --git a/src/dashboard/Pipfile.lock b/src/dashboard/Pipfile.lock index dd26d999..85e8b979 100644 --- a/src/dashboard/Pipfile.lock +++ b/src/dashboard/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d9c2919121acd17c2e73c77004ecf48566e097ae7294ad7da7d03d7df9ef3013" + "sha256": "abd1de7b273b496e48fca82fee5c385ba876719e9c303045680e368dda8fa461" }, "pipfile-spec": 6, "requires": { @@ -24,6 +24,14 @@ "markers": "python_version >= '3.8'", "version": "==3.8.1" }, + "attrs": { + "hashes": [ + "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", + "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308" + ], + "markers": "python_version >= '3.8'", + "version": "==24.3.0" + }, "certifi": { "hashes": [ "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", @@ -218,6 +226,23 @@ "markers": "python_version >= '3.6'", "version": "==3.10" }, + "jsonschema": { + "hashes": [ + "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", + "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.23.0" + }, + "jsonschema-specifications": { + "hashes": [ + "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", + "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf" + ], + "markers": "python_version >= '3.9'", + "version": "==2024.10.1" + }, "mypy": { "hashes": [ "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", @@ -362,6 +387,14 @@ "markers": "python_version >= '3.8'", "version": "==3.2.4" }, + "referencing": { + "hashes": [ + "sha256:363d9c65f080d0d70bc41c721dce3c7f3e77fc09f269cd5c8813da18069a6794", + "sha256:ca2e6492769e3602957e9b831b94211599d2aade9477f5d44110d2530cf9aade" + ], + "markers": "python_version >= '3.9'", + "version": "==0.36.1" + }, "requests": { "hashes": [ "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", @@ -370,6 +403,115 @@ "markers": "python_version >= '3.8'", "version": "==2.32.3" }, + "rpds-py": { + "hashes": [ + "sha256:009de23c9c9ee54bf11303a966edf4d9087cd43a6003672e6aa7def643d06518", + "sha256:02fbb9c288ae08bcb34fb41d516d5eeb0455ac35b5512d03181d755d80810059", + "sha256:0a0461200769ab3b9ab7e513f6013b7a97fdeee41c29b9db343f3c5a8e2b9e61", + "sha256:0b09865a9abc0ddff4e50b5ef65467cd94176bf1e0004184eb915cbc10fc05c5", + "sha256:0b8db6b5b2d4491ad5b6bdc2bc7c017eec108acbf4e6785f42a9eb0ba234f4c9", + "sha256:0c150c7a61ed4a4f4955a96626574e9baf1adf772c2fb61ef6a5027e52803543", + "sha256:0f3cec041684de9a4684b1572fe28c7267410e02450f4561700ca5a3bc6695a2", + "sha256:1352ae4f7c717ae8cba93421a63373e582d19d55d2ee2cbb184344c82d2ae55a", + "sha256:177c7c0fce2855833819c98e43c262007f42ce86651ffbb84f37883308cb0e7d", + "sha256:1978d0021e943aae58b9b0b196fb4895a25cc53d3956b8e35e0b7682eefb6d56", + "sha256:1a60bce91f81ddaac922a40bbb571a12c1070cb20ebd6d49c48e0b101d87300d", + "sha256:1aef18820ef3e4587ebe8b3bc9ba6e55892a6d7b93bac6d29d9f631a3b4befbd", + "sha256:1e9663daaf7a63ceccbbb8e3808fe90415b0757e2abddbfc2e06c857bf8c5e2b", + "sha256:20070c65396f7373f5df4005862fa162db5d25d56150bddd0b3e8214e8ef45b4", + "sha256:214b7a953d73b5e87f0ebece4a32a5bd83c60a3ecc9d4ec8f1dca968a2d91e99", + "sha256:22bebe05a9ffc70ebfa127efbc429bc26ec9e9b4ee4d15a740033efda515cf3d", + "sha256:24e8abb5878e250f2eb0d7859a8e561846f98910326d06c0d51381fed59357bd", + "sha256:26fd7cac7dd51011a245f29a2cc6489c4608b5a8ce8d75661bb4a1066c52dfbe", + "sha256:27b1d3b3915a99208fee9ab092b8184c420f2905b7d7feb4aeb5e4a9c509b8a1", + "sha256:27e98004595899949bd7a7b34e91fa7c44d7a97c40fcaf1d874168bb652ec67e", + "sha256:2b8f60e1b739a74bab7e01fcbe3dddd4657ec685caa04681df9d562ef15b625f", + "sha256:2de29005e11637e7a2361fa151f780ff8eb2543a0da1413bb951e9f14b699ef3", + "sha256:2e8b55d8517a2fda8d95cb45d62a5a8bbf9dd0ad39c5b25c8833efea07b880ca", + "sha256:2fa4331c200c2521512595253f5bb70858b90f750d39b8cbfd67465f8d1b596d", + "sha256:3445e07bf2e8ecfeef6ef67ac83de670358abf2996916039b16a218e3d95e97e", + "sha256:3453e8d41fe5f17d1f8e9c383a7473cd46a63661628ec58e07777c2fff7196dc", + "sha256:378753b4a4de2a7b34063d6f95ae81bfa7b15f2c1a04a9518e8644e81807ebea", + "sha256:3af6e48651c4e0d2d166dc1b033b7042ea3f871504b6805ba5f4fe31581d8d38", + "sha256:3dfcbc95bd7992b16f3f7ba05af8a64ca694331bd24f9157b49dadeeb287493b", + "sha256:3f21f0495edea7fdbaaa87e633a8689cd285f8f4af5c869f27bc8074638ad69c", + "sha256:4041711832360a9b75cfb11b25a6a97c8fb49c07b8bd43d0d02b45d0b499a4ff", + "sha256:44d61b4b7d0c2c9ac019c314e52d7cbda0ae31078aabd0f22e583af3e0d79723", + "sha256:4617e1915a539a0d9a9567795023de41a87106522ff83fbfaf1f6baf8e85437e", + "sha256:4b232061ca880db21fa14defe219840ad9b74b6158adb52ddf0e87bead9e8493", + "sha256:5246b14ca64a8675e0a7161f7af68fe3e910e6b90542b4bfb5439ba752191df6", + "sha256:5725dd9cc02068996d4438d397e255dcb1df776b7ceea3b9cb972bdb11260a83", + "sha256:583f6a1993ca3369e0f80ba99d796d8e6b1a3a2a442dd4e1a79e652116413091", + "sha256:59259dc58e57b10e7e18ce02c311804c10c5a793e6568f8af4dead03264584d1", + "sha256:593eba61ba0c3baae5bc9be2f5232430453fb4432048de28399ca7376de9c627", + "sha256:59f4a79c19232a5774aee369a0c296712ad0e77f24e62cad53160312b1c1eaa1", + "sha256:5f0e260eaf54380380ac3808aa4ebe2d8ca28b9087cf411649f96bad6900c728", + "sha256:62d9cfcf4948683a18a9aff0ab7e1474d407b7bab2ca03116109f8464698ab16", + "sha256:64607d4cbf1b7e3c3c8a14948b99345eda0e161b852e122c6bb71aab6d1d798c", + "sha256:655ca44a831ecb238d124e0402d98f6212ac527a0ba6c55ca26f616604e60a45", + "sha256:666ecce376999bf619756a24ce15bb14c5bfaf04bf00abc7e663ce17c3f34fe7", + "sha256:68049202f67380ff9aa52f12e92b1c30115f32e6895cd7198fa2a7961621fc5a", + "sha256:69803198097467ee7282750acb507fba35ca22cc3b85f16cf45fb01cb9097730", + "sha256:6c7b99ca52c2c1752b544e310101b98a659b720b21db00e65edca34483259967", + "sha256:6dd9412824c4ce1aca56c47b0991e65bebb7ac3f4edccfd3f156150c96a7bf25", + "sha256:70eb60b3ae9245ddea20f8a4190bd79c705a22f8028aaf8bbdebe4716c3fab24", + "sha256:70fb28128acbfd264eda9bf47015537ba3fe86e40d046eb2963d75024be4d055", + "sha256:7b2513ba235829860b13faa931f3b6846548021846ac808455301c23a101689d", + "sha256:7ef9d9da710be50ff6809fed8f1963fecdfecc8b86656cadfca3bc24289414b0", + "sha256:81e69b0a0e2537f26d73b4e43ad7bc8c8efb39621639b4434b76a3de50c6966e", + "sha256:8633e471c6207a039eff6aa116e35f69f3156b3989ea3e2d755f7bc41754a4a7", + "sha256:8bd7c8cfc0b8247c8799080fbff54e0b9619e17cdfeb0478ba7295d43f635d7c", + "sha256:9253fc214112405f0afa7db88739294295f0e08466987f1d70e29930262b4c8f", + "sha256:99b37292234e61325e7a5bb9689e55e48c3f5f603af88b1642666277a81f1fbd", + "sha256:9bd7228827ec7bb817089e2eb301d907c0d9827a9e558f22f762bb690b131652", + "sha256:9beeb01d8c190d7581a4d59522cd3d4b6887040dcfc744af99aa59fef3e041a8", + "sha256:a63cbdd98acef6570c62b92a1e43266f9e8b21e699c363c0fef13bd530799c11", + "sha256:a76e42402542b1fae59798fab64432b2d015ab9d0c8c47ba7addddbaf7952333", + "sha256:ac0a03221cdb5058ce0167ecc92a8c89e8d0decdc9e99a2ec23380793c4dcb96", + "sha256:b0b4136a252cadfa1adb705bb81524eee47d9f6aab4f2ee4fa1e9d3cd4581f64", + "sha256:b25bc607423935079e05619d7de556c91fb6adeae9d5f80868dde3468657994b", + "sha256:b3d504047aba448d70cf6fa22e06cb09f7cbd761939fdd47604f5e007675c24e", + "sha256:bb47271f60660803ad11f4c61b42242b8c1312a31c98c578f79ef9387bbde21c", + "sha256:bbb232860e3d03d544bc03ac57855cd82ddf19c7a07651a7c0fdb95e9efea8b9", + "sha256:bc27863442d388870c1809a87507727b799c8460573cfbb6dc0eeaef5a11b5ec", + "sha256:bc51abd01f08117283c5ebf64844a35144a0843ff7b2983e0648e4d3d9f10dbb", + "sha256:be2eb3f2495ba669d2a985f9b426c1797b7d48d6963899276d22f23e33d47e37", + "sha256:bf9db5488121b596dbfc6718c76092fda77b703c1f7533a226a5a9f65248f8ad", + "sha256:c58e2339def52ef6b71b8f36d13c3688ea23fa093353f3a4fee2556e62086ec9", + "sha256:cfbc454a2880389dbb9b5b398e50d439e2e58669160f27b60e5eca11f68ae17c", + "sha256:cff63a0272fcd259dcc3be1657b07c929c466b067ceb1c20060e8d10af56f5bf", + "sha256:d115bffdd417c6d806ea9069237a4ae02f513b778e3789a359bc5856e0404cc4", + "sha256:d20cfb4e099748ea39e6f7b16c91ab057989712d31761d3300d43134e26e165f", + "sha256:d48424e39c2611ee1b84ad0f44fb3b2b53d473e65de061e3f460fc0be5f1939d", + "sha256:e0fa2d4ec53dc51cf7d3bb22e0aa0143966119f42a0c3e4998293a3dd2856b09", + "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d", + "sha256:e35ba67d65d49080e8e5a1dd40101fccdd9798adb9b050ff670b7d74fa41c566", + "sha256:e3fb866d9932a3d7d0c82da76d816996d1667c44891bd861a0f97ba27e84fc74", + "sha256:e61b02c3f7a1e0b75e20c3978f7135fd13cb6cf551bf4a6d29b999a88830a338", + "sha256:e67ba3c290821343c192f7eae1d8fd5999ca2dc99994114643e2f2d3e6138b15", + "sha256:e79dd39f1e8c3504be0607e5fc6e86bb60fe3584bec8b782578c3b0fde8d932c", + "sha256:e89391e6d60251560f0a8f4bd32137b077a80d9b7dbe6d5cab1cd80d2746f648", + "sha256:ea7433ce7e4bfc3a85654aeb6747babe3f66eaf9a1d0c1e7a4435bbdf27fea84", + "sha256:eaf16ae9ae519a0e237a0f528fd9f0197b9bb70f40263ee57ae53c2b8d48aeb3", + "sha256:eb0c341fa71df5a4595f9501df4ac5abfb5a09580081dffbd1ddd4654e6e9123", + "sha256:f276b245347e6e36526cbd4a266a417796fc531ddf391e43574cf6466c492520", + "sha256:f47ad3d5f3258bd7058d2d506852217865afefe6153a36eb4b6928758041d831", + "sha256:f56a6b404f74ab372da986d240e2e002769a7d7102cc73eb238a4f72eec5284e", + "sha256:f5cf2a0c2bdadf3791b5c205d55a37a54025c6e18a71c71f82bb536cf9a454bf", + "sha256:f5d36399a1b96e1a5fdc91e0522544580dbebeb1f77f27b2b0ab25559e103b8b", + "sha256:f60bd8423be1d9d833f230fdbccf8f57af322d96bcad6599e5a771b151398eb2", + "sha256:f612463ac081803f243ff13cccc648578e2279295048f2a8d5eb430af2bae6e3", + "sha256:f73d3fef726b3243a811121de45193c0ca75f6407fe66f3f4e183c983573e130", + "sha256:f82a116a1d03628a8ace4859556fb39fd1424c933341a08ea3ed6de1edb0283b", + "sha256:fb0ba113b4983beac1a2eb16faffd76cb41e176bf58c4afe3e14b9c681f702de", + "sha256:fb4f868f712b2dd4bcc538b0a0c1f63a2b1d584c925e69a224d759e7070a12d5", + "sha256:fb6116dfb8d1925cbdb52595560584db42a7f664617a1f7d7f6e32f138cdf37d", + "sha256:fda7cb070f442bf80b642cd56483b5548e43d366fe3f39b98e67cce780cded00", + "sha256:feea821ee2a9273771bae61194004ee2fc33f8ec7db08117ef9147d4bbcbca8e" + ], + "markers": "python_version >= '3.9'", + "version": "==0.22.3" + }, "sentry-sdk": { "extras": [ "django" diff --git a/src/dashboard/apps/consent/migrations/0004_consent_contractual_fields.py b/src/dashboard/apps/consent/migrations/0004_consent_contractual_fields.py new file mode 100644 index 00000000..d94bbf8f --- /dev/null +++ b/src/dashboard/apps/consent/migrations/0004_consent_contractual_fields.py @@ -0,0 +1,104 @@ +# Generated by Django 5.1.5 on 2025-01-22 15:24 + +import apps.consent.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="allows_daily_index_readings", + field=models.BooleanField( + default=False, + verbose_name="allow history of daily index readings in kWh", + ), + ), + migrations.AddField( + model_name="consent", + name="allows_load_curve", + field=models.BooleanField( + default=False, + 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( + default=False, + verbose_name="allows historical maximum daily power in kVa or kWh ", + ), + ), + migrations.AddField( + model_name="consent", + name="allows_measurements", + field=models.BooleanField( + default=False, verbose_name="allows historical measurements in kWh" + ), + ), + migrations.AddField( + model_name="consent", + name="allows_technical_contractual_data", + field=models.BooleanField( + default=False, + verbose_name="allows the technical and contractual data available", + ), + ), + migrations.AddField( + model_name="consent", + name="company", + field=models.JSONField( + blank=True, + null=True, + validators=[apps.consent.validators.validate_company_schema], + verbose_name="company informations", + ), + ), + migrations.AddField( + model_name="consent", + name="company_representative", + field=models.JSONField( + blank=True, + null=True, + validators=[apps.consent.validators.validate_representative_schema], + verbose_name="company representative", + ), + ), + migrations.AddField( + model_name="consent", + name="control_authority", + field=models.JSONField( + blank=True, + null=True, + validators=[apps.consent.validators.validate_control_authority_schema], + verbose_name="company representative", + ), + ), + 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( + default=False, verbose_name="the signatory is authorized" + ), + ), + migrations.AddField( + model_name="consent", + name="signed_at", + 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..e876358b 100644 --- a/src/dashboard/apps/consent/models.py +++ b/src/dashboard/apps/consent/models.py @@ -10,11 +10,19 @@ from .exceptions import ConsentWorkflowError from .managers import ConsentManager from .utils import consent_end_date +from .validators import ( + validate_company_schema, + validate_control_authority_schema, + validate_representative_schema, +) 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 +56,55 @@ class Consent(DashboardBase): end = models.DateTimeField(_("end date"), default=consent_end_date) revoked_at = models.DateTimeField(_("revoked at"), null=True, blank=True) + # Contractual field of the company + # These fields are populated with data from the linked entity. + company = models.JSONField( + _("company informations"), + blank=True, + null=True, + validators=[validate_company_schema], + ) + + # Contractual field of the company representative + # These fields are populated with the current user data. + company_representative = models.JSONField( + _("company representative"), + blank=True, + null=True, + validators=[validate_representative_schema], + ) + + # Contractual field of the control authority + # These fields are populated via `settings.CONSENT_ADMINISTRATION_FIELDS`. + control_authority = models.JSONField( + _("company representative"), + blank=True, + null=True, + validators=[validate_control_authority_schema], + ) + + # Fields populated via the consent form + is_authorized_signatory = models.BooleanField( + _("the signatory is authorized"), default=False + ) + allows_measurements = models.BooleanField( + _("allows historical measurements in kWh"), default=False + ) + allows_daily_index_readings = models.BooleanField( + _("allow history of daily index readings in kWh"), default=False + ) + allows_max_daily_power = models.BooleanField( + _("allows historical maximum daily power in kVa or kWh "), default=False + ) + allows_load_curve = models.BooleanField( + _("allows history of load curve, at steps returned by Enedis"), default=False + ) + allows_technical_contractual_data = models.BooleanField( + _("allows the technical and contractual data available"), default=False + ) + signed_at = 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() @@ -109,7 +166,7 @@ def _is_update_allowed(self) -> bool: Workflow according to consent status: - AWAITING: - - can be updated without restriction + - can be updated without restriction. - VALIDATED - if the status is updated to something other than REVOKED, an exception is diff --git a/src/dashboard/apps/consent/schemas.py b/src/dashboard/apps/consent/schemas.py new file mode 100644 index 00000000..80531c82 --- /dev/null +++ b/src/dashboard/apps/consent/schemas.py @@ -0,0 +1,80 @@ +"""Dashboard consent app JSON schemas. + +JSON schemas: +- company_schema +- representative_schema +- control_authority_schema +""" + +company_schema = { + "type": "object", + "properties": { + "company_type": {"type": ["string", "null"], "maxLength": 255}, + "name": {"type": ["string", "null"], "maxLength": 255}, + "legal_form": {"type": ["string", "null"], "maxLength": 50}, + "trade_name": {"type": ["string", "null"], "maxLength": 255}, + "siret": { + "type": ["string", "null"], + "maxLength": 14, + "pattern": "^[0-9]{14}$", + }, + "naf": { + "type": ["string", "null"], + "maxLength": 5, + "pattern": "^[0-9]{4}[A-Za-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]{5}$", + }, + "city": {"type": ["string", "null"], "maxLength": 255}, + }, + "required": ["line_1", "zip_code", "city"], + }, + }, + "required": ["name", "siret", "address"], + "additionalProperties": False, +} + +representative_schema = { + "type": "object", + "properties": { + "firstname": {"type": ["string", "null"], "maxLength": 150}, + "lastname": {"type": ["string", "null"], "maxLength": 150}, + "email": {"type": ["string", "null"], "format": "email"}, + "phone": {"type": ["string", "null"], "maxLength": 20}, + }, + "required": ["firstname", "lastname"], + "additionalProperties": False, +} + +control_authority_schema = { + "type": "object", + "properties": { + "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]{5}$", + }, + "city": {"type": ["string", "null"], "maxLength": 255}, + }, + "required": ["line_1", "zip_code", "city"], + }, + }, + "required": ["name", "represented_by", "email", "address"], + "additionalProperties": False, +} 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/consent/tests/test_validators.py b/src/dashboard/apps/consent/tests/test_validators.py new file mode 100644 index 00000000..6f01b838 --- /dev/null +++ b/src/dashboard/apps/consent/tests/test_validators.py @@ -0,0 +1,129 @@ +"""Dashboard consent validators tests.""" + +import pytest +from django.core.exceptions import ValidationError + +from apps.consent.schemas import ( + company_schema, + control_authority_schema, + representative_schema, +) +from apps.consent.validators import json_schema_validator + +VALID_COMPANY_DATA = { + "company_type": "SARL", + "name": "My Company", + "legal_form": "SARL", + "trade_name": "The test company", + "siret": "12345678901234", + "naf": "1234A", + "address": { + "line_1": "1 rue Exemple", + "line_2": None, + "zip_code": "75000", + "city": "Paris", + }, +} + +VALID_REPRESENTATIVE_DATA = { + "firstname": "Alice", + "lastname": "Brown", + "email": "alice.brown@example.com", + "phone": "9876543210", +} + +VALID_CONTROL_AUTHORITY_DATA = { + "name": "QualiCharge", + "represented_by": "John Doe", + "email": "mail@test.com", + "address": { + "line_1": "1 Rue Exemple", + "line_2": None, + "zip_code": "75000", + "city": "Paris", + }, +} + + +def test_company_json_schema_validator_valid(): + """Test the json schema validator with a valid company data.""" + validator = json_schema_validator(company_schema) + validator(VALID_COMPANY_DATA) + + +def test_company_json_schema_validator_invalid(): + """Test the json schema validator with a valid company data.""" + validator = json_schema_validator(company_schema) + + # test without properties + invalid_value = {} + with pytest.raises(ValidationError): + validator(invalid_value) + + # test with invalid value (invalid siret) + invalid_value = VALID_COMPANY_DATA + invalid_value["siret"] = "1234" + with pytest.raises(ValidationError): + validator(invalid_value) + + # test with additional properties + invalid_value = VALID_COMPANY_DATA + invalid_value["additional_property"] = "" + with pytest.raises(ValidationError): + validator(invalid_value) + + +def test_representative_json_schema_validator_valid(): + """Test the json schema validator with a valid representative company data.""" + validator = json_schema_validator(representative_schema) + validator(VALID_REPRESENTATIVE_DATA) + + +def test_representative_json_schema_validator_invalid(): + """Test the json schema validator with a valid representative company data.""" + validator = json_schema_validator(representative_schema) + + # test without properties + invalid_value = {} + with pytest.raises(ValidationError): + validator(invalid_value) + + # test with invalid value (invalid email) + invalid_value = VALID_REPRESENTATIVE_DATA + invalid_value["firstname"] = 1234 + with pytest.raises(ValidationError): + validator(invalid_value) + + # test with additional properties + invalid_value = VALID_REPRESENTATIVE_DATA + invalid_value["additional_property"] = "" + with pytest.raises(ValidationError): + validator(invalid_value) + + +def test_control_authority_json_schema_validator_valid(): + """Test the json schema validator with a valid control authority data.""" + validator = json_schema_validator(control_authority_schema) + validator(VALID_CONTROL_AUTHORITY_DATA) + + +def test_control_authority_json_schema_validator_invalid(): + """Test the json schema validator with a valid control authority data.""" + validator = json_schema_validator(control_authority_schema) + + # test without properties + invalid_value = {} + with pytest.raises(ValidationError): + validator(invalid_value) + + # test with invalid value (invalid email) + invalid_value = VALID_CONTROL_AUTHORITY_DATA + invalid_value["represented_by"] = 1234 + with pytest.raises(ValidationError): + validator(invalid_value) + + # test with additional properties + invalid_value = VALID_CONTROL_AUTHORITY_DATA + invalid_value["additional_property"] = "" + with pytest.raises(ValidationError): + validator(invalid_value) diff --git a/src/dashboard/apps/consent/validators.py b/src/dashboard/apps/consent/validators.py new file mode 100644 index 00000000..e1444086 --- /dev/null +++ b/src/dashboard/apps/consent/validators.py @@ -0,0 +1,42 @@ +"""Dashboard consent app validators.""" + +from django.core.exceptions import ValidationError +from jsonschema import ValidationError as JSONSchemaValidationError +from jsonschema import validate + +from apps.consent.schemas import ( + company_schema, + control_authority_schema, + representative_schema, +) + + +def json_schema_validator(schema): + """JSON schema validator.""" + + def validator(value): + """Validate a JSON object against a schema.""" + try: + validate(instance=value, schema=schema) + except JSONSchemaValidationError as e: + raise ValidationError(f"Invalid JSON: {e.message}") from e + + return validator + + +def validate_company_schema(value): + """Validate a company JSON object against the company schema.""" + validator = json_schema_validator(company_schema) + return validator(value) + + +def validate_representative_schema(value): + """Validate a representative JSON object against the representative schema.""" + validator = json_schema_validator(representative_schema) + return validator(value) + + +def validate_control_authority_schema(value): + """Validate a control authority JSON object against the control authority schema.""" + validator = json_schema_validator(control_authority_schema) + return validator(value) 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..18562acc --- /dev/null +++ b/src/dashboard/apps/core/tests/test_validators.py @@ -0,0 +1,58 @@ +"""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", "", 12345], +) +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..8cde34c9 --- /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 _ + + +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 = _( + "The SIRET must be composed only of numbers and must " + "contain exactly 14 digits." + ) + + if value is None: + return + + if not isinstance(value, str): + raise ValidationError(error_message) + + if not re.match(r"^\d{14}$", value): + raise ValidationError(error_message) + + +def validate_naf_code(value: str | None) -> None: + """Validate a NAF code. + + NAF code must respect the format "####A" (4 digits + 1 letter). + """ + error_message = _( + "The NAF code must be in the format of 4 digits " + "followed by a letter (e.g.: 6820A)." + ) + + if value is None: + return + + if not isinstance(value, str): + raise ValidationError(error_message) + + if not re.match(r"^\d{4}[A-Za-z]$", value): + raise ValidationError(error_message) + + +def validate_zip_code(value: str | 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