From 0bc0f40a3cf6971baab4ebd507b7980406502d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Reuiller?= Date: Mon, 2 Dec 2024 18:16:54 +0100 Subject: [PATCH] check brand uniqueness in sync command --- .../commands/sync_with_emplois_inclusion.py | 40 ++- lemarche/siaes/tests/test_commands.py | 236 +++++++++++++++++- lemarche/utils/apis/api_emplois_inclusion.py | 1 + 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/lemarche/siaes/management/commands/sync_with_emplois_inclusion.py b/lemarche/siaes/management/commands/sync_with_emplois_inclusion.py index d3fba8636..8993ae7d1 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,6 +17,9 @@ 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", # see UPDATE_FIELDS_IF_EMPTY @@ -245,11 +250,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: @@ -276,8 +291,21 @@ def c4_update_siae(self, c1_siae, c4_siae, dry_run): # update fields only if empty for key in UPDATE_FIELDS_IF_EMPTY: - if key in c1_siae and not c4_siae[key]: + if key in c1_siae and not getattr(c4_siae, key, None): 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 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/tests/test_commands.py b/lemarche/siaes/tests/test_commands.py index 1da10b327..987373b89 100644 --- a/lemarche/siaes/tests/test_commands.py +++ b/lemarche/siaes/tests/test_commands.py @@ -1,3 +1,7 @@ +import logging +import os +from unittest.mock import patch + from django.core.management import call_command from django.test import TransactionTestCase @@ -6,7 +10,237 @@ from lemarche.sectors.factories import SectorFactory from lemarche.siaes import constants as siae_constants from lemarche.siaes.factories import SiaeFactory -from lemarche.siaes.models import SiaeActivity +from lemarche.siaes.models import Siae, SiaeActivity + + +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 class SiaeActivitiesCreateCommandTest(TransactionTestCase): diff --git a/lemarche/utils/apis/api_emplois_inclusion.py b/lemarche/utils/apis/api_emplois_inclusion.py index fa8aa4a01..593795b07 100644 --- a/lemarche/utils/apis/api_emplois_inclusion.py +++ b/lemarche/utils/apis/api_emplois_inclusion.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) +# Doc : https://emplois.inclusion.beta.gouv.fr/api/v1/redoc/#tag/marche/operation/marche_list API_ENDPOINT = f"{settings.API_EMPLOIS_INCLUSION_URL}/marche" API_HEADERS = {"Authorization": f"Token {settings.API_EMPLOIS_INCLUSION_TOKEN}"}