Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Action Builder connector #826

Merged
merged 19 commits into from
Jun 30, 2023
Merged

Action Builder connector #826

merged 19 commits into from
Jun 30, 2023

Conversation

ydamit
Copy link
Contributor

@ydamit ydamit commented May 26, 2023

A very initial submission of a class for an Action Builder connection. Woefully bare bones, this is the minimum viable product necessary for a working Action Builder template sync in the TMC environment.

@ydamit ydamit added high priority Priority - addressing this is an urgent need for a broad swath of Parsons users new connector Work type - creating a new Parsons connector for a tool labels May 26, 2023
Copy link
Contributor

@willyraedy willyraedy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exciting! Seems like a killer connector.

A few places where I think we could make the API a little more user friendly and want to make sure the structuring of tags doesn't lead to unexpected results. And then would love to see if there's a way to simplify one of the tests.

parsons/action_builder/action_builder.py Show resolved Hide resolved

params = {"page": page, "per_page": per_page, "filter": filter}

campaign = self._campaign_check(campaign)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: usually best to do validations that throw first thing in the function. Also, seems like campaign is a required arg. Seems like it should be optional

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agree!


return self.api.get_request(url=url, params=params)

def _get_entry_list(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes me think of a list of an object called an entry. Perhaps _get_all_records or if entry is actually a word that AB uses, maybe _get_all_entries

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I copied it pretty much wholesale from the Action Network connector, so we may eventually want to change that one, too.

return_list = []

# Keep getting the next page until record limit is exceeded or an empty result returns
while True:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long term, I'm curious if we could draw on some pagination libraries. Seems like this is probably pretty common logic

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No objection from me, just so long as they can handle OSDI weirdness!


return self.api.post_request(url=url, data=json.dumps(data))

def upsert_entity(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My instinct is to make this an internal function and have the public api specify which entity is being updated. Might make the function validation simpler as well

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, though perhaps a little grudgingly. Both Action Network and Action Builder use a common endpoint for inserting and updating: the signup helper. Action Network has much simpler upsert logic, because records are unique on additional data: email and/or mobile number. Action Builder has no such uniqueness, so you're right that these should be separate. I'm just grumbly because I need to make sure this change is reflected in the sync that's live, but that's on me for running a sync on branch like that, before the connector is even merged.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could also see an argument for both. Having convenience methods like upsert_person (or whatever it would be) that are just syntactic sugar on the more general upsert_entity. But I do think having the more semantically clear parts of the API is important

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've split it into insert_entity_record() and update_entity_record(), each with their own validation and tests. I think that's probably as far as we should go in this pass, but am definitely open to introducing _person() versions in future updates that hard-code the entity type to the one that's guaranteed present in any Action Builder setup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh interesting. we may have missed each other. I think upsert is a better public api to use than an insert/update split. I was suggesting the _person, _campaign versions.

we might be reaching the limits of asyncronous review and may need to grab some time


return self.upsert_entity(identifiers=identifiers, data=data, campaign=campaign)

def upsert_connection(self, identifiers, tag_data=None, campaign=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless there's an insane number of entities and therefore types of connections, would also lean towards having a more explicit public api of like upsert_person_campaign_connection and using this as a private method to keep things dry

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I completely understand the suggestion here. Just in case there's any misunderstanding about connections and how they're applied, I'll try to summarize here.

Connections can exist between any two entity records, regardless of whether the entity type is person or not. The system is strict about there only ever being one connection record between two specific entity records, and the connection helper endpoint is pretty much the only way to create or update connections (other than the signup helper, which doesn't really work differently in this regard). So you don't actually ever know for sure (unless you make wasteful additional API calls) whether you're inserting or updating.

Connection records can accept tags, much the same way as entity records. These can help illuminate important information about the relationship between entities. For instance, between two people records, you could have a tag indicate the human relationship between them, or on a connection between a person and an organization, you could have one that indicates the person's role at that organization.

Given all this, I have a hard time imagining what public method we could write that would be more intuitive than this one, and use this one as internal. But very open to anything I'm just not seeing!

from test.utils import assert_matching_tables


class TestActionBuilder(unittest.TestCase):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests!

self.fake_field_1 = "Fake Field 1"
self.fake_section = "Fake Section 1"

self.fake_tags_list_1 = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this setup gets pretty involved. you might consider some generator functions that accept args and spit out these more complicated objects

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably right, but I'm hoping it's ok to save that for the next version of updates to this connector.

Copy link
Collaborator

@shaunagm shaunagm Jun 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth thinking about creating some general guidance for implementing factory functions to create test data (and maybe even make some default factory functions that create common objects that people can subclass/build off of). Right now most connectors rely on creating json or python dicts, in the tests like Yotam's done here or imported from separate files, and either way it's messy and tedious to write and generally not ideal.

But factory methods are a relatively advanced python concept so we haven't wanted to say "just do this" without giving contributors support. If we can make some good guidance/templates and some default classes, we could slowly transition over connectors (especially highly used connectors, and new connectors) to use factory functions and that would probably be better for the project/community overall.

Edit: moved this to an issue (#835) where it won't get lost (or block/confuse this PR]

return incoming, compare

@requests_mock.Mocker()
def test_upsert_entity(self, m):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a lot going on in this test along with the compare_to_incoming. I'm not sure I really follow what's happening so might be easier to grab 10 min and talk through it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to do that. In a nutshell, though, compare_to_incoming() is just trying to make sure to exclude out any keys (including in nested dicts) from an incoming dict that don't also appear on the existing dict to which the incoming dict is to be compared. In other words, this if an incoming dict has extra keys anywhere, don't include them in the comparison.


@requests_mock.Mocker()
def test_add_tags_to_record(self, m):
m.post(f"{self.api_url}/people", json=self.tagging_callback)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting! I've not used requests_mock. is this mocking the json method? what is the request param that the tagging_callback is receiving?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also just wrapping my head around it. The idea as I understand it is to intercept specific HTTP requests and force a desired response. This is done by setting any of the properties available in a requests response object. So you'll notice that most of the tests above just return a text, and to fit that expectation, it's json.dumps() applied to whatever Python object was prepared for the purpose of that test.

In more complicated cases such as this test, we can pass request (the details of the request) and context (the details of the response) to a callback function, to return something that requires more processing. Specifically here, we're extracting just the list of dicts that should appear under the add_tags key of the top-most dict in the request data, and returning that to compare to what we expect that tagging data to look like.

@ydamit ydamit requested a review from willyraedy June 5, 2023 22:26
@ydamit
Copy link
Contributor Author

ydamit commented Jun 5, 2023

@willyraedy , I think I've incorporated everything we discussed today. Let me know, though, if I left anything out or if you notice anything new that requires attention!

@sharinetmc
Copy link
Contributor

Hi @willyraedy, it looks like yotam made some changes par your comments. Wondering if you had any other suggestions for him for review!

@sharinetmc
Copy link
Contributor

Hi @willyraedy! Just wondering if there are too many things on your plate at the moment, if I should just go over this PR with Kasia tomorrow during our pairing! Thanks!

@willyraedy
Copy link
Contributor

@sharinetmc Looking at it now!

Copy link
Contributor

@willyraedy willyraedy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Digging the new APIs and tests are much simpler.

Apologies I lost track of this. Thought you had the green light from our convo

@shaunagm
Copy link
Collaborator

Looks like this is ready to merge, so I'm going to merge it. Thank you for the contribution @ydamit!

@shaunagm shaunagm merged commit 3fa3d38 into main Jun 30, 2023
@ydamit ydamit deleted the yotam-actionbuilder-connector branch October 10, 2023 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
high priority Priority - addressing this is an urgent need for a broad swath of Parsons users new connector Work type - creating a new Parsons connector for a tool
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants