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