diff --git a/lemarche/crm/management/commands/crm_brevo_sync_companies.py b/lemarche/crm/management/commands/crm_brevo_sync_companies.py index c8f71c99a..4590066e3 100644 --- a/lemarche/crm/management/commands/crm_brevo_sync_companies.py +++ b/lemarche/crm/management/commands/crm_brevo_sync_companies.py @@ -8,7 +8,7 @@ from lemarche.utils.commands import BaseCommand -ten_days_ago = timezone.now() - timedelta(days=10) +two_weeks_ago = timezone.now() - timedelta(weeks=2) class Command(BaseCommand): @@ -34,11 +34,29 @@ def handle(self, recently_updated: bool, **options): self.stdout.write(f"Sync Siae > Brevo: we have {Siae.objects.count()} siaes") # Update only the recently updated siaes if recently_updated: - siaes_qs = siaes_qs.filter(updated_at__gte=ten_days_ago) + siaes_qs = siaes_qs.filter(updated_at__gte=two_weeks_ago) self.stdout.write(f"Sync Siae > Brevo: {siaes_qs.count()} recently updated") - # Step 2: loop on the siaes + # Step 2: Add the 90-day limited annotations + siaes_qs = siaes_qs.with_tender_stats(since_days=90) + + # Step 3: loop on the siaes for index, siae in enumerate(siaes_qs): + new_extra_data = { + "completion_rate": siae.completion_rate if siae.completion_rate is not None else 0, + "tender_received": siae.tender_email_send_count_annotated, + "tender_interest": siae.tender_detail_contact_click_count_annotated, + } + + # extra_data update if needed + if siae.extra_data.get("brevo_company_data") != new_extra_data: + siae.extra_data.update( + { + "brevo_company_data": new_extra_data, + } + ) + siae.save(update_fields=["extra_data"]) + api_brevo.create_or_update_company(siae) if (index % 10) == 0: # avoid API rate-limiting time.sleep(1) diff --git a/lemarche/crm/tests.py b/lemarche/crm/tests.py new file mode 100644 index 000000000..9dc517fcd --- /dev/null +++ b/lemarche/crm/tests.py @@ -0,0 +1,219 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from lemarche.siaes.factories import SiaeFactory +from lemarche.siaes.models import Siae +from lemarche.tenders.factories import TenderFactory +from lemarche.tenders.models import TenderSiae +from lemarche.users.factories import UserFactory +from lemarche.users.models import User + + +now = timezone.now() +date_tomorrow = now + timedelta(days=1) +old_date = timezone.now() - timedelta(days=91) +recent_date = now - timedelta(days=10) + + +class CrmBrevoSyncCompaniesCommandTest(TestCase): + @classmethod + def setUpTestData(cls): + """Siae instances initialization""" + cls.user_siae = UserFactory(kind=User.KIND_SIAE) + cls.siae_with_name = SiaeFactory(name="Test Company 1") + cls.siae_with_user = SiaeFactory(users=[cls.user_siae]) + cls.siae_with_brevo_id = SiaeFactory( + brevo_company_id="123456789", + completion_rate=50, + ) + + cls.tender_1 = TenderFactory(deadline_date=date_tomorrow) + cls.tender_2 = TenderFactory(deadline_date=date_tomorrow) + + TenderSiae.objects.create( + tender=cls.tender_1, + siae=cls.siae_with_user, + detail_contact_click_date=recent_date, + ) + + TenderSiae.objects.create( + tender=cls.tender_1, + siae=cls.siae_with_brevo_id, + email_send_date=recent_date, + detail_contact_click_date=old_date, + ) + + cls.siae_with_user_stats = Siae.objects.with_tender_stats().filter(id=cls.siae_with_user.id).first() + cls.siae_with_brevo_id_all_stats = ( + Siae.objects.with_tender_stats().filter(id=cls.siae_with_brevo_id.id).first() + ) + cls.siae_with_brevo_id_recent_stats = ( + Siae.objects.with_tender_stats(since_days=90).filter(id=cls.siae_with_brevo_id.id).first() + ) + + # siae_with_brevo_id.extra_data initialization + cls.siae_with_brevo_id.extra_data = { + "brevo_company_data": { + "completion_rate": cls.siae_with_brevo_id.completion_rate + if cls.siae_with_brevo_id.completion_rate is not None + else 0, + "tender_received": cls.siae_with_brevo_id_recent_stats.tender_email_send_count_annotated, + "tender_interest": cls.siae_with_brevo_id_recent_stats.tender_detail_contact_click_count_annotated, + } + } + cls.siae_with_brevo_id.save() + cls.initial_extra_data = cls.siae_with_brevo_id.extra_data.copy() + + def test_annotated_fields_set_up(self): + """Test annotated fields are correctly set up""" + self.assertEqual( + self.siae_with_user_stats.tender_email_send_count_annotated, + 0, + "Le nombre total de besoins reçus devrait être 0", + ) + self.assertEqual( + self.siae_with_user_stats.tender_detail_contact_click_count_annotated, + 1, + "Le nombre total de besoins intéressés devrait être 1", + ) + self.assertEqual( + self.siae_with_brevo_id_all_stats.tender_email_send_count_annotated, + 1, + "Le nombre total de besoins reçus devrait être 1", + ) + self.assertEqual( + self.siae_with_brevo_id_all_stats.tender_detail_contact_click_count_annotated, + 1, + "Le nombre total de besoins intéressés devrait être 1", + ) + self.assertEqual( + self.siae_with_brevo_id_recent_stats.tender_email_send_count_annotated, + 1, + "Le nombre de besoins reçus dans les 90 derniers jours devrait être 1", + ) + self.assertEqual( + self.siae_with_brevo_id_recent_stats.tender_detail_contact_click_count_annotated, + 0, + "Le nombre de besoins intéressés dans les 90 derniers jours devrait être 0", + ) + + @patch("lemarche.utils.apis.api_brevo.create_or_update_company") + def test_new_siaes_are_synced_in_brevo(self, mock_create_or_update_company): + """Test new siaes are synced in brevo""" + call_command("crm_brevo_sync_companies") + + self.assertEqual(mock_create_or_update_company.call_count, 3) + + def test_siae_has_tender_stats(self): + self.assertIsNotNone( + self.siae_with_user_stats, + "Cette SIAE devrait avoir des statistiques sur les besoins.", + ) + self.assertIsNotNone( + self.siae_with_brevo_id_all_stats, + "Cette SIAE devrait avoir des statistiques sur les besoins.", + ) + + def test_siae_extra_data_is_set_on_first_sync(self): + """ + - Test siae is updated if extra_data is changed. + - Test siae.extra_data update does not erase existing data. + """ + initial_extra_data = self.siae_with_user.extra_data.copy() + initial_extra_data["test_data"] = "test value" + + self.siae_with_user.extra_data = initial_extra_data + self.siae_with_user.save(update_fields=["extra_data"]) + + call_command("crm_brevo_sync_companies", recently_updated=True) + + self.siae_with_user.refresh_from_db() + + expected_extra_data = { + "brevo_company_data": { + "completion_rate": self.siae_with_user.completion_rate + if self.siae_with_user.completion_rate is not None + else 0, + "tender_received": self.siae_with_user_stats.tender_email_send_count_annotated, + "tender_interest": self.siae_with_user_stats.tender_detail_contact_click_count_annotated, + }, + "test_data": "test value", + } + + self.assertNotEqual(initial_extra_data, expected_extra_data, "siae.extra_data aurait dû être mis à jour.") + self.assertEqual( + self.siae_with_user.extra_data, expected_extra_data, "siae.extra_data n'est pas conforme aux attentes." + ) + + def test_siae_extra_data_is_not_updated_if_no_changes(self): + """Test siae.extra_data is not updated if no changes.""" + call_command("crm_brevo_sync_companies", recently_updated=True) + + self.siae_with_brevo_id.refresh_from_db() + self.assertEqual( + self.initial_extra_data, + self.siae_with_brevo_id.extra_data, + "siae.extra_data a été mis à jour alors qu'il n'y avait pas de changement.", + ) + + def test_fields_update_within_90_days_and_ignore_older_changes(self): + """Test fields update within 90 days and ignore older changes.""" + TenderSiae.objects.create( + tender=self.tender_2, + siae=self.siae_with_brevo_id, + email_send_date=now, + detail_contact_click_date=now, + ) + + call_command("crm_brevo_sync_companies", recently_updated=True) + + self.siae_with_brevo_id_all_stats = ( + Siae.objects.with_tender_stats().filter(id=self.siae_with_brevo_id.id).first() + ) + self.siae_with_brevo_id_recent_stats = ( + Siae.objects.with_tender_stats(since_days=90).filter(id=self.siae_with_brevo_id.id).first() + ) + + # Tender stats without date limit + self.assertEqual( + self.siae_with_brevo_id_all_stats.tender_email_send_count_annotated, + 2, + "Le nombre total des besoins reçus devrait être 2", + ) + self.assertEqual( + self.siae_with_brevo_id_all_stats.tender_detail_contact_click_count_annotated, + 2, + "Le nombre de bsoins interessés devrait être 2", + ) + + # Tender stats with date limit + self.assertEqual( + self.siae_with_brevo_id_recent_stats.tender_email_send_count_annotated, + 2, + "Le nombre de besoins reçus dans les 90 jours devraient être 2", + ) + self.assertEqual( + self.siae_with_brevo_id_recent_stats.tender_detail_contact_click_count_annotated, + 1, + "Les nombre de bsoins interessés dans les 90 jours devraient être 1", + ) + + expected_extra_data = { + "brevo_company_data": { + "completion_rate": self.siae_with_brevo_id.completion_rate + if self.siae_with_brevo_id.completion_rate is not None + else 0, + "tender_received": self.siae_with_brevo_id_recent_stats.tender_email_send_count_annotated, + "tender_interest": self.siae_with_brevo_id_recent_stats.tender_detail_contact_click_count_annotated, + } + } + + self.assertNotEqual( + self.initial_extra_data, + expected_extra_data, + "Les valeurs récentes dans extra_data devraient être mises à jour en fonction du filtre de 90 jours.", + ) diff --git a/lemarche/siaes/models.py b/lemarche/siaes/models.py index bd5ff0fc2..068b5a845 100644 --- a/lemarche/siaes/models.py +++ b/lemarche/siaes/models.py @@ -1,3 +1,4 @@ +from datetime import timedelta from uuid import uuid4 from django.conf import settings @@ -78,6 +79,24 @@ def get_city_filter(perimeter, with_country=False): return filters +def count_field(field_name, date_limit): + """ + Helper method to construct a conditional count annotation. + """ + condition = ( + Q(**{f"tendersiae__{field_name}__gte": date_limit}) + if date_limit + else Q(**{f"tendersiae__{field_name}__isnull": False}) + ) + return Sum( + Case( + When(condition, then=1), + default=0, + output_field=IntegerField(), + ) + ) + + class SiaeGroupQuerySet(models.QuerySet): def with_siae_stats(self): return self.annotate(siae_count_annotated=Count("siaes", distinct=True)) @@ -425,41 +444,20 @@ def filter_with_tender_tendersiae_status(self, tender, tendersiae_status=None): return qs.distinct() - def with_tender_stats(self): + def with_tender_stats(self, since_days=None): """ - Enrich each Siae with stats on their linked Tender + Enrich each Siae with stats on their linked Tender. + Optionally, limit the stats to the last `since_days` days. """ + date_limit = timezone.now() - timedelta(days=since_days) if since_days else None + return self.annotate( tender_count_annotated=Count("tenders", distinct=True), - tender_email_send_count_annotated=Sum( - Case(When(tendersiae__email_send_date__isnull=False, then=1), default=0, output_field=IntegerField()) - ), - tender_email_link_click_count_annotated=Sum( - Case( - When(tendersiae__email_link_click_date__isnull=False, then=1), - default=0, - output_field=IntegerField(), - ) - ), - tender_detail_display_count_annotated=Sum( - Case( - When(tendersiae__detail_display_date__isnull=False, then=1), default=0, output_field=IntegerField() - ) - ), - tender_detail_contact_click_count_annotated=Sum( - Case( - When(tendersiae__detail_contact_click_date__isnull=False, then=1), - default=0, - output_field=IntegerField(), - ) - ), - tender_detail_not_interested_count_annotated=Sum( - Case( - When(tendersiae__detail_not_interested_click_date__isnull=False, then=1), - default=0, - output_field=IntegerField(), - ) - ), + tender_email_send_count_annotated=count_field("email_send_date", date_limit), + tender_email_link_click_count_annotated=count_field("email_link_click_date", date_limit), + tender_detail_display_count_annotated=count_field("detail_display_date", date_limit), + tender_detail_contact_click_count_annotated=count_field("detail_contact_click_date", date_limit), + tender_detail_not_interested_count_annotated=count_field("detail_not_interested_click_date", date_limit), ) def with_brand_or_name(self, with_order_by=False): diff --git a/lemarche/utils/apis/api_brevo.py b/lemarche/utils/apis/api_brevo.py index b85317879..fd940eadc 100644 --- a/lemarche/utils/apis/api_brevo.py +++ b/lemarche/utils/apis/api_brevo.py @@ -146,6 +146,9 @@ def create_or_update_company(siae): "geo_range": siae.geo_range, "app_url": get_object_share_url(siae), "app_admin_url": get_object_admin_url(siae), + "taux_de_completion": siae.extra_data.get("brevo_company_data", {}).get("completion_rate"), + "nombre_de_besoins_recus": siae.extra_data.get("brevo_company_data", {}).get("tender_received"), + "nombre_de_besoins_interesses": siae.extra_data.get("brevo_company_data", {}).get("tender_interest"), }, )