From 3fa3d383325f54c4e574f2156f8596c09585e43d Mon Sep 17 00:00:00 2001 From: ydamit <29988641+ydamit@users.noreply.github.com> Date: Fri, 30 Jun 2023 11:19:16 -0400 Subject: [PATCH] Action Builder connector (#826) * establish initial class for Action Builder connector with methods tested in TMC environment * add docstrings * improve comments * include init for class * include unit tests * add intro documentation * lint using black * more linting * final linting * throw missing campaign error early in methods * rename internal method to _get_all_records() * make upsert_entity() method internal and create public insert & update methods * update methods and tests for insert/update split * improve commenting on taxonomy application in add_tags_to_record() * simplify and rename record tagging convenience method * adapt tagging test to reflect refactor to add_section_field_values_to_record() * change identifier argument to singular for all methods except upsert_connection() * simplify dict comparison in upsert method and public dependents * linting --- docs/action_builder.rst | 88 ++++ docs/index.rst | 1 + parsons/__init__.py | 1 + parsons/action_builder/__init__.py | 3 + parsons/action_builder/action_builder.py | 341 ++++++++++++++++ .../test_action_builder.py | 379 ++++++++++++++++++ 6 files changed, 813 insertions(+) create mode 100644 docs/action_builder.rst create mode 100644 parsons/action_builder/__init__.py create mode 100644 parsons/action_builder/action_builder.py create mode 100644 test/test_action_builder/test_action_builder.py diff --git a/docs/action_builder.rst b/docs/action_builder.rst new file mode 100644 index 0000000000..e6d328ede4 --- /dev/null +++ b/docs/action_builder.rst @@ -0,0 +1,88 @@ +Action Builder +========== + +******** +Overview +******** + +`Action Builder `_ is an online tool for field organizing, with an +original use-case designed for the Labor context. While it has essentially no built-in outreach +capabilities, it does provide robust record and relationship storage, including the ability to +create custom record types. For more information, see +`Action Builder developer docs `_ + +.. note:: + Custom Fields/Tags + Action Builder custom fields are treated as tags in both the SQL Mirror, and the API. This + means that, with a couple exceptions such as date, values must be created ahead of time to be + applied to a record. Each tag has two layers of taxonomy above it as well, that appear slightly + differently in the SQL Mirror and in the API. In the SQL Mirror, each tag has a + ``tag_category``, and each category has a ``tag_group``. In the API, the equivalents are called + ``tag_field`` and ``tag_section``, respectively (closer to the naming in the UI). Tags can be + applied on Connections as well as on Entities. + +*********** +Quick Start +*********** + +To instantiate a class, you can either pass in the API token as an argument or set the +``ACTION_BUILDER_API_TOKEN`` environmental variable. The subdomain at which you access the UI must +also be provided. If all calls from this object will be to the same Campaign in Action Builder, +an optional campaign argument may also be supplied. If not supplied when instantiating, campaign +may be passed to individual methods, instead. + +.. code-block:: python + + from parsons import ActionBuilder + + # First approach: Use API credentials via environmental variables + bldr = ActionBuilder(subdomain='yourorgsubdomain') + + # Second approach: Pass API credentials as arguments + bldr = ActionBuilder(api_token='MY_API_TOKEN', subdomain='yourorgsubdomain') + + # Third approach: Include campaign argument + bldr = ActionBuilder( + api_token = 'MY_API_TOKEN', + subdomain = 'yourorgsubdomain', + campaign = 'your-desired-campaign-id' + ) + +You can then call various endpoints: + +.. code-block:: python + # Assuming instantiation with campaign provided + + # List all tags stored in the provided Action Builder campaign + all_tags = bldr.get_campaign_tags() + + # Add a new tag value to the options available for the the field + bldr.insert_new_tag( + tag_name = 'Mom's Phone', # This is new + tag_field = 'Favorite Toy', # This would already exist, created in the UI + tag_section = 'Preferences' # This would already exist, created in the UI + ) + + # Add a person record to the provided Action Builder campaign + bldr.upsert_entity( + entity_type = 'Person', + data = {"person": {"given_name": "Rory"}} + ) + + # Connect two records and apply some tags to the Connection + tag_data = { # All of the values below must already have been created + "action_builder:name": "Friend of the Family", + "action_builder:field": "Relationship", + "action_builder:section": "People to People Info" + } + + bldr.upsert_connection( + ["entity-interact-id-1", "entity-interact-id-2"], # Any two entity IDs + tag_data = tag_data + ) + +*** +API +*** +.. autoclass :: parsons.ActionBuilder + :inherited-members: diff --git a/docs/index.rst b/docs/index.rst index 2183d89469..093849d15a 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -180,6 +180,7 @@ Indices and tables actblue action_kit + action_builder action_network airtable alchemer diff --git a/parsons/__init__.py b/parsons/__init__.py index eff0c761cf..d611ebf63b 100644 --- a/parsons/__init__.py +++ b/parsons/__init__.py @@ -29,6 +29,7 @@ for module_path, connector_name in ( ("parsons.actblue.actblue", "ActBlue"), ("parsons.action_kit.action_kit", "ActionKit"), + ("parsons.action_builder.action_builder", "ActionBuilder"), ("parsons.action_network.action_network", "ActionNetwork"), ("parsons.airtable.airtable", "Airtable"), ("parsons.alchemer.alchemer", "Alchemer"), diff --git a/parsons/action_builder/__init__.py b/parsons/action_builder/__init__.py new file mode 100644 index 0000000000..82c465cf6c --- /dev/null +++ b/parsons/action_builder/__init__.py @@ -0,0 +1,3 @@ +from parsons.action_builder.action_builder import ActionBuilder + +__all__ = ["ActionBuilder"] diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py new file mode 100644 index 0000000000..17aea11fa2 --- /dev/null +++ b/parsons/action_builder/action_builder.py @@ -0,0 +1,341 @@ +import json +from parsons import Table +from parsons.utilities import check_env +from parsons.utilities.api_connector import APIConnector +import logging + +logger = logging.getLogger(__name__) + +API_URL = "https://{subdomain}.actionbuilder.org/api/rest/v1" + + +class ActionBuilder(object): + """ + `Args:` + api_token: str + The OSDI API token + subdomain: str + The part of the web app URL preceding '.actionbuilder.org' + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be retrieved + or edited. Can also be supplied in individual methods in case multiple campaigns need + to be referenced. + """ + + def __init__(self, api_token=None, subdomain=None, campaign=None): + self.api_token = check_env.check("ACTION_BUILDER_API_TOKEN", api_token) + self.headers = { + "Content-Type": "application/json", + "OSDI-API-Token": self.api_token, + } + self.api_url = API_URL.format(subdomain=subdomain) + self.api = APIConnector(self.api_url, headers=self.headers) + self.campaign = campaign + + def _campaign_check(self, campaign): + # Raise an error if campaign is not provided via instatiation nor method argument + + final_campaign = campaign or self.campaign + if not final_campaign: + raise ValueError("No campaign provided!") + + return final_campaign + + def _get_page(self, campaign, object_name, page, per_page=25, filter=None): + # Returns data from one page of results + + campaign = self._campaign_check(campaign) + + if per_page > 25: + per_page = 25 + logger.info( + "Action Builder's API will not return more than 25 entries per page. \ + Changing per_page parameter to 25." + ) + + params = {"page": page, "per_page": per_page, "filter": filter} + + url = f"campaigns/{campaign}/{object_name}" + + return self.api.get_request(url=url, params=params) + + def _get_all_records( + self, campaign, object_name, limit=None, per_page=25, filter=None + ): + # Returns a list of entries for a given object, such as people, tags, or connections. + # See Action Builder API docs for more: https://www.actionbuilder.org/docs/v1/index.html + + count = 0 + page = 1 + return_list = [] + + # Keep getting the next page until record limit is exceeded or an empty result returns + while True: + # Get this page and increase page number to the next one + response = self._get_page( + campaign, object_name, page, per_page, filter=filter + ) + page = page + 1 + + # Check that there's actually data + response_list = response.get("_embedded", {}).get(f"osdi:{object_name}") + + if not response_list: + # This page has no data, so we're done + return Table(return_list) + + # Assuming there's data, add it to the running response list + return_list.extend(response_list) + count = count + len(response_list) + if limit: + if count >= limit: + # Limit reached or exceeded, so return just the requested limit amount + return Table(return_list[0:limit]) + + def get_campaign_tags(self, campaign=None, limit=None, per_page=25, filter=None): + """ + Retrieve all tags (i.e. custom field values) within provided limit and filters + `Args:` + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be + retrieved or edited. Not necessary if supplied when instantiating the class. + limit: int + The number of entries to return. When None, returns all entries. + per_page: int + The number of entries per page to return. 25 maximum and default. + filter + The OData query for filtering results. E.g. "modified_date gt '2014-03-25'". + When None, no filter is applied. + `Returns:` + Parsons Table of full set of tags available in Action Builder. + """ + + return self._get_all_records( + campaign, "tags", limit=limit, per_page=per_page, filter=filter + ) + + def get_tag_by_name(self, tag_name, campaign=None): + """ + Convenience method to retrieve data on a single tag by its name/value + `Args:` + tag_name: str + The value of the tag to search for. + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be + retrieved or edited. Not necessary if supplied when instantiating the class. + `Returns:` + Parsons Table of data found on tag in Action Builder from searching by name. + """ + + filter = f"name eq '{tag_name}'" + + return self.get_campaign_tags(campaign=campaign, filter=filter) + + def insert_new_tag(self, tag_name, tag_field, tag_section, campaign=None): + """ + Load a new tag value into Action Builder. Required before applying the value to any entity + records. + `Args:` + tag_name: str + The name of the new tag, i.e. the custom field value. + tag_field: str + The name of the tag category, i.e. the custom field name. + tag_section: str + The name of the tag section, i.e. the custom field group name. + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be + retrieved or edited. Not necessary if supplied when instantiating the class. + `Returns:` + Dict containing Action Builder tag data. + """ + + campaign = self._campaign_check(campaign) + url = f"campaigns/{campaign}/tags" + + data = { + "name": tag_name, + "action_builder:field": tag_field, + "action_builder:section": tag_section, + } + + return self.api.post_request(url=url, data=json.dumps(data)) + + def _upsert_entity(self, data, campaign): + # Internal method leveraging the record signup helper endpoint to upsert entity records + + url = f"campaigns/{campaign}/people" + + return self.api.post_request(url=url, data=json.dumps(data)) + + def insert_entity_record(self, entity_type, data=None, campaign=None): + """ + Load a new entity record in Action Builder of the type provided. + `Args:` + entity_type: str + The name of the record type being inserted. Required if identifiers are not + provided. + data: dict + The details to include on the record being upserted, to be included as the value + of the `person` key. See + [documentation for the Person Signup Helper](https://www.actionbuilder.org/docs/v1/person_signup_helper.html#post) + for examples, and + [the Person endpoint](https://www.actionbuilder.org/docs/v1/people.html#field-names) + for full entity object composition. + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be + retrieved or edited. Not necessary if supplied when instantiating the class. + `Returns:` + Dict containing Action Builder entity data. + """ # noqa: E501 + + error = "Must provide data with name or given_name when inserting new record" + if not isinstance(data, dict): + raise ValueError(error) + name_check = [ + key for key in data.get("person", {}) if key in ("name", "given_name") + ] + if not name_check: + raise ValueError(error) + + campaign = self._campaign_check(campaign) + + if not isinstance(data, dict): + data = {} + + if "person" not in data: + # The POST data must live inside of person key + data["person"] = {} + + data["person"]["action_builder:entity_type"] = entity_type + + return self._upsert_entity(data=data, campaign=campaign) + + def update_entity_record(self, identifier, data, campaign=None): + """ + Update an entity record in Action Builder based on the identifier passed. + `Args:` + identifier: str + The unique identifier for a record being updated. ID strings will need to begin + with the origin system, followed by a colon, e.g. `action_builder:abc123-...`. + data: dict + The details to include on the record being upserted, to be included as the value + of the `person` key. See + [documentation for the Person Signup Helper](https://www.actionbuilder.org/docs/v1/person_signup_helper.html#post) + for examples, and + [the Person endpoint](https://www.actionbuilder.org/docs/v1/people.html#field-names) + for full entity object composition. + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be + retrieved or edited. Not necessary if supplied when instantiating the class. + `Returns:` + Dict containing Action Builder entity data. + """ # noqa: E501 + + campaign = self._campaign_check(campaign) + + if isinstance(identifier, str): + # Ensure identifier is a list, even though singular string is called for + identifier = [identifier] + + # Default to assuming identifier comes from Action Builder and add prefix if missing + identifiers = [ + f"action_builder:{id}" if ":" not in id else id for id in identifier + ] + + if not isinstance(data, dict): + data = {} + + if "person" not in data: + # The POST data must live inside of person key + data["person"] = {} + + data["person"]["identifiers"] = identifiers + + return self._upsert_entity(data=data, campaign=campaign) + + def add_section_field_values_to_record( + self, identifier, section, field_values, campaign=None + ): + """ + Add one or more tags (i.e. custom field value) to an existing entity record in Action + Builder. The tags, along with their field and section, must already exist (except for + date fields). + `Args:` + identifier: str + The unique identifier for a record being updated. ID strings will need to begin + with the origin system, followed by a colon, e.g. `action_builder:abc123-...`. + section: str + The name of the tag section, i.e. the custom field group name. + field_values: dict + A collection of field names and tags stored as keys and values. + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be + retrieved or edited. Not necessary if supplied when instantiating the class. + `Returns:` + Dict containing Action Builder entity data of the entity being tagged. + """ + + tag_data = [ + { + "action_builder:name": tag, + "action_builder:field": field, + "action_builder:section": section + } + for field, tag in field_values.items() + ] + + data = {"add_tags": tag_data} + + return self.update_entity_record( + identifier=identifier, data=data, campaign=campaign + ) + + def upsert_connection(self, identifiers, tag_data=None, campaign=None): + """ + Load or update a connection record in Action Builder between two existing entity records. + Only one connection record is allowed per pair of entities, so if the connection already + exists, this method will update, but will otherwise create a new connection record. + `Args:` + identifiers: list + A list of two unique identifier strings for records being connected. ID strings + will need to begin with the origin system, followed by a colon, e.g. + `action_builder:abc123-...`. Requires exactly two identifiers. + tag_data: list + List of dicts of tags to be added to the connection record (i.e. Connection Info). + See [documentation on Connection Helper](https://www.actionbuilder.org/docs/v1/connection_helper.html#post) + for examples. + campaign: str + Optional. The 36-character "interact ID" of the campaign whose data is to be + retrieved or edited. Not necessary if supplied when instantiating the class. + `Returns:` + Dict containing Action Builder connection data. + """ # noqa: E501 + + # Check that there are exactly two identifiers and that campaign is provided first + if not isinstance(identifiers, list): + raise ValueError("Must provide identifiers as a list") + + if len(identifiers) != 2: + raise ValueError("Must provide exactly two identifiers") + + campaign = self._campaign_check(campaign) + + url = f"campaigns/{campaign}/people/{identifiers[0]}/connections" + + data = { + "connection": { + # person_id is used even if entity is not Person + "person_id": identifiers[1] + } + } + + if tag_data: + if isinstance(tag_data, dict): + tag_data = [tag_data] + + if not isinstance(tag_data[0], dict): + raise ValueError("Must provide tag_data as a dict or list of dicts") + + data["add_tags"] = tag_data + + return self.api.post_request(url=url, data=json.dumps(data)) diff --git a/test/test_action_builder/test_action_builder.py b/test/test_action_builder/test_action_builder.py new file mode 100644 index 0000000000..4e8e00676e --- /dev/null +++ b/test/test_action_builder/test_action_builder.py @@ -0,0 +1,379 @@ +import unittest +import requests_mock +import json +from parsons import Table, ActionBuilder +from test.utils import assert_matching_tables + + +class TestActionBuilder(unittest.TestCase): + @requests_mock.Mocker() + def setUp(self, m): + self.subdomain = "fake_subdomain" + self.campaign = "fake-campaign" + self.api_url = "https://{}.actionbuilder.org/api/rest/v1/campaigns/{}".format( + self.subdomain, self.campaign + ) + self.api_key = "fake_key" + + self.bldr = ActionBuilder( + api_token=self.api_key, subdomain=self.subdomain, campaign=self.campaign + ) + + self.fake_datetime = "2023-05-19T00:00:00.000Z" + self.fake_date = "2023-05-19" + + self.fake_tag_1 = "Fake Tag 1" + self.fake_tag_2 = "Fake Tag 2" + self.fake_tag_3 = "Fake Tag 3" + self.fake_tag_4 = "Fake Tag 3" + + self.fake_field_1 = "Fake Field 1" + self.fake_section = "Fake Section 1" + + self.fake_tags_list_1 = { + "per_page": 2, + "page": 1, + "total_pages": 9, + "_embedded": { + "osdi:tags": [ + { + "origin_system": "Action Builder", + "identifiers": ["action_builder:fake-action-builder-id-1"], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + "name": self.fake_tag_1, + "action_builder:section": self.fake_section, + "action_builder:field": self.fake_field_1, + "action_builder:field_type": "standard", + "action_builder:locked": False, + "action_builder:allow_multiple_responses": False, + }, + { + "origin_system": "Action Builder", + "identifiers": ["action_builder:fake-action-builder-id-2"], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + "name": self.fake_tag_2, + "action_builder:section": self.fake_section, + "action_builder:field": self.fake_field_1, + "action_builder:field_type": "standard", + "action_builder:locked": False, + "action_builder:allow_multiple_responses": False, + }, + ] + }, + } + + self.fake_tag_name_search_result = { + "per_page": 1, + "page": 1, + "total_pages": 1, + "_embedded": { + "osdi:tags": [ + { + "origin_system": "Action Builder", + "identifiers": ["action_builder:fake-action-builder-id-1"], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + "name": self.fake_tag_1, + "action_builder:section": self.fake_section, + "action_builder:field": self.fake_field_1, + "action_builder:field_type": "standard", + "action_builder:locked": False, + "action_builder:allow_multiple_responses": False, + } + ] + }, + } + + self.fake_tags_list_2 = { + "per_page": 2, + "page": 2, + "total_pages": 9, + "_embedded": { + "osdi:tags": [ + { + "origin_system": "Action Builder", + "identifiers": ["action_builder:fake-action-builder-id-3"], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + "name": self.fake_tag_3, + "action_builder:section": self.fake_section, + "action_builder:field": self.fake_field_1, + "action_builder:field_type": "standard", + "action_builder:locked": False, + "action_builder:allow_multiple_responses": False, + }, + { + "origin_system": "Action Builder", + "identifiers": ["action_builder:fake-action-builder-id-4"], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + "name": self.fake_tag_4, + "action_builder:section": self.fake_section, + "action_builder:field": self.fake_field_1, + "action_builder:field_type": "standard", + "action_builder:locked": False, + "action_builder:allow_multiple_responses": False, + }, + ] + }, + } + + self.fake_tags_list = ( + self.fake_tags_list_1["_embedded"]["osdi:tags"] + + self.fake_tags_list_2["_embedded"]["osdi:tags"] + ) + + self.fake_field_values = { + "Fake Field 2": "Fake Tag 5", + self.fake_field_1: self.fake_tag_4 + } + + self.fake_tagging = [ + { + "action_builder:name": self.fake_tag_4, + "action_builder:field": self.fake_field_1, + "action_builder:section": self.fake_section, + }, + { + "action_builder:name": "Fake Tag 5", + "action_builder:field": "Fake Field 2", + "action_builder:section": self.fake_section, + } + ] + + self.fake_entity_id = "fake-entity-id-1" + + self.fake_upserted_response = { + "origin_system": "Action Builder", + "identifiers": [f"action_builder:{self.fake_entity_id}"], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + "action_builder:entity_type": "Person", + "given_name": "Fakey", + "family_name": "McFakerson", + "preferred_language": "en", + "email_addresses": [ + { + "action_builder:identifier": "action_builder:fake-email-id-1", + "address": "fakey@mcfakerson.com", + "address_type": "Work", + "status": "unsubscribed", + "source": "api", + } + ] + } + + self.fake_upsert_person = { + "person": { + "identifiers": [f"action_builder:{self.fake_entity_id}"], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + "action_builder:entity_type": "Person", + "given_name": "Fakey", + "family_name": "McFakerson", + "preferred_language": "en", + "email_addresses": [ + { + "action_builder:identifier": "action_builder:fake-email-id-1", + "address": "fakey@mcfakerson.com", + "address_type": "Work", + "status": "unsubscribed" + } + ] + } + } + + self.fake_insert_person = { + "entity_type": "Person", + "data": { + "person": { + "given_name": "Fakey", + "family_name": "McFakerson", + "email_addresses": [ + {"address": "fakey@mcfakerson.com", "status": "unsubscribed"} + ], + "created_date": self.fake_datetime, + "modified_date": self.fake_datetime, + } + } + } + + self.fake_update_person = { + k: v for k, v in self.fake_insert_person.items() if k != "entity_type" + } + self.fake_update_person["identifier"] = [ + f"action_builder:{self.fake_entity_id}" + ] + + self.fake_connection = {"person_id": "fake-entity-id-2"} + + @requests_mock.Mocker() + def test_get_page(self, m): + m.get( + f"{self.api_url}/tags?page=2&per_page=2", + text=json.dumps(self.fake_tags_list_2), + ) + self.assertEqual( + self.bldr._get_page(self.campaign, "tags", 2, 2), self.fake_tags_list_2 + ) + + @requests_mock.Mocker() + def test_get_all_records(self, m): + m.get( + f"{self.api_url}/tags?page=1&per_page=25", + text=json.dumps(self.fake_tags_list_1), + ) + m.get( + f"{self.api_url}/tags?page=2&per_page=25", + text=json.dumps(self.fake_tags_list_2), + ) + m.get( + f"{self.api_url}/tags?page=3&per_page=25", + text=json.dumps({"_embedded": {"osdi:tags": []}}), + ) + assert_matching_tables( + self.bldr._get_all_records(self.campaign, "tags"), Table(self.fake_tags_list) + ) + + @requests_mock.Mocker() + def test_get_campaign_tags(self, m): + m.get( + f"{self.api_url}/tags?page=1&per_page=25", + text=json.dumps(self.fake_tags_list_1), + ) + m.get( + f"{self.api_url}/tags?page=2&per_page=25", + text=json.dumps(self.fake_tags_list_2), + ) + m.get( + f"{self.api_url}/tags?page=3&per_page=25", + text=json.dumps({"_embedded": {"osdi:tags": []}}), + ) + assert_matching_tables( + self.bldr.get_campaign_tags(), Table(self.fake_tags_list) + ) + + @requests_mock.Mocker() + def test_get_tag_by_name(self, m): + m.get( + f"{self.api_url}/tags?filter=name eq '{self.fake_tag_1}'", + text=json.dumps(self.fake_tag_name_search_result), + ) + m.get( + f"{self.api_url}/tags?page=2&per_page=25&filter=name eq '{self.fake_tag_1}'", + text=json.dumps({"_embedded": {"osdi:tags": []}}), + ) + assert_matching_tables( + self.bldr.get_tag_by_name(self.fake_tag_1), + Table([self.fake_tags_list_1["_embedded"]["osdi:tags"][0]]), + ) + + def prepare_dict_key_intersection(self, dict1, dict2): + # Internal method to compare a reference dict to a new incoming one, keeping only common + # keys whose values are not lists (i.e. nested). + + common_keys = {key for key, value in dict1.items() if key in dict2 + and not isinstance(value, list)} + + dict1_comp = {key: value for key, value in dict1.items() + if key in common_keys} + + dict2_comp = {key: value for key, value in dict2.items() + if key in common_keys} + + return dict1_comp, dict2_comp + + @requests_mock.Mocker() + def test_upsert_entity(self, m): + m.post(f"{self.api_url}/people", text=json.dumps(self.fake_upserted_response)) + + # Flatten and remove items added for spreadable arguments + upsert_person = self.fake_upsert_person["person"] + upsert_response = self.bldr._upsert_entity(self.fake_upsert_person, self.campaign) + + person_comp, upsert_response_comp = self.prepare_dict_key_intersection( + upsert_person, upsert_response + ) + + upsert_email = upsert_person["email_addresses"][0] + response_email = upsert_response["email_addresses"][0] + + email_comp, response_email_comp = self.prepare_dict_key_intersection( + upsert_email, response_email + ) + + self.assertEqual(person_comp, upsert_response_comp) + self.assertEqual(email_comp, response_email_comp) + + @requests_mock.Mocker() + def test_insert_entity_record(self, m): + m.post(f"{self.api_url}/people", text=json.dumps(self.fake_upserted_response)) + + # Flatten and remove items added for spreadable arguments + insert_person = { + **{k: v for k, v in self.fake_insert_person.items() if k != "data"}, + **self.fake_insert_person["data"]["person"], + } + insert_response = self.bldr.insert_entity_record(**self.fake_insert_person) + + person_comp, insert_response_comp = self.prepare_dict_key_intersection( + insert_person, insert_response + ) + + self.assertEqual(person_comp, insert_response_comp) + + @requests_mock.Mocker() + def test_update_entity_record(self, m): + m.post(f"{self.api_url}/people", text=json.dumps(self.fake_upserted_response)) + + # Flatten and remove items added for spreadable arguments + update_person = { + **{k: v for k, v in self.fake_update_person.items() if k != "data"}, + **self.fake_update_person["data"]["person"], + } + update_response = self.bldr.update_entity_record(**self.fake_update_person) + + person_comp, update_response_comp = self.prepare_dict_key_intersection( + update_person, update_response + ) + + self.assertEqual(person_comp, update_response_comp) + + def tagging_callback(self, request, context): + # Internal method for returning the constructed tag data to test + + post_data = request.json() + tagging_data = post_data["add_tags"] + + # Force the sort to allow for predictable comparison + return sorted(tagging_data, key=lambda k: k['action_builder:name']) + + @requests_mock.Mocker() + def test_add_section_field_values_to_record(self, m): + m.post(f"{self.api_url}/people", json=self.tagging_callback) + add_tags_response = self.bldr.add_section_field_values_to_record( + self.fake_entity_id, + self.fake_section, + self.fake_field_values + ) + self.assertEqual(add_tags_response, self.fake_tagging) + + def connect_callback(self, request, context): + # Internal method for returning constructed connection data to test + + post_data = request.json() + connection_data = post_data["connection"] + return connection_data + + @requests_mock.Mocker() + def test_upsert_connection(self, m): + m.post( + f"{self.api_url}/people/{self.fake_entity_id}/connections", + json=self.connect_callback, + ) + connect_response = self.bldr.upsert_connection( + [self.fake_entity_id, "fake-entity-id-2"] + ) + self.assertEqual(connect_response, self.fake_connection)