From dd743657be4ead9246d548c6d89b150a4bf60dbe Mon Sep 17 00:00:00 2001 From: Thuc Nguyen <59547942+thucngyyen@users.noreply.github.com> Date: Tue, 31 Jan 2023 07:04:23 -0800 Subject: [PATCH] Add conversion tag and conversion event models (#69) (#70) * Add conversions models * Lint --- .../ads/test_conversion_events.py | 91 ++++++ integration_tests/ads/test_conversion_tags.py | 99 ++++++ pinterest/ads/conversion_events.py | 116 +++++++ pinterest/ads/conversion_tags.py | 294 ++++++++++++++++++ .../pinterest/ads/test_conversion_events.py | 70 +++++ .../src/pinterest/ads/test_conversion_tags.py | 261 ++++++++++++++++ 6 files changed, 931 insertions(+) create mode 100644 integration_tests/ads/test_conversion_events.py create mode 100644 integration_tests/ads/test_conversion_tags.py create mode 100644 pinterest/ads/conversion_events.py create mode 100644 pinterest/ads/conversion_tags.py create mode 100644 tests/src/pinterest/ads/test_conversion_events.py create mode 100644 tests/src/pinterest/ads/test_conversion_tags.py diff --git a/integration_tests/ads/test_conversion_events.py b/integration_tests/ads/test_conversion_events.py new file mode 100644 index 0000000..8179592 --- /dev/null +++ b/integration_tests/ads/test_conversion_events.py @@ -0,0 +1,91 @@ +""" +Test Conversion Model +""" +import os as _os +from integration_tests.base_test import BaseTestCase +from integration_tests.config import DEFAULT_AD_ACCOUNT_ID + +from pinterest.client import PinterestSDKClient +from pinterest.ads.conversion_events import Conversion + +class TestSendConversionEvent(BaseTestCase): + """ + Test send Conversion Event + """ + + def test_send_conversion_success(self): + """ + Test send ConversionEvent successfully + """ + client = PinterestSDKClient.create_client_with_token(_os.environ.get('CONVERSION_ACCESS_TOKEN')) + + NUMBER_OF_CONVERSION_EVENTS = 2 + raw_user_data = dict( + em = ["964bbaf162703657e787eb4455197c8b35c18940c75980b0285619fe9b8acec8"] #random hash256 + ) + conversion_events = [ + Conversion.create_conversion_event( + event_name = "add_to_cart", + action_source = "app_ios", + event_time = 1670026573, + event_id = "eventId0001", + user_data = raw_user_data, + ) + for _ in range(NUMBER_OF_CONVERSION_EVENTS) + ] + + response = Conversion.send_conversion_events( + client = client, + ad_account_id = DEFAULT_AD_ACCOUNT_ID, + conversion_events = conversion_events, + test = True, + ) + + assert response + assert response.num_events_received == 2 + assert response.num_events_processed == 2 + assert len(response.events) == 2 + + assert response.events[0].status == "processed" + assert response.events[0].error_message == "" + assert response.events[0].warning_message == "" + + assert response.events[1].status == "processed" + assert response.events[1].error_message == "" + assert response.events[1].warning_message == "" + + def test_send_conversion_fail(self): + """ + Test send ConversionEvent fail with non-hashed email + """ + client = PinterestSDKClient.create_client_with_token(_os.environ.get('CONVERSION_ACCESS_TOKEN')) + + NUMBER_OF_CONVERSION_EVENTS = 2 + raw_user_data = dict( + em = ["test_non_hashed_email@pinterest.com"] + ) + conversion_events = [ + Conversion.create_conversion_event( + event_name = "add_to_cart", + action_source = "app_ios", + event_time = 1670026573, + event_id = "eventId0001", + user_data = raw_user_data, + ) + for _ in range(NUMBER_OF_CONVERSION_EVENTS) + ] + + response = Conversion.send_conversion_events( + client = client, + ad_account_id = DEFAULT_AD_ACCOUNT_ID, + conversion_events = conversion_events, + test = True, + ) + + assert response + assert response.num_events_received == 2 + assert response.num_events_processed == 2 + assert len(response.events) == 2 + + assert response.events[0].warning_message == "'em' is not in sha256 hashed format." + assert response.events[1].warning_message == "'em' is not in sha256 hashed format." diff --git a/integration_tests/ads/test_conversion_tags.py b/integration_tests/ads/test_conversion_tags.py new file mode 100644 index 0000000..6fb6f35 --- /dev/null +++ b/integration_tests/ads/test_conversion_tags.py @@ -0,0 +1,99 @@ +""" +Test Conversion Tag Model +""" + +from integration_tests.base_test import BaseTestCase +from integration_tests.config import DEFAULT_AD_ACCOUNT_ID + +from pinterest.ads.conversion_tags import ConversionTag + +class TestCreateConversionTag(BaseTestCase): + """ + Test creating Conversion Tag + """ + + def test_create_conversion_tag_success(self): + """ + Test creating a new Conversion Tag successfully + """ + conversion_tag = ConversionTag.create( + ad_account_id = DEFAULT_AD_ACCOUNT_ID, + name = "Test Conversion Tag" + ) + + assert conversion_tag + assert getattr(conversion_tag, "_id") + assert getattr(conversion_tag, "_name") == "Test Conversion Tag" + +class TestGetConversionTag(BaseTestCase): + """ + Test get Conversion Tag + """ + + def test_get_conversion_tag_success(self): + """ + Test get conversion tag from existing conversion tag + """ + exiting_conversion_tag_id = self.conversion_tag_utils.get_conversion_tag_id() + conversion_tag = ConversionTag( + client = self.test_client, + ad_account_id = DEFAULT_AD_ACCOUNT_ID, + conversion_tag_id = exiting_conversion_tag_id, + ) + + assert conversion_tag + assert getattr(conversion_tag, "_id") + assert getattr(conversion_tag, "_name") == getattr(self.conversion_tag_utils.get_conversion_tag(), "_name") + +class TestGetListConversionTag(BaseTestCase): + """ + Test get list of ConversionTags + """ + def test_get_list_success(self): + """ + Test get list successfully + """ + # Create new account so the integration test does not get slow as number of conversion + # tags increasing while testing + ad_account_id = getattr(self.ad_account_utils.create_new_ad_account(), "_id") + + NUMBER_OF_NEW_CONVERSION_TAG = 3 + for _ in range(NUMBER_OF_NEW_CONVERSION_TAG): + self.conversion_tag_utils.create_new_conversion_tag( + name = "SDK_TEST_CONVERSION_TAG", + ad_account_id = ad_account_id, + ) + + conversion_tags = ConversionTag.get_all(ad_account_id = ad_account_id) + + assert len(conversion_tags) == NUMBER_OF_NEW_CONVERSION_TAG + +class TestGetPageVsitConversionTag(BaseTestCase): + """ + Test get page visit conversion tag events + """ + def test_get_page_visit_success(self): + """ + Test get page visit converion tag events for an Ad Account + """ + conversion_tag_events, bookmark = ConversionTag.get_page_visit_conversion_tag_events( + ad_account_id = DEFAULT_AD_ACCOUNT_ID + ) + + assert not conversion_tag_events + assert not bookmark + +class TestGetOcpmEligibleConversionTag(BaseTestCase): + """ + Test get ocpm eligible conversion tag events + """ + def test_get_ocpm_eligible_conversion_tags(self): + """ + Test get ocpm eligible conversion tag events for an Ad Account + """ + property, conversion_tag_events = ConversionTag.get_ocpm_eligible_conversion_tag_events( + ad_account_id = DEFAULT_AD_ACCOUNT_ID + ) + + assert not property + assert not conversion_tag_events diff --git a/pinterest/ads/conversion_events.py b/pinterest/ads/conversion_events.py new file mode 100644 index 0000000..90b0a39 --- /dev/null +++ b/pinterest/ads/conversion_events.py @@ -0,0 +1,116 @@ +""" +Conversion Event Class for Pinterest Python SDK +""" +from __future__ import annotations + +from openapi_generated.pinterest_client.api.conversion_events_api import ConversionEventsApi +from openapi_generated.pinterest_client.model.conversion_events import ConversionEvents +from openapi_generated.pinterest_client.model.conversion_events_data import ConversionEventsData +from openapi_generated.pinterest_client.model.conversion_api_response_events import ConversionApiResponseEvents +from openapi_generated.pinterest_client.model.conversion_events_user_data import ConversionEventsUserData + +from pinterest.client import PinterestSDKClient +from pinterest.utils.base_model import PinterestBaseModel + +class Conversion(PinterestBaseModel): + # pylint: disable=too-many-locals + """ + Conversion Event Model used to send conversion events to Pinterest API + """ + + @classmethod + def create_conversion_event( + cls, + event_name : str, + action_source : str, + event_time : int, + event_id : str, + user_data : dict, + event_source_url : str = None, + partner_name : str = None, + app_id : str = None, + app_name : str = None, + app_version : str = None, + device_brand : str = None, + device_carrier : str = None, + device_model : str = None, + device_type : str = None, + os_version : str = None, + language : str = None, + **kwargs + ) -> ConversionEventsData: + """ Create Conversion Event Data to be sent + + Args: + event_name (str): The type of the user event, Enum: "add_to_cart", "checkout", "custom", + "lead", "page_visit", "search", "signup", "view_category", "watch_video" + action_source (str): The source indicating where the conversion event occurred, Enum: + "app_adroid", "app_ios", "web", "offline" + event_time (int): The time when the event happened. Unix timestamp in seconds + event_id (str): The unique id string that identifies this event and can be used for deduping + between events ingested via both the conversion API and Pinterest tracking + user_data (dict): Object containing customer information data. Note, it is required at least + one of 1) em, 2) hashed_maids or 3) pair client_ip_address + client_user_agent. + event_source_url (str, optional): URL of the web conversion event + partner_name (str, optional): The third party partner name responsible to send the event to + Conversion API on behalf of the adverstiser. Only send this field if Pinterest has worked + directly with you to define a value for partner_name. + app_id (str, optional): The app store app ID. + app_name (str, optional): Name of the app. + app_version (str, optional): Version of the app. + device_brand (str, optional): Brand of the user device. + device_carrier (str, optional): User device's model carrier. + device_model (str, optional): Model of the user device. + device_type (str, optional): Type of the user device. + os_version (str, optional): Version of the device operating system. + language (str, optional): Two-character ISO-639-1 language code indicating the user's language. + + Returns: + ConversionEventsData: ConversionEventData to be sent + """ + return ConversionEventsData( + event_name = event_name, + action_source = action_source, + event_time = event_time, + event_id = event_id, + user_data = ConversionEventsUserData(**user_data), + event_source_url = event_source_url, + partner_name = partner_name, + app_id = app_id, + app_name = app_name, + app_version = app_version, + device_brand = device_brand, + device_carrier = device_carrier, + device_model = device_model, + device_type = device_type, + os_version = os_version, + language = language, + **kwargs, + ) + + @classmethod + def send_conversion_events( + cls, + ad_account_id : str, + conversion_events : list[ConversionEventsData], + test : bool = False, + client : PinterestSDKClient = None, + **kwargs, + )-> tuple(int, int, list[ConversionApiResponseEvents]): + """ + Send conversion events to Pinterest API for Conversions + + Note: Highly recommend to use create_client_with_token (with Conversion Access Token) to create new client + for this functionality. + """ + + response = ConversionEventsApi(api_client=cls._get_client(client)).events_create( + ad_account_id = str(ad_account_id), + conversion_events = ConversionEvents( + data = conversion_events + ), + test = test, + **kwargs, + ) + + return response diff --git a/pinterest/ads/conversion_tags.py b/pinterest/ads/conversion_tags.py new file mode 100644 index 0000000..f30b17e --- /dev/null +++ b/pinterest/ads/conversion_tags.py @@ -0,0 +1,294 @@ +""" +Conversion Class for Pinterest Python SDK +""" +from __future__ import annotations + +from openapi_generated.pinterest_client.api.conversion_tags_api import ConversionTagsApi +from openapi_generated.pinterest_client.model.entity_status import EntityStatus +from openapi_generated.pinterest_client.model.conversion_tag_type import ConversionTagType +from openapi_generated.pinterest_client.model.conversion_tag_create import ConversionTagCreate +from openapi_generated.pinterest_client.model.conversion_tag_configs import ConversionTagConfigs +from openapi_generated.pinterest_client.model.conversion_tag_response import ConversionTagResponse +from openapi_generated.pinterest_client.model.conversion_event_response import ConversionEventResponse +from openapi_generated.pinterest_client.model.enhanced_match_status_type import EnhancedMatchStatusType + +from pinterest.client import PinterestSDKClient +from pinterest.utils.bookmark import Bookmark +from pinterest.utils.base_model import PinterestBaseModel +from pinterest.utils.error_handling import verify_api_response + +class ConversionTag(PinterestBaseModel): + # pylint: disable=too-few-public-methods, too-many-arguments, duplicate-code + """ + Conversion Tag model used to view, create, update its attributes and list its different entities + """ + def __init__( + self, + ad_account_id : str, + conversion_tag_id : str, + client : PinterestSDKClient = None, + **kwargs + ) -> None: + """ + Initialize Conversion Tag Object + + Args: + ad_account_id (str): ConversionTag's Ad Account ID + conversion_tag_id (str): ConversionTag ID, must be associated with Ad Account ID provided + client (PinterestSDKClient, optional): PinterestSDKClient Object. Uses the default client, if not provided. + """ + self._id = None + self._ad_account_id = None + self._code_snippet = None + self._enhanced_match_status = None + self._last_fired_time_ms = None + self._name = None + self._status = None + self._version = None + self._configs = None + + PinterestBaseModel.__init__( + self, + _id = str(conversion_tag_id), + generated_api = ConversionTagsApi, + generated_api_get_fn = "conversion_tags_get", + generated_api_get_fn_args={"ad_account_id": ad_account_id, "conversion_tag_id": conversion_tag_id}, + model_attribute_types = ConversionTagResponse.openapi_types, + client=client, + ) + self._ad_account_id = str(ad_account_id) + self._populate_fields(**kwargs) + + @property + def id(self) -> str: + # pylint: disable=missing-function-docstring + return self._id + + @property + def ad_account_id(self) -> str: + # pylint: disable=missing-function-docstring + return self._ad_account_id + + @property + def code_snippet(self) -> str: + # pylint: disable=missing-function-docstring + return self._code_snippet + + @property + def enhanced_match_status(self) -> EnhancedMatchStatusType: + # pylint: disable=missing-function-docstring + return self._enhanced_match_status + + @property + def last_fired_time_ms(self) -> float: + # pylint: disable=missing-function-docstring + return self._last_fired_time_ms + + @property + def name(self) -> str: + # pylint: disable=missing-function-docstring + return self._name + + @property + def status(self) -> EntityStatus: + # pylint: disable=missing-function-docstring + return self._status + + @property + def version(self) -> str: + # pylint: disable=missing-function-docstring + return self._version + + @property + def configs(self) -> ConversionTagConfigs: + # pylint: disable=missing-function-docstring + return self._configs + + @classmethod + def create( + cls, + ad_account_id : str, + name : str, + aem_enabled : bool = False, + md_frequency : float = 0.0, + aem_fnln_enabled : bool = False, + aem_ph_enabled : bool = False, + aem_ge_enabled : bool = False, + aem_db_enabled : bool = False, + aem_loc_enabled : bool = False, + client:PinterestSDKClient = None, + **kwargs + ) -> ConversionTag: + # pylint: disable=too-many-locals,too-many-arguments + """ + Create a conversion tag, also known as\ + Pinterest tag + with the option to enable enhance match.

+ + The Pinterest Tag tracks actions people take on the ad account\u2019\ + s website after they view the ad account's ad on Pinterest. The advertiser\ + needs to customize this tag to track conversions.

+ + For more information,\ + see:

+ + Set up the Pinterest tag

+ + Pinterest\ + Tag

+ + Enhanced match" + + Args: + ad_account_id (str): ConversionTag's Ad Account ID + name (str): ConversionTag name + + Returns: + ConversionTag: ConversionTag Object + """ + response = cls._create( + params = { + "ad_account_id" : str(ad_account_id), + "conversion_tag_create" : ConversionTagCreate( + name = name, + aem_enabled = aem_enabled, + md_frequency = md_frequency, + aem_fnln_enabled = aem_fnln_enabled, + aem_ph_enabled = aem_ph_enabled, + aem_ge_enabled = aem_ge_enabled, + aem_db_enabled = aem_db_enabled, + aem_loc_enabled = aem_loc_enabled, + **kwargs + ) + }, + api = ConversionTagsApi, + create_fn = ConversionTagsApi.conversion_tags_create, + map_fn = lambda obj : obj, + ) + + return cls( + ad_account_id = response.ad_account_id, + conversion_tag_id = response.id, + client = cls._get_client(client), + ) + + @classmethod + def get_all( + cls, + ad_account_id : str, + filter_deleted : bool = False, + client : PinterestSDKClient = None, + **kwargs, + ) -> list[ConversionTag]: + """ + Get a list of ConversionTag, filter by specified arguments + + Args: + ad_account_id (str): _description_ + filter_deleted (bool, optional): _description_. Defaults to False. + client (_type_, optional): _description_. Defaults to PinterestSDKClient=None. + + Returns: + list[ConversionTag]: List of ConversionTags + """ + params = {"ad_account_id" : ad_account_id, "filter_deleted": filter_deleted} + + def _map_function(obj): + return ConversionTag( + ad_account_id = ad_account_id, + conversion_tag_id = obj.get('id'), + client = client, + _model_data = obj.to_dict() + ) + + return cls._list( + params = params, + api = ConversionTagsApi, + list_fn = ConversionTagsApi.conversion_tags_list, + map_fn = _map_function, + client = client, + **kwargs, + )[0] #This method doesn't have bookmark + + @classmethod + def get_page_visit_conversion_tag_events( + cls, + ad_account_id : str, + page_size : int = None, + order : str = "ASCENDING", + bookmark : str = None, + client : PinterestSDKClient = None, + **kwargs + ) -> tuple[list[ConversionEventResponse], Bookmark]: + """ + Get page visit conversion tag events for an ad account + + Args: + ad_account (str): Ad Account ID + client (PinterestSDKClient, optional): PinterestSDKClient Object. Uses the default client, if not provided. + + Returns: + list[ConversionEventResponse]: List of ConversionTagEvent + """ + params = {"ad_account_id" : ad_account_id} + + def _map_function(obj): + return ConversionEventResponse( + conversion_event = ConversionTagType(obj.get("conversion_event")), + conversion_tag_id = str(obj.get("conversion_tag_id")), + ad_account_id = str(obj.get("ad_account_id")), + created_time = int(obj.get("created_time")), + ) + + return cls._list( + params = params, + page_size = page_size, + order = order, + bookmark = bookmark, + api = ConversionTagsApi, + list_fn = ConversionTagsApi.page_visit_conversion_tags_get, + map_fn = _map_function, + client = client, + **kwargs, + ) + + @classmethod + def get_ocpm_eligible_conversion_tag_events( + cls, + ad_account_id : str, + client : PinterestSDKClient = None, + **kwargs + ) -> tuple[str, list[ConversionEventResponse]]: + """ + Get OCPM eligible conversion tag events for an Ad Account + + Args: + ad_account_id (str): Ad Account ID + client (PinterestSDKClient, optional): PinterestSDKClient Object. Uses the default client, if not provided. + + Returns: + list[ConversionEventResponse]: List of ConversionTagEvent + """ + api_response = ConversionTagsApi(api_client=cls._get_client(client)).ocpm_eligible_conversion_tags_get( + ad_account_id = ad_account_id, + **kwargs, + ) + + verify_api_response(api_response) + + # Convert to dict to get the property name + dict_response = api_response.to_dict() + if len(dict_response) == 0: + return None, None + + property_name = list(dict_response.keys())[0] + + # Access through $api_response to get original types of conversion tag events + conversion_tag_events = api_response[property_name] + + return property_name, conversion_tag_events diff --git a/tests/src/pinterest/ads/test_conversion_events.py b/tests/src/pinterest/ads/test_conversion_events.py new file mode 100644 index 0000000..5034b9e --- /dev/null +++ b/tests/src/pinterest/ads/test_conversion_events.py @@ -0,0 +1,70 @@ +from unittest import TestCase +from unittest.mock import patch + + +from openapi_generated.pinterest_client.model.conversion_api_response import ConversionApiResponse +from openapi_generated.pinterest_client.model.conversion_api_response_events import ConversionApiResponseEvents + + +from pinterest.ads.conversion_events import Conversion + +class TestConversionEvent(TestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.test_ad_account_id = "777777777777" + + @patch('pinterest.ads.conversion_events.ConversionEventsApi.events_create') + def test_send_conversion_event_success(self, create_mock): + """ + Test if ConversionEvent can be sent to Pinterest API + """ + create_mock.return_value = ConversionApiResponse( + num_events_received = 2, + num_events_processed = 2, + events = [ + ConversionApiResponseEvents( + status="processed", + error_message = "", + warning_message = "", + ), + ConversionApiResponseEvents( + status="processed", + error_message = "", + warning_message = "", + ) + ] + ) + + NUMBER_OF_CONVERSION_EVENTS = 2 + raw_user_data = dict( + em = ["964bbaf162703657e787eb4455197c8b35c18940c75980b0285619fe9b8acec8"] #random hash256 + ) + conversion_events = [ + Conversion.create_conversion_event( + event_name = "add_to_cart", + action_source = "app_ios", + event_time = 1670026573, + event_id = "eventId0001", + user_data = raw_user_data, + ) + for _ in range(NUMBER_OF_CONVERSION_EVENTS) + ] + + response = Conversion.send_conversion_events( + ad_account_id = self.test_ad_account_id, + conversion_events = conversion_events, + test = True, + ) + + assert response + assert response.num_events_received == 2 + assert response.num_events_processed == 2 + assert len(response.events) == 2 + + assert response.events[0].status == "processed" + assert response.events[0].error_message == "" + assert response.events[0].warning_message == "" + + assert response.events[1].status == "processed" + assert response.events[1].error_message == "" + assert response.events[1].warning_message == "" diff --git a/tests/src/pinterest/ads/test_conversion_tags.py b/tests/src/pinterest/ads/test_conversion_tags.py new file mode 100644 index 0000000..6a01a20 --- /dev/null +++ b/tests/src/pinterest/ads/test_conversion_tags.py @@ -0,0 +1,261 @@ +''' +Test Conversion Tag Model +''' +from unittest import TestCase +from unittest.mock import patch + +from pinterest.ads.conversion_tags import ConversionTag + +from openapi_generated.pinterest_client.model.entity_status import EntityStatus +from openapi_generated.pinterest_client.model.conversion_tag_type import ConversionTagType +from openapi_generated.pinterest_client.model.conversion_tag_configs import ConversionTagConfigs +from openapi_generated.pinterest_client.model.conversion_tag_response import ConversionTagResponse +from openapi_generated.pinterest_client.model.conversion_event_response import ConversionEventResponse +from openapi_generated.pinterest_client.model.enhanced_match_status_type import EnhancedMatchStatusType +from openapi_generated.pinterest_client.model.conversion_tag_list_response import ConversionTagListResponse +from openapi_generated.pinterest_client.model.conversion_tags_ocpm_eligible_response import ConversionTagsOcpmEligibleResponse + + +class TestConversionTagCreate(TestCase): + """ + Test Conversion Tag create successfully + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.test_conversion_tag_id = "111111111111" + self.test_ad_account_id = "777777777777" + + @patch('pinterest.ads.conversion_tags.ConversionTagsApi.conversion_tags_get') + def test_create_conversion_tag_using_existing_conversion_tag(self, get_mock): + """ + Test if Conversion Tag can be created successfully from a ad_account_id and + conversion_tag_id + """ + test_configs = ConversionTagConfigs( + aem_enabled = True, + md_frequency = float(0.6), + aem_fnln_enabled = True, + aem_ph_enabled = True, + aem_ge_enabled = True, + aem_db_enabled = True, + aem_loc_enabled = True, + ) + get_mock.return_value = ConversionTagResponse( + ad_account_id = self.test_ad_account_id, + code_snippet = "