Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/daisychain.rst
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:
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ Indices and tables
controlshift
copper
crowdtangle
daisychain
databases
donorbox
empower
Expand Down
1 change: 1 addition & 0 deletions parsons/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
("parsons.controlshift.controlshift", "Controlshift"),
("parsons.copper.copper", "Copper"),
("parsons.crowdtangle.crowdtangle", "CrowdTangle"),
("parsons.daisychain.daisychain", "Daisychain"),
("parsons.databases.database_connector", "DatabaseConnector"),
("parsons.databases.discover_database", "discover_database"),
("parsons.databases.db_sync", "DBSync"),
Expand Down
3 changes: 3 additions & 0 deletions parsons/daisychain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from parsons.daisychain.daisychain import Daisychain

__all__ = ["Daisychain"]
150 changes: 150 additions & 0 deletions parsons/daisychain/daisychain.py
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):
Copy link
Collaborator

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?

Copy link
Collaborator Author

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

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
1 change: 1 addition & 0 deletions test/test_daisychain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Empty init file for test module
141 changes: 141 additions & 0 deletions test/test_daisychain/test_daisychain.py
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")
Loading