Skip to content

Instantly share code, notes, and snippets.

@aparakian
Created August 25, 2016 23:24
Show Gist options
  • Save aparakian/cfb55967826f1beb9221026b637a963e to your computer and use it in GitHub Desktop.
Save aparakian/cfb55967826f1beb9221026b637a963e to your computer and use it in GitHub Desktop.
from copy import copy, deepcopy
import logging
from celery import subtask
from ads_app.api.objects import AdSet, Ad
from .base import BaseFacebookCampaignHandler
from sjp_app.ads.utils import get_ad_object_job_id
from sjp_app.utils.campaign_actions import log_campaign_action, ACTIONS
from w4.documents.mongo_job import MongoJob
class FBCampaignUpdater(BaseFacebookCampaignHandler):
"""
This class is used to update a campaign and its related objects. It can
pause ads / adsets, change the campaign budget, bid, etc...
PLEASE MAKE SURE THAT NO CODE IS DUPLICATED BETWEEN THIS CLASS
AND THE Pusher. IF YOU END UP DUPLICATING CODE, IT MEANS YOU'RE
DOING SOMETHING WRONG: EITHER CREATE A UTILS METHOD THAT IS USED
BY BOTH THIS CLASS AND THE Pusher OR EMPOWER OUR OBJECTS IN THE
`ads_app.api.objects` MODULE.
IF YOU'RE LOST AND CANNOT FIND A WAY TO DO THINGS CLEANLY, ASK
THE HELP OF A SENIOR DEVELOPER.
This class is organized as follow:
- campaign related methods
- adset related methods
- ads related methods
- subroutines
Please respect this organization.
"""
def __init__(self, sjp_campaign):
"""
:param sjp_campaign: a SjpCampaign object
"""
super(FBCampaignUpdater, self).__init__(sjp_campaign)
self.fb_campaign_id = sjp_campaign.fb_campaign_id
# adsets related methods
def update_adsets_bid(self):
"""
Update the bid type and value of all adsets in campaign
and send event to datadog
"""
adsets = self.api.get_adset_for_campaign(self.fb_campaign_id)
params = AdSet.get_bid_parameters(
self.sjp_campaign.billing_event,
self.sjp_campaign.optimization_goal,
self.sjp_campaign.bid_value,
self.sjp_campaign.automatic_bid
)
params.update(AdSet.get_promoted_object(self.sjp_campaign.conversion_event))
self._update_batch_of_objects(adsets, params)
change = "updating adsets bid"
title = "Bid changed for campaign",
text = "New bid type: %s. New bid value: %s cents." % (
self.sjp_campaign.billing_event, self.sjp_campaign.bid_value
)
self.check_errors_and_alert(change, title, text, adsets)
def update_adsets_placement(self):
'''
Update the placement of all adsets in campaign and send event to datadog
'''
# we need to read the current targeting to update only the relevant part
adsets = self.api.get_adset_for_campaign(self.fb_campaign_id, fields=[AdSet.Field.targeting])
params = {
AdSet.Field.targeting: {"page_types": self.sjp_campaign.placement}
}
self._update_batch_of_objects(adsets, params)
change = "updating adsets placement"
title = "Placement changed for campaign"
text = "New placement: %s." % self.sjp_campaign.placement
self.check_errors_and_alert(change, title, text, adsets)
def pause_adsets_on_inactive_jobs(self, check_filters=False):
"""
Pause every adset that relates to an inactive
job, using batch.
:param check_filters: check if your jobs are in campaign filters
"""
adsets = self.api.get_adset_for_campaign(self.fb_campaign_id)
adsets_to_be_paused = self.filter_ad_object_of_inactive_jobs(adsets, check_filters=check_filters)
if adsets_to_be_paused:
with log_campaign_action(ACTIONS.PAUSE_ADSETS, self.sjp_campaign):
self.pause_batch_of_adsets(adsets_to_be_paused)
def update_adsets_end_date(self, end_date):
"""
Update every end_date of each active adset of a campaign.
"""
adsets = self.api.get_adset_for_campaign(self.fb_campaign_id)
params = {AdSet.Field.end_time: end_date.strftime('%Y-%m-%d')}
self._update_batch_of_objects(adsets, params)
change = "updating adsets end_date"
title = "Updated campaign end_date to %s." % self.sjp_campaign.end_date.strftime('%Y-%m-%d')
text = "All adsets of campaign %s had their end_date updated." % self.sjp_campaign.fb_campaign_id
self.check_errors_and_alert(change, title, text, adsets)
def pause_batch_of_adsets(self, adsets):
'''
Pause all the given adsets and send stats to datadog
'''
params = {AdSet.Field.status: AdSet.Status.paused}
self._update_batch_of_objects(adsets, params)
change = "pausing adsets"
title = "Adset has been paused for campaign"
self.check_errors_and_alert(change, title, adsets)
# launch redistribution right away
subtask("redistribute_campaign_budget").apply_async(kwargs={'sjp_campaign_id': str(self.sjp_campaign.id)})
@log_campaign_action(ACTIONS.REDISTRIBUTE_BUDGET)
def update_adsets_daily_budget(self, budget):
"""
Update the daily_budget to the given budget for all active adset in the campaign
Args:
- (int) budget : new daily budget (>0, in cents)
"""
if budget <= 0:
raise ValueError('Budget should be > 0')
adsets = self.api.get_adset_for_campaign(self.fb_campaign_id)
self._update_batch_of_objects(adsets, {AdSet.Field.daily_budget: str(budget)})
change = "updating adsets daily budget"
self.check_errors_and_alert(change, adsets)
# ads related methods
@log_campaign_action(ACTIONS.ADD_IMAGES)
def add_new_images_to_campaign(self, new_images):
'''
Update images of all adsets in campaign and send event to datadog
'''
# create the image adlabels
image_adlabels = self.create_image_adlabels(new_images)
# FIXME: for the carousel ads, we should make sure we add the image in each
# carousel, so the grouping by job_id won't work, instead we should just
# take all the carousel ads and add the image inside
ads = self.api.get_ads_for_campaign(self.fb_campaign_id, fields=[
Ad.Field.name,
Ad.Field.adset,
"creative{object_story_spec}",
Ad.Field.adlabels,
])
# this is a small trick to get 1 ad per (job, adset)
# (a job can be advertised with several adsets)
ads_by_job = {
(ad.adset["id"], get_ad_object_job_id(ad)): ad
for ad in ads
if get_ad_object_job_id(ad)
}
batch = self.api.new_batch()
new_ads = []
for existing_ad in ads_by_job.itervalues():
for image_params in image_adlabels:
ad = copy(existing_ad)
ad.replace_image(image_params["image"].facebook_img_hash, image_params["adlabel"])
ad.remote_create(adset=ad.adset, batch=batch)
new_ads.append(ad)
batch.execute()
change = "adding new images"
title = "New images added for campaign"
text = "%d ads created." % len(new_ads)
self.check_errors_and_alert(change, title, text, new_ads)
@log_campaign_action(ACTIONS.REMOVE_IMAGES)
def remove_images_from_campaign(self, removed_images):
"""
Remove images from a campaign. We simply retrieve all the ads,
all the creatives of these ads, and compare the name of the
creatives to to the list of google_ids we have.
:param removed_images: a list of image google_ids
"""
# get the image adlabels. we can't filter on the /adlabel route
# so it's by far the easiest way
image_adlabels = self.create_image_adlabels(removed_images)
ads = self.api.account.get_ads_by_labels(params={
"ad_label_ids": [params["adlabel"].get_id() for params in image_adlabels],
}, fields=[
Ad.Field.campaign_id,
Ad.Field.effective_status,
])
# Restrict to our campaign and to active ads
ads_to_be_paused = [
ad
for ad in ads
if (
ad[Ad.Field.campaign_id] == str(self.fb_campaign_id) and
ad[Ad.Field.effective_status] in [Ad.EffectiveStatus.active, Ad.EffectiveStatus.campaign_paused]
)
]
if ads_to_be_paused:
# NB: we do not delete the adlabels because images are cross-campaigns
self.pause_batch_of_ads(ads_to_be_paused, "image removed")
def pause_ads_on_inactive_jobs(self, check_filters=False):
"""
Pause every ads that relates to an inactive
job, using batch.
:param check_filters: check if your jobs are in campaign filters
"""
ads = self.api.get_ads_for_campaign(self.fb_campaign_id)
ads_to_be_paused = self.filter_ad_object_of_inactive_jobs(ads, check_filters=check_filters)
if ads_to_be_paused:
with log_campaign_action(ACTIONS.PAUSE_ADS, self.sjp_campaign):
return self.pause_batch_of_ads(ads_to_be_paused)
def pause_batch_of_ads(self, ads, reason=None):
'''
Pause all the given adsets and send stats to datadog
'''
params = {Ad.Field.status: Ad.Status.paused}
warnings = self._update_batch_of_objects(ads, params)
# FIXME: we don't do stats because this is only called manually (bundled jobs)
change = "pausing ads"
title = "Ad has been paused for campaign"
text = "Reason: %s." % reason
tags = ['reason:%s' % reason]
self.check_errors_and_alert(change, title, text, tags, ads)
# subroutines
def filter_ad_object_of_inactive_jobs(self, objects, check_filters=False):
"""
Filter a list of object and return the one tied to inactive jobs. If
`check_filters` is `True` this list will also include objects tied to
jobs that are no longer in the campaign filter. The objects are supposed
to be either `Ad` or `AdSet` instances.
:param objects: a list of objects to verify
:param check_filters: check if the related ad object job is in campaign filters
"""
stale_objects = []
if check_filters:
job_ids_in_filters = self.sjp_campaign.get_active_jobs_in_filters()
for obj in objects:
job_id = get_ad_object_job_id(obj)
if not job_id:
logging.debug("[Updater] No job_id found")
continue
logging.debug("[Updater] Job id found %s" % job_id)
job = MongoJob.objects.filter(job_id=job_id).first()
if not job:
logging.debug("[Updater] No job found with job_id %s" % job_id)
continue
if not job.is_active() or (check_filters and job.job_id not in job_ids_in_filters):
logging.debug("[Updater] Pausing adset [%s] %s" % (obj.get_id(), obj[obj.Field.name]))
stale_objects.append(obj)
return stale_objects
def _update_batch_of_objects(self, adsets, params):
"""
Update a batch of adsets given some parameters.
:param adsets: an iterable of adsets to update
:param params: the params to set on the adsets
"""
batch = self.api.new_batch()
for adset in adsets:
adset.remote_update(params=deepcopy(params), batch=batch)
batch.execute()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment