diff --git a/adserver/migrations/0107_flight_auto_renew_notified_and_more.py b/adserver/migrations/0107_flight_auto_renew_notified_and_more.py new file mode 100644 index 00000000..c60eb7dd --- /dev/null +++ b/adserver/migrations/0107_flight_auto_renew_notified_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-03-10 15:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adserver', '0106_add_advertiser_flight_logos'), + ] + + operations = [ + migrations.AddField( + model_name='flight', + name='auto_renew_notified', + field=models.BooleanField(default=False, help_text='Whether the user has been notified of their flight renewal', verbose_name='Auto renew notification sent'), + ), + migrations.AddField( + model_name='historicalflight', + name='auto_renew_notified', + field=models.BooleanField(default=False, help_text='Whether the user has been notified of their flight renewal', verbose_name='Auto renew notification sent'), + ), + ] diff --git a/adserver/models.py b/adserver/models.py index 319771e4..92647a80 100644 --- a/adserver/models.py +++ b/adserver/models.py @@ -832,6 +832,11 @@ class Flight(TimeStampedModel, IndestructibleModel): _("Automatically renew when complete"), default=False, ) + auto_renew_notified = models.BooleanField( + _("Auto renew notification sent"), + default=False, + help_text=_("Whether the user has been notified of their flight renewal"), + ) auto_renew_payment_method = models.CharField( _("Auto renewal payment method"), max_length=100, @@ -1511,6 +1516,25 @@ def percent_complete(self): return self.total_value() / projected_total * 100 return 0 + def duration_percent_complete(self) -> float: + """Percentage of the flight duration that has elapsed [0.0, 100.0].""" + start_datetime = pytz.utc.localize( + datetime.datetime.combine(self.start_date, datetime.datetime.min.time()) + ) + end_datetime = pytz.utc.localize( + datetime.datetime.combine(self.end_date, datetime.datetime.max.time()) + ) + now = timezone.now() + + if now < start_datetime: + return 0.0 + if now > end_datetime: + return 100.0 + + total_duration = end_datetime - start_datetime + elapsed_duration = now - start_datetime + return elapsed_duration.total_seconds() / total_duration.total_seconds() * 100 + def daily_cap_exceeded(self): """ Check if the daily cap for a given flight has been exceeded. diff --git a/adserver/tasks.py b/adserver/tasks.py index 86316208..f940ca6e 100644 --- a/adserver/tasks.py +++ b/adserver/tasks.py @@ -1033,17 +1033,21 @@ def notify_of_first_flight_launched(): @app.task() -def notify_of_autorenewing_flights(days_before=7): +def notify_of_autorenewing_flights(completion_threshold=80): """Send a note to flights set to renew automatically.""" - # Flight must end in exactly `days_before` days - # to receive the notification - end_date = get_ad_day().date() + datetime.timedelta(days=days_before) - for flight in Flight.objects.filter( live=True, auto_renew=True, - end_date=end_date, + auto_renew_notified=False, ).select_related(): + # Flight must be at least 80% complete to receive the notification + # By both duration AND actual spend/fill + if ( + flight.percent_complete() < completion_threshold + or flight.duration_percent_complete() < completion_threshold + ): + continue + log.info("Notifying about flight %s auto-renewing", flight) if settings.FRONT_ENABLED: advertiser = flight.campaign.advertiser @@ -1073,6 +1077,9 @@ def notify_of_autorenewing_flights(days_before=7): message.draft = True # Only create a draft for now message.send() + flight.auto_renew_notified = True + flight.save(update_fields=["auto_renew_notified"]) + @app.task() def notify_of_completed_flights(): diff --git a/adserver/tests/test_models.py b/adserver/tests/test_models.py index e483eff5..9679ebf8 100644 --- a/adserver/tests/test_models.py +++ b/adserver/tests/test_models.py @@ -725,6 +725,47 @@ def test_projected_total_value(self): self.assertAlmostEqual(self.flight.projected_total_value(), 5.0) + def test_duration_percent_complete(self): + import pytz + + now = timezone.now() + self.flight.start_date = now.date() + self.flight.end_date = now.date() + datetime.timedelta(days=9) + self.flight.save() + + start_datetime = pytz.utc.localize( + datetime.datetime.combine( + self.flight.start_date, datetime.datetime.min.time() + ) + ) + end_datetime = pytz.utc.localize( + datetime.datetime.combine( + self.flight.end_date, datetime.datetime.max.time() + ) + ) + total_seconds = (end_datetime - start_datetime).total_seconds() + + with mock.patch("adserver.models.timezone.now") as mock_now: + # Before start + mock_now.return_value = start_datetime - datetime.timedelta(days=1) + self.assertEqual(self.flight.duration_percent_complete(), 0.0) + + # After end + mock_now.return_value = end_datetime + datetime.timedelta(days=1) + self.assertEqual(self.flight.duration_percent_complete(), 100.0) + + # 50% complete + mock_now.return_value = start_datetime + datetime.timedelta( + seconds=total_seconds / 2 + ) + self.assertAlmostEqual(self.flight.duration_percent_complete(), 50.0) + + # 25% complete + mock_now.return_value = start_datetime + datetime.timedelta( + seconds=total_seconds / 4 + ) + self.assertAlmostEqual(self.flight.duration_percent_complete(), 25.0) + @override_settings(ADSERVER_DO_NOT_TRACK=True) def test_offer_ad(self): request = self.factory.get("/") diff --git a/adserver/tests/test_tasks.py b/adserver/tests/test_tasks.py index 1a9dda9e..de0fa26e 100644 --- a/adserver/tests/test_tasks.py +++ b/adserver/tests/test_tasks.py @@ -180,8 +180,25 @@ def test_notify_of_autorenewing_flights(self): # Shouldn't be any completed flight messages self.assertEqual(len(mail.outbox), 0) - self.flight.end_date = get_ad_day().date() + datetime.timedelta(days=7) + # Set up flight to be 0% complete and auto renewing + self.flight.start_date = get_ad_day().date() + self.flight.end_date = get_ad_day().date() + datetime.timedelta(days=10) self.flight.auto_renew = True + self.flight.sold_clicks = 100 + self.flight.cpc = 1.0 + self.flight.total_clicks = 0 + self.flight.save() + + # It is 0% complete, so no email should be sent + notify_of_autorenewing_flights() + self.assertEqual(len(mail.outbox), 0) + self.flight.refresh_from_db() + self.assertFalse(self.flight.auto_renew_notified) + + # Move the start date back so it is 90% complete (9 days of 10 days elapsed) + self.flight.start_date = get_ad_day().date() - datetime.timedelta(days=9) + self.flight.end_date = get_ad_day().date() + datetime.timedelta(days=1) + self.flight.total_clicks = 90 self.flight.save() notify_of_autorenewing_flights() @@ -191,6 +208,12 @@ def test_notify_of_autorenewing_flights(self): self.assertTrue( mail.outbox[0].subject.startswith("Advertising flight renewing") ) + self.flight.refresh_from_db() + self.assertTrue(self.flight.auto_renew_notified) + + # Calling again shouldn't send another email because auto_renew_notified is now True + notify_of_autorenewing_flights() + self.assertEqual(len(mail.outbox), 1) @override_settings( # Use the memory email backend instead of front for testing