Created
March 9, 2016 01:55
-
-
Save aparakian/0c9c18111bd179e5b4ab to your computer and use it in GitHub Desktop.
select2 integration
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{% 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