-
Notifications
You must be signed in to change notification settings - Fork 134
Daisychain connector #1283
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
Open
austinweisgrau
wants to merge
6
commits into
move-coop:main
Choose a base branch
from
austinweisgrau:daisychain
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+367
−0
Open
Daisychain connector #1283
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a00d4d6
Daisychain connector
austinweisgrau 8c60cfa
backward compatible type hints
austinweisgrau 204af00
docs and tests for daisychain connector
austinweisgrau be36bba
check for Daisychain token in env
austinweisgrau 09f6e5a
Merge branch 'main' into daisychain
austinweisgrau b437cb3
linting
austinweisgrau File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| Daisychain | ||
| ========== | ||
|
|
||
| ******** | ||
| Overview | ||
| ******** | ||
|
|
||
| `Daisychain <https://www.daisychain.app/>`_ is a digital organizing automation tool that allows organizers to string together automated digital outreach steps, similar to Zapier but designed specifically for digital organizing. The Parsons connector provides methods for finding contacts and triggering events through the Daisychain API. | ||
|
|
||
| This connector implements the ``people/match`` and ``actions`` endpoints of the Daisychain API, allowing you to find existing people records and post actions that can trigger automations. | ||
|
|
||
| .. note:: | ||
| Authentication | ||
| The Daisychain API uses API token authentication. You can generate your API token from your Daisychain account settings. | ||
|
|
||
| *********** | ||
| Quick Start | ||
| *********** | ||
|
|
||
| To instantiate the class, you can either pass in the API token as an argument or set the ``DAISYCHAIN_API_TOKEN`` environmental variable. | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| from parsons import Daisychain | ||
|
|
||
| # First approach: Use API credentials via environmental variables | ||
| dc = Daisychain() | ||
|
|
||
| # Second approach: Pass API credentials as arguments | ||
| dc = Daisychain(api_token='MY_API_TOKEN') | ||
|
|
||
| You can then call various endpoints: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| # Find a person by email address | ||
| people = dc.find_person(email_address='person@example.com') | ||
|
|
||
| # Find a person by phone number | ||
| people = dc.find_person(phone_number='+15555551234') | ||
|
|
||
| # Find a person by email and phone (AND logic) | ||
| people = dc.find_person(email_address='person@example.com', phone_number='+15555551234') | ||
|
|
||
| # Post an action to trigger an automation | ||
| # This will create a new person if they don't exist, or associate the action with an existing person | ||
| person_id = dc.post_action( | ||
| email_address='person@example.com', | ||
| phone_number='+15555551234', | ||
| first_name='Jane', | ||
| last_name='Doe', | ||
| email_opt_in=True, | ||
| sms_opt_in=True, | ||
| action_data={'type': 'petition_signature', 'petition_id': '12345'} | ||
| ) | ||
|
|
||
| # Use action_data with custom fields to trigger specific automations | ||
| # The action_data can contain any JSON and be matched in Daisychain automation builder | ||
| person_id = dc.post_action( | ||
| email_address='volunteer@example.com', | ||
| action_data={'event_type': 'volunteer_signup', 'event_id': 'canvass_2024'} | ||
| ) | ||
|
|
||
| *** | ||
| API | ||
| *** | ||
| .. autoclass :: parsons.Daisychain | ||
| :inherited-members: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -198,6 +198,7 @@ Indices and tables | |
| controlshift | ||
| copper | ||
| crowdtangle | ||
| daisychain | ||
| databases | ||
| donorbox | ||
| empower | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from parsons.daisychain.daisychain import Daisychain | ||
|
|
||
| __all__ = ["Daisychain"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| from typing import Optional, Union | ||
|
|
||
| from parsons.utilities.api_connector import APIConnector | ||
|
|
||
|
|
||
| class Daisychain: | ||
| def __init__(self, api_token: str): | ||
| self.connection = APIConnector( | ||
| "https://go.daisychain.app/api/v1/", headers={"X-API-Token": api_token} | ||
| ) | ||
|
|
||
| def request( | ||
| self, | ||
| endpoint: str, | ||
| method: str, | ||
| data_key: str, | ||
| json: Optional[Union[list, dict]] = None, | ||
| ) -> list[dict]: | ||
| """Get request with pagination.""" | ||
| results = [] | ||
| response = self.connection.request(endpoint, method, json=json) | ||
| self.connection.validate_response(response) | ||
| response_data = response.json() | ||
| results.extend(response_data.get(data_key, [])) | ||
| while response_data.get("meta", {}).get("next_page"): | ||
| response = self.connection.request( | ||
| url=endpoint, | ||
| req_type=method, | ||
| json=json, | ||
| params={"page": response["meta"]["next_page"]}, | ||
| ) | ||
| self.connection.validate_response(response) | ||
| response_data = response.json() | ||
| results.extend(response_data.get(data_key, [])) | ||
| return results | ||
|
|
||
| def find_person( | ||
| self, email_address: Optional[str] = None, phone_number: Optional[str] = None | ||
| ) -> list[dict]: | ||
| """ | ||
| Find a person by email address and/or phone number. | ||
|
|
||
| If multiple parameters are provided, they will be | ||
| combined with AND logic. All parameters are optional, | ||
| but at least one must be provided. | ||
|
|
||
| Parameters: | ||
| email_address (string): | ||
| Email address of the person to match. This is a case | ||
| insensitive match. In Daisychain it is possible for | ||
| multiple people records to have the same email address. | ||
| phone_number (string): | ||
| Phone number of the person to match. In Daisychain | ||
| it is possible for multiple people records to have the | ||
| same phone number. We will do our best to parse any | ||
| string provided, but E.164 format is preferred | ||
|
|
||
| Returns: | ||
| a list of person dictionaries | ||
| """ | ||
| assert email_address or phone_number, ( | ||
| "At least one of email address or phone number must be provided." | ||
| ) | ||
| payload: dict[str, dict[str, str]] = {"person": {}} | ||
| if email_address: | ||
| payload["person"]["email"] = email_address | ||
| if phone_number: | ||
| payload["person"]["phone_number"] = phone_number | ||
|
|
||
| result = self.request("people/match", "post", data_key="people", json=payload) | ||
|
|
||
| return result | ||
|
|
||
| def post_action( | ||
| self, | ||
| email_address: Optional[str] = None, | ||
| phone_number: Optional[str] = None, | ||
| first_name: Optional[str] = None, | ||
| last_name: Optional[str] = None, | ||
| addresses: Optional[list[dict]] = None, | ||
| email_opt_in: bool = False, | ||
| sms_opt_in: bool = False, | ||
| action_data: Optional[dict] = None, | ||
| ) -> str: | ||
| """Record an action on a person in Daisychain. | ||
|
|
||
| Actions are events that are associated with People. They're | ||
| things that people have done or things that have happened | ||
| to them. | ||
|
|
||
| Actions of this kind have no defined schema | ||
| and can contain any data that you want, represented as | ||
| json. The actions will appear in the timeline view of the | ||
| person they are associated with and can be used to trigger | ||
| automations. | ||
|
|
||
| If used as the trigger for an automation it is possible to | ||
| write a jmespath expression in the Daisychain automation | ||
| builder to match against the data in the action. This can | ||
| be used to filter automation executions based on the data | ||
| you or another system provides. For example, you could | ||
| create an action with a action_data field of {"type": | ||
| "donation"} and then use the jmespath expression | ||
| action.type == 'donation' to match against it while | ||
| authoring an automation. This feature can be used to | ||
| create powerful automations that can be triggered by any | ||
| external system that can make an API call. | ||
|
|
||
| Person Creation and Match | ||
|
|
||
| The action creation endpoint is designed to allow other | ||
| systems to create actions for people in Daisychain without | ||
| knowing in advance whether or not that person already | ||
| exists. | ||
|
|
||
| It will create a person if one does not exist with the | ||
| provided email or phone number. If a person does exist | ||
| with the provided email or phone number, the action will | ||
| be associated with that person. Daisychain matches on | ||
| email and phone number in that order of priority. If | ||
| different people exist with the provided email and phone | ||
| number, the action will be associated with the person with | ||
| the matching email. | ||
|
|
||
| Parameters: | ||
|
|
||
| Returns: | ||
| person id (string) | ||
|
|
||
| """ | ||
| assert email_address or phone_number, ( | ||
| "At least one of email address or phone number must be provided." | ||
| ) | ||
| if not action_data: | ||
| action_data = {} | ||
| payload = { | ||
| "person": { | ||
| "first_name": first_name, | ||
| "last_name": last_name, | ||
| "addresses": addresses, | ||
| "phones": [{"value": phone_number}], | ||
| "emails": [{"value": email_address}], | ||
| "email_opt_in": email_opt_in, | ||
| "phone_opt_in": sms_opt_in, | ||
| }, | ||
| "action_data": action_data, | ||
| } | ||
| response = self.connection.post_request("actions", json=payload) | ||
| person_id = response["person"]["id"] | ||
| return person_id | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # Empty init file for test module |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import unittest | ||
|
|
||
| import pytest | ||
| import requests_mock | ||
|
|
||
| from parsons import Daisychain | ||
|
|
||
|
|
||
| class TestDaisychain(unittest.TestCase): | ||
| def setUp(self): | ||
| self.api_token = "test_api_token" | ||
| self.connector = Daisychain(api_token=self.api_token) | ||
|
|
||
| def test_initialization(self): | ||
| """Test that Daisychain initializes with correct API token header.""" | ||
| assert self.connector.connection.headers["X-API-Token"] == self.api_token | ||
| assert self.connector.connection.uri == "https://go.daisychain.app/api/v1/" | ||
|
|
||
| @requests_mock.Mocker() | ||
| def test_find_person_email(self, mocker): | ||
| """Test finding a person by email address.""" | ||
| expected_response = { | ||
| "people": [ | ||
| { | ||
| "id": "person123", | ||
| "email": "test@example.com", | ||
| "first_name": "Test", | ||
| "last_name": "User", | ||
| } | ||
| ] | ||
| } | ||
|
|
||
| mocker.post("https://go.daisychain.app/api/v1/people/match", json=expected_response) | ||
|
|
||
| result = self.connector.find_person(email_address="test@example.com") | ||
|
|
||
| assert result == expected_response["people"] | ||
| assert len(result) == 1 | ||
| assert result[0]["email"] == "test@example.com" | ||
|
|
||
| @requests_mock.Mocker() | ||
| def test_find_person_phone(self, mocker): | ||
| """Test finding a person by phone number.""" | ||
| expected_response = { | ||
| "people": [ | ||
| { | ||
| "id": "person456", | ||
| "phone_number": "+15555551234", | ||
| "first_name": "Jane", | ||
| "last_name": "Doe", | ||
| } | ||
| ] | ||
| } | ||
|
|
||
| mocker.post("https://go.daisychain.app/api/v1/people/match", json=expected_response) | ||
|
|
||
| result = self.connector.find_person(phone_number="+15555551234") | ||
|
|
||
| assert result == expected_response["people"] | ||
| assert result[0]["phone_number"] == "+15555551234" | ||
|
|
||
| @requests_mock.Mocker() | ||
| def test_find_person_email_and_phone(self, mocker): | ||
| """Test finding a person by both email and phone.""" | ||
| expected_response = { | ||
| "people": [ | ||
| { | ||
| "id": "person789", | ||
| "email": "combo@example.com", | ||
| "phone_number": "+15555559999", | ||
| "first_name": "Combo", | ||
| "last_name": "Test", | ||
| } | ||
| ] | ||
| } | ||
|
|
||
| mocker.post("https://go.daisychain.app/api/v1/people/match", json=expected_response) | ||
|
|
||
| result = self.connector.find_person( | ||
| email_address="combo@example.com", phone_number="+15555559999" | ||
| ) | ||
|
|
||
| assert result == expected_response["people"] | ||
|
|
||
| def test_find_person_requires_parameter(self): | ||
| """Test that find_person requires at least one parameter.""" | ||
| with pytest.raises(AssertionError): | ||
| self.connector.find_person() | ||
|
|
||
| @requests_mock.Mocker() | ||
| def test_post_action_minimal(self, mocker): | ||
| """Test posting an action with minimal data.""" | ||
| expected_response = {"person": {"id": "person123"}} | ||
|
|
||
| mocker.post("https://go.daisychain.app/api/v1/actions", json=expected_response) | ||
|
|
||
| person_id = self.connector.post_action(email_address="test@example.com") | ||
|
|
||
| assert person_id == "person123" | ||
|
|
||
| @requests_mock.Mocker() | ||
| def test_post_action_full(self, mocker): | ||
| """Test posting an action with full person data.""" | ||
| expected_response = {"person": {"id": "person456"}} | ||
|
|
||
| mocker.post("https://go.daisychain.app/api/v1/actions", json=expected_response) | ||
|
|
||
| person_id = self.connector.post_action( | ||
| email_address="test@example.com", | ||
| phone_number="+15555551234", | ||
| first_name="Jane", | ||
| last_name="Doe", | ||
| addresses=[{"city": "New York", "state": "NY"}], | ||
| email_opt_in=True, | ||
| sms_opt_in=True, | ||
| action_data={"type": "petition_signature", "petition_id": "12345"}, | ||
| ) | ||
|
|
||
| assert person_id == "person456" | ||
|
|
||
| @requests_mock.Mocker() | ||
| def test_post_action_custom_data(self, mocker): | ||
| """Test posting an action with custom action data.""" | ||
| expected_response = {"person": {"id": "person789"}} | ||
|
|
||
| mocker.post("https://go.daisychain.app/api/v1/actions", json=expected_response) | ||
|
|
||
| custom_data = { | ||
| "event_type": "volunteer_signup", | ||
| "event_id": "canvass_2024", | ||
| "custom_field": "custom_value", | ||
| } | ||
|
|
||
| person_id = self.connector.post_action(phone_number="+15555559999", action_data=custom_data) | ||
|
|
||
| assert person_id == "person789" | ||
|
|
||
| def test_post_action_requires_contact(self): | ||
| """Test that post_action requires at least email or phone.""" | ||
| with pytest.raises(AssertionError): | ||
| self.connector.post_action(first_name="Test", last_name="User") |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't seem like it's doing the typical check for an environmental variable version of the api_token?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! fixed with be36bba