Skip to content

Commit

Permalink
Add pricing to the ad server
Browse files Browse the repository at this point in the history
  • Loading branch information
davidfischer committed Aug 23, 2023
1 parent 41627f6 commit a5ccc34
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 1 deletion.
4 changes: 4 additions & 0 deletions adserver/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ class TopicAdmin(admin.ModelAdmin):
list_display = (
"name",
"slug",
"selectable",
)
list_filter = ("selectable",)
list_per_page = 1000
ordering = ("slug",)
search_fields = ("name", "slug")
Expand Down Expand Up @@ -137,7 +139,9 @@ class RegionAdmin(admin.ModelAdmin):
"name",
"slug",
"order",
"selectable",
)
list_filter = ("selectable",)
list_per_page = 1000
ordering = ("order", "slug")
search_fields = ("name", "slug")
Expand Down
70 changes: 69 additions & 1 deletion adserver/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,15 @@ class FlightRequestForm(FlightCreateForm):
label=_("Budget"),
)

regions = forms.MultipleChoiceField(
required=False,
widget=forms.CheckboxSelectMultiple(),
)
topics = forms.MultipleChoiceField(
required=False,
widget=forms.CheckboxSelectMultiple(),
)

note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea)

DEFAULT_FLIGHT_BUDGET = 3_000
Expand Down Expand Up @@ -603,12 +612,23 @@ def __init__(self, *args, **kwargs):
"advertisements": self.old_flight.advertisements.filter(live=True)
if self.old_flight
else Advertisement.objects.none(),
"regions": self.old_flight.targeting_parameters.get(
"include_regions", []
)
if self.old_flight and self.old_flight.targeting_parameters
else [],
"topics": self.old_flight.targeting_parameters.get("include_topics", [])
if self.old_flight and self.old_flight.targeting_parameters
else [],
}
)

# Sets self.advertiser
super().__init__(*args, **kwargs)

self.fields["budget"].widget.attrs["data-bind"] = "textInput: budget"
self.fields["regions"].widget.attrs["data-bind"] = "checked: regions"
self.fields["topics"].widget.attrs["data-bind"] = "checked: topics"
self.fields["note"].widget.attrs["rows"] = 3
self.fields["note"].help_text = _(
"Do you have any changes you'd like to make from previous flights or any special instructions?"
Expand All @@ -623,6 +643,15 @@ def __init__(self, *args, **kwargs):
else Advertisement.objects.none()
)

self.fields["regions"].choices = [
(r.slug, r.name)
for r in Region.objects.filter(selectable=True).order_by("order", "slug")
]
self.fields["topics"].choices = [
(t.slug, t.name)
for t in Topic.objects.filter(selectable=True).order_by("slug")
]

