Skip to content

Commit aedb646

Browse files
authored
Merge pull request #74 from stac-labs/action_network_additions
Action Network: add more useful functions
2 parents a448ac6 + e39303c commit aedb646

File tree

2 files changed

+345
-2
lines changed

2 files changed

+345
-2
lines changed

src/stac_utils/action_network.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
import json
33
import requests
44
from .http import HTTPClient
5+
import pandas as pd
6+
import logging
7+
8+
# logging
9+
logger = logging.getLogger(__name__)
510

611

712
ROW_LIMIT = 10000
@@ -35,3 +40,142 @@ def check_response_for_rate_limit(
3540
) -> [int, float, None]:
3641
"""Checks ActionNetwork response for rate limit, always returns 1"""
3742
return 1
43+
44+
@staticmethod
45+
def extract_action_network_id(identifiers: list[str]) -> str:
46+
"""
47+
Action Network may have a list of identifiers for a given person.
48+
This function grabs the Action Network ID.
49+
Refer to https://actionnetwork.org/docs/v2/ for more information
50+
51+
:param identifiers: A list of Action Network identifier strings associated with a person.
52+
:return: The extracted Action Network ID, or an empty string if an Action Network ID is not found.
53+
"""
54+
for identifier in identifiers:
55+
if identifier.startswith("action_network:"):
56+
return identifier.split(":", 1)[1]
57+
return ""
58+
59+
def create_people_dataframe(self, people_data: list[dict]) -> pd.DataFrame:
60+
"""
61+
Given an iterable of Action Network people from the Action Network people endpoint, returns a pandas dataframe
62+
with fields:
63+
* action_network_id
64+
* first_name
65+
* last_name
66+
* email_address
67+
* phone
68+
* zip5
69+
* street_name
70+
* city
71+
* state
72+
Refer to https://actionnetwork.org/docs/v2/people for more information
73+
74+
:param people_data: A list of people dictionaries returned by the Action Network API, from the people endpoint
75+
:return: Pandas dataframe with person fields
76+
77+
"""
78+
rows = []
79+
for person in people_data:
80+
# common to all address fields
81+
address = person.get("postal_addresses", [{}])[0]
82+
rows.append(
83+
{
84+
"action_network_id": self.extract_action_network_id(
85+
person.get("identifiers", [""])
86+
),
87+
"first_name": person.get("given_name", ""),
88+
"last_name": person.get("family_name", ""),
89+
"email_address": person.get("email_addresses", [{}])[0].get(
90+
"address", ""
91+
),
92+
"phone": person.get("phone_numbers", [{}])[0].get("number", ""),
93+
"zip5": address.get("postal_code", "")[:5],
94+
"street_name": address.get("address_lines", [""])[0],
95+
"city": address.get("locality", ""),
96+
"state": address.get("region", ""),
97+
}
98+
)
99+
return pd.DataFrame(rows)
100+
101+
def paginate_endpoint(
102+
self, base_endpoint: str, embedded_key: str, max_pages: int = None, **kwargs
103+
) -> list[dict]:
104+
"""
105+
Generic pagination helper for Action Network endpoints that return the "_embedded" resource, which all endpoints
106+
that are collections of items (i.e. forms, events, submissions, etc.) do
107+
108+
:param base_endpoint: the endpoint to paginate (i.e "forms" )
109+
:param embedded_key: the expected key inside the "_embedded" object
110+
(i.e "osdi:submissions" for base_endpoint "forms/{form_id}/submissions"
111+
or "osdi:forms" for base_endpoint "forms")
112+
:param max_pages: optional parameter to limit the number of pages (can be used for testing)
113+
:return: list of embedded items from all pages
114+
"""
115+
results = []
116+
page = 1
117+
118+
while True:
119+
full_endpoint = f"{base_endpoint}?page={page}"
120+
data = self.get(full_endpoint, **kwargs)
121+
122+
embedded = data.get("_embedded", {})
123+
items = embedded.get(embedded_key, [])
124+
if not items:
125+
# should flag end of pagination
126+
logger.debug(f"No items found at page {page} for key '{embedded_key}'")
127+
break
128+
129+
results.extend(items)
130+
131+
if max_pages is not None and page >= max_pages:
132+
break
133+
134+
page += 1
135+
136+
return results
137+
138+
def fetch_related_people(
139+
self, resource: dict, person_link_keys: list[str] = None, **kwargs
140+
) -> list[dict]:
141+
"""
142+
Given a resource dict (i.e. a submission or signup), fetches all related person records
143+
from the Action Network API by following links in the `_links` section.
144+
145+
Note this will only work for Action Network resources that have a person reference in the _links section
146+
147+
When using this function, please add error handling in the caller function, for action_network_ids not found
148+
* requests.exceptions.HTTPError: 404 Client Error
149+
150+
:param resource: the resource dict containing `_links`
151+
:param person_link_keys: optional list of keys in `_links` that indicate person links;
152+
defaults to ['osdi:person'] but can include others if relevant (i.e. osdi:creator)
153+
:return: list of person dicts fetched
154+
"""
155+
# default to 'osdi:person'
156+
if person_link_keys is None:
157+
person_link_keys = ["osdi:person"]
158+
159+
people = []
160+
links = resource.get("_links", {})
161+
162+
# go through each relevant key in _link for the signups
163+
for key in person_link_keys:
164+
link_info = links.get(key)
165+
if not link_info:
166+
continue
167+
168+
# assumes 1 href per _link key
169+
href = link_info.get("href")
170+
171+
if not href or "people/" not in href:
172+
continue
173+
174+
# Extract action network id from url
175+
action_network_id = href.split("people/")[-1]
176+
177+
# this can lead to errors, so log them in the caller function ...
178+
person = self.get(f"people/{action_network_id}", **kwargs)
179+
people.append(person)
180+
181+
return people

