diff --git a/lemarche/siaes/management/commands/sync_with_emplois_inclusion.py b/lemarche/siaes/management/commands/sync_with_emplois_inclusion.py index 930f1fa41..8cdd26ca4 100644 --- a/lemarche/siaes/management/commands/sync_with_emplois_inclusion.py +++ b/lemarche/siaes/management/commands/sync_with_emplois_inclusion.py @@ -1,9 +1,11 @@ +import logging import os import re from django.conf import settings from django.contrib.gis.geos import GEOSGeometry from django.core.management.base import CommandError +from django.db.models import Q from django.utils import timezone from stdnum.fr import siret @@ -15,10 +17,13 @@ from lemarche.utils.data import rename_dict_key +logger = logging.getLogger(__name__) + + UPDATE_FIELDS = [ # "name", # what happens to the slug if the name is updated? - "brand", - # "kind" + # "brand", # see UPDATE_FIELDS_IF_EMPTY + "kind", "siret", "siret_is_valid", "naf", @@ -39,6 +44,8 @@ "c1_last_sync_date", ] +UPDATE_FIELDS_IF_EMPTY = ["brand"] + C1_EXTRA_KEYS = ["convention_is_active", "convention_asp_id"] @@ -206,9 +213,13 @@ def filter_c1_export(self, c1_list): c1_list_filtered = [] for c1_siae in c1_list: - if c1_siae["kind"] not in ("RESERVED",): - c1_list_filtered.append(c1_siae) - + if c1_siae["kind"] not in ("RESERVED",): # do nothing if kind is filtered as reserved + if c1_siae["kind"] in siae_constants.KIND_INSERTION_LIST + siae_constants.KIND_HANDICAP_LIST: + c1_list_filtered.append(c1_siae) + else: + logger.error( + f"Kind not supported: {c1_siae['kind']}/{c1_siae['id']}/{c1_siae['name']}/{c1_siae['siret']}" + ) return c1_list_filtered def c4_update(self, c1_list, dry_run): @@ -243,11 +254,21 @@ def c4_create_siae(self, c1_siae, dry_run): c1_siae["contact_email"] = c1_siae["admin_email"] or c1_siae["email"] c1_siae["contact_phone"] = c1_siae["phone"] - # create object + # create object if brand is empty or not already used if not dry_run: - siae = Siae.objects.create(**c1_siae) - self.add_siae_to_contact_list(siae) - self.stdout_info(f"New Siae created / {siae.id} / {siae.name} / {siae.siret}") + if ( + "brand" not in c1_siae + or c1_siae["brand"] == "" + or not Siae.objects.filter(Q(name=c1_siae["brand"]) | Q(brand=c1_siae["brand"])).exists() + ): + siae = Siae.objects.create(**c1_siae) + + self.add_siae_to_contact_list(siae) + self.stdout_info(f"New Siae created / {siae.id} / {siae.name} / {siae.siret}") + else: + logger.error( + f"Brand name is already used by another SIAE: '{c1_siae['brand']}' / name: '{c1_siae['name']}'" + ) def add_siae_to_contact_list(self, siae: Siae): if siae.kind != "OPCS" and siae.is_active: @@ -272,5 +293,23 @@ def c4_update_siae(self, c1_siae, c4_siae, dry_run): if key in c1_siae: c1_siae_filtered[key] = c1_siae[key] - Siae.objects.filter(c1_id=c4_siae.c1_id).update(**c1_siae_filtered) # avoid updated_at change + # update fields only if empty + for key in UPDATE_FIELDS_IF_EMPTY: + if key in c1_siae and not getattr(c4_siae, key, None): + c1_siae_filtered[key] = c1_siae[key] + + # update siae only if brand is empty or not already used + if ( + "brand" not in c1_siae_filtered + or c1_siae_filtered["brand"] == "" + or not Siae.objects.exclude(c1_id=c4_siae.c1_id) + .filter(Q(name=c1_siae_filtered["brand"]) | Q(brand=c1_siae_filtered["brand"])) + .exists() + ): + Siae.objects.filter(c1_id=c4_siae.c1_id).update(**c1_siae_filtered) # avoid updated_at change + else: + logger.error( + f"Brand name is already used by another SIAE: '{c1_siae['brand']}' / name: '{c1_siae['name']}'" + ) + # self.stdout_info(f"Siae updated / {c4_siae.id} / {c4_siae.siret}") diff --git a/lemarche/siaes/migrations/0078_alter_historicalsiae_brand_alter_siae_brand.py b/lemarche/siaes/migrations/0078_alter_historicalsiae_brand_alter_siae_brand.py new file mode 100644 index 000000000..7fac2e2c1 --- /dev/null +++ b/lemarche/siaes/migrations/0078_alter_historicalsiae_brand_alter_siae_brand.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.15 on 2024-11-27 16:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("siaes", "0077_remove_siaeactivity_location_siaeactivity_locations_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalsiae", + name="brand", + field=models.CharField(blank=True, max_length=255, verbose_name="Nom commercial"), + ), + migrations.AlterField( + model_name="siae", + name="brand", + field=models.CharField(blank=True, max_length=255, verbose_name="Nom commercial"), + ), + ] diff --git a/lemarche/siaes/models.py b/lemarche/siaes/models.py index 73673de74..646fdcbd0 100644 --- a/lemarche/siaes/models.py +++ b/lemarche/siaes/models.py @@ -6,6 +6,7 @@ from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.measure import D from django.contrib.postgres.search import TrigramSimilarity # SearchVector +from django.core.exceptions import ValidationError from django.db import IntegrityError, models, transaction from django.db.models import ( BooleanField, @@ -536,7 +537,7 @@ class Siae(models.Model): FIELDS_FROM_C1 = [ "name", "slug", # generated from 'name' - "brand", + # "brand", # see UPDATE_FIELDS_IF_EMPTY in management/commands/sync_with_emplois_inclusion.py "siret", "naf", "website", @@ -617,7 +618,7 @@ class Siae(models.Model): name = models.CharField(verbose_name="Raison sociale", max_length=255) slug = models.SlugField(verbose_name="Slug", max_length=255, unique=True) - brand = models.CharField(verbose_name="Enseigne", max_length=255, blank=True) + brand = models.CharField(verbose_name="Nom commercial", max_length=255, blank=True) kind = models.CharField( verbose_name="Type de structure", max_length=6, @@ -1245,6 +1246,18 @@ def set_super_badge(self): self.save(update_fields=update_fields_list) + def clean(self): + """ + Validate that brand is not used as a brand or name by another Siae + Does not use a unique constraint on model because it allows blank values and checks two fields simultaneously. + """ + super().clean() + if self.brand: + # Check if brand is used as name by another Siae + name_exists = Siae.objects.exclude(id=self.id).filter(Q(name=self.brand) | Q(brand=self.brand)).exists() + if name_exists: + raise ValidationError({"brand": "Ce nom commercial est déjà utilisé par une autre structure."}) + @receiver(post_save, sender=Siae) def siae_post_save(sender, instance, **kwargs): diff --git a/lemarche/siaes/tests/test_commands.py b/lemarche/siaes/tests/test_commands.py index 60c7acd86..b78cc1541 100644 --- a/lemarche/siaes/tests/test_commands.py +++ b/lemarche/siaes/tests/test_commands.py @@ -1,4 +1,8 @@ import factory +import logging +import os +from unittest.mock import patch + from django.core.management import call_command from django.db.models import signals from django.test import TransactionTestCase @@ -8,10 +12,302 @@ from lemarche.sectors.factories import SectorFactory from lemarche.siaes import constants as siae_constants from lemarche.siaes.factories import SiaeActivityFactory, SiaeFactory -from lemarche.siaes.models import SiaeActivity +from lemarche.siaes.models import Siae, SiaeActivity from lemarche.users.factories import UserFactory +class SyncWithEmploisInclusionCommandTest(TransactionTestCase): + def setUp(self): + logging.disable(logging.DEBUG) # setting in tests disables all logging by default + + @patch("lemarche.utils.apis.api_emplois_inclusion.get_siae_list") + def test_sync_with_emplois_inclusion_create_new_siae(self, mock_get_siae_list): + # Mock API response for a new SIAE + mock_get_siae_list.return_value = [ + { + "id": 123, + "siret": "12345678901234", + "naf": "8899B", + "kind": "EI", + "name": "New SIAE", + "brand": "", + "phone": "", + "email": "", + "website": "", + "description": "", + "address_line_1": "1 rue Test", + "address_line_2": "", + "post_code": "37000", + "city": "Tours", + "department": "37", + "source": "ASP", + "latitude": 0, + "longitude": 0, + "convention_is_active": True, + "convention_asp_id": 0, + "admin_name": "", + "admin_email": "", + } + ] + + # Verify SIAE doesn't exist + self.assertEqual(Siae.objects.count(), 0) + + # Run command + os.environ["API_EMPLOIS_INCLUSION_TOKEN"] = "test" + with self.assertNoLogs("lemarche.siaes.management.commands.sync_with_emplois_inclusion"): + call_command("sync_with_emplois_inclusion") + + # Verify SIAE was created + self.assertEqual(Siae.objects.count(), 1) + siae = Siae.objects.first() + self.assertEqual(siae.siret, "12345678901234") + self.assertEqual(siae.name, "New SIAE") + self.assertEqual(siae.brand, "") + + @patch("lemarche.utils.apis.api_emplois_inclusion.get_siae_list") + def test_sync_with_emplois_inclusion_update_existing_siae(self, mock_get_siae_list): + # Create existing SIAE + existing_siae = SiaeFactory(c1_id=123, siret="12345678901234", kind=siae_constants.KIND_EI) + + return_value = [ + { + "id": 123, + "siret": "12345678901234", + "naf": "8899B", + "kind": "EI", + "name": "New SIAE", + "brand": "Updated Name", + "phone": "", + "email": "", + "website": "", + "description": "", + "address_line_1": "2 rue Test", + "address_line_2": "", + "post_code": "69001", + "city": "Lyon", + "department": "69", + "source": "ASP", + "latitude": 0, + "longitude": 0, + "convention_is_active": True, + "convention_asp_id": 0, + "admin_name": "", + "admin_email": "", + } + ] + + # Mock API response with updated data + mock_get_siae_list.return_value = return_value + + # Run command + os.environ["API_EMPLOIS_INCLUSION_TOKEN"] = "test" + with self.assertNoLogs("lemarche.siaes.management.commands.sync_with_emplois_inclusion"): + call_command("sync_with_emplois_inclusion") + + # Verify SIAE was updated + self.assertEqual(Siae.objects.count(), 1) + updated_siae = Siae.objects.get(id=existing_siae.id) + self.assertEqual(updated_siae.brand, "Updated Name") # update first time + self.assertEqual(updated_siae.address, "2 rue Test") + self.assertEqual(updated_siae.post_code, "69001") + self.assertEqual(updated_siae.city, "Lyon") + self.assertEqual(updated_siae.department, "69") + + # Mock API response with updated data for the same SIAE with different brand name + return_value[0]["brand"] = "Other Name" + mock_get_siae_list.return_value = return_value + + # Run command + with self.assertNoLogs("lemarche.siaes.management.commands.sync_with_emplois_inclusion"): + call_command("sync_with_emplois_inclusion") + + # Verify SIAE was updated + self.assertEqual(Siae.objects.count(), 1) + updated_siae.refresh_from_db() + self.assertEqual(updated_siae.brand, "Updated Name") # Brand name can only be updated once + + @patch("lemarche.utils.apis.api_emplois_inclusion.get_siae_list") + def test_sync_with_emplois_inclusion_with_duplicate_brand_name_on_create(self, mock_get_siae_list): + # Create existing SIAE with the same brand name + SiaeFactory(siret="98765432101233", brand="Duplicate Brand", kind=siae_constants.KIND_EI) + + # Mock API response with duplicate brand name + mock_get_siae_list.return_value = [ + { + "id": 123, + "siret": "12345678901234", + "naf": "8899B", + "kind": "EI", + "name": "New SIAE", + "brand": "Duplicate Brand", + "phone": "", + "email": "", + "website": "", + "description": "", + "address_line_1": "1 rue Test", + "address_line_2": "", + "post_code": "37000", + "city": "Tours", + "department": "37", + "source": "ASP", + "latitude": 0, + "longitude": 0, + "convention_is_active": True, + "convention_asp_id": 0, + "admin_name": "", + "admin_email": "", + } + ] + + # Run command (should not raise exception) + os.environ["API_EMPLOIS_INCLUSION_TOKEN"] = "test" + with self.assertLogs("lemarche.siaes.management.commands.sync_with_emplois_inclusion", level="ERROR") as log: + call_command("sync_with_emplois_inclusion") + + # Verify warning was logged + self.assertIn("Brand name is already used by another SIAE", log.output[0]) + + # Verify both SIAEs exist + self.assertEqual(Siae.objects.count(), 1) + + @patch("lemarche.utils.apis.api_emplois_inclusion.get_siae_list") + def test_sync_with_emplois_inclusion_with_duplicate_brand_name_on_update(self, mock_get_siae_list): + # Create existing SIAE with the same brand name + SiaeFactory(siret="98765432101233", brand="Duplicate Brand", kind=siae_constants.KIND_EI) + SiaeFactory(siret="98765432101234", c1_id=123, kind=siae_constants.KIND_EI) + + self.assertEqual(Siae.objects.count(), 2) + + # Mock API response with duplicate brand name + mock_get_siae_list.return_value = [ + { + "id": 123, + "siret": "12345678901234", + "naf": "8899B", + "kind": "EI", + "name": "New SIAE", + "brand": "Duplicate Brand", + "phone": "", + "email": "", + "website": "", + "description": "", + "address_line_1": "1 rue Test", + "address_line_2": "", + "post_code": "37000", + "city": "Tours", + "department": "37", + "source": "ASP", + "latitude": 0, + "longitude": 0, + "convention_is_active": True, + "convention_asp_id": 0, + "admin_name": "", + "admin_email": "", + }, + { + "id": 124, + "siret": "12345678901235", + "naf": "8899B", + "kind": "EI", + "name": "Other New SIAE", + "brand": "", + "phone": "", + "email": "", + "website": "", + "description": "", + "address_line_1": "1 rue Test", + "address_line_2": "", + "post_code": "37000", + "city": "Tours", + "department": "37", + "source": "ASP", + "latitude": 0, + "longitude": 0, + "convention_is_active": True, + "convention_asp_id": 0, + "admin_name": "", + "admin_email": "", + }, + ] + + # Run command (should not raise exception) + os.environ["API_EMPLOIS_INCLUSION_TOKEN"] = "test" + with self.assertLogs("lemarche.siaes.management.commands.sync_with_emplois_inclusion", level="ERROR") as log: + call_command("sync_with_emplois_inclusion") + + # Verify warning was logged + self.assertIn("Brand name is already used by another SIAE: 'Duplicate Brand'", log.output[0]) + + # Verify both SIAEs exist + self.assertEqual(Siae.objects.count(), 3) + self.assertEqual(Siae.objects.filter(brand="Duplicate Brand").count(), 1) + + self.assertEqual(Siae.objects.filter(name="Other New SIAE").count(), 1) # error logged but sync continued + + @patch("lemarche.utils.apis.api_emplois_inclusion.get_siae_list") + def test_sync_with_emplois_inclusion_with_kind_not_supported(self, mock_get_siae_list): + mock_get_siae_list.return_value = [ + { + "id": 123, + "siret": "12345678901234", + "kind": "FAKE", + "name": "Fake SIAE", + "naf": "8899B", + "brand": "", + "phone": "", + "email": "", + "website": "", + "description": "", + "address_line_1": "1 rue Test", + "address_line_2": "", + "post_code": "37000", + "city": "Tours", + "department": "37", + "source": "ASP", + "latitude": 0, + "longitude": 0, + "convention_is_active": True, + "convention_asp_id": 0, + "admin_name": "", + "admin_email": "", + }, + { + "id": 124, + "siret": "12345678901235", + "naf": "8899B", + "kind": "EI", + "name": "Other SIAE", + "brand": "", + "phone": "", + "email": "", + "website": "", + "description": "", + "address_line_1": "1 rue Test", + "address_line_2": "", + "post_code": "37000", + "city": "Tours", + "department": "37", + "source": "ASP", + "latitude": 0, + "longitude": 0, + "convention_is_active": True, + "convention_asp_id": 0, + "admin_name": "", + "admin_email": "", + }, + ] + os.environ["API_EMPLOIS_INCLUSION_TOKEN"] = "test" + with self.assertLogs("lemarche.siaes.management.commands.sync_with_emplois_inclusion", level="ERROR") as log: + call_command("sync_with_emplois_inclusion") + + self.assertIn("Kind not supported: FAKE", log.output[0]) + + # Verify only one SIAE was created to check if the sync was not interrupted + self.assertEqual(Siae.objects.count(), 1) + self.assertEqual(Siae.objects.first().name, "Other SIAE") + + class SiaeActivitiesCreateCommandTest(TransactionTestCase): def setUp(self): self.sector1 = SectorFactory() diff --git a/lemarche/templates/dashboard/siae_edit_info.html b/lemarche/templates/dashboard/siae_edit_info.html index ec6417c80..c875bf82c 100644 --- a/lemarche/templates/dashboard/siae_edit_info.html +++ b/lemarche/templates/dashboard/siae_edit_info.html @@ -1,235 +1,245 @@ {% extends "dashboard/siae_edit_base.html" %} {% load static dsfr_tags get_verbose_name %} - {% block extra_css %} - + {% endblock extra_css %} - {% block content_siae_form %} -