diff --git a/adserver/forms.py b/adserver/forms.py index 7941024f..ad927dd8 100644 --- a/adserver/forms.py +++ b/adserver/forms.py @@ -90,6 +90,7 @@ class Meta: "campaign", "start_date", "end_date", + "hard_stop", "live", "priority_multiplier", "pacing_interval", diff --git a/adserver/migrations/0085_flight_hard_stop_date.py b/adserver/migrations/0085_flight_hard_stop_date.py new file mode 100644 index 00000000..c91f87fd --- /dev/null +++ b/adserver/migrations/0085_flight_hard_stop_date.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.20 on 2023-07-25 23:46 +import datetime + +from django.db import migrations +from django.db import models + +import adserver.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adserver', '0084_publisher_traffic_shaping'), + ] + + operations = [ + migrations.AddField( + model_name='flight', + name='hard_stop', + field=models.BooleanField(default=False, help_text='The flight will be stopped on the end date even if not completely fulfilled', verbose_name='Hard stop'), + ), + migrations.AddField( + model_name='historicalflight', + name='hard_stop', + field=models.BooleanField(default=False, help_text='The flight will be stopped on the end date even if not completely fulfilled', verbose_name='Hard stop'), + ), + migrations.AlterField( + model_name='flight', + name='end_date', + field=models.DateField(default=adserver.models.default_flight_end_date, help_text='The estimated end date for the flight', verbose_name='End Date'), + ), + migrations.AlterField( + model_name='flight', + name='start_date', + field=models.DateField(db_index=True, default=datetime.date.today, help_text='This flight will not be shown before this date', verbose_name='Start Date'), + ), + migrations.AlterField( + model_name='historicalflight', + name='end_date', + field=models.DateField(default=adserver.models.default_flight_end_date, help_text='The estimated end date for the flight', verbose_name='End Date'), + ), + migrations.AlterField( + model_name='historicalflight', + name='start_date', + field=models.DateField(db_index=True, default=datetime.date.today, help_text='This flight will not be shown before this date', verbose_name='Start Date'), + ), + ] diff --git a/adserver/models.py b/adserver/models.py index 9a77d138..0199bbec 100644 --- a/adserver/models.py +++ b/adserver/models.py @@ -720,12 +720,19 @@ class Flight(TimeStampedModel, IndestructibleModel): _("Start Date"), default=datetime.date.today, db_index=True, - help_text=_("This ad will not be shown before this date"), + help_text=_("This flight will not be shown before this date"), ) end_date = models.DateField( _("End Date"), default=default_flight_end_date, - help_text=_("The target end date for the ad (it may go after this date)"), + help_text=_("The estimated end date for the flight"), + ) + hard_stop = models.BooleanField( + _("Hard stop"), + default=False, + help_text=_( + "The flight will be stopped on the end date even if not completely fulfilled" + ), ) live = models.BooleanField(_("Live"), default=False) priority_multiplier = models.IntegerField( @@ -1167,10 +1174,12 @@ def clicks_today(self): return aggregation or 0 def views_needed_this_interval(self): + today = get_ad_day().date() if ( not self.live or self.views_remaining() <= 0 - or self.start_date > get_ad_day().date() + or self.start_date > today + or (self.hard_stop and self.end_date < today) ): return 0 @@ -1186,10 +1195,12 @@ def views_needed_this_interval(self): def clicks_needed_this_interval(self): """Calculates clicks needed based on the impressions this flight's ads have.""" + today = get_ad_day().date() if ( not self.live or self.clicks_remaining() <= 0 - or self.start_date > get_ad_day().date() + or self.start_date > today + or (self.hard_stop and self.end_date < today) ): return 0 diff --git a/adserver/tasks.py b/adserver/tasks.py index 9c9b58c3..9e1c1329 100644 --- a/adserver/tasks.py +++ b/adserver/tasks.py @@ -14,6 +14,7 @@ from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from django_slack import slack_message +from simple_history.utils import update_change_reason from .constants import FLIGHT_STATE_CURRENT from .constants import FLIGHT_STATE_UPCOMING @@ -737,7 +738,33 @@ def notify_of_completed_flights(): completed_flights_by_advertiser = defaultdict(list) for flight in Flight.objects.filter(live=True).select_related(): - if ( + # Check for hard stopped flights + if flight.hard_stop and flight.end_date <= cutoff.date(): + log.info("Flight %s is being hard stopped.", flight) + value_remaining = round(flight.value_remaining(), 2) + flight_url = generate_absolute_url(flight.get_absolute_url()) + + # Send an internal notification about this flight being hard stopped. + slack_message( + "adserver/slack/generic-message.slack", + { + "text": f"Flight {flight.name} was hard stopped. There was ${value_remaining:.2f} value remaining. {flight_url}" + }, + ) + + # Mark the flight as no longer live. It was hard stopped + flight.live = False + flight.save() + + # Store the change reason in the history + update_change_reason( + flight, f"Hard stopped with ${value_remaining} value remaining." + ) + + completed_flights_by_advertiser[flight.campaign.advertiser.slug].append( + flight + ) + elif ( flight.clicks_remaining() == 0 and flight.views_remaining() == 0 and AdImpression.objects.filter( diff --git a/adserver/templates/adserver/includes/flight-metadata.html b/adserver/templates/adserver/includes/flight-metadata.html index 0897d58c..ce52b41d 100644 --- a/adserver/templates/adserver/includes/flight-metadata.html +++ b/adserver/templates/adserver/includes/flight-metadata.html @@ -44,7 +44,10 @@ {% endif %} {% if flight.end_date %}
{% trans 'Estimated end date' %}
-
{{ flight.end_date }}
+
+ {{ flight.end_date }} + {% if flight.hard_stop %} ({% trans 'Hard stop' %}){% endif %} +
{% endif %}
{% trans 'Ad selection' %}
diff --git a/adserver/tests/test_tasks.py b/adserver/tests/test_tasks.py index 26baaac5..5863849c 100644 --- a/adserver/tests/test_tasks.py +++ b/adserver/tests/test_tasks.py @@ -178,6 +178,49 @@ def test_notify_completed_flights(self): self.flight.refresh_from_db() self.assertFalse(self.flight.live) + @override_settings( + # Use the memory email backend instead of front for testing + FRONT_BACKEND="django.core.mail.backends.locmem.EmailBackend", + FRONT_ENABLED=True, + ) + def test_notify_completed_flights_hard_stop(self): + # Ensure there's a recipient for a wrapup email + self.staff_user.advertisers.add(self.advertiser) + + backend = get_backend() + backend.reset_messages() + + notify_of_completed_flights() + messages = backend.retrieve_messages() + + # Shouldn't be any completed flight messages + self.assertEqual(len(messages), 0) + self.assertEqual(len(mail.outbox), 0) + + # Set this flight to hard stop + self.flight.sold_clicks = 100 + self.flight.total_views = 1_000 + self.flight.total_clicks = 50 + self.flight.hard_stop = True + self.flight.start_date = timezone.now() - datetime.timedelta(days=31) + self.flight.end_date = timezone.now() - datetime.timedelta(days=1) + self.flight.save() + + # This should hard stop the flight + notify_of_completed_flights() + self.flight.refresh_from_db() + + # Flight should no longer be live + self.assertFalse(self.flight.live) + + messages = backend.retrieve_messages() + self.assertEqual(len(messages), 1) + self.assertTrue( + "was hard stopped. There was $100.00 value remaining" in messages[0]["text"] + ) + self.assertEqual(len(mail.outbox), 1) + self.assertTrue(mail.outbox[0].subject.startswith("Advertising flight wrapup")) + def test_notify_of_publisher_changes(self): # Publisher changes only apply to paid campaigns self.publisher.allow_paid_campaigns = True