src/tests/test_action_network.py

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import os
22
import unittest
33
from unittest.mock import MagicMock, patch
4-
5-
from src.stac_utils.action_network import ActionNetworkClient
4+
import pandas as pd
5+
from src.stac_utils.action_network import ActionNetworkClient, logger
66

77

88
class TestActionNetworkClient(unittest.TestCase):
99
def setUp(self) -> None:
1010
self.test_client = ActionNetworkClient()
11+
self.test_logger = logger
1112

1213
def test_init_env_keys(self):
1314
"""Test that client initializes with environmental keys"""
@@ -42,3 +43,201 @@ def test_check_response_for_rate_limit(self):
4243
"""Test that response has rate limit of 1"""
4344
test_client = ActionNetworkClient("foo")
4445
self.assertEqual(1, test_client.check_response_for_rate_limit(None))
46+
47+
def test_extract_action_network_id_valid(self):
48+
"""Test that the correct Action Network ID is extracted"""
49+
identifiers = ["not_an:123aabb", "action_network:foo12bar", "random_id:120930a"]
50+
output = self.test_client.extract_action_network_id(identifiers)
51+
self.assertEqual(output, "foo12bar")
52+
53+
def test_extract_action_network_id_empty(self):
54+
"""test for empty string when no Action Network ID"""
55+
identifiers = ["not_an:123aabb", "random_id:120930a"]
56+
output = self.test_client.extract_action_network_id(identifiers)
57+
self.assertEqual(output, "")
58+
59+
def test_create_people_dataframe(self):
60+
"""Test that create_people_dataframe function correctly extracts/maps all fields from person record into
61+
the resultant DataFrame."""
62+
people_data = [
63+
{
64+
"identifiers": ["action_network:askjdaskdjfh12", "spam:fo1212o"],
65+
"given_name": "fake_first_name",
66+
"family_name": "fake_last_name",
67+
"email_addresses": [{"address": "fake@example.com"}],
68+
"phone_numbers": [{"number": "999-999-9999"}],
69+
"postal_addresses": [
70+
{
71+
"postal_code": "90210-1234",
72+
"address_lines": ["999 Fake Street"],
73+
"locality": "Beverly Hills",
74+
"region": "CA",
75+
}
76+
],
77+
}
78+
]
79+
80+
expected_df = pd.DataFrame(
81+
[
82+
{
83+
"action_network_id": "askjdaskdjfh12",
84+
"first_name": "fake_first_name",
85+
"last_name": "fake_last_name",
86+
"email_address": "fake@example.com",
87+
"phone": "999-999-9999",
88+
"zip5": "90210",
89+
"street_name": "999 Fake Street",
90+
"city": "Beverly Hills",
91+
"state": "CA",
92+
}
93+
]
94+
)
95+
96+
result_df = self.test_client.create_people_dataframe(people_data)
97+
# preserve column/row order when comparing dfs
98+
self.assertEqual(
99+
expected_df.to_dict(orient="records"), result_df.to_dict(orient="records")
100+
)
101+
102+
@patch.object(ActionNetworkClient, "get")
103+
def test_paginate_endpoint_valid(self, mock_get):
104+
"""
105+
Test to see if the paginate_endpoint function gathers results across multiple pages using the embedded_key
106+
param.
107+
"""
108+
# check paginated API responses
109+
mock_get.side_effect = [
110+
{"_embedded": {"osdi:forms": [{"val": 1}, {"val": 2}]}},
111+
{"_embedded": {"osdi:forms": [{"val": 3}]}},
112+
{"_embedded": {"osdi:forms": [{"val": 4}]}},
113+
]
114+
115+
# checks if the results properly grab only first 2 pages of results, based on mock calls
116+
results = self.test_client.paginate_endpoint(
117+
"forms", embedded_key="osdi:forms", max_pages=2
118+
)
119+
self.assertEqual(results, [{"val": 1}, {"val": 2}, {"val": 3}])
120+
self.assertEqual(mock_get.call_count, 2)
121+
122+
@patch.object(logger, "debug")
123+
@patch.object(ActionNetworkClient, "get")
124+
def test_paginate_endpoint_empty(self, mock_get, mock_debug):
125+
"""
126+
check for logger debug message when the next page's _embedded list is empty
127+
"""
128+
mock_get.side_effect = [
129+
{"_embedded": {"osdi:forms": [{"val": 1}, {"val": 2}]}},
130+
{"_embedded": {"osdi:forms": [{"val": 3}]}},
131+
{"_embedded": {"osdi:forms": []}},
132+
]
133+
self.test_client.paginate_endpoint("forms", embedded_key="osdi:forms")
134+
mock_debug.assert_called_with("No items found at page 3 for key 'osdi:forms'")
135+
136+
@patch.object(ActionNetworkClient, "get")
137+
def test_fetch_related_people_valid(self, mock_get):
138+
"""
139+
Tests if the fetch_related_people correctly fetches and returns related people records
140+
"""
141+
# fake data for GET calls (not all data in a person dict, but doesnt matter for testing)
142+
person_1 = {
143+
"identifiers": ["action_network:12ab234"],
144+
"given_name": "Fake",
145+
"family_name": "Name",
146+
}
147+
148+
person_2 = {
149+
"identifiers": ["action_network:182awe"],
150+
"given_name": "Mock",
151+
"family_name": "Test",
152+
}
153+
154+
# setup mock_get to return these person dicts in order
155+
mock_get.side_effect = [person_1, person_2]
156+
157+
# build resoruce, including two keys for fun to test
158+
resource = {
159+
"_links": {
160+
"osdi:person": {
161+
"href": "https://actionnetwork.org/api/v2/people/12ab234"
162+
},
163+
"osdi:creator": {
164+
"href": "https://actionnetwork.org/api/v2/people/182awe"
165+
},
166+
}
167+
}
168+
169+
# get output of function
170+
people = self.test_client.fetch_related_people(
171+
resource, person_link_keys=["osdi:person", "osdi:creator"]
172+
)
173+
174+
# check for GET request
175+
mock_get.assert_any_call("people/12ab234")
176+
mock_get.assert_any_call("people/182awe")
177+
178+
# check that the output matches the mocked responses
179+
self.assertEqual(people, [person_1, person_2])
180+
181+
@patch.object(ActionNetworkClient, "get")
182+
def test_fetch_related_people_default_key(self, mock_get):
183+
"""
184+
Tests if person_link_keys defaults to ["osdi:person"]
185+
"""
186+
# fake data for GET calls (not all data in a person dict, but doesnt matter for testing)
187+
person_1 = {
188+
"identifiers": ["action_network:12ab234"],
189+
"given_name": "Fake",
190+
"family_name": "Name",
191+
}
192+
193+
# setup mock_get to return these person dicts in order
194+
mock_get.side_effect = [person_1]
195+
196+
# build resoruce, including two keys for fun to test
197+
resource = {
198+
"_links": {
199+
"osdi:person": {
200+
"href": "https://actionnetwork.org/api/v2/people/12ab234"
201+
},
202+
}
203+
}
204+
205+
# get output of function, using default key
206+
people = self.test_client.fetch_related_people(resource)
207+
208+
# check for GET request
209+
mock_get.assert_any_call("people/12ab234")
210+
211+
# check that the output matches the mocked responses
212+
self.assertEqual(people, [person_1])
213+
214+
@patch.object(ActionNetworkClient, "get")
215+
def test_fetch_related_people_link_info_none(self, mock_get):
216+
"""Test that fetch_related_people skips if link_info is None."""
217+
resource = {
218+
"_links": {
219+
"osdi:person": None,
220+
}
221+
}
222+
people = self.test_client.fetch_related_people(
223+
resource, person_link_keys=["osdi:person"]
224+
)
225+
self.assertEqual(people, [])
226+
mock_get.assert_not_called()
227+
228+
@patch.object(ActionNetworkClient, "get")
229+
def test_fetch_related_people_href_empty_or_missing_correct_endpoint(self, mock_get):
230+
"""Test that fetch_related_people skips if href key is missing or doesn't contain 'people/'."""
231+
resource = {
232+
"_links": {
233+
"osdi:person": {"href": None},
234+
"osdi:creator": {
235+
"href": "https://actionnetwork.org/api/v2/superfake/spam"
236+
},
237+
}
238+
}
239+
people = self.test_client.fetch_related_people(
240+
resource, person_link_keys=["osdi:person", "osdi:creator"]
241+
)
242+
self.assertEqual(people, [])
243+
mock_get.assert_not_called()

0 commit comments

Comments
 (0)