Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Flight hard stop #772

Merged
merged 4 commits into from
Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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):
davidfischer marked this conversation as resolved.
Show resolved Hide resolved
"""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