diff --git a/lemarche/siaes/models.py b/lemarche/siaes/models.py index 7e7d08b20..adcdc1e25 100644 --- a/lemarche/siaes/models.py +++ b/lemarche/siaes/models.py @@ -493,6 +493,11 @@ def with_employees_stats(self): ), ) + def order_by_super_siaes(self): + return self.order_by( + "-super_badge", "-tender_detail_contact_click_count", "-tender_detail_display_count", "-completion_rate" + ) + class Siae(models.Model): FIELDS_FROM_C1 = [ diff --git a/lemarche/templates/tenders/_detail_admin_extra_info.html b/lemarche/templates/tenders/_detail_admin_extra_info.html index d4365f86e..bb5485fa1 100644 --- a/lemarche/templates/tenders/_detail_admin_extra_info.html +++ b/lemarche/templates/tenders/_detail_admin_extra_info.html @@ -8,7 +8,7 @@

Informations Admin

  • {% if tender.is_sent %} - Validé le {{ tender.sent_at|date }} + Validé le {{ tender.first_sent_at|date }} {% else %} Statut : {{ tender.get_status_display }} {% endif %} diff --git a/lemarche/templates/tenders/_list_item_buyer.html b/lemarche/templates/tenders/_list_item_buyer.html index 9887e8407..0bc2fdce5 100644 --- a/lemarche/templates/tenders/_list_item_buyer.html +++ b/lemarche/templates/tenders/_list_item_buyer.html @@ -36,7 +36,7 @@

    {{ tender.title }}

    {% if tender.is_sent %}
    - Publié le {{ tender.sent_at|date }} + Publié le {{ tender.first_sent_at|date }}
    {% endif %} diff --git a/lemarche/templates/tenders/admin_change_form.html b/lemarche/templates/tenders/admin_change_form.html index dd8c3266b..4d053a7c0 100644 --- a/lemarche/templates/tenders/admin_change_form.html +++ b/lemarche/templates/tenders/admin_change_form.html @@ -39,8 +39,8 @@
    {% if original.validated_at %} Validé le {{ original.validated_at }}.  - {% if original.sent_at %} - Envoyé le {{ original.sent_at }}. + {% if original.first_sent_at %} + Envoyé le {{ original.first_sent_at }}. {% endif %} {% else %} diff --git a/lemarche/tenders/admin.py b/lemarche/tenders/admin.py index 987def9f6..2ca949351 100644 --- a/lemarche/tenders/admin.py +++ b/lemarche/tenders/admin.py @@ -135,7 +135,9 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin): "siae_transactioned", "created_at", "validated_at", - "sent_at", + "first_sent_at", + "limit_send_to_siae_batch", + "limit_nb_siae_interested", ] list_filter = [ @@ -206,6 +208,7 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin): }, ), TenderQuestionInline, + ("Paramètres d'envois", {"fields": ("limit_send_to_siae_batch", "limit_nb_siae_interested")}), ( "Filtres", { @@ -312,7 +315,7 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin): "status", "published_at", "validated_at", - "sent_at", + "first_sent_at", ) }, ), diff --git a/lemarche/tenders/factories.py b/lemarche/tenders/factories.py index 2ffa66732..48e0d87f6 100644 --- a/lemarche/tenders/factories.py +++ b/lemarche/tenders/factories.py @@ -44,7 +44,7 @@ class Meta: # marche_benefits = factory.fuzzy.FuzzyChoice([key for (key, _) in constants.MARCHE_BENEFIT_CHOICES]) status = tender_constants.STATUS_SENT validated_at = timezone.now() - sent_at = timezone.now() + first_sent_at = timezone.now() @factory.post_generation def perimeters(self, create, extracted, **kwargs): diff --git a/lemarche/tenders/management/commands/send_author_incremental_emails.py b/lemarche/tenders/management/commands/send_author_incremental_emails.py index 585e0df1b..3451a4be3 100644 --- a/lemarche/tenders/management/commands/send_author_incremental_emails.py +++ b/lemarche/tenders/management/commands/send_author_incremental_emails.py @@ -29,8 +29,8 @@ def handle(self, dry_run=False, **options): two_days_ago = timezone.now() - timedelta(days=2) three_days_ago = timezone.now() - timedelta(days=3) tender_sent_incremental = Tender.objects.sent().is_incremental() - tender_sent_incremental_2_days = tender_sent_incremental.filter(sent_at__gte=three_days_ago).filter( - sent_at__lt=two_days_ago + tender_sent_incremental_2_days = tender_sent_incremental.filter(first_sent_at__gte=three_days_ago).filter( + first_sent_at__lt=two_days_ago ) self.stdout.write(f"Found {tender_sent_incremental_2_days.count()} Tenders") diff --git a/lemarche/tenders/management/commands/send_validated_tenders.py b/lemarche/tenders/management/commands/send_validated_tenders.py index 59d921b88..a15deddd5 100644 --- a/lemarche/tenders/management/commands/send_validated_tenders.py +++ b/lemarche/tenders/management/commands/send_validated_tenders.py @@ -22,4 +22,5 @@ def handle(self, *args, **options): if validated_tenders_to_send.count(): self.stdout.write(f"Found {validated_tenders_to_send.count()} validated tender(s) to send") for tender in validated_tenders_to_send: + self.stdout.write(f"Found {tender} ") send_validated_tender(tender) diff --git a/lemarche/tenders/migrations/0065_tender_limit_nb_siae_interested_and_more.py b/lemarche/tenders/migrations/0065_tender_limit_nb_siae_interested_and_more.py new file mode 100644 index 000000000..5fefaebd9 --- /dev/null +++ b/lemarche/tenders/migrations/0065_tender_limit_nb_siae_interested_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.2 on 2023-12-11 15:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tenders", "0064_tender_status_sent"), + ] + + operations = [ + migrations.AddField( + model_name="tender", + name="limit_nb_siae_interested", + field=models.PositiveSmallIntegerField( + default=5, help_text="Champ renseigné par un ADMIN", verbose_name="Limite des SIAES intéressées" + ), + ), + migrations.AddField( + model_name="tender", + name="limit_send_to_siae_batch", + field=models.PositiveSmallIntegerField( + default=10, help_text="Champ renseigné par un ADMIN", verbose_name="Nombre de SIAES par envoi" + ), + ), + migrations.RenameField( + model_name="tender", + old_name="sent_at", + new_name="first_sent_at", + ), + migrations.AlterField( + model_name="tender", + name="first_sent_at", + field=models.DateTimeField(blank=True, null=True, verbose_name="Date du premier envoi"), + ), + migrations.AddField( + model_name="tender", + name="last_sent_at", + field=models.DateTimeField(blank=True, null=True, verbose_name="Date du dernier envoi"), + ), + migrations.AddField( + model_name="tender", + name="version", + field=models.PositiveIntegerField(default=0, verbose_name="Version"), + ), + # this is migration to manage the existants stock + migrations.AlterField( + model_name="tender", + name="version", + field=models.PositiveIntegerField(default=1, verbose_name="Version"), + ), + ] diff --git a/lemarche/tenders/models.py b/lemarche/tenders/models.py index 5ad432449..55d47b070 100644 --- a/lemarche/tenders/models.py +++ b/lemarche/tenders/models.py @@ -45,10 +45,21 @@ def validated(self): return self.filter(validated_at__isnull=False) def validated_but_not_sent(self): - return self.filter(validated_at__isnull=False).filter(sent_at__isnull=True) + yesterday = timezone.now() - timedelta(days=1) + + return self.with_siae_stats().filter( + (Q(version=0) & Q(validated_at__isnull=False) & Q(first_sent_at__isnull=True)) + | ( + Q(version=1) + & Q(validated_at__isnull=False) + & Q(siae_detail_contact_click_count_annotated__lte=F("limit_nb_siae_interested")) + & ~Q(siae_count_annotated=F("siae_email_send_count_annotated")) + & (Q(first_sent_at__isnull=True) | Q(last_sent_at__lt=yesterday)) + ) + ) def sent(self): - return self.filter(sent_at__isnull=False) + return self.filter(first_sent_at__isnull=False) def is_incremental(self): return self.filter( @@ -216,7 +227,7 @@ class Tender(models.Model): FIELDS_STATS_TIMESTAMPS = [ "published_at", "validated_at", - "sent_at", + "first_sent_at", "siae_list_last_seen_date", "created_at", "updated_at", @@ -417,8 +428,8 @@ class Tender(models.Model): default=tender_constants.STATUS_DRAFT, ) validated_at = models.DateTimeField("Date de validation", blank=True, null=True) - sent_at = models.DateTimeField("Date d'envoi", blank=True, null=True) - + first_sent_at = models.DateTimeField("Date du premier envoi", blank=True, null=True) + last_sent_at = models.DateTimeField("Date du dernier envoi", blank=True, null=True) # admin notes = GenericRelation("notes.Note", related_query_name="tender") siae_transactioned = models.BooleanField( @@ -437,7 +448,17 @@ class Tender(models.Model): null=True, default=None, ) + limit_send_to_siae_batch = models.PositiveSmallIntegerField( + verbose_name="Nombre de SIAES par envoi", + help_text="Champ renseigné par un ADMIN", + default=10, + ) + limit_nb_siae_interested = models.PositiveSmallIntegerField( + verbose_name="Limite des SIAES intéressées", + help_text="Champ renseigné par un ADMIN", + default=5, + ) # stats siae_count = models.IntegerField( "Nombre de structures concernées", help_text=RECALCULATED_FIELD_HELP_TEXT, default=0 @@ -468,6 +489,7 @@ class Tender(models.Model): choices=tender_constants.SOURCE_CHOICES, default=tender_constants.SOURCE_FORM, ) + version = models.PositiveIntegerField(verbose_name="Version", default=1) extra_data = models.JSONField(verbose_name="Données complémentaires", editable=False, default=dict) import_raw_object = models.JSONField(verbose_name="Données d'import", editable=False, null=True) @@ -692,7 +714,7 @@ def is_pending_validation_or_validated(self) -> bool: @property def is_sent(self) -> bool: - return bool(self.sent_at) and self.status == tender_constants.STATUS_SENT + return bool(self.first_sent_at) and self.status == tender_constants.STATUS_SENT @property def is_validated_or_sent(self) -> bool: @@ -709,11 +731,14 @@ def set_validated(self): self.save() def set_sent(self): - self.sent_at = timezone.now() - self.status = tender_constants.STATUS_SENT + if not self.first_sent_at: + self.first_sent_at = timezone.now() + self.status = tender_constants.STATUS_SENT + + self.last_sent_at = timezone.now() log_item = { "action": "send", - "date": self.sent_at.isoformat(), + "date": self.last_sent_at.isoformat(), } self.logs.append(log_item) self.save() diff --git a/lemarche/tenders/tests.py b/lemarche/tenders/tests.py index 67421793f..54eecf1bc 100644 --- a/lemarche/tenders/tests.py +++ b/lemarche/tenders/tests.py @@ -109,7 +109,7 @@ def test_status(self): tender_pending_validation = TenderFactory(status=tender_constants.STATUS_PUBLISHED) tender_validated_half = TenderFactory(status=tender_constants.STATUS_VALIDATED) tender_validated_full = TenderFactory(status=tender_constants.STATUS_VALIDATED, validated_at=timezone.now()) - tender_sent = TenderFactory(status=tender_constants.STATUS_SENT, sent_at=timezone.now()) + tender_sent = TenderFactory(status=tender_constants.STATUS_SENT, first_sent_at=timezone.now()) self.assertTrue(tender_draft.is_draft, True) self.assertTrue(tender_pending_validation.is_pending_validation, True) self.assertTrue(tender_validated_half.is_validated, False) @@ -198,8 +198,8 @@ def test_validated(self): self.assertEqual(Tender.objects.validated().count(), 1) def test_sent(self): - TenderFactory(sent_at=timezone.now()) - TenderFactory(sent_at=None) + TenderFactory(first_sent_at=timezone.now()) + TenderFactory(first_sent_at=None) self.assertEqual(Tender.objects.sent().count(), 1) def test_is_live(self): @@ -339,7 +339,7 @@ def setUpTestData(cls): TenderQuestionFactory(tender=cls.tender_with_siae_1) def test_filter_with_siaes(self): - self.tender_with_siae_2.sent_at = None + self.tender_with_siae_2.first_sent_at = None self.tender_with_siae_2.save() # tender_with_siae_2 is not sent self.assertEqual(Tender.objects.filter_with_siaes(self.user_siae.siaes.all()).count(), 1) diff --git a/lemarche/www/tenders/tasks.py b/lemarche/www/tenders/tasks.py index 094a44556..6cd7d8bcf 100644 --- a/lemarche/www/tenders/tasks.py +++ b/lemarche/www/tenders/tasks.py @@ -1,3 +1,4 @@ +import logging from datetime import timedelta from django.conf import settings @@ -14,9 +15,13 @@ from lemarche.utils.urls import get_admin_url_object, get_domain_url, get_share_url_object +logger = logging.getLogger(__name__) + + def send_validated_tender(tender: Tender): # find the matching Siaes? done in Tender post_save signal # notify author + # TODO: we still notify author for each send ? send_confirmation_published_email_to_author(tender, nb_matched_siaes=tender.siaes.count()) # send the tender to all matching Siaes & Partners send_tender_emails_to_siaes(tender) @@ -54,7 +59,9 @@ def send_tender_emails_to_siaes(tender: Tender): siae_users_send_count = 0 # queryset - siaes = tender.siaes.all() + all_siaes = tender.siaes.filter(tendersiae__email_send_date=None).order_by_super_siaes() + logger.info(f"total siaes {all_siaes.count()}") + siaes = all_siaes[: tender.limit_send_to_siae_batch] for siae in siaes: # send to siae 'contact_email' @@ -65,8 +72,9 @@ def send_tender_emails_to_siaes(tender: Tender): if user.email != siae.contact_email: send_tender_email_to_siae(tender, siae, email_subject, email_to_override=user.email) siae_users_send_count += 1 - - tender.tendersiae_set.update(email_send_date=timezone.now(), updated_at=timezone.now()) + TenderSiae.objects.filter(tender=tender, siae__in=siaes).update( + email_send_date=timezone.now(), updated_at=timezone.now() + ) # log email batch siaes_log_item = { @@ -76,6 +84,8 @@ def send_tender_emails_to_siaes(tender: Tender): "email_timestamp": timezone.now().isoformat(), } tender.logs.append(siaes_log_item) + logger.info(siaes_log_item) + siae_users_log_item = { "action": "email_siae_users_matched", "email_subject": email_subject, @@ -84,6 +94,8 @@ def send_tender_emails_to_siaes(tender: Tender): "siae_users_count": siae_users_count, } tender.logs.append(siae_users_log_item) + logger.info(siae_users_log_item) + tender.save() @@ -494,7 +506,7 @@ def send_author_incremental_2_days_email(tender: Tender): variables = { "TENDER_AUTHOR_FIRST_NAME": tender.author.first_name, "TENDER_TITLE": tender.title, - "TENDER_VALIDATE_AT": tender.sent_at.strftime("%d %B %Y"), # TODO: TENDER_SENT_AT? + "TENDER_VALIDATE_AT": tender.first_sent_at.strftime("%d %B %Y"), # TODO: TENDER_SENT_AT? "TENDER_KIND": tender.get_kind_display(), } @@ -528,7 +540,7 @@ def send_tenders_author_feedback_or_survey(tender: Tender, kind="feedback_30d"): variables = { "TENDER_AUTHOR_FIRST_NAME": tender.author.first_name, "TENDER_TITLE": tender.title, - "TENDER_VALIDATE_AT": tender.sent_at.strftime("%d %B %Y"), # TODO: TENDER_SENT_AT? + "TENDER_VALIDATE_AT": tender.first_sent_at.strftime("%d %B %Y"), # TODO: TENDER_SENT_AT? "TENDER_KIND": tender.get_kind_display(), } diff --git a/lemarche/www/tenders/tests.py b/lemarche/www/tenders/tests.py index 5fd32560f..a4b99aee9 100644 --- a/lemarche/www/tenders/tests.py +++ b/lemarche/www/tenders/tests.py @@ -599,7 +599,7 @@ def setUpTestData(cls): sectors=[sector_1], location=grenoble_perimeter, status=tender_constants.STATUS_SENT, - sent_at=timezone.now(), + first_sent_at=timezone.now(), ) cls.tendersiae_1_1 = TenderSiae.objects.create( tender=cls.tender_1, @@ -615,7 +615,7 @@ def setUpTestData(cls): author=cls.user_buyer_1, contact_company_name="Another company", status=tender_constants.STATUS_SENT, - sent_at=timezone.now(), + first_sent_at=timezone.now(), ) def test_anyone_can_view_sent_tenders(self):