def create_form_helper(self):
helper = FormHelper()
helper.attrs = {"id": "flight-request-form"}
Expand All @@ -640,12 +669,51 @@ def create_form_helper(self):
"budget",
"$",
min=0,
step=100,
data_bind="textInput: budget",
),
),
Field("note"),
css_class="my-3",
),
Fieldset(
_("Flight targeting"),
HTML(
"<p class='form-text'>"
+ str(_("Estimated CPM: "))
+ "<span id='estimated-cpm' data-bind='text: estimatedCpm()'></span>"
+ "</p>"
+ "<p class='form-text small text-muted'>"
+ str(
_(
"Your account manager will confirm your campaign's rate before it starts."
)
)
+ "</p>"
),
Div(
Div(
Field("regions"),
css_class="form-group col-lg-6",
),
Div(
Field("topics"),
css_class="form-group col-lg-6",
),
css_class="form-row",
),
HTML(
"<p class='form-text small text-muted'>"
+ str(
_(
"If you need more fine targeting than these options and it's different from any previous flights you've run, "
"please let your account manager know in the 'note' field below."
)
)
+ "</p>"
),
css_class="my-3",
),
Field("note"),
Fieldset(
_("Advertisements"),
Field(
Expand Down
43 changes: 43 additions & 0 deletions adserver/migrations/0086_region_topic_pricing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 4.2.4 on 2023-08-23 21:11
import jsonfield.fields
from django.db import migrations
from django.db import models

import adserver.validators


class Migration(migrations.Migration):

dependencies = [
("adserver", "0085_flight_hard_stop_date"),
]

operations = [
migrations.AddField(
model_name="region",
name="prices",
field=jsonfield.fields.JSONField(
blank=True,
help_text="Topic pricing matrix for this region",
null=True,
validators=[adserver.validators.TopicPricingValidator()],
verbose_name="Topic prices",
),
),
migrations.AddField(
model_name="region",
name="selectable",
field=models.BooleanField(
default=False,
help_text="Whether advertisers can select this region for new flights",
),
),
migrations.AddField(
model_name="topic",
name="selectable",
field=models.BooleanField(
default=False,
help_text="Whether advertisers can select this region for new flights",
),
),
]
19 changes: 19 additions & 0 deletions adserver/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
from .utils import get_client_user_agent
from .utils import get_domain_from_url
from .validators import TargetingParametersValidator
from .validators import TopicPricingValidator
from .validators import TrafficFillValidator

log = logging.getLogger(__name__) # noqa
Expand Down Expand Up @@ -115,6 +116,11 @@ class Topic(TimeStampedModel, models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(_("Slug"), max_length=200, unique=True)

selectable = models.BooleanField(
default=False,
help_text=_("Whether advertisers can select this region for new flights"),
)

def __str__(self):
"""String representation."""
return self.name
Expand Down Expand Up @@ -185,6 +191,19 @@ class Region(TimeStampedModel, models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(_("Slug"), max_length=200, unique=True)

selectable = models.BooleanField(
default=False,
help_text=_("Whether advertisers can select this region for new flights"),
)

prices = JSONField(
_("Topic prices"),
blank=True,
null=True,
validators=[TopicPricingValidator()],
help_text=_("Topic pricing matrix for this region"),
)

# Lower order takes precedence
# When mapping country to a single region, the lowest order region is returned
order = models.PositiveSmallIntegerField(
Expand Down
1 change: 1 addition & 0 deletions adserver/templates/adserver/advertiser/flight-request.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ <h1>{% block heading %}{% trans 'Request a new flight' %}{% endblock heading %}<

<div class="col-md-8">
{% if next %}
{{ pricing|json_script:"data-pricing" }}
{% crispy form form.helper %}
{% else %}
<form>
Expand Down
14 changes: 14 additions & 0 deletions adserver/tests/test_advertiser_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,20 @@ def test_flight_request_view(self):
self.assertIsNotNone(new_flight)
self.assertFalse(new_flight.live)

backend.reset_messages()

# Modeled on a past flight
resp = self.client.get(url + "?old_flight=" + self.flight.slug + "&next=step-2")
self.assertEqual(resp.status_code, 200)
self.assertContains(
resp,
"Your account manager will be notified to review your ads and targeting.",
)

resp = self.client.post(url, data=data, follow=True)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, f"Successfully setup a new")

def test_ad_detail_view(self):
url = reverse(
"advertisement_detail",
Expand Down
20 changes: 20 additions & 0 deletions adserver/tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ..models import Campaign
from ..models import Flight
from ..validators import TargetingParametersValidator
from ..validators import TopicPricingValidator
from ..validators import TrafficFillValidator


Expand Down Expand Up @@ -90,3 +91,22 @@ def test_traffic_cap_validator(self):
self.assertRaises(
ValidationError, validator, {"regions": {"invalid-region": 0.1}}
)


class TestTopicPricingValidator(TestCase):
def test_targeting_validator(self):
validator = TopicPricingValidator(message="Test Message")

# Ok
validator({})
validator({"devops": 2.5})
validator({"devops": 2.5, "security-privacy": 2.0})

# Invalid
self.assertRaises(ValidationError, validator, "str")
self.assertRaises(ValidationError, validator, [])
self.assertRaises(ValidationError, validator, {"unknown-topic": 5.0})
self.assertRaises(
ValidationError, validator, {"security-privacy": "invalid-type"}
)
self.assertRaises(ValidationError, validator, {"security-privacy": -1.0})
40 changes: 40 additions & 0 deletions adserver/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,46 @@
from .utils import COUNTRY_DICT


@deconstructible
class TopicPricingValidator(BaseValidator):

"""Field validator for per topic pricing for regions."""

message = _("Enter a valid value")
code = "topic-pricing-validator"
limit_value = None # required for classes extending BaseValidator

def __init__(self, message=None):
"""Initialization for the topic pricing validator."""
if message:
self.message = message

def __call__(self, value):
from .models import Topic # noqa

topics = Topic.load_from_cache()

if not isinstance(value, dict):
raise ValidationError(
_("Value must be a dict, not %(type)s"), params={"type": type(value)}
)

if value:
for topic_slug in value:
if topic_slug not in topics and topic_slug != "all-developers":
raise ValidationError(
_("%(value)s is not a valid topic"),
params={"value": topic_slug},
)

price = value[topic_slug]
if not isinstance(price, (int, float)) or price <= 0:
raise ValidationError(
_("%(price)s must be a positive number"),
params={"price": price},
)


@deconstructible
class TargetingParametersValidator(BaseValidator):

Expand Down
6 changes: 6 additions & 0 deletions adserver/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,11 @@ def form_valid(self, form):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

pricing = {}
for region in Region.objects.filter(selectable=True):
if region.prices:
pricing[region.slug] = region.prices

context.update(
{
"advertiser": self.advertiser,
Expand All @@ -507,6 +512,7 @@ def get_context_data(self, **kwargs):
).order_by("-start_date")[:50],
"old_flight": self.old_flight,
"next": self.request.GET.get("next"),
"pricing": pricing,
}
)

Expand Down
Loading

0 comments on commit a5ccc34

Please sign in to comment.