From cd521d7e795079d56ebd1499b8e50873bc06b026 Mon Sep 17 00:00:00 2001 From: Yotam Date: Thu, 18 May 2023 18:08:44 -0400 Subject: [PATCH 01/19] establish initial class for Action Builder connector with methods tested in TMC environment --- parsons/action_builder/action_builder.py | 182 +++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 parsons/action_builder/action_builder.py diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py new file mode 100644 index 0000000000..1c6169f131 --- /dev/null +++ b/parsons/action_builder/action_builder.py @@ -0,0 +1,182 @@ +import json +from parsons import Table +from parsons.utilities import check_env +from parsons.utilities.api_connector import APIConnector +import logging + +API_URL = 'https://{subdomain}.actionbuilder.org/api/rest/v1' + +class ActionBuilder(object): + + 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): + 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 + 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 + } + + campaign = self._campaign_check(campaign) + url = f'campaigns/{campaign}/{object_name}' + + return self.api.get_request(url=url, params=params) + + def _get_entry_list(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 actions + # Filter can only be applied to people, petitions, events, forms, fundraising_pages, + # event_campaigns, campaigns, advocacy_campaigns, signatures, attendances, submissions, + # donations and outreaches. + # See Action Builder API docs for more info: https://www.actionbuilder.org/docs/v1/index.html + count = 0 + page = 1 + return_list = [] + while True: + response = self._get_page(campaign, object_name, page, per_page, filter=filter) + page = page + 1 + response_list = response.get('_embedded', {}).get(f"osdi:{object_name}") + if not response_list: + return Table(return_list) + return_list.extend(response_list) + count = count + len(response_list) + if limit: + if count >= limit: + return Table(return_list[0:limit]) + + def get_campaign_tags(self, campaign=None, limit=None, per_page=25, filter=None): + + return self._get_entry_list(campaign, 'tags', limit=limit, per_page=per_page, filter=filter) + + def get_tag_by_name(self, tag_name, campaign=None): + + 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): + + 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, entity_type=None, identifiers=None, data=None, campaign=None): + + if {entity_type, identifiers} == {None}: + error_msg = 'Must provide either entity_type (to insert a new record) ' + error_msg += 'or identifiers (to update an existing record)' + raise ValueError(error_msg) + + if not isinstance(data, dict): + data = {} + + name_check = [key for key in data.get('person', {}) if key in ('name', 'given_name')] + if identifiers is None and not name_check: + raise ValueError('Must provide name or given name if inserting new record') + + campaign = self._campaign_check(campaign) + + url = f'campaigns/{campaign}/people' + + if 'person' not in data: + data['person'] = {} + + if identifiers: + if isinstance(identifiers, str): + identifiers = [identifiers] + identifiers = [f'action_builder:{x}' if ':' not in x else x for x in identifiers] + data['person']['identifiers'] = identifiers + + if entity_type: + data['person']['action_builder:entity_type'] = entity_type + + return self.api.post_request(url=url, data=json.dumps(data)) + + def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, campaign=None): + + # Ensure all tag args are lists + tag_name = tag_name if isinstance(tag_name, list) else [tag_name] + tag_field = tag_field if isinstance(tag_field, list) else [tag_field] + tag_section = tag_section if isinstance(tag_section, list) else [tag_section] + + # Use lists of tuples to identify length ordering + lengths = [] + lengths.append(('name', len(tag_name))) + lengths.append(('field', len(tag_field))) + lengths.append(('section', len(tag_section))) + + ordered_lengths = sorted(lengths, key=lambda x: x[1], reverse=True) + sorted_keys = [x[0] for x in ordered_lengths] + + # Raise an error if there are fewer specific items provided than generic + if sorted_keys[0] != 'name': + raise ValueError('Not enough tag_names provided for tag_fields or tag_sections') + + if sorted_keys[1] != 'field': + raise ValueError('Not enough tag_fields provided for tag_sections') + + # Construct tag data + tag_data = [{ + "action_builder:name": x, + "action_builder:field": tag_field[min(i, len(tag_field) - 1)], + "action_builder:section": tag_section[min(i, len(tag_section) - 1)], + } for i, x in enumerate(tag_name)] + + data = {"add_tags": tag_data} + + return self.upsert_entity(identifiers=identifiers, data=data, campaign=campaign) + + def upsert_connection(self, identifiers, tag_data=None, campaign=None): + + if not isinstance(identifiers, list): + raise ValueError('Must provide identifiers as a list') + + if len(identifiers) != 2: + raise ValueError('Most provide exactly two identifiers') + + campaign = self._campaign_check(campaign) + + url = f'campaigns/{campaign}/people/{identifiers[0]}/connections' + + data = { + "connection": { + "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)) \ No newline at end of file From cd38195b9df717c31e2d815baf3d9d404692df29 Mon Sep 17 00:00:00 2001 From: Yotam Date: Fri, 19 May 2023 09:47:25 -0400 Subject: [PATCH 02/19] add docstrings --- parsons/action_builder/action_builder.py | 118 +++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index 1c6169f131..aee9feee50 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -7,6 +7,17 @@ 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 UI 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) @@ -64,16 +75,59 @@ def _get_entry_list(self, campaign, object_name, limit=None, per_page=25, filter 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_entry_list(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' @@ -87,6 +141,30 @@ def insert_new_tag(self, tag_name, tag_field, tag_section, campaign=None): return self.api.post_request(url=url, data=json.dumps(data)) def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign=None): + """ + Load or update an entity record in Action Builder based on whether any identifiers are + passed. + `Args:` + entity_type: str + The name of the record type being inserted. Required if identifiers are not + provided. + identifiers: list + The list of strings of unique identifiers 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 if {entity_type, identifiers} == {None}: error_msg = 'Must provide either entity_type (to insert a new record) ' @@ -119,6 +197,27 @@ def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign= return self.api.post_request(url=url, data=json.dumps(data)) def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, campaign=None): + """ + Add a tag (i.e. custom field value) to an existing entity record in Action Builder. The + tag, along with its category/field and section/group, must already exist unless it is a + date field. + `Args:` + identifiers: list + The list of strings of unique identifiers for a record being updated. ID strings + will need to begin with the origin system, followed by a colon, e.g. + `action_builder:abc123-...`. + 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 entity data of the entity being tagged. + """ # Ensure all tag args are lists tag_name = tag_name if isinstance(tag_name, list) else [tag_name] @@ -153,6 +252,25 @@ def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, camp return self.upsert_entity(identifiers=identifiers, 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 + The list of strings of unique identifiers 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 if not isinstance(identifiers, list): raise ValueError('Must provide identifiers as a list') From 3f7ab0d3d6d76446c471ece3ad8f5277d3eb87e0 Mon Sep 17 00:00:00 2001 From: Yotam Date: Fri, 19 May 2023 16:40:17 -0400 Subject: [PATCH 03/19] improve comments --- parsons/action_builder/action_builder.py | 69 ++++++++++++++++-------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index aee9feee50..5c13fb6830 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -12,7 +12,7 @@ class ActionBuilder(object): api_token: str The OSDI API token subdomain: str - The part of the UI URL preceding '.actionbuilder.org' + 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 @@ -30,6 +30,8 @@ def __init__(self, api_token=None, subdomain=None, campaign=None): 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!') @@ -37,11 +39,13 @@ def _campaign_check(self, campaign): return final_campaign def _get_page(self, campaign, object_name, page, per_page=25, filter=None): - # returns data from one page of results + # Returns data from one page of results + 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, @@ -54,24 +58,33 @@ def _get_page(self, campaign, object_name, page, per_page=25, filter=None): return self.api.get_request(url=url, params=params) def _get_entry_list(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 actions - # Filter can only be applied to people, petitions, events, forms, fundraising_pages, - # event_campaigns, campaigns, advocacy_campaigns, signatures, attendances, submissions, - # donations and outreaches. - # See Action Builder API docs for more info: https://www.actionbuilder.org/docs/v1/index.html + # 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): @@ -166,6 +179,7 @@ def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign= Dict containing Action Builder entity data. """ # noqa: E501 + # Check that we have appropriate entity type/identifier, name, and campaign first if {entity_type, identifiers} == {None}: error_msg = 'Must provide either entity_type (to insert a new record) ' error_msg += 'or identifiers (to update an existing record)' @@ -183,15 +197,22 @@ def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign= url = f'campaigns/{campaign}/people' if 'person' not in data: + # The POST data must live inside of person key data['person'] = {} if identifiers: + # Updating an existing record + if isinstance(identifiers, str): + # Ensure identifiers is a list, even if it will usually just be one item identifiers = [identifiers] + + # Default to assuming identifier comes from Action Builder and add prefix if missing identifiers = [f'action_builder:{x}' if ':' not in x else x for x in identifiers] data['person']['identifiers'] = identifiers if entity_type: + # Inserting a new record data['person']['action_builder:entity_type'] = entity_type return self.api.post_request(url=url, data=json.dumps(data)) @@ -199,19 +220,23 @@ def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign= def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, campaign=None): """ Add a tag (i.e. custom field value) to an existing entity record in Action Builder. The - tag, along with its category/field and section/group, must already exist unless it is a - date field. + tags, along with their category/field and section/group, must already exist (except for + date fields). If fewer fields are provided than tags, the excess tags will be applied to + the final field provided. Similarly, the final section will be applied to any fields or + tags in excess. `Args:` identifiers: list The list of strings of unique identifiers for a record being updated. ID strings will need to begin with the origin system, followed by a colon, e.g. `action_builder:abc123-...`. - 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. + tag_name: str or list + The name(s) of the new tag(s), i.e. the custom field value(s). + tag_field: str or list + The name(s) of the tag category(ies), i.e. the custom field name(s). Must have + equal or fewer items as `tag_name`. + tag_section: str or list + The name(s) of the tag section(s), i.e. the custom field group name(s). Must have + equal or fewer items as `tag_section`. 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. @@ -224,12 +249,13 @@ def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, camp tag_field = tag_field if isinstance(tag_field, list) else [tag_field] tag_section = tag_section if isinstance(tag_section, list) else [tag_section] - # Use lists of tuples to identify length ordering + # Use lists of tuples to identify length ordering in case amounts differ lengths = [] - lengths.append(('name', len(tag_name))) + lengths.append(('name', len(tag_name))) # Most specific - should be >= other lengths lengths.append(('field', len(tag_field))) - lengths.append(('section', len(tag_section))) + lengths.append(('section', len(tag_section))) # Most generic - should be <= other lengths + # Sort by length, maintaining original order in ties ordered_lengths = sorted(lengths, key=lambda x: x[1], reverse=True) sorted_keys = [x[0] for x in ordered_lengths] @@ -240,7 +266,7 @@ def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, camp if sorted_keys[1] != 'field': raise ValueError('Not enough tag_fields provided for tag_sections') - # Construct tag data + # Construct tag data, applying final generic taxonomy to any excess specific items tag_data = [{ "action_builder:name": x, "action_builder:field": tag_field[min(i, len(tag_field) - 1)], @@ -272,6 +298,7 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): 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') @@ -284,7 +311,7 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): data = { "connection": { - "person_id": identifiers[1] + "person_id": identifiers[1] # person_id is used even if entity is not Person } } @@ -297,4 +324,4 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): data["add_tags"] = tag_data - return self.api.post_request(url=url, data=json.dumps(data)) \ No newline at end of file + return self.api.post_request(url=url, data=json.dumps(data)) From 36dc399ed57495e17b1c0be0b6886593861c4aa5 Mon Sep 17 00:00:00 2001 From: Yotam Date: Mon, 22 May 2023 13:23:07 -0400 Subject: [PATCH 04/19] include init for class --- parsons/action_builder/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 parsons/action_builder/__init__.py diff --git a/parsons/action_builder/__init__.py b/parsons/action_builder/__init__.py new file mode 100644 index 0000000000..ec668b78db --- /dev/null +++ b/parsons/action_builder/__init__.py @@ -0,0 +1,5 @@ +from parsons.action_builder.action_builder import ActionBuilder + +__all__ = [ + 'ActionBuilder' +] \ No newline at end of file From 122afee87fae1e35b2035c13154d147f77cc52d4 Mon Sep 17 00:00:00 2001 From: Yotam Date: Fri, 26 May 2023 08:20:39 -0400 Subject: [PATCH 05/19] include unit tests --- parsons/__init__.py | 1 + parsons/action_builder/action_builder.py | 7 +- .../test_action_builder.py | 291 ++++++++++++++++++ 3 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 test/test_action_builder/test_action_builder.py diff --git a/parsons/__init__.py b/parsons/__init__.py index fb65cf548c..0c11c83056 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/action_builder.py b/parsons/action_builder/action_builder.py index 5c13fb6830..8a1d31f030 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -180,7 +180,7 @@ def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign= """ # noqa: E501 # Check that we have appropriate entity type/identifier, name, and campaign first - if {entity_type, identifiers} == {None}: + if entity_type == identifiers == None: error_msg = 'Must provide either entity_type (to insert a new record) ' error_msg += 'or identifiers (to update an existing record)' raise ValueError(error_msg) @@ -303,7 +303,7 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): raise ValueError('Must provide identifiers as a list') if len(identifiers) != 2: - raise ValueError('Most provide exactly two identifiers') + raise ValueError('Must provide exactly two identifiers') campaign = self._campaign_check(campaign) @@ -311,7 +311,8 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): data = { "connection": { - "person_id": identifiers[1] # person_id is used even if entity is not Person + # person_id is used even if entity is not Person + "person_id": identifiers[1] } } 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..c96df94878 --- /dev/null +++ b/test/test_action_builder/test_action_builder.py @@ -0,0 +1,291 @@ +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_tag_names = ['Fake Tag 5', self.fake_tag_4, self.fake_tag_3, self.fake_tag_2] + self.fake_tag_fields = ['Fake Field 2', self.fake_field_1] + + self.fake_tagging = [ + { + 'action_builder:name': 'Fake Tag 5', + 'action_builder:field': 'Fake Field 2', + 'action_builder:section': self.fake_section + }, + { + 'action_builder:name': self.fake_tag_4, + 'action_builder:field': self.fake_field_1, + 'action_builder:section': self.fake_section + }, + { + 'action_builder:name': self.fake_tag_3, + 'action_builder:field': self.fake_field_1, + 'action_builder:section': self.fake_section + }, + { + 'action_builder:name': self.fake_tag_2, + 'action_builder:field': self.fake_field_1, + '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': f'action_builder:fake-email-id-1', + 'address': 'fakey@mcfakerson.com', + 'address_type': 'Work', + 'status': 'unsubscribed', + 'source': 'api' + }] + } + + self.fake_insert_person = { + 'entity_type': 'Person', + 'data': { + 'person': { + 'given_name': 'Fakey', + 'family_name': 'McFakerson', + 'email_address': [{'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['identifiers'] = [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_entry_list(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_entry_list(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 compare_to_incoming(self, dict1, dict2): + # Internal method to compare a reference dict to a new incoming one, keeping only common + # keys (or where incoming key is substring). Recursive to handle nesting/JSON. + + incoming = {[key for key in dict1 if k in key][0]:v for k,v in dict2.items()} + compare = {k:v for k,v in dict1.items() if k in incoming} + + for key, val in incoming.items(): + print(key) + if isinstance(val, dict): + insub, compsub = self.compare_to_incoming(compare[key], val) + incoming[key] = insub + compare[key] = compsub + elif isinstance(val, list) and isinstance(val[0], dict): + for i in range(len(val)): + print(i) + insub, compsub = self.compare_to_incoming(compare[key][i], val[i]) + incoming[key][i] = insub + compare[key][i] = compsub + + return incoming, compare + + @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 + insert_flattened = {**{k:v for k,v in self.fake_insert_person.items() if k != 'data'}, + **self.fake_insert_person['data']['person']} + insert_incoming, insert_compare = self.compare_to_incoming( + self.bldr.upsert_entity(**self.fake_insert_person), + insert_flattened + ) + + update_flattened = {**{k:v for k,v in self.fake_update_person.items() if k != 'data'}, + **self.fake_update_person['data']['person']} + update_incoming, update_compare = self.compare_to_incoming( + self.bldr.upsert_entity(**self.fake_update_person), + update_flattened + ) + self.assertEqual(insert_incoming, insert_compare) + self.assertEqual(update_incoming, update_compare) + + 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'] + return tagging_data + + @requests_mock.Mocker() + def test_add_tags_to_record(self, m): + m.post(f"{self.api_url}/people", json=self.tagging_callback) + add_tags_response = self.bldr.add_tags_to_record( + self.fake_entity_id, + self.fake_tag_names, + self.fake_tag_fields, + self.fake_section + ) + 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) From 6a8aedc1a710467050c1858a2dcab14b3eeb907b Mon Sep 17 00:00:00 2001 From: Yotam Date: Fri, 26 May 2023 09:01:12 -0400 Subject: [PATCH 06/19] add intro documentation --- docs/action_builder.rst | 88 +++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 89 insertions(+) create mode 100644 docs/action_builder.rst 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 990e326e76..5e781423fe 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -226,6 +226,7 @@ Indices and tables actblue action_kit + action_builder action_network airtable alchemer From e209ed8d21a3336cf563add0f9ed939a3db2c896 Mon Sep 17 00:00:00 2001 From: Yotam Date: Fri, 26 May 2023 09:07:05 -0400 Subject: [PATCH 07/19] lint using black --- parsons/action_builder/__init__.py | 4 +- parsons/action_builder/action_builder.py | 205 +++++++++++++---------- 2 files changed, 114 insertions(+), 95 deletions(-) diff --git a/parsons/action_builder/__init__.py b/parsons/action_builder/__init__.py index ec668b78db..82c465cf6c 100644 --- a/parsons/action_builder/__init__.py +++ b/parsons/action_builder/__init__.py @@ -1,5 +1,3 @@ from parsons.action_builder.action_builder import ActionBuilder -__all__ = [ - 'ActionBuilder' -] \ No newline at end of file +__all__ = ["ActionBuilder"] diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index 8a1d31f030..b2a3bf4776 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -4,7 +4,8 @@ from parsons.utilities.api_connector import APIConnector import logging -API_URL = 'https://{subdomain}.actionbuilder.org/api/rest/v1' +API_URL = "https://{subdomain}.actionbuilder.org/api/rest/v1" + class ActionBuilder(object): """ @@ -18,46 +19,46 @@ class ActionBuilder(object): 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.api_token = check_env.check("ACTION_BUILDER_API_TOKEN", api_token) self.headers = { "Content-Type": "application/json", - "OSDI-API-Token": self.api_token + "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 + final_campaign = campaign or self.campaign if not final_campaign: - raise ValueError('No campaign provided!') - + 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 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.") + 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} - params = { - "page": page, - "per_page": per_page, - "filter": filter - } - campaign = self._campaign_check(campaign) - url = f'campaigns/{campaign}/{object_name}' - + url = f"campaigns/{campaign}/{object_name}" + return self.api.get_request(url=url, params=params) - - def _get_entry_list(self, campaign, object_name, limit=None, per_page=25, filter=None): + + def _get_entry_list( + 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 @@ -67,13 +68,14 @@ def _get_entry_list(self, campaign, object_name, limit=None, per_page=25, filter # 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) + 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}") + response_list = response.get("_embedded", {}).get(f"osdi:{object_name}") if not response_list: # This page has no data, so we're done @@ -86,7 +88,7 @@ def _get_entry_list(self, campaign, object_name, limit=None, per_page=25, filter 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 @@ -104,9 +106,11 @@ def get_campaign_tags(self, campaign=None, limit=None, per_page=25, filter=None) `Returns:` Parsons Table of full set of tags available in Action Builder. """ - - return self._get_entry_list(campaign, 'tags', limit=limit, per_page=per_page, filter=filter) - + + return self._get_entry_list( + 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 @@ -119,11 +123,11 @@ def get_tag_by_name(self, tag_name, campaign=None): `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 @@ -141,19 +145,21 @@ def insert_new_tag(self, tag_name, tag_field, tag_section, campaign=None): `Returns:` Dict containing Action Builder tag data. """ - + campaign = self._campaign_check(campaign) - url = f'campaigns/{campaign}/tags' - + url = f"campaigns/{campaign}/tags" + data = { "name": tag_name, "action_builder:field": tag_field, - "action_builder:section": tag_section + "action_builder:section": tag_section, } - + return self.api.post_request(url=url, data=json.dumps(data)) - - def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign=None): + + def upsert_entity( + self, entity_type=None, identifiers=None, data=None, campaign=None + ): """ Load or update an entity record in Action Builder based on whether any identifiers are passed. @@ -177,29 +183,31 @@ def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign= retrieved or edited. Not necessary if supplied when instantiating the class. `Returns:` Dict containing Action Builder entity data. - """ # noqa: E501 - + """ + # Check that we have appropriate entity type/identifier, name, and campaign first if entity_type == identifiers == None: - error_msg = 'Must provide either entity_type (to insert a new record) ' - error_msg += 'or identifiers (to update an existing record)' + error_msg = "Must provide either entity_type (to insert a new record) " + error_msg += "or identifiers (to update an existing record)" raise ValueError(error_msg) - + if not isinstance(data, dict): data = {} - - name_check = [key for key in data.get('person', {}) if key in ('name', 'given_name')] + + name_check = [ + key for key in data.get("person", {}) if key in ("name", "given_name") + ] if identifiers is None and not name_check: - raise ValueError('Must provide name or given name if inserting new record') - + raise ValueError("Must provide name or given name if inserting new record") + campaign = self._campaign_check(campaign) - - url = f'campaigns/{campaign}/people' - - if 'person' not in data: + + url = f"campaigns/{campaign}/people" + + if "person" not in data: # The POST data must live inside of person key - data['person'] = {} - + data["person"] = {} + if identifiers: # Updating an existing record @@ -208,16 +216,20 @@ def upsert_entity(self, entity_type=None, identifiers=None, data=None, campaign= identifiers = [identifiers] # Default to assuming identifier comes from Action Builder and add prefix if missing - identifiers = [f'action_builder:{x}' if ':' not in x else x for x in identifiers] - data['person']['identifiers'] = identifiers - + identifiers = [ + f"action_builder:{x}" if ":" not in x else x for x in identifiers + ] + data["person"]["identifiers"] = identifiers + if entity_type: # Inserting a new record - data['person']['action_builder:entity_type'] = entity_type + data["person"]["action_builder:entity_type"] = entity_type return self.api.post_request(url=url, data=json.dumps(data)) - - def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, campaign=None): + + def add_tags_to_record( + self, identifiers, tag_name, tag_field, tag_section, campaign=None + ): """ Add a tag (i.e. custom field value) to an existing entity record in Action Builder. The tags, along with their category/field and section/group, must already exist (except for @@ -243,40 +255,49 @@ def add_tags_to_record(self, identifiers, tag_name, tag_field, tag_section, camp `Returns:` Dict containing Action Builder entity data of the entity being tagged. """ - + # Ensure all tag args are lists tag_name = tag_name if isinstance(tag_name, list) else [tag_name] tag_field = tag_field if isinstance(tag_field, list) else [tag_field] tag_section = tag_section if isinstance(tag_section, list) else [tag_section] - + # Use lists of tuples to identify length ordering in case amounts differ lengths = [] - lengths.append(('name', len(tag_name))) # Most specific - should be >= other lengths - lengths.append(('field', len(tag_field))) - lengths.append(('section', len(tag_section))) # Most generic - should be <= other lengths + lengths.append( + ("name", len(tag_name)) + ) # Most specific - should be >= other lengths + lengths.append(("field", len(tag_field))) + lengths.append( + ("section", len(tag_section)) + ) # Most generic - should be <= other lengths # Sort by length, maintaining original order in ties ordered_lengths = sorted(lengths, key=lambda x: x[1], reverse=True) sorted_keys = [x[0] for x in ordered_lengths] - + # Raise an error if there are fewer specific items provided than generic - if sorted_keys[0] != 'name': - raise ValueError('Not enough tag_names provided for tag_fields or tag_sections') - - if sorted_keys[1] != 'field': - raise ValueError('Not enough tag_fields provided for tag_sections') - + if sorted_keys[0] != "name": + raise ValueError( + "Not enough tag_names provided for tag_fields or tag_sections" + ) + + if sorted_keys[1] != "field": + raise ValueError("Not enough tag_fields provided for tag_sections") + # Construct tag data, applying final generic taxonomy to any excess specific items - tag_data = [{ - "action_builder:name": x, - "action_builder:field": tag_field[min(i, len(tag_field) - 1)], - "action_builder:section": tag_section[min(i, len(tag_section) - 1)], - } for i, x in enumerate(tag_name)] - + tag_data = [ + { + "action_builder:name": x, + "action_builder:field": tag_field[min(i, len(tag_field) - 1)], + "action_builder:section": tag_section[min(i, len(tag_section) - 1)], + } + for i, x in enumerate(tag_name) + ] + data = {"add_tags": tag_data} - + return self.upsert_entity(identifiers=identifiers, 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. @@ -296,33 +317,33 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): 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') - + raise ValueError("Must provide identifiers as a list") + if len(identifiers) != 2: - raise ValueError('Must provide exactly two identifiers') - + raise ValueError("Must provide exactly two identifiers") + campaign = self._campaign_check(campaign) - - url = f'campaigns/{campaign}/people/{identifiers[0]}/connections' - + + 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') - + 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)) From 00d9aabfb27450009da79b0eec19c6076c44aebc Mon Sep 17 00:00:00 2001 From: Yotam Date: Fri, 26 May 2023 09:34:06 -0400 Subject: [PATCH 08/19] more linting --- parsons/action_builder/action_builder.py | 8 +- .../test_action_builder.py | 406 ++++++++++-------- 2 files changed, 235 insertions(+), 179 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index b2a3bf4776..2992a9ffe9 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -4,6 +4,8 @@ from parsons.utilities.api_connector import APIConnector import logging +logger = logging.getLogger(__name__) + API_URL = "https://{subdomain}.actionbuilder.org/api/rest/v1" @@ -183,10 +185,10 @@ def upsert_entity( retrieved or edited. Not necessary if supplied when instantiating the class. `Returns:` Dict containing Action Builder entity data. - """ + """ # noqa: E501 # Check that we have appropriate entity type/identifier, name, and campaign first - if entity_type == identifiers == None: + if entity_type == identifiers is None: error_msg = "Must provide either entity_type (to insert a new record) " error_msg += "or identifiers (to update an existing record)" raise ValueError(error_msg) @@ -317,7 +319,7 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): 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): diff --git a/test/test_action_builder/test_action_builder.py b/test/test_action_builder/test_action_builder.py index c96df94878..1007332662 100644 --- a/test/test_action_builder/test_action_builder.py +++ b/test/test_action_builder/test_action_builder.py @@ -4,222 +4,272 @@ from parsons import Table, ActionBuilder from test.utils import assert_matching_tables -class TestActionBuilder(unittest.TestCase): +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.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) + 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_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_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_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, - } - ] - }} + "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, - } - ] - }} + "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, - } - ] - }} + "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_tags_list = ( + self.fake_tags_list_1["_embedded"]["osdi:tags"] + + self.fake_tags_list_2["_embedded"]["osdi:tags"] + ) - self.fake_tag_names = ['Fake Tag 5', self.fake_tag_4, self.fake_tag_3, self.fake_tag_2] - self.fake_tag_fields = ['Fake Field 2', self.fake_field_1] + self.fake_tag_names = [ + "Fake Tag 5", + self.fake_tag_4, + self.fake_tag_3, + self.fake_tag_2, + ] + self.fake_tag_fields = ["Fake Field 2", self.fake_field_1] self.fake_tagging = [ { - 'action_builder:name': 'Fake Tag 5', - 'action_builder:field': 'Fake Field 2', - 'action_builder:section': self.fake_section + "action_builder:name": "Fake Tag 5", + "action_builder:field": "Fake Field 2", + "action_builder:section": self.fake_section, }, { - 'action_builder:name': self.fake_tag_4, - 'action_builder:field': self.fake_field_1, - 'action_builder:section': self.fake_section + "action_builder:name": self.fake_tag_4, + "action_builder:field": self.fake_field_1, + "action_builder:section": self.fake_section, }, { - 'action_builder:name': self.fake_tag_3, - 'action_builder:field': self.fake_field_1, - 'action_builder:section': self.fake_section + "action_builder:name": self.fake_tag_3, + "action_builder:field": self.fake_field_1, + "action_builder:section": self.fake_section, }, { - 'action_builder:name': self.fake_tag_2, - 'action_builder:field': self.fake_field_1, - 'action_builder:section': self.fake_section - } + "action_builder:name": self.fake_tag_2, + "action_builder:field": self.fake_field_1, + "action_builder:section": self.fake_section, + }, ] - self.fake_entity_id = 'fake-entity-id-1' + 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': f'action_builder:fake-email-id-1', - 'address': 'fakey@mcfakerson.com', - 'address_type': 'Work', - 'status': 'unsubscribed', - 'source': 'api' - }] + "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": f"action_builder:fake-email-id-1", + "address": "fakey@mcfakerson.com", + "address_type": "Work", + "status": "unsubscribed", + "source": "api", + } + ], } self.fake_insert_person = { - 'entity_type': 'Person', - 'data': { - 'person': { - 'given_name': 'Fakey', - 'family_name': 'McFakerson', - 'email_address': [{'address': 'fakey@mcfakerson.com', - 'status': 'unsubscribed'}], - 'created_date': self.fake_datetime, - 'modified_date': self.fake_datetime + "entity_type": "Person", + "data": { + "person": { + "given_name": "Fakey", + "family_name": "McFakerson", + "email_address": [ + {"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['identifiers'] = [f'action_builder:{self.fake_entity_id}'] + self.fake_update_person = { + k: v for k, v in self.fake_insert_person.items() if k != "entity_type" + } + self.fake_update_person["identifiers"] = [ + f"action_builder:{self.fake_entity_id}" + ] - self.fake_connection = {'person_id': 'fake-entity-id-2'} + 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) + 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_entry_list(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_entry_list(self.campaign, 'tags'), - Table(self.fake_tags_list)) + 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_entry_list(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)) + 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]])) + 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 compare_to_incoming(self, dict1, dict2): # Internal method to compare a reference dict to a new incoming one, keeping only common # keys (or where incoming key is substring). Recursive to handle nesting/JSON. - incoming = {[key for key in dict1 if k in key][0]:v for k,v in dict2.items()} - compare = {k:v for k,v in dict1.items() if k in incoming} - + incoming = {[key for key in dict1 if k in key][0]: v for k, v in dict2.items()} + compare = {k: v for k, v in dict1.items() if k in incoming} + for key, val in incoming.items(): print(key) if isinstance(val, dict): @@ -232,7 +282,7 @@ def compare_to_incoming(self, dict1, dict2): insub, compsub = self.compare_to_incoming(compare[key][i], val[i]) incoming[key][i] = insub compare[key][i] = compsub - + return incoming, compare @requests_mock.Mocker() @@ -240,18 +290,20 @@ 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 - insert_flattened = {**{k:v for k,v in self.fake_insert_person.items() if k != 'data'}, - **self.fake_insert_person['data']['person']} + insert_flattened = { + **{k: v for k, v in self.fake_insert_person.items() if k != "data"}, + **self.fake_insert_person["data"]["person"], + } insert_incoming, insert_compare = self.compare_to_incoming( - self.bldr.upsert_entity(**self.fake_insert_person), - insert_flattened + self.bldr.upsert_entity(**self.fake_insert_person), insert_flattened ) - update_flattened = {**{k:v for k,v in self.fake_update_person.items() if k != 'data'}, - **self.fake_update_person['data']['person']} + update_flattened = { + **{k: v for k, v in self.fake_update_person.items() if k != "data"}, + **self.fake_update_person["data"]["person"], + } update_incoming, update_compare = self.compare_to_incoming( - self.bldr.upsert_entity(**self.fake_update_person), - update_flattened + self.bldr.upsert_entity(**self.fake_update_person), update_flattened ) self.assertEqual(insert_incoming, insert_compare) self.assertEqual(update_incoming, update_compare) @@ -260,7 +312,7 @@ 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'] + tagging_data = post_data["add_tags"] return tagging_data @requests_mock.Mocker() @@ -270,7 +322,7 @@ def test_add_tags_to_record(self, m): self.fake_entity_id, self.fake_tag_names, self.fake_tag_fields, - self.fake_section + self.fake_section, ) self.assertEqual(add_tags_response, self.fake_tagging) @@ -278,14 +330,16 @@ def connect_callback(self, request, context): # Internal method for returning constructed connection data to test post_data = request.json() - connection_data = post_data['connection'] + 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 + json=self.connect_callback, + ) + connect_response = self.bldr.upsert_connection( + [self.fake_entity_id, "fake-entity-id-2"] ) - connect_response = self.bldr.upsert_connection([self.fake_entity_id, 'fake-entity-id-2']) self.assertEqual(connect_response, self.fake_connection) From 36973fadd48f562a80b930848faab281949a68c7 Mon Sep 17 00:00:00 2001 From: Yotam Date: Fri, 26 May 2023 12:43:52 -0400 Subject: [PATCH 09/19] final linting --- parsons/action_builder/action_builder.py | 4 ++-- test/test_action_builder/test_action_builder.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index 2992a9ffe9..185ef9fd35 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -185,7 +185,7 @@ def upsert_entity( retrieved or edited. Not necessary if supplied when instantiating the class. `Returns:` Dict containing Action Builder entity data. - """ # noqa: E501 + """ # noqa: E501 # Check that we have appropriate entity type/identifier, name, and campaign first if entity_type == identifiers is None: @@ -319,7 +319,7 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): retrieved or edited. Not necessary if supplied when instantiating the class. `Returns:` Dict containing Action Builder connection data. - """ # noqa: E501 + """ # noqa: E501 # Check that there are exactly two identifiers and that campaign is provided first if not isinstance(identifiers, list): diff --git a/test/test_action_builder/test_action_builder.py b/test/test_action_builder/test_action_builder.py index 1007332662..76296d5a6a 100644 --- a/test/test_action_builder/test_action_builder.py +++ b/test/test_action_builder/test_action_builder.py @@ -169,7 +169,7 @@ def setUp(self, m): "preferred_language": "en", "email_addresses": [ { - "action_builder:identifier": f"action_builder:fake-email-id-1", + "action_builder:identifier": "action_builder:fake-email-id-1", "address": "fakey@mcfakerson.com", "address_type": "Work", "status": "unsubscribed", From 8e5c284df72dfb04f85f055b33ffebb87b64140e Mon Sep 17 00:00:00 2001 From: Yotam Date: Tue, 30 May 2023 11:08:28 -0400 Subject: [PATCH 10/19] throw missing campaign error early in methods --- parsons/action_builder/action_builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index 185ef9fd35..c9104eeefd 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -44,6 +44,8 @@ def _campaign_check(self, 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( @@ -53,7 +55,6 @@ def _get_page(self, campaign, object_name, page, per_page=25, filter=None): params = {"page": page, "per_page": per_page, "filter": filter} - campaign = self._campaign_check(campaign) url = f"campaigns/{campaign}/{object_name}" return self.api.get_request(url=url, params=params) From 64b0c89296c7c5e0f991284cc091cf2d35580b65 Mon Sep 17 00:00:00 2001 From: Yotam Date: Tue, 30 May 2023 14:54:47 -0400 Subject: [PATCH 11/19] rename internal method to _get_all_records() --- parsons/action_builder/action_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index c9104eeefd..f1c4230a35 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -59,7 +59,7 @@ def _get_page(self, campaign, object_name, page, per_page=25, filter=None): return self.api.get_request(url=url, params=params) - def _get_entry_list( + 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. @@ -110,7 +110,7 @@ def get_campaign_tags(self, campaign=None, limit=None, per_page=25, filter=None) Parsons Table of full set of tags available in Action Builder. """ - return self._get_entry_list( + return self._get_all_records( campaign, "tags", limit=limit, per_page=per_page, filter=filter ) From 8b666d1696263152f15b620bf4e4cf45f7cd71ff Mon Sep 17 00:00:00 2001 From: Yotam Date: Tue, 30 May 2023 15:23:32 -0400 Subject: [PATCH 12/19] make upsert_entity() method internal and create public insert & update methods --- parsons/action_builder/action_builder.py | 88 ++++++++++++++---------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index f1c4230a35..da8b96b54e 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -160,9 +160,14 @@ def insert_new_tag(self, tag_name, tag_field, tag_section, campaign=None): return self.api.post_request(url=url, data=json.dumps(data)) - def upsert_entity( - self, entity_type=None, identifiers=None, data=None, campaign=None - ): + 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 or update an entity record in Action Builder based on whether any identifiers are passed. @@ -170,6 +175,36 @@ def upsert_entity( 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 + + 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, identifiers, data, campaign=None): + """ + Load or update an entity record in Action Builder based on whether any identifiers are + passed. + `Args:` identifiers: list The list of strings of unique identifiers for a record being updated. ID strings will need to begin with the origin system, followed by a colon, e.g. @@ -188,47 +223,28 @@ def upsert_entity( Dict containing Action Builder entity data. """ # noqa: E501 - # Check that we have appropriate entity type/identifier, name, and campaign first - if entity_type == identifiers is None: - error_msg = "Must provide either entity_type (to insert a new record) " - error_msg += "or identifiers (to update an existing record)" - raise ValueError(error_msg) - + error = "Must provide data with name or given_name when inserting new record" if not isinstance(data, dict): - data = {} - + raise ValueError(error) name_check = [ key for key in data.get("person", {}) if key in ("name", "given_name") ] - if identifiers is None and not name_check: - raise ValueError("Must provide name or given name if inserting new record") + if not name_check: + raise ValueError(error) campaign = self._campaign_check(campaign) - url = f"campaigns/{campaign}/people" - - if "person" not in data: - # The POST data must live inside of person key - data["person"] = {} + if isinstance(identifiers, str): + # Ensure identifiers is a list, even if it will usually just be one item + identifiers = [identifiers] - if identifiers: - # Updating an existing record - - if isinstance(identifiers, str): - # Ensure identifiers is a list, even if it will usually just be one item - identifiers = [identifiers] - - # Default to assuming identifier comes from Action Builder and add prefix if missing - identifiers = [ - f"action_builder:{x}" if ":" not in x else x for x in identifiers - ] - data["person"]["identifiers"] = identifiers - - if entity_type: - # Inserting a new record - data["person"]["action_builder:entity_type"] = entity_type + # 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 identifiers + ] + data["person"]["identifiers"] = identifiers - return self.api.post_request(url=url, data=json.dumps(data)) + return self._upsert_entity(data=data, campaign=campaign) def add_tags_to_record( self, identifiers, tag_name, tag_field, tag_section, campaign=None @@ -299,7 +315,7 @@ def add_tags_to_record( data = {"add_tags": tag_data} - return self.upsert_entity(identifiers=identifiers, data=data, campaign=campaign) + return self._upsert_entity(identifiers=identifiers, data=data, campaign=campaign) def upsert_connection(self, identifiers, tag_data=None, campaign=None): """ From d3497ebfa036c181d5bc6be9b57ec78357ee3157 Mon Sep 17 00:00:00 2001 From: Yotam Date: Tue, 30 May 2023 19:09:26 -0400 Subject: [PATCH 13/19] update methods and tests for insert/update split --- parsons/action_builder/action_builder.py | 32 ++++++++---- .../test_action_builder.py | 52 ++++++++++++++++--- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index da8b96b54e..201c97accd 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -189,6 +189,17 @@ def insert_entity_record(self, entity_type, data=None, campaign=None): 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 = {} @@ -223,15 +234,6 @@ def update_entity_record(self, identifiers, data, campaign=None): 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 isinstance(identifiers, str): @@ -242,6 +244,14 @@ def update_entity_record(self, identifiers, data, campaign=None): identifiers = [ f"action_builder:{id}" if ":" not in id else id for id in identifiers ] + + 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) @@ -315,7 +325,9 @@ def add_tags_to_record( data = {"add_tags": tag_data} - return self._upsert_entity(identifiers=identifiers, data=data, campaign=campaign) + return self.update_entity_record( + identifiers=identifiers, data=data, campaign=campaign + ) def upsert_connection(self, identifiers, tag_data=None, campaign=None): """ diff --git a/test/test_action_builder/test_action_builder.py b/test/test_action_builder/test_action_builder.py index 76296d5a6a..fde0fa9097 100644 --- a/test/test_action_builder/test_action_builder.py +++ b/test/test_action_builder/test_action_builder.py @@ -175,7 +175,27 @@ def setUp(self, m): "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 = { @@ -190,7 +210,7 @@ def setUp(self, m): "created_date": self.fake_datetime, "modified_date": self.fake_datetime, } - }, + } } self.fake_update_person = { @@ -213,7 +233,7 @@ def test_get_page(self, m): ) @requests_mock.Mocker() - def test_get_entry_list(self, m): + 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), @@ -227,7 +247,7 @@ def test_get_entry_list(self, m): text=json.dumps({"_embedded": {"osdi:tags": []}}), ) assert_matching_tables( - self.bldr._get_entry_list(self.campaign, "tags"), Table(self.fake_tags_list) + self.bldr._get_all_records(self.campaign, "tags"), Table(self.fake_tags_list) ) @requests_mock.Mocker() @@ -289,23 +309,41 @@ def compare_to_incoming(self, dict1, dict2): 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_flattened = self.fake_upsert_person["person"] + upsert_incoming, upsert_compare = self.compare_to_incoming( + self.bldr._upsert_entity(self.fake_upsert_person, self.campaign), + upsert_flattened + ) + + self.assertEqual(upsert_incoming, upsert_compare) + + @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_flattened = { **{k: v for k, v in self.fake_insert_person.items() if k != "data"}, **self.fake_insert_person["data"]["person"], } insert_incoming, insert_compare = self.compare_to_incoming( - self.bldr.upsert_entity(**self.fake_insert_person), insert_flattened + self.bldr.insert_entity_record(**self.fake_insert_person), insert_flattened ) + self.assertEqual(insert_incoming, insert_compare) + @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_flattened = { **{k: v for k, v in self.fake_update_person.items() if k != "data"}, **self.fake_update_person["data"]["person"], } update_incoming, update_compare = self.compare_to_incoming( - self.bldr.upsert_entity(**self.fake_update_person), update_flattened + self.bldr.update_entity_record(**self.fake_update_person), update_flattened ) - self.assertEqual(insert_incoming, insert_compare) self.assertEqual(update_incoming, update_compare) def tagging_callback(self, request, context): From 8fe88c032784d4e8eb96a44846fb136a57660677 Mon Sep 17 00:00:00 2001 From: Yotam Date: Thu, 1 Jun 2023 10:05:52 -0400 Subject: [PATCH 14/19] improve commenting on taxonomy application in add_tags_to_record() --- parsons/action_builder/action_builder.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index 201c97accd..f6e0b737b8 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -262,9 +262,10 @@ def add_tags_to_record( """ Add a tag (i.e. custom field value) to an existing entity record in Action Builder. The tags, along with their category/field and section/group, must already exist (except for - date fields). If fewer fields are provided than tags, the excess tags will be applied to - the final field provided. Similarly, the final section will be applied to any fields or - tags in excess. + date fields). If the same number of tags, fields, and sections are supplied, they will be + matched up in the order provided. If fewer fields are provided than tags, final field will + be matched to all remaining tags. Similarly, the final section will be matched to any + remaining fields and tags. `Args:` identifiers: list The list of strings of unique identifiers for a record being updated. ID strings @@ -273,11 +274,11 @@ def add_tags_to_record( tag_name: str or list The name(s) of the new tag(s), i.e. the custom field value(s). tag_field: str or list - The name(s) of the tag category(ies), i.e. the custom field name(s). Must have - equal or fewer items as `tag_name`. + The name(s) of the tag category(ies), i.e. the custom field name(s). Cannot have + more items than `tag_name`. tag_section: str or list - The name(s) of the tag section(s), i.e. the custom field group name(s). Must have - equal or fewer items as `tag_section`. + The name(s) of the tag section(s), i.e. the custom field group name(s). Cannot + have more items than `tag_section` or than `tag_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. @@ -304,16 +305,19 @@ def add_tags_to_record( ordered_lengths = sorted(lengths, key=lambda x: x[1], reverse=True) sorted_keys = [x[0] for x in ordered_lengths] - # Raise an error if there are fewer specific items provided than generic + # Raise an error if there are more fields than tags, or more sections than either if sorted_keys[0] != "name": + # More fields than tags raise ValueError( "Not enough tag_names provided for tag_fields or tag_sections" ) if sorted_keys[1] != "field": + # More sections than fields (given that above scenario has been cleared) raise ValueError("Not enough tag_fields provided for tag_sections") - # Construct tag data, applying final generic taxonomy to any excess specific items + # Construct tag data, for each tag. If there are more tags than fields, keep applying the + # last one to any remaining tags. If there are more sections, apply the last one similarly tag_data = [ { "action_builder:name": x, From e6cc165208b54a64ec71fc23f7b1e181532d13c7 Mon Sep 17 00:00:00 2001 From: Yotam Date: Mon, 5 Jun 2023 17:23:13 -0400 Subject: [PATCH 15/19] simplify and rename record tagging convenience method --- parsons/action_builder/action_builder.py | 65 +++++------------------- 1 file changed, 13 insertions(+), 52 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index f6e0b737b8..a0f4817a52 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -256,29 +256,22 @@ def update_entity_record(self, identifiers, data, campaign=None): return self._upsert_entity(data=data, campaign=campaign) - def add_tags_to_record( - self, identifiers, tag_name, tag_field, tag_section, campaign=None + def add_section_field_values_to_record( + self, identifiers, section, field_values, campaign=None ): """ - Add a tag (i.e. custom field value) to an existing entity record in Action Builder. The - tags, along with their category/field and section/group, must already exist (except for - date fields). If the same number of tags, fields, and sections are supplied, they will be - matched up in the order provided. If fewer fields are provided than tags, final field will - be matched to all remaining tags. Similarly, the final section will be matched to any - remaining fields and tags. + 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:` identifiers: list The list of strings of unique identifiers for a record being updated. ID strings will need to begin with the origin system, followed by a colon, e.g. `action_builder:abc123-...`. - tag_name: str or list - The name(s) of the new tag(s), i.e. the custom field value(s). - tag_field: str or list - The name(s) of the tag category(ies), i.e. the custom field name(s). Cannot have - more items than `tag_name`. - tag_section: str or list - The name(s) of the tag section(s), i.e. the custom field group name(s). Cannot - have more items than `tag_section` or than `tag_name`. + 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. @@ -286,45 +279,13 @@ def add_tags_to_record( Dict containing Action Builder entity data of the entity being tagged. """ - # Ensure all tag args are lists - tag_name = tag_name if isinstance(tag_name, list) else [tag_name] - tag_field = tag_field if isinstance(tag_field, list) else [tag_field] - tag_section = tag_section if isinstance(tag_section, list) else [tag_section] - - # Use lists of tuples to identify length ordering in case amounts differ - lengths = [] - lengths.append( - ("name", len(tag_name)) - ) # Most specific - should be >= other lengths - lengths.append(("field", len(tag_field))) - lengths.append( - ("section", len(tag_section)) - ) # Most generic - should be <= other lengths - - # Sort by length, maintaining original order in ties - ordered_lengths = sorted(lengths, key=lambda x: x[1], reverse=True) - sorted_keys = [x[0] for x in ordered_lengths] - - # Raise an error if there are more fields than tags, or more sections than either - if sorted_keys[0] != "name": - # More fields than tags - raise ValueError( - "Not enough tag_names provided for tag_fields or tag_sections" - ) - - if sorted_keys[1] != "field": - # More sections than fields (given that above scenario has been cleared) - raise ValueError("Not enough tag_fields provided for tag_sections") - - # Construct tag data, for each tag. If there are more tags than fields, keep applying the - # last one to any remaining tags. If there are more sections, apply the last one similarly tag_data = [ { - "action_builder:name": x, - "action_builder:field": tag_field[min(i, len(tag_field) - 1)], - "action_builder:section": tag_section[min(i, len(tag_section) - 1)], + "action_builder:name": tag, + "action_builder:field": field, + "action_builder:section": section } - for i, x in enumerate(tag_name) + for tag, field in field_values.items() ] data = {"add_tags": tag_data} From e5d7fe0403a12ebd88657f316623cb0b8541bb45 Mon Sep 17 00:00:00 2001 From: Yotam Date: Mon, 5 Jun 2023 17:37:52 -0400 Subject: [PATCH 16/19] adapt tagging test to reflect refactor to add_section_field_values_to_record() --- parsons/action_builder/action_builder.py | 2 +- .../test_action_builder.py | 38 +++++++------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index a0f4817a52..37ceb6882f 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -285,7 +285,7 @@ def add_section_field_values_to_record( "action_builder:field": field, "action_builder:section": section } - for tag, field in field_values.items() + for field, tag in field_values.items() ] data = {"add_tags": tag_data} diff --git a/test/test_action_builder/test_action_builder.py b/test/test_action_builder/test_action_builder.py index fde0fa9097..5f091b7cca 100644 --- a/test/test_action_builder/test_action_builder.py +++ b/test/test_action_builder/test_action_builder.py @@ -125,35 +125,22 @@ def setUp(self, m): + self.fake_tags_list_2["_embedded"]["osdi:tags"] ) - self.fake_tag_names = [ - "Fake Tag 5", - self.fake_tag_4, - self.fake_tag_3, - self.fake_tag_2, - ] - self.fake_tag_fields = ["Fake Field 2", self.fake_field_1] + self.fake_field_values = { + "Fake Field 2": "Fake Tag 5", + self.fake_field_1: self.fake_tag_4 + } self.fake_tagging = [ - { - "action_builder:name": "Fake Tag 5", - "action_builder:field": "Fake Field 2", - "action_builder:section": self.fake_section, - }, { "action_builder:name": self.fake_tag_4, "action_builder:field": self.fake_field_1, "action_builder:section": self.fake_section, }, { - "action_builder:name": self.fake_tag_3, - "action_builder:field": self.fake_field_1, - "action_builder:section": self.fake_section, - }, - { - "action_builder:name": self.fake_tag_2, - "action_builder:field": self.fake_field_1, + "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" @@ -351,16 +338,17 @@ def tagging_callback(self, request, context): post_data = request.json() tagging_data = post_data["add_tags"] - return tagging_data + + # 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_tags_to_record(self, m): + 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_tags_to_record( + add_tags_response = self.bldr.add_section_field_values_to_record( self.fake_entity_id, - self.fake_tag_names, - self.fake_tag_fields, self.fake_section, + self.fake_field_values ) self.assertEqual(add_tags_response, self.fake_tagging) From 8894844dfcfb4cb8365fee23effac28a332bd6a5 Mon Sep 17 00:00:00 2001 From: Yotam Date: Mon, 5 Jun 2023 17:44:33 -0400 Subject: [PATCH 17/19] change identifier argument to singular for all methods except upsert_connection() --- parsons/action_builder/action_builder.py | 36 +++++++++++------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/parsons/action_builder/action_builder.py b/parsons/action_builder/action_builder.py index 37ceb6882f..17aea11fa2 100644 --- a/parsons/action_builder/action_builder.py +++ b/parsons/action_builder/action_builder.py @@ -169,8 +169,7 @@ def _upsert_entity(self, data, campaign): def insert_entity_record(self, entity_type, data=None, campaign=None): """ - Load or update an entity record in Action Builder based on whether any identifiers are - passed. + 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 @@ -211,15 +210,13 @@ def insert_entity_record(self, entity_type, data=None, campaign=None): return self._upsert_entity(data=data, campaign=campaign) - def update_entity_record(self, identifiers, data, campaign=None): + def update_entity_record(self, identifier, data, campaign=None): """ - Load or update an entity record in Action Builder based on whether any identifiers are - passed. + Update an entity record in Action Builder based on the identifier passed. `Args:` - identifiers: list - The list of strings of unique identifiers for a record being updated. ID strings - will need to begin with the origin system, followed by a colon, e.g. - `action_builder:abc123-...`. + 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 @@ -236,13 +233,13 @@ def update_entity_record(self, identifiers, data, campaign=None): campaign = self._campaign_check(campaign) - if isinstance(identifiers, str): - # Ensure identifiers is a list, even if it will usually just be one item - identifiers = [identifiers] + 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 identifiers + f"action_builder:{id}" if ":" not in id else id for id in identifier ] if not isinstance(data, dict): @@ -257,17 +254,16 @@ def update_entity_record(self, identifiers, data, campaign=None): return self._upsert_entity(data=data, campaign=campaign) def add_section_field_values_to_record( - self, identifiers, section, field_values, campaign=None + 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:` - identifiers: list - The list of strings of unique identifiers for a record being updated. ID strings - will need to begin with the origin system, followed by a colon, e.g. - `action_builder:abc123-...`. + 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 @@ -291,7 +287,7 @@ def add_section_field_values_to_record( data = {"add_tags": tag_data} return self.update_entity_record( - identifiers=identifiers, data=data, campaign=campaign + identifier=identifier, data=data, campaign=campaign ) def upsert_connection(self, identifiers, tag_data=None, campaign=None): @@ -301,7 +297,7 @@ def upsert_connection(self, identifiers, tag_data=None, campaign=None): exists, this method will update, but will otherwise create a new connection record. `Args:` identifiers: list - The list of strings of unique identifiers for records being connected. ID strings + 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 From ef7b3ffb466c8ab90449fd853fbdf11e4be6edf4 Mon Sep 17 00:00:00 2001 From: Yotam Date: Mon, 5 Jun 2023 18:10:10 -0400 Subject: [PATCH 18/19] simplify dict comparison in upsert method and public dependents --- .../test_action_builder.py | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/test/test_action_builder/test_action_builder.py b/test/test_action_builder/test_action_builder.py index 5f091b7cca..bd77d6d773 100644 --- a/test/test_action_builder/test_action_builder.py +++ b/test/test_action_builder/test_action_builder.py @@ -191,7 +191,7 @@ def setUp(self, m): "person": { "given_name": "Fakey", "family_name": "McFakerson", - "email_address": [ + "email_addresses": [ {"address": "fakey@mcfakerson.com", "status": "unsubscribed"} ], "created_date": self.fake_datetime, @@ -203,7 +203,7 @@ def setUp(self, m): self.fake_update_person = { k: v for k, v in self.fake_insert_person.items() if k != "entity_type" } - self.fake_update_person["identifiers"] = [ + self.fake_update_person["identifier"] = [ f"action_builder:{self.fake_entity_id}" ] @@ -270,68 +270,76 @@ def test_get_tag_by_name(self, m): Table([self.fake_tags_list_1["_embedded"]["osdi:tags"][0]]), ) - def compare_to_incoming(self, dict1, dict2): + def prepare_dict_key_intersection(self, dict1, dict2): # Internal method to compare a reference dict to a new incoming one, keeping only common - # keys (or where incoming key is substring). Recursive to handle nesting/JSON. - - incoming = {[key for key in dict1 if k in key][0]: v for k, v in dict2.items()} - compare = {k: v for k, v in dict1.items() if k in incoming} - - for key, val in incoming.items(): - print(key) - if isinstance(val, dict): - insub, compsub = self.compare_to_incoming(compare[key], val) - incoming[key] = insub - compare[key] = compsub - elif isinstance(val, list) and isinstance(val[0], dict): - for i in range(len(val)): - print(i) - insub, compsub = self.compare_to_incoming(compare[key][i], val[i]) - incoming[key][i] = insub - compare[key][i] = compsub - - return incoming, compare + # 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_flattened = self.fake_upsert_person["person"] - upsert_incoming, upsert_compare = self.compare_to_incoming( - self.bldr._upsert_entity(self.fake_upsert_person, self.campaign), - upsert_flattened + 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(upsert_incoming, upsert_compare) + 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_flattened = { + insert_person = { **{k: v for k, v in self.fake_insert_person.items() if k != "data"}, **self.fake_insert_person["data"]["person"], } - insert_incoming, insert_compare = self.compare_to_incoming( - self.bldr.insert_entity_record(**self.fake_insert_person), insert_flattened + 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(insert_incoming, insert_compare) + + 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_flattened = { + update_person = { **{k: v for k, v in self.fake_update_person.items() if k != "data"}, **self.fake_update_person["data"]["person"], } - update_incoming, update_compare = self.compare_to_incoming( - self.bldr.update_entity_record(**self.fake_update_person), update_flattened + 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(update_incoming, update_compare) + + self.assertEqual(person_comp, update_response_comp) def tagging_callback(self, request, context): # Internal method for returning the constructed tag data to test From a1c5c264e481da4896c67a27dfc66863f60ef77c Mon Sep 17 00:00:00 2001 From: Yotam Date: Mon, 5 Jun 2023 18:17:51 -0400 Subject: [PATCH 19/19] linting --- test/test_action_builder/test_action_builder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/test_action_builder/test_action_builder.py b/test/test_action_builder/test_action_builder.py index bd77d6d773..4e8e00676e 100644 --- a/test/test_action_builder/test_action_builder.py +++ b/test/test_action_builder/test_action_builder.py @@ -274,14 +274,14 @@ 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 + 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} + 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} + dict2_comp = {key: value for key, value in dict2.items() + if key in common_keys} return dict1_comp, dict2_comp