Skip to content

Commit

Permalink
Merge pull request #772 from readthedocs/davidfischer/flight-hard-stop
Browse files Browse the repository at this point in the history
Flight hard stop
  • Loading branch information
davidfischer committed Jul 27, 2023
2 parents 461d701 + 903bf25 commit 90d443b
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 6 deletions.
1 change: 1 addition & 0 deletions adserver/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class Meta:
"campaign",
"start_date",
"end_date",
"hard_stop",
"live",
"priority_multiplier",
"pacing_interval",
Expand Down
47 changes: 47 additions & 0 deletions adserver/migrations/0085_flight_hard_stop_date.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
19 changes: 15 additions & 4 deletions adserver/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
29 changes: 28 additions & 1 deletion adserver/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion adserver/templates/adserver/includes/flight-metadata.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@
{% endif %}
{% if flight.end_date %}
<dt title="{% trans 'Note: your campaign may run beyond this date due to availability.' %}" data-toggle="tooltip" data-placement="left">{% trans 'Estimated end date' %}</dt>
<dd>{{ flight.end_date }}</dd>
<dd>
<span>{{ flight.end_date }}</span>
{% if flight.hard_stop %}<span title="{% trans 'The flight will be stopped on this date even if not completely fulfilled. The balance will be credited.' %}" data-toggle="tooltip" data-placement="left"> ({% trans 'Hard stop' %})</span>{% endif %}
</dd>
{% endif %}

<dt title="{% trans 'Determines which ad is chosen when a flight has multiple ads.' %}" data-toggle="tooltip" data-placement="left">{% trans 'Ad selection' %} <span class="fa fa-info-circle fa-fw mr-2 text-muted" aria-hidden="true"></span></dt>
Expand Down
43 changes: 43 additions & 0 deletions adserver/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 90d443b

Please sign in to comment.