diff --git a/README.md b/README.md index 7863612..9440b38 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,14 @@ Setting | Description | Default `DFP_USE_EXISTING_ORDER_IF_EXISTS` | Whether we should modify an existing order if one already exists with name `DFP_ORDER_NAME` | `False` `DFP_NUM_CREATIVES_PER_LINE_ITEM` | The number of duplicate creatives to attach to each line item. Due to [DFP limitations](https://support.google.com/dfp_sb/answer/82245?hl=en), this should be equal to or greater than the number of ad units you serve on a given page. | the length of setting `DFP_TARGETED_PLACEMENT_NAMES` `DFP_CURRENCY_CODE` | The currency to use in line items. | `'USD'` +`DFP_ALLOW_NO_INVENTORY_TARGETING` | If no placement should be used, for example for a run of network. If True, DFP_TARGETED_PLACEMENT_NAMES still need to be set to an empty array. | `False` +`DFP_ASSOCIATIONS_BATCH` | Determine number of line item/creative associations to be created in one batch. | the number of line items to be created multiplied by `DFP_NUM_CREATIVES_PER_LINE_ITEM` +`PREBID_BIDDER_PARAMS` | Whether DFP targeting keys should be created following Bidders' Params structure. This is used when it's required to send all bids to the ad server. | `False` ## Limitations -* Currently, the names of the bidder code targeting key (`hb_bidder`) and price bucket targeting key (`hb_pb`) are not customizable. The `hb_bidder` targeting key is currently required (see [#18](../../issues/18)) -* This tool does not support additional line item targeting beyond placement, `hb_bidder`, and `hb_pb` values. Placement targeting is currently required (see [#16](../../issues/16)), and targeting by ad unit isn't supported (see [#17](../../issues/17)) +* Currently, the names of the bidder code targeting key (`hb_bidder`) is not customizable. The `hb_bidder` targeting key is currently required (see [#18](../../issues/18)) +* This tool does not support additional line item targeting beyond placement, `hb_bidder`, and `hb_pb` values. Targeting by ad unit isn't supported (see [#17](../../issues/17)) * The price bucketing setting `PREBID_PRICE_BUCKETS` only allows for uniform bucketing. For example, you can create $0.01 buckets from $0 - $20, but you cannot specify $0.01 buckets from $0 - $5 and $0.50 buckets from $5 - $20. Using entirely $0.01 buckets will still work for the custom buckets—you'll just have more line items than you need. * This tool does not modify existing orders or line items, it only creates them. If you need to make a change to an order, it's easiest to archive the existing order and recreate it. diff --git a/dfp/associate_line_items_and_creatives.py b/dfp/associate_line_items_and_creatives.py index 99092cf..d403aae 100644 --- a/dfp/associate_line_items_and_creatives.py +++ b/dfp/associate_line_items_and_creatives.py @@ -4,6 +4,10 @@ from dfp.client import get_client +import settings +import time +import suds +from pprint import pprint logger = logging.getLogger(__name__) @@ -41,10 +45,40 @@ def make_licas(line_item_ids, creative_ids, size_overrides=[]): # settings, as recommended: http://prebid.org/adops/step-by-step.html 'sizes': sizes }) - licas = lica_service.createLineItemCreativeAssociations(licas) - if licas: - logger.info( - u'Created {0} line item <> creative associations.'.format(len(licas))) + associations_batch = getattr(settings, 'DFP_ASSOCIATIONS_BATCH', None) + + if not associations_batch is None: + while licas: + batch = [] + + for b in range(0, associations_batch): + if licas: + batch.append(licas.pop(0)) + + try: + time.sleep(1) + batch = lica_service.createLineItemCreativeAssociations(batch) + except suds.WebFault as err: + logger.info(u'A common error was raised (it can happen). Waiting 30 seconds and retrying...') + time.sleep(30) + try: + batch = lica_service.createLineItemCreativeAssociations(batch) + except suds.WebFault as err: + logger.info(u'A common error was raised (it can happen). Waiting 30 seconds and retrying...') + time.sleep(30) + batch = lica_service.createLineItemCreativeAssociations(batch) + + if batch: + logger.info( + u'Created {0} line item <> creative associations.'.format(len(batch))) + else: + logger.info(u'No line item <> creative associations created.') else: - logger.info(u'No line item <> creative associations created.') + licas = lica_service.createLineItemCreativeAssociations(licas) + + if licas: + logger.info( + u'Created {0} line item <> creative associations.'.format(len(licas))) + else: + logger.info(u'No line item <> creative associations created.') diff --git a/dfp/create_creatives.py b/dfp/create_creatives.py index e973c54..40deb8d 100644 --- a/dfp/create_creatives.py +++ b/dfp/create_creatives.py @@ -7,6 +7,8 @@ from dfp.client import get_client +import settings + logger = logging.getLogger(__name__) @@ -48,6 +50,19 @@ def create_creative_config(name, advertiser_id): with open(snippet_file_path, 'r') as snippet_file: snippet = snippet_file.read() + # Determine what bidder params should be + bidder_code = getattr(settings, 'PREBID_BIDDER_CODE', None) + bidder_params = getattr(settings, 'PREBID_BIDDER_PARAMS', None) + hb_adid_key = 'hb_adid' + + if bidder_params is True: + hb_adid_key += '_' + bidder_code + + if len(hb_adid_key) > 20: + hb_adid_key = hb_adid_key[:20] + + snippet = snippet.replace('{hb_adid_key}', hb_adid_key) + # https://developers.google.com/doubleclick-publishers/docs/reference/v201802/CreativeService.Creative config = { 'xsi_type': 'ThirdPartyCreative', diff --git a/dfp/create_line_items.py b/dfp/create_line_items.py index 8763926..ca66368 100755 --- a/dfp/create_line_items.py +++ b/dfp/create_line_items.py @@ -3,6 +3,7 @@ from dfp.client import get_client +import settings def create_line_items(line_items): """ @@ -25,7 +26,7 @@ def create_line_items(line_items): def create_line_item_config(name, order_id, placement_ids, cpm_micro_amount, sizes, hb_bidder_key_id, hb_pb_key_id, hb_bidder_value_id, hb_pb_value_id, - currency_code='USD'): + currency_code='USD', root_ad_unit_id=None): """ Creates a line item config object. @@ -56,13 +57,18 @@ def create_line_item_config(name, order_id, placement_ids, cpm_micro_amount, # https://github.com/googleads/googleads-python-lib/blob/master/examples/dfp/v201802/line_item_service/target_custom_criteria.py # create custom criterias - hb_bidder_criteria = { - 'xsi_type': 'CustomCriteria', - 'keyId': hb_bidder_key_id, - 'valueIds': [hb_bidder_value_id], - 'operator': 'IS' + inventory = { + 'targetedPlacementIds': placement_ids } + if not root_ad_unit_id is None: + inventory = { + 'targetedAdUnits': [{ + 'adUnitId': root_ad_unit_id, + 'includeDescendants': 'true' + }] + } + hb_pb_criteria = { 'xsi_type': 'CustomCriteria', 'keyId': hb_pb_key_id, @@ -70,13 +76,24 @@ def create_line_item_config(name, order_id, placement_ids, cpm_micro_amount, 'operator': 'IS' } - # The custom criteria will resemble: - # (hb_bidder_criteria.key == hb_bidder_criteria.value AND - # hb_pb_criteria.key == hb_pb_criteria.value) + children = [hb_pb_criteria] + + bidder_params = getattr(settings, 'PREBID_BIDDER_PARAMS', None) + + if not bidder_params is True: + hb_bidder_criteria = { + 'xsi_type': 'CustomCriteria', + 'keyId': hb_bidder_key_id, + 'valueIds': [hb_bidder_value_id], + 'operator': 'IS' + } + + children.append(hb_bidder_criteria) + top_set = { 'xsi_type': 'CustomCriteriaSet', 'logicalOperator': 'AND', - 'children': [hb_bidder_criteria, hb_pb_criteria] + 'children': children } # https://developers.google.com/doubleclick-publishers/docs/reference/v201802/LineItemService.LineItem @@ -85,9 +102,7 @@ def create_line_item_config(name, order_id, placement_ids, cpm_micro_amount, 'orderId': order_id, # https://developers.google.com/doubleclick-publishers/docs/reference/v201802/LineItemService.Targeting 'targeting': { - 'inventoryTargeting': { - 'targetedPlacementIds': placement_ids - }, + 'inventoryTargeting': inventory, 'customTargeting': top_set, }, 'startDateTimeType': 'IMMEDIATELY', diff --git a/dfp/creative_snippet.html b/dfp/creative_snippet.html index 4a68081..5437527 100644 --- a/dfp/creative_snippet.html +++ b/dfp/creative_snippet.html @@ -4,7 +4,7 @@ w = w.parent; if (w.pbjs) { try { - w.pbjs.renderAd(document, '%%PATTERN:hb_adid%%'); + w.pbjs.renderAd(document, '%%PATTERN:{hb_adid_key}%%'); break; } catch (e) { continue; diff --git a/dfp/get_ad_units.py b/dfp/get_ad_units.py new file mode 100644 index 0000000..89d936f --- /dev/null +++ b/dfp/get_ad_units.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging + +from googleads import dfp + +from dfp.client import get_client + + +logger = logging.getLogger(__name__) + +def get_all_ad_units(print_ad_units=False): + """ + Gets ad units from DFP. + + Returns: + array of ad units + """ + + # Initialize units array + ad_units = [] + + dfp_client = get_client() + + # Initialize appropriate service. + ad_unit_service = dfp_client.GetService('InventoryService', version='v201802') + + # Create a statement to select ad units. + statement = dfp.StatementBuilder() + + # Retrieve a small amount of ad units at a time, paging + # through until all ad units have been retrieved. + while True: + response = ad_unit_service.getAdUnitsByStatement(statement.ToStatement()) + if 'results' in response: + for ad_unit in response['results']: + ad_units.append(ad_unit) + if print_ad_units: + print('Ad unit with ID "%s" and name "%s" was found.' % (ad_unit['id'], ad_unit['name'])) + statement.offset += dfp.SUGGESTED_PAGE_LIMIT + else: + break + + return ad_units + +def get_root_ad_unit_id(): + """ + Gets root ad unit ID from DFP. + + Returns: + an ad unit ID, or None + """ + + dfp_client = get_client() + network_service = dfp_client.GetService('NetworkService', version='v201802') + current_network = network_service.getCurrentNetwork() + + if hasattr(current_network, 'effectiveRootAdUnitId'): + return current_network.effectiveRootAdUnitId + + return None + +def main(): + get_all_ad_units(print_ad_units=True) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/settings.py b/settings.py index 47c4377..d4854da 100644 --- a/settings.py +++ b/settings.py @@ -18,7 +18,7 @@ # The exact name of the DFP advertiser for the created order DFP_ADVERTISER_NAME = None -# Names of placements the line items should target. +# Names of placements the line items should target. Has priority over ad units. DFP_TARGETED_PLACEMENT_NAMES = [] # Sizes of placements. These are used to set line item and creative sizes. @@ -33,6 +33,10 @@ }, ] +# If no placement should be used, for example for a run of network. If True, +# DFP_TARGETED_PLACEMENT_NAMES still need to be set to an empty array. +DFP_ALLOW_NO_INVENTORY_TARGETING = False + # Whether we should create the advertiser in DFP if it does not exist. # If False, the program will exit rather than create an advertiser. DFP_CREATE_ADVERTISER_IF_DOES_NOT_EXIST = False @@ -58,12 +62,23 @@ # The currency to use in DFP when setting line item CPMs. Defaults to 'USD'. # DFP_CURRENCY_CODE = 'USD' +# Optional +# Determine if line items and creative should be associated in batch. +# Useful to avoid timeouts if many of them have to be created. +# DFP_ASSOCIATIONS_BATCH = 50 + ######################################################################### # PREBID SETTINGS ######################################################################### PREBID_BIDDER_CODE = None +# Whether DFP targeting keys should be created following Bidders' Params structure. +# This is used when it's required to send all bids to the ad server. +# See: http://prebid.org/dev-docs/bidders.html +# And: http://prebid.org/adops/send-all-bids-adops.html +PREBID_BIDDER_PARAMS = False + # Price buckets. This should match your Prebid settings for the partner. See: # http://prebid.org/dev-docs/publisher-api-reference.html#module_pbjs.setPriceGranularity # FIXME: this should be an array of buckets. See: @@ -81,4 +96,4 @@ try: from local_settings import * except ImportError: - pass + pass \ No newline at end of file diff --git a/tasks/add_new_prebid_partner.py b/tasks/add_new_prebid_partner.py index 00698d9..4144942 100755 --- a/tasks/add_new_prebid_partner.py +++ b/tasks/add_new_prebid_partner.py @@ -6,6 +6,7 @@ import sys from builtins import input from pprint import pprint +import sys from colorama import init @@ -19,9 +20,11 @@ import dfp.get_custom_targeting import dfp.get_placements import dfp.get_users +import dfp.get_ad_units from dfp.exceptions import ( BadSettingException, - MissingSettingException + MissingSettingException, + DFPObjectNotFound ) from tasks.price_utils import ( get_prices_array, @@ -70,15 +73,38 @@ def setup_partner(user_email, advertiser_name, order_name, placements, # Create creatives. creative_configs = dfp.create_creatives.create_duplicate_creative_configs( bidder_code, order_name, advertiser_id, num_creatives) + creative_ids = dfp.create_creatives.create_creatives(creative_configs) + # Determine what bidder params should be + bidder_params = getattr(settings, 'PREBID_BIDDER_PARAMS', None) + hb_pb_key = 'hb_pb' + + if bidder_params is True: + hb_pb_key += '_' + bidder_code + hb_adid_key = 'hb_adid_' + bidder_code + hb_size_key = 'hb_size_' + bidder_code + + if len(hb_pb_key) > 20: + hb_pb_key = hb_pb_key[:20] + + if len(hb_adid_key) > 20: + hb_adid_key = hb_adid_key[:20] + + if len(hb_size_key) > 20: + hb_size_key = hb_size_key[:20] + + # Create adid and size keys + get_or_create_dfp_targeting_key(hb_adid_key) + get_or_create_dfp_targeting_key(hb_size_key) + # Get DFP key IDs for line item targeting. hb_bidder_key_id = get_or_create_dfp_targeting_key('hb_bidder') - hb_pb_key_id = get_or_create_dfp_targeting_key('hb_pb') + hb_pb_key_id = get_or_create_dfp_targeting_key(hb_pb_key) # Instantiate DFP targeting value ID getters for the targeting keys. HBBidderValueGetter = DFPValueIdGetter('hb_bidder') - HBPBValueGetter = DFPValueIdGetter('hb_pb') + HBPBValueGetter = DFPValueIdGetter(hb_pb_key) # Create line items. line_items_config = create_line_item_configs(prices, order_id, @@ -87,6 +113,7 @@ def setup_partner(user_email, advertiser_name, order_name, placements, logger.info("Creating line items...") line_item_ids = dfp.create_line_items.create_line_items(line_items_config) + logger.info("Associating creatives with line items...") # Associate creatives with line items. dfp.associate_line_items_and_creatives.make_licas(line_item_ids, creative_ids, size_overrides=sizes) @@ -181,6 +208,15 @@ def create_line_item_configs(prices, order_id, placement_ids, bidder_code, hb_bidder_value_id = HBBidderValueGetter.get_value_id(bidder_code) line_items_config = [] + root_ad_unit_id = None + + if not placement_ids: + # Since the placement ids array is empty, it means we should target a run of network for the line item + root_ad_unit_id = dfp.get_ad_units.get_root_ad_unit_id() + + if root_ad_unit_id is None: + raise DFPObjectNotFound('Could not find the root ad unit to target a run of network.') + for price in prices: price_str = num_to_str(micro_amount_to_num(price)) @@ -205,6 +241,7 @@ def create_line_item_configs(prices, order_id, placement_ids, bidder_code, hb_bidder_value_id=hb_bidder_value_id, hb_pb_value_id=hb_pb_value_id, currency_code=currency_code, + root_ad_unit_id=root_ad_unit_id ) line_items_config.append(config) @@ -278,14 +315,15 @@ def main(): raise MissingSettingException('DFP_ORDER_NAME') placements = getattr(settings, 'DFP_TARGETED_PLACEMENT_NAMES', None) + no_inventory = getattr(settings, 'DFP_ALLOW_NO_INVENTORY_TARGETING', None) if placements is None: raise MissingSettingException('DFP_TARGETED_PLACEMENT_NAMES') - elif len(placements) < 1: + elif len(placements) < 1 and no_inventory is not True: raise BadSettingException('The setting "DFP_TARGETED_PLACEMENT_NAMES" ' 'must contain at least one DFP placement ID.') sizes = getattr(settings, 'DFP_PLACEMENT_SIZES', None) - if sizes is None: + if sizes is None : raise MissingSettingException('DFP_PLACEMENT_SIZES') elif len(sizes) < 1: raise BadSettingException('The setting "DFP_PLACEMENT_SIZES" ' @@ -301,10 +339,42 @@ def main(): len(placements) ) + # In the case where no inventory is being used, make sure at least one + # creative is created + if not num_creatives > 0: + num_creatives = 1 + bidder_code = getattr(settings, 'PREBID_BIDDER_CODE', None) if bidder_code is None: raise MissingSettingException('PREBID_BIDDER_CODE') + bidder_params = getattr(settings, 'PREBID_BIDDER_PARAMS', None) + hb_pb_key = 'hb_pb' + additional_keys = '' + + if bidder_params is True: + hb_pb_key += '_' + bidder_code + hb_adid_key = 'hb_adid_' + bidder_code + hb_size_key = 'hb_size_' + bidder_code + + if len(hb_pb_key) > 20: + hb_pb_key = hb_pb_key[:20] + + if len(hb_adid_key) > 20: + hb_adid_key = hb_adid_key[:20] + + if len(hb_size_key) > 20: + hb_size_key = hb_size_key[:20] + + additional_keys = u""" + + Additionally, keys {name_start_format}{hb_adid_key}{format_end} and {name_start_format}{hb_size_key}{format_end} will be created.""".format( + hb_adid_key=hb_adid_key, + hb_size_key=hb_size_key, + name_start_format=color.BOLD, + format_end=color.END, + ) + price_buckets = getattr(settings, 'PREBID_PRICE_BUCKETS', None) if price_buckets is None: raise MissingSettingException('PREBID_PRICE_BUCKETS') @@ -324,15 +394,16 @@ def main(): {name_start_format}Owner{format_end}: {value_start_format}{user_email}{format_end} Line items will have targeting: - {name_start_format}hb_pb{format_end} = {value_start_format}{prices_summary}{format_end} + {name_start_format}{hb_pb_key}{format_end} = {value_start_format}{prices_summary}{format_end} {name_start_format}hb_bidder{format_end} = {value_start_format}{bidder_code}{format_end} - {name_start_format}placements{format_end} = {value_start_format}{placements}{format_end} + {name_start_format}placements{format_end} = {value_start_format}{placements}{format_end}{additional_keys} """.format( num_line_items = len(prices), order_name=order_name, advertiser=advertiser_name, user_email=user_email, + hb_pb_key=hb_pb_key, prices_summary=prices_summary, bidder_code=bidder_code, placements=placements, @@ -340,6 +411,7 @@ def main(): name_start_format=color.BOLD, format_end=color.END, value_start_format=color.BLUE, + additional_keys=additional_keys )) ok = input('Is this correct? (y/n)\n') diff --git a/tests/test_dfp_create_creatives.py b/tests/test_dfp_create_creatives.py index 0cbb4d4..b79fe87 100644 --- a/tests/test_dfp_create_creatives.py +++ b/tests/test_dfp_create_creatives.py @@ -43,6 +43,19 @@ def test_create_creative_config(self, mock_dfp_client): with open(snippet_file_path, 'r') as snippet_file: snippet = snippet_file.read() + # Determine what bidder params should be + bidder_code = getattr(settings, 'PREBID_BIDDER_CODE', None) + bidder_params = getattr(settings, 'PREBID_BIDDER_PARAMS', None) + hb_adid_key = 'hb_adid' + + if bidder_params is True: + hb_adid_key += '_' + bidder_code + + if len(hb_adid_key) > 20: + hb_adid_key = hb_adid_key[:20] + + snippet = snippet.replace('{hb_adid_key}', hb_adid_key) + self.assertEqual( dfp.create_creatives.create_creative_config( name='My Creative', diff --git a/tests/test_dfp_create_line_items.py b/tests/test_dfp_create_line_items.py index 119f2c2..459cfa7 100644 --- a/tests/test_dfp_create_line_items.py +++ b/tests/test_dfp_create_line_items.py @@ -28,6 +28,7 @@ def test_create_line_items_call(self, mock_dfp_client): hb_pb_key_id=888888, hb_bidder_value_id=222222, hb_pb_value_id=111111, + root_ad_unit_id=None ) ] @@ -57,6 +58,7 @@ def test_create_line_item_config(self, mock_dfp_client): hb_pb_key_id=888888, hb_bidder_value_id=222222, hb_pb_value_id=111111, + root_ad_unit_id=None ), { 'orderId': 1234567, @@ -68,15 +70,15 @@ def test_create_line_item_config(self, mock_dfp_client): 'customTargeting': { 'children': [ { - 'keyId': 999999, + 'keyId': 888888, 'operator': 'IS', - 'valueIds': [222222], + 'valueIds': [111111], 'xsi_type': 'CustomCriteria' }, { - 'keyId': 888888, + 'keyId': 999999, 'operator': 'IS', - 'valueIds': [111111], + 'valueIds': [222222], 'xsi_type': 'CustomCriteria' } ], @@ -120,6 +122,7 @@ def test_create_line_item_config(self, mock_dfp_client): hb_bidder_value_id=222222, hb_pb_value_id=111111, currency_code='EUR', + root_ad_unit_id=None ), { 'orderId': 22334455, @@ -131,15 +134,15 @@ def test_create_line_item_config(self, mock_dfp_client): 'customTargeting': { 'children': [ { - 'keyId': 999999, + 'keyId': 888888, 'operator': 'IS', - 'valueIds': [222222], + 'valueIds': [111111], 'xsi_type': 'CustomCriteria' }, { - 'keyId': 888888, + 'keyId': 999999, 'operator': 'IS', - 'valueIds': [111111], + 'valueIds': [222222], 'xsi_type': 'CustomCriteria' } ], diff --git a/tests/test_dfp_get_ad_units.py b/tests/test_dfp_get_ad_units.py new file mode 100644 index 0000000..76c36dd --- /dev/null +++ b/tests/test_dfp_get_ad_units.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python + +from unittest import TestCase +from mock import MagicMock, Mock, patch + +import dfp.get_orders + + +@patch('googleads.dfp.DfpClient.LoadFromStorage') +class DFPServiceTests(TestCase): + + def test_get_all_ad_units(self, mock_dfp_client): + """ + Ensure `get_all_ad_units` makes one call to DFP. + """ + mock_dfp_client.return_value = MagicMock() + + # Response for fetching ad units. + (mock_dfp_client.return_value + .GetService.return_value + .getAdUnitsByStatement) = MagicMock() + + dfp.get_ad_units.get_all_ad_units() + + # Confirm that it loaded the mock DFP client. + mock_dfp_client.assert_called_once() + + expected_arg = {'query': 'LIMIT 500 OFFSET 0', 'values': None} + (mock_dfp_client.return_value + .GetService.return_value + .getAdUnitsByStatement.assert_called_once_with(expected_arg) + ) \ No newline at end of file