Skip to content

Instantly share code, notes, and snippets.

@aparakian
Created March 9, 2016 01:55
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save aparakian/0c9c18111bd179e5b4ab to your computer and use it in GitHub Desktop.
Save aparakian/0c9c18111bd179e5b4ab to your computer and use it in GitHub Desktop.
select2 integration
(function ($) {
$(function () {
$('.django-select2').select2({
placeholder: "Search for an audience",
ajax: {
data: function (params) {
return {
search: params.term
};
},
processResults: function (data) {
return {
results: data
};
}
}
});
});
}(this.jQuery));
# -*- coding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
from itertools import chain
from django import forms
from django.core.urlresolvers import reverse
from django.utils.encoding import force_text
class Select2Mixin(object):
"""
This mixin is responsible for rendering the necessary
data attributes for select2 as well as adding the static
form media and AJAX options.
"""
def __init__(self, **kwargs):
"""
Return Select2Mixin.
Args:
data_view (str): URL pattern name
data_url (str): URL
"""
self.data_view = kwargs.pop('data_view', None)
self.data_url = kwargs.pop('data_url', None)
if not (self.data_view or self.data_url):
raise ValueError('You must ether specify "data_view" or "data_url".')
super(Select2Mixin, self).__init__(**kwargs)
def get_url(self):
"""Return URL from instance or by reversing :attr:`.data_view`."""
if self.data_url:
return self.data_url
return reverse(self.data_view)
def build_attrs(self, extra_attrs=None, **kwargs):
"""Add select2 data attributes."""
attrs = super(Select2Mixin, self).build_attrs(extra_attrs=extra_attrs, **kwargs)
attrs.setdefault('data-minimum-input-length', 0)
attrs.setdefault('data-ajax--url', self.get_url())
attrs.setdefault('data-ajax--type', "GET")
attrs['class'] = 'django-select2'
return attrs
def render_options(self, *args):
"""Render only selected options."""
try:
selected_choices, = args
except ValueError: # Signature contained `choices` prior to Django 1.10
choices, selected_choices = args
choices = chain(self.choices, choices)
else:
choices = self.choices
output = ['<option></option>' if not self.is_required else '']
selected_choices = {force_text(v) for v in selected_choices}
choices = {(k, v) for k, v in choices if force_text(k) in selected_choices}
for option_value, option_label in choices:
output.append(self.render_option(selected_choices, option_value, option_label))
return '\n'.join(output)
def _get_media(self):
"""
Construct Media as a dynamic property.
.. Note:: For more information visit
https://docs.djangoproject.com/en/1.8/topics/forms/media/#media-as-a-dynamic-property
"""
return forms.Media(
js=('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.min.js',),
css={'screen': ('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css',)}
)
media = property(_get_media)
class Select2Widget(Select2Mixin, forms.Select):
"""
Select2 drop in widget.
Example usage::
cUsage example::
class MyWidget(Select2Widget):
data_view = 'my_view_name'
or::
class MyForm(forms.Form):
my_field = forms.ChoicesField(
widget=Select2Widget(
data_url='/url/to/json/response'
)
)
"""
pass
from datetime import datetime
from django import forms
from django.core.exceptions import ValidationError
from django.forms.forms import NON_FIELD_ERRORS
from facebookads.exceptions import FacebookError
from mongodbforms.documents import DocumentForm
from mongodbforms.fields import ReferenceField, ListOfFilesField
import mongoengine
from suit.widgets import SuitDateWidget
from ads_app.parameters import BILLING_EVENTS
from sjp_app import validators, widgets
from sjp_app.ads.generator import Generator
from sjp_app.ads.targetings import LocationTargetingGenerator, InterestsTargetingGenerator
from sjp_app.documents import SjpCampaign, SjpAdsCampaign, ExtraTargetingTemplate
from sjp_app.utils.stats import on_manual_state_change
from sjp_app.utils.ad_preview import get_ad_preview_data, get_ad_preview
from sjp_app.utils.django_select2 import Select2Widget
from w4.forms import fields
from w4.forms import validators as base_validators
from w4.forms.widgets import GridFsImageWidget
from w4.contrib.admin.widgets import AdminJSONTextareaWidget
from w4.utils.app import get_adsops_as_django_choices
from w4.utils.dates import relativedelta
class SjpCampaignAdminForm(DocumentForm):
filters = fields.DictField(
widget=widgets.FilterWidget,
validators=[validators.validate_pages_should_exist],
help_text=SjpCampaign.filters.help_text
)
jira_task = fields.JiraTaskField(required=False)
class Meta(object):
document = SjpCampaign
def clean_name(self):
if isinstance(self.cleaned_data.get('name'), unicode):
return self.cleaned_data['name'].strip()
class BaseChildAdminForm(DocumentForm):
def _post_clean(self):
"""
Make sure that we do not link to an existing campaign.
"""
super(BaseChildAdminForm, self)._post_clean()
changed_fields = getattr(self.instance, '_changed_fields', [])
Klass = self.instance.__class__
if "sjp_campaign" not in self.errors:
has_new_sjp_campaign = (
("sjp_campaign" in changed_fields and self.instance.sjp_campaign) or
not self.instance.id
)
if has_new_sjp_campaign and Klass.objects(sjp_campaign=self.instance.sjp_campaign).count():
self.add_error(
"sjp_campaign",
"Other SjpAdsCampaign are already linked to %s" % self.instance.sjp_campaign
)
elif self.instance.is_active and not self.instance.sjp_campaign.is_active:
self.add_error(
"sjp_campaign",
"Parent sjp campaign should be active."
)
class ExtraTargetingTemplateAdminForm(DocumentForm):
template = fields.DictField(
widget=AdminJSONTextareaWidget,
validators=[validators.validate_extra_targeting],
help_text=ExtraTargetingTemplate.template.help_text
)
def __init__(self, *args, **kwargs):
kwargs.setdefault("initial", {})
initial_template_id = kwargs["initial"].pop("template_id", None)
super(ExtraTargetingTemplateAdminForm, self).__init__(*args, **kwargs)
# We check if we're in change mode (No instance passed in kwargs)
# We disable the template field (add readonly attribute on field)
if kwargs.get('instance'):
self.fields['template'].widget.attrs['readonly'] = True
# try to retrieve the initial template value
if initial_template_id:
try:
previous_template = ExtraTargetingTemplate.objects.get(id=initial_template_id).template
self.fields['template'].initial = previous_template
except (ExtraTargetingTemplate.DoesNotExist, mongoengine.ValidationError):
self.fields['template'].initial = None
class Meta(object):
document = ExtraTargetingTemplate
def _post_clean(self):
"""
We verify that the template hasn't been changed for existing template,
We set the author on new templates.
"""
super(ExtraTargetingTemplateAdminForm, self)._post_clean()
changed_fields = getattr(self.instance, '_changed_fields', [])
is_new = not self.instance.id
if not is_new and "template" in changed_fields:
self.add_error("template", "You can't edit an existing template!")
if is_new:
self.instance.author = self.user.username
class SjpAdsCampaignAdminForm(BaseChildAdminForm):
target_click = forms.IntegerField(
required=True, label=SjpAdsCampaign.target_click.verbose_name,
help_text=SjpAdsCampaign.target_click.help_text
)
image_urls = ListOfFilesField(
forms.ImageField(
widget=GridFsImageWidget, required=False,
validators=[base_validators.ImageSizeMinWidthValidator(SjpAdsCampaign.DEFAULT_MIN_WIDTH),
base_validators.ImageSizeMinHeightValidator(SjpAdsCampaign.DEFAULT_MIN_HEIGHT),
base_validators.ImageSizeMinRatioValidator(SjpAdsCampaign.DEFAULT_MIN_RATIO),
base_validators.ImageSizeMaxRatioValidator(SjpAdsCampaign.DEFAULT_MAX_RATIO)]
),
required=False
)
target_click_multiplier = forms.FloatField(
required=True, min_value=0, initial=SjpAdsCampaign.target_click_multiplier.default,
label=SjpAdsCampaign.target_click_multiplier.verbose_name,
help_text=SjpAdsCampaign.target_click_multiplier.help_text
)
force_state = forms.BooleanField(
required=False, label="Force state transition",
help_text="Use this field to force a state transition."
)
adsops = forms.TypedChoiceField(
choices=get_adsops_as_django_choices, required=False,
empty_value=None
)
end_date = forms.DateTimeField(widget=SuitDateWidget, required=False)
class Meta(object):
document = SjpAdsCampaign
def _post_clean(self):
"""
We verify many rules: state transitions, bid validation,
duration validation, etc.
If end_date got changed, update duration accordingly
"""
super(SjpAdsCampaignAdminForm, self)._post_clean()
# we do not use Django's `clean_<fieldname>()` methods
# because these validation are based on multiple fields
# and Django doesn't provide a way to do that out of the box
self.compute_duration_if_needed()
self.do_configuration_validation()
self.do_bid_validation()
self.do_state_validation()
self.do_duration_validation()
self.do_fb_objective_validation()
self.do_target_click_validation()
# Datadog event when manual state change and form is valid (ie no errors)
if self.instance.id and self.instance.has_field_changed("state") and not self.errors:
on_manual_state_change(self.instance, self.cleaned_data["state_comment"])
def compute_duration_if_needed(self):
"""
If we want to edit the end date of a running campaign, we must also adjust
the duration so that redistribution works properly
Combine the times to avoid missing the last day
"""
if self.instance.has_field_changed("end_date") and self.instance.start_date:
self.instance.end_date = datetime.combine(self.instance.end_date.date(), self.instance.start_date.time())
try:
self.instance.compute_duration(commit=False)
except ValueError as e:
self.add_error("end_date", e.message)
def do_configuration_validation(self):
"""
Perform the configuration validation on the campaign, only if the document is new
and has a valid `sjp_campaign` relation. What is checked:
- no test pages in filters
- pages need the "sjp" label
- no page can be excluded from jobboard
- pages need a company name
- jobs on the pages need a jobg8 category
"""
if self.instance.id or not self.instance.sjp_campaign:
return
pages = self.instance.sjp_campaign.get_pages()
# pages in filters are not is_test
if any(page.Profile.get('is_test') for page in pages):
self.add_error(NON_FIELD_ERRORS, "Some of the pages in the filters are test pages.")
# page have to have "sjp" label
if any(page.Parameters.get('label') != 'sjp' for page in pages):
self.add_error(NON_FIELD_ERRORS, "Some of the pages in the filters does not have 'sjp' label.")
# page shouldn't be excluded from job board or redirect to ATS
if any(page.Parameters.get('excluded_from_jobboard') and not page.is_ats_redirection() for page in pages):
self.add_error(NON_FIELD_ERRORS, "Some of the pages in the filters are excluded from job board.")
# company name shouldn't be empty
if any(not page.get_company_name() for page in pages):
self.add_error(NON_FIELD_ERRORS, "Some of the pages in the filters does not have a company name.")
# jobs should have a category
jobs = self.instance.sjp_campaign.get_active_jobs({"limit": 200}) # limit the number of jobs
for job in jobs:
try:
InterestsTargetingGenerator.get_job_mapped_categories(job)
except LookupError as e:
self.add_error(NON_FIELD_ERRORS, str(e))
def do_bid_validation(self):
"""
Perform bid validation on the campaign:
- constraints on the bid value (depends on the "billing_event")
- additionally, if "automatic_bid" is set, the "target_click_multiplier" is reset to 1
"""
is_new = not self.instance.id
if self.instance.automatic_bid:
if self.instance.has_field_changed("automatic_bid") or is_new:
self.instance.target_click_multiplier = 1
return
if not is_new and not self.instance.has_any_field_changed("bid_value", "billing_event", "automatic_bid"):
return
if (
self.instance.billing_event == 'IMPRESSIONS' and
(self.instance.bid_value < 1000 or self.instance.bid_value > 10000)
):
self.add_error(
"bid_value",
"The bid value is out of the range, it should be between 1k & 10k for oCPM bid."
)
if self.instance.billing_event == 'LINK_CLICKS' and self.instance.bid_value > 200:
self.add_error(
"bid_value",
"The bid is out of the range, it should be less than 200 (2$) for CPA bid."
)
def do_state_validation(self):
"""
Perform the state validation on the campaign:
- no one can change the state to "paused"
- no one can change a state from "paused" state, except if "force_state" is selected
- in any case, if state changes, we require the "state_comment"
"""
is_new = not self.instance.id
initial_state = self.instance.get_initial_value("state")
state_changed = self.instance.has_field_changed("state")
if (is_new or state_changed) and self.instance.is_paused:
self.add_error("state", "You cannot pause a campaign manually.")
elif not is_new and state_changed and initial_state == SjpAdsCampaign.STATES.PAUSED:
if not self.cleaned_data["force_state"]:
self.add_error("state", "You cannot change the state of a paused campaign.")
# validates a comment was left when doing active <=> inactive state transition.
if (
not is_new and state_changed and
(not self.instance.has_field_changed("state_comment") or not self.cleaned_data['state_comment'])
):
self.add_error(
"state_comment",
"You must leave a comment when manually changing campaign state!"
)
def do_duration_validation(self):
"""
Perform the duration validation on the campaign:
- make sure end_date won't be in the past
- make sure the duration isn't more than 364 days
"""
duration_changed = self.instance.has_any_field_changed("duration", "duration_unit")
if not duration_changed:
return
duration = self.instance.get_timedelta_from_duration(
self.instance.duration_unit,
self.instance.duration or 0,
)
field = "duration" if "duration" in self.fields else "end_date"
if duration > relativedelta(days=364):
self.add_error(field, "Duration is greater than 364 days.")
if self.instance.id and self.instance.start_date:
if self.instance.start_date + duration < datetime.now():
self.add_error(field, "New duration is too short (new end_date in the past)")
def do_fb_objective_validation(self):
"""
Perform the fb_objective validation on the campaign:
- "fb_objective" cannot be changed if the Facebook campaign is already created
- the "billing_event" cannot be set to impressions with a LINK_CLICKS objective
"""
if self.instance.fb_campaign_id and self.instance.has_field_changed("fb_objective"):
self.add_error(
"fb_objective",
"You cannot change the objective once the campaign is launched! "
"Create a new campaign with another objective."
)
if (
self.instance.has_field_changed("billing_event") and
self.instance.fb_objective == SjpAdsCampaign.FB_OBJECTIVES.LINK_CLICKS
):
if self.cleaned_data["billing_event"] == BILLING_EVENTS.IMPRESSIONS:
self.add_error("billing_event", "You cannot set this billing event for this FB Objective")
def do_target_click_validation(self):
"""
Perform the "target_click" validation. Basically require a comment whenever
one changes the target.
"""
if (
self.instance.id and
self.instance.has_field_changed("target_click") and
not (
self.instance.has_field_changed("target_click_comment") and
self.cleaned_data['target_click_comment']
)
):
self.add_error(
"target_click_comment",
"You must leave a comment when manually changing the adjusted monthly target click!"
)
def save(self, *args, **kwargs):
"""
Override the save method to do the latest modifications to the
document before it is saved, and after the validation passed
"""
if not self.instance.id: # is new
self.instance.client_target_click = self.instance.target_click
self.instance.client_duration = self.instance.duration
self.instance.client_duration_unit = self.instance.duration_unit
return super(SjpAdsCampaignAdminForm, self).save(*args, **kwargs)
class SjpAdsCampaignPushForm(DocumentForm):
csv = fields.TSVField(help_text="Paste Tab-separated-values data (copied from Google sheet for instance).")
group_jobs_by_targeting = forms.BooleanField(
required=False,
help_text="<strong>[Experimentation]</strong>Group jobs in the same adset using their targeting."
)
class Meta(object):
document = SjpAdsCampaign
fields = ('csv', 'group_jobs_by_targeting')
class SjpAdsCampaignGenerateAdminForm(DocumentForm):
job_state = forms.ChoiceField(
label="Job State",
choices=Generator.JOB_STATES.as_django_choices(),
initial=Generator.DEFAULT_JOB_STATE,
help_text=(
"Generate for either new jobs or all jobs in the campaign. "
"'New jobs' means the jobs that don't have any adset yet."
)
)
geolocation_precision = forms.ChoiceField(
label="Geolocation precision",
choices=LocationTargetingGenerator.GEOLOCATION_PRECISION.as_django_choices()
)
extra_targeting = ReferenceField(
ExtraTargetingTemplate.objects,
help_text=(
"An optional extra targeting."
),
required=False
)
bla = forms.ChoiceField(
required=False,
widget=Select2Widget(data_view='audiences_proxy')
)
def _post_clean(self):
if self.cleaned_data["extra_targeting"] is not None: # when the non-default value is selected
self.cleaned_data["extra_targeting"] = str(self.cleaned_data["extra_targeting"].id)
class Meta(object):
model = SjpAdsCampaign
fields = ['job_state', 'geolocation_precision', 'extra_targeting']
class AdsPreviewForm(forms.Form):
company_name = forms.CharField()
image = forms.ImageField()
language = forms.ChoiceField(choices=[
("en", "English"),
("fr", "French")
# TODO: list of supported languages
])
def clean(self):
preview_data = get_ad_preview_data(
self.cleaned_data["language"],
self.cleaned_data["company_name"],
self.cleaned_data["image"]
)
try:
self.cleaned_data["desktop_preview"] = get_ad_preview(preview_data)
self.cleaned_data["mobile_preview"] = get_ad_preview(preview_data, mobile=True)
except (FacebookError, ValueError) as e:
raise ValidationError("%s: %s" % (e.__class__.__name__, e))
return self.cleaned_data
{% extends "admin/base_sjp_child_change_form.html" %}
{% load admin_tweaks %}
{% load assets %}
{% block extrahead %}{{ block.super }}
<style>
.alert {
overflow: scroll;
}
.page-header {
border-color: #DDDDDD;
}
.field-image_urls ul > li > label {
display: inline-block;
}
.field-image_urls ul {
list-style-type: none;
}
.field-image_urls ul > li {
margin-top: 15px;
}
.has-automatic-bid {
text-decoration: line-through;
-webkit-text-decoration: line-through;
}
</style>
{{ extra_forms.push.form.media }}
{{ extra_forms.generation.form.media }}
{% include_js "/js/django_select2.js" %}
{% endblock %}
{% block extrajs %}{{ block.super }}
{% include_js_lib "jquery" %}
{% include_js_lib "bootstrap" %}
{% include_js_lib "underscore" %}
{% include_js_lib "backbone" %}
{% bundle_js "assets/javascripts/apps/sjp_admin/ads_change_form.js" %}
<script type="text/javascript">
window.AdsChangeForm.init($("#sjpadscampaign_form"));
</script>
{% endblock %}
{% block object-tools-items %}{{ block.super }}
{% if ads_campaign.fb_campaign_id %}
<li class="tool-link">
<a href="https://business.facebook.com/ads/manage/summary/campaign/?campaign_id={{ ads_campaign.fb_campaign_id }}"
target="_blank">
<img src="/images/sjp/admin_fb_icon.svg">Facebook campaign
</a>
</li>
{% endif %}
{% if ads_campaign.sjp_campaign.jira_task_url %}
<li class="tool-link">
<a href="{{ ads_campaign.sjp_campaign.jira_task_url|safe }}" target="_blank">
<img src="/images/sjp/admin_jira_icon.svg">Jira Link
</a>
</li>
{% endif %}
{% for page_id, link in page_links %}
<li class="tool-link">
<a href="{{ link }}" target="_blank">
<img src="/images/sjp/admin_page_icon.svg">Page link ({{ page_id }})
</a>
</li>
{% endfor %}
<li class="tool-link">
<a href="{{ simple_backoffice }}">
<img src="/images/sjp/admin_simple_backoffice_icon.svg">(beta) Simple backoffice
</a>
</li>
{% endblock %}
{% block content %}
<h1>{{ads_campaign.sjp_campaign}}</h1>
{{ block.super }}
<div class="inner-two-columns">
<div class="inner-center-column">
<div class="page-header">
<h1>Operational actions <small>generating, pushing, cooking...</small></h1>
</div>
</div>
</div>
{% if ads_campaign.is_over %}
<div class="suit-tab suit-tab-general">
<div class="inner-two-columns">
<div class="inner-center-column">
<div class="alert alert-warning">
<h2 class="legend">Campaign is over.</h2>
</div>
</div>
</div>
</div>
{% endif %}
{% if not ads_campaign.is_inactive %}
<div>
<!-- Generation section, title and alerts -->
<div class="inner-two-columns">
<div class="inner-center-column">
<h2 class="legend">Csv Generation</h2>
{% include "admin/generation_info.html" with csv_url=ads_campaign.generation_info.csv_url %}
</div>
</div>
<!-- Form section -->
<div class="inner-two-columns">
<form action="{{ extra_forms.generation.route }}" method="post" class="form-horizontal">
<!-- Submit box -->
<div class="inner-right-column">
<div class="box save-box">
<div class="submit-row clearfix">
<button type="submit" class="btn btn-high btn-success">Generate CSV</button>
</div>
</div>
</div>
<!-- Form -->
<div class="inner-center-column">
{% admin_form extra_forms.generation.form %}
<input type="hidden" name="sjp_campaign_id" value="{{ads_campaign.sjp_campaign.id}}">
</div>
</form>
</div>
<!-- Push section, title and alerts -->
<div class="inner-two-columns">
<div class="inner-center-column">
<h2 class="legend">Csv Push</h2>
{% if not ads_campaign.last_push_error.resolved %}
{% include "admin/display_error.html" with error=ads_campaign.last_push_error action="push" only %}
{% elif ads_campaign.has_pushed %}
<div class="alert alert-success">
<h2>Last push successful !</h2>
<ul>
<li>Pushed at: {{ads_campaign.last_pushed_at}} </li>
</ul>
</div>
{% else %}
<div class="alert">
<h2> Campaign never pushed to FB</h2>
</div>
{% endif %}
</div>
</div>
<!-- Form section -->
<div class="inner-two-columns">
<form action="{{extra_forms.push.route}}" method="POST" class="form-horizontal">
<!-- Submit box -->
<div class="inner-right-column">
<div class="box save-box">
<div class="submit-row clearfix">
<button type="submit" class="btn btn-high btn-success">Push CSV to facebook</button>
</div>
</div>
</div>
<!-- Form -->
<div class="inner-center-column">
{% admin_form extra_forms.push.form %}
<input type="hidden" name="sjp_campaign_id" value="{{ads_campaign.sjp_campaign.id}}">
</div>
</form>
</div>
<!-- Other -->
<div class="inner-two-columns">
<div class="inner-center-column">
{% if not ads_campaign.last_pause_adset_error.resolved or not ads_campaign.last_redistribute_budget_error.resolve or not ads_campaign.last_update_images_error.resolved %}
<h2 class="legend">Other errors / warnings</h2>
{% endif %}
{% include "admin/display_error.html" with error=ads_campaign.last_pause_adset_error action="pause" only %}
{% include "admin/display_error.html" with error=ads_campaign.last_redistribute_budget_error action="budget" only %}
{% include "admin/display_error.html" with error=ads_campaign.last_update_images_error action="images" only %}
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra-object-tools-items %}
{% if last_used_template_link %}
<li class="tool-link">
<a href="{{ last_used_template_link }}" target="_blank">
<img src="/images/sjp/admin_template.svg"/>Last used template
</a>
</li>
{% endif %}
{% endblock %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment