Skip to content

Commit

Permalink
Bulk ad upload
Browse files Browse the repository at this point in the history
  • Loading branch information
davidfischer committed Oct 31, 2024
1 parent c84adf3 commit f35e258
Show file tree
Hide file tree
Showing 8 changed files with 449 additions and 3 deletions.
159 changes: 159 additions & 0 deletions adserver/forms.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""Forms for the ad server."""

import csv
import logging
from datetime import timedelta
from io import BytesIO
from io import TextIOWrapper

import bleach
import requests
import stripe
from crispy_forms.bootstrap import PrependedText
from crispy_forms.helper import FormHelper
Expand All @@ -17,8 +21,11 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.core.files.images import get_image_dimensions
from django.core.files.storage import default_storage
from django.core.mail import EmailMessage
from django.core.validators import URLValidator
from django.db.models import Q
from django.template.loader import render_to_string
from django.urls import reverse
Expand Down Expand Up @@ -1150,6 +1157,158 @@ class Meta:
}


class BulkAdvertisementUploadCSVForm(forms.Form):
"""
Used by advertisers to upload ads in bulk.
The actual saving of bulk ads is done by the BulkAdvertisementPreviewForm.
"""

REQUIRED_FIELD_NAMES = [
"Name",
"Live",
"Link URL",
"Image URL",
"Headline",
"Content",
"Call to Action",
]
MAXIMUM_TEXT_LENGTH = 100
IMAGE_WIDTH = 240
IMAGE_HEIGHT = 180

advertisements = forms.FileField(
label=_("Advertisements"), help_text=_("Upload a CSV using our ad template")
)

def __init__(self, *args, **kwargs):
"""Add the form helper and customize the look of the form."""
super().__init__(*args, **kwargs)

self.fields["advertisements"].widget.attrs["accept"] = "text/csv"

self.helper = FormHelper()
self.helper.attrs = {
"id": "advertisements-bulk-upload",
"enctype": "multipart/form-data",
}

self.helper.layout = Layout(
Fieldset(
"",
Field("advertisements", placeholder="Upload a CSV file"),
css_class="my-3",
),
Submit("submit", _("Preview ads")),
)

def clean_advertisements(self):
"""Verify the CSV can be opened and has all the right fields."""
csvfile = self.cleaned_data["advertisements"]
try:
reader = csv.DictReader(
TextIOWrapper(csvfile, encoding="utf-8", newline="")
)
except Exception:
raise forms.ValidationError(_("Could not open the CSV file."))

for fieldname in self.REQUIRED_FIELD_NAMES:
if fieldname not in reader.fieldnames:
raise forms.ValidationError(
_("Missing required field %(fieldname)s.")
% {"fieldname": fieldname}
)

ads = []
url_validator = URLValidator(schemes=("http", "https"))
for row in reader:
image_url = row["Image URL"].strip()
link_url = row["Link URL"].strip()
name = row["Name"].strip()
headline = row["Headline"].strip()
content = row["Content"].strip()
cta = row["Call to Action"].strip()

text_length = len(f"{headline}{content}{cta}")
if text_length > self.MAXIMUM_TEXT_LENGTH:
raise forms.ValidationError(
"Total text for '%(ad)s' must be %(max_chars)s or less (it is %(text_len)s)"
% {
"ad": name,
"max_chars": self.MAXIMUM_TEXT_LENGTH,
"text_len": text_length,
}
)

for url in (image_url, link_url):
try:
url_validator(url)
except ValidationError:
raise forms.ValidationError(
_("'%(url)s' is an invalid URL.") % {"url": url}
)

image_resp = None
try:
image_resp = requests.get(image_url, timeout=3, stream=True)
except Exception:
pass

if not image_resp or not image_resp.ok:
raise forms.ValidationError(
_("Could not retrieve image '%(image)s'.") % {"image": image_url}
)

image = BytesIO(image_resp.raw.read())
width, height = get_image_dimensions(image)
if width is None or height is None:
forms.ValidationError(
_("Image for %(name)s isn't a valid image"),
params={
"name": name,
},
)
if width != self.IMAGE_WIDTH or height != self.IMAGE_HEIGHT:
forms.ValidationError(
_(
"Images must be %(required_width)s * %(required_height)s "
"(for %(name)s it is %(width)s * %(height)s)"
),
params={
"name": name,
"required_width": self.IMAGE_WIDTH,
"required_height": self.IMAGE_HEIGHT,
"width": width,
"height": height,
},
)
image_name = image_url[image_url.rfind("/") + 1 :]
image_path = f"images/{timezone.now():%Y}/{timezone.now():%m}/{image_name}"
default_storage.save(image_path, image)

ads.append(
{
"name": name,
"image_path": image_path,
"image_name": image_name,
"image_url": default_storage.url(image_path),
"live": row["Live"].strip().lower() == "true",
"link": link_url,
"headline": headline,
"content": content,
"cta": cta,
}
)

return ads

def get_ads(self):
if not self.is_valid():
raise RuntimeError("Form must be valid and bound to get the ads")

return self.cleaned_data["advertisements"]


class AdvertisementCopyForm(forms.Form):
"""Used by advertisers to re-use their ads."""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Name,Live,Link URL,Image URL,Headline,Content,Call to Action
Your Ad Name (required),"Whether to make your ad immediately live (required, true or false)",Your ad landing page URL (required),"Your ad image URL (required, the size must be 240*180px)",Ad headline (optional),"Ad content(required, the length of the headline, content and CTA combined must be 100 characters or less)",Ad CTA (optional)
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{% extends "adserver/advertiser/overview.html" %}
{% load i18n %}
{% load static %}
{% load humanize %}
{% load crispy_forms_tags %}


{% block title %}{% trans 'Bulk create ads' %}{% endblock %}


{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'flight_list' advertiser.slug %}">{% trans 'Flights' %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'flight_detail' advertiser.slug flight.slug %}">{{ flight.name }}</a></li>
<li class="breadcrumb-item active">{% trans 'Bulk create ads' %}</li>
{% endblock breadcrumbs %}


{% block content_container %}

<h1>{% block heading %}{% trans 'Bulk create ads' %}{% endblock heading %}</h1>

<div class="row">

<div class="col-md-8">

{% if not preview_ads %}
{% url 'advertiser_bulk_create_template' as bulk_create_template_url %}
<p class="mb-2">{% blocktrans %}Create multiple ads for your flight by uploading a CSV file. Download the <a href="{{ bulk_create_template_url }}">CSV template</a> and upload it with your ads.{% endblocktrans %}</p>

<p class="mb-5">{% trans 'For tips on crafting high-performing ads across EthicalAds, see our <a target="_blank" href="https://www.ethicalads.io/downloads/ethicalads-creatives-that-convert.pdf">"creatives that convert" guide</a>.' %}</p>

{% crispy form form.helper %}
{% else %}
{% url 'advertisement_bulk_create' advertiser flight as bulk_create_url %}
<p class="mb-5">{% blocktrans %}Preview and save your ads or update your CSV and <a href="{{ advertisement_bulk_create }}">upload again</a>.{% endblocktrans %}</p>

<div class="mb-2">
{% for advertisement in preview_ads %}
<h5>{{ advertisement.name }}</h5>
{% with ad_type=preview_ad_type %}
{% include "adserver/includes/ad-preview.html" %}
{% endwith %}
{% endfor %}
</div>

<form method="post">
{% csrf_token %}
<input type="hidden" name="signed_advertisements" value="{{ signed_advertisements }}">
<input type="submit" class="btn btn-primary" value="{% trans 'Save your uploaded ads' %}">
</form>
{% endif %}
</div>

</div>

{% endblock content_container %}
11 changes: 8 additions & 3 deletions adserver/templates/adserver/advertiser/flight-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,17 @@ <h1>{% block heading %}{% trans 'Flight' %}: {{ flight.name }}{% endblock headin
<section class="mt-5">

<div class="row">
<h5 class="col-md-8">{% trans 'Live ads' %}</h5>
<h5 class="col-md-6">{% trans 'Live ads' %}</h5>

<aside class="col-md-4 text-right">
<aside class="col-md-6 text-right">
<a href="{% url 'advertisement_create' advertiser.slug flight.slug %}" class="btn btn-sm btn-outline-primary mb-3" role="button" aria-pressed="true">
<span class="fa fa-plus mr-1" aria-hidden="true"></span>
<span>{% trans 'Create advertisement' %}</span>
</a>
<a href="{% url 'advertisement_bulk_create' advertiser.slug flight.slug %}" class="btn btn-sm btn-outline-primary mb-3" role="button" aria-pressed="true">
<span class="fa fa-plus-circle mr-1" aria-hidden="true"></span>
<span>{% trans 'Bulk create' %}</span>
</a>
<a href="{% url 'advertisement_copy' advertiser.slug flight.slug %}" class="btn btn-sm btn-outline-primary mb-3" role="button" aria-pressed="true">
<span class="fa fa-clone mr-1" aria-hidden="true"></span>
<span>{% trans 'Copy existing ads' %}</span>
Expand All @@ -73,7 +77,8 @@ <h5 class="col-md-8">{% trans 'Live ads' %}</h5>
</div>
{% else %}
{% url 'advertisement_create' advertiser.slug flight.slug as create_ad_url %}
<p class="text-center">{% blocktrans %}There are no live ads in this flight yet but you can <a href="{{ create_ad_url }}">create one</a>.{% endblocktrans %}</p>
{% url 'advertisement_bulk_create' advertiser.slug flight.slug as bulk_create_ad_url %}
<p class="text-center">{% blocktrans %}There are no live ads in this flight yet but you can <a href="{{ create_ad_url }}">create one</a> or create them in <a href="{{ bulk_create_ad_url }}"></a>bulk</a>.{% endblocktrans %}</p>
{% endif %}


Expand Down
3 changes: 3 additions & 0 deletions adserver/tests/data/bulk_ad_upload.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Name,Live,Link URL,Image URL,Headline,Content,Call to Action
Ad1,true,http://example.com/ad1,https://ethicalads.blob.core.windows.net/media/images/2022/11/ethicalads-housead.png,Ad headline,"Ad content",Ad CTA
Ad2,true,http://example.com/ad2,https://ethicalads.blob.core.windows.net/media/images/2022/11/ethicalads-housead.png,Ad headline2,"Ad content2",Ad CTA2
50 changes: 50 additions & 0 deletions adserver/tests/test_advertiser_dashboard.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime

import bs4
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
Expand All @@ -11,6 +12,7 @@
from django.urls import reverse
from django_dynamic_fixture import get
from django_slack.utils import get_backend
from django.conf import settings

from ..constants import PAID_CAMPAIGN
from ..constants import PUBLISHER_HOUSE_CAMPAIGN
Expand Down Expand Up @@ -752,6 +754,54 @@ def test_ad_create_view(self):
Advertisement.objects.filter(flight=self.flight, name="New Name").exists()
)

def test_ad_bulk_create_view(self):
url = reverse(
"advertisement_bulk_create",
kwargs={
"advertiser_slug": self.advertiser.slug,
"flight_slug": self.flight.slug,
},
)

# Anonymous - no access
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertTrue(response["location"].startswith("/accounts/login/"))

self.client.force_login(self.user)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Bulk create ads")

with open(settings.BASE_DIR + "/adserver/tests/data/bulk_ad_upload.csv") as fd:
resp = self.client.post(url, data={
"advertisements": fd,
})

self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Preview and save your ads")

soup = bs4.BeautifulSoup(resp.content)
elem = soup.find("input", attrs={"name": "signed_advertisements"})
self.assertIsNotNone(elem)

signed_ads = elem.attrs["value"]

resp = self.client.post(url, follow=True, data={
"signed_advertisements": signed_ads,
})

self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Successfully uploaded")

signed_ads = "invalid"
resp = self.client.post(url, follow=True, data={
"signed_advertisements": signed_ads,
})

self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Upload expired or invalid")

def test_ad_copy_view(self):
url = reverse(
"advertisement_copy",
Expand Down
14 changes: 14 additions & 0 deletions adserver/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .views import AccountOverviewView
from .views import AccountSupportView
from .views import AdClickProxyView
from .views import AdvertisementBulkCreateView
from .views import AdvertisementCopyView
from .views import AdvertisementCreateView
from .views import AdvertisementDetailView
Expand Down Expand Up @@ -143,6 +144,14 @@
name="publisher_uplift_report_export",
),
# Advertiser management and reporting
path(
r"advertiser/advertisement-bulk-create-template.csv",
TemplateView.as_view(
template_name="adserver/advertiser/advertisement-bulk-create-template.csv",
content_type="text/csv",
),
name="advertiser_bulk_create_template",
),
path(
r"advertiser/<slug:advertiser_slug>/",
AdvertiserMainView.as_view(),
Expand Down Expand Up @@ -248,6 +257,11 @@
AdvertisementCreateView.as_view(),
name="advertisement_create",
),
path(
r"advertiser/<slug:advertiser_slug>/flights/<slug:flight_slug>/advertisements/bulk-create/",
AdvertisementBulkCreateView.as_view(),
name="advertisement_bulk_create",
),
path(
r"advertiser/<slug:advertiser_slug>/flights/<slug:flight_slug>/advertisements/<slug:advertisement_slug>/",
AdvertisementDetailView.as_view(),
Expand Down
Loading

0 comments on commit f35e258

Please sign in to comment.