class VANConnector(object): def __init__(self, api_key=None, auth_name='default', db=None): self.api_key = check_env.check('VAN_API_KEY', api_key) if db == 'MyVoters': self.db_code = 0 elif db in ['MyMembers', 'MyCampaign', 'EveryAction']: self.db_code = 1 else: raise KeyError('Invalid database type specified. Pick one of:' ' MyVoters, MyCampaign, MyMembers, EveryAction.') self.uri = URI self.db = db self.auth_name = auth_name self.auth = (self.auth_name, self.api_key + '|' + str(self.db_code)) self.api = APIConnector(self.uri, auth=self.auth, data_key='items') def get_request(self, endpoint, **kwargs): r = self.api.get_request(self.uri + endpoint, **kwargs) data = self.api.data_parse(r) # Paginate while self.api.next_page_check_url(r): r = self.api.get_request(r[self.pagination_key], **kwargs) data.extend(self.api.data_parse(r)) return data def post_request(self, endpoint, **kwargs): return self.api.post_request(self.uri + endpoint, **kwargs) def delete_request(self, endpoint, **kwargs): return self.api.delete_request(self.uri + endpoint, **kwargs) def patch_request(self, endpoint, **kwargs): return self.api.patch_request(self.uri + endpoint, **kwargs) def put_request(self, endpoint, **kwargs): return self.api.put_request(self.uri + endpoint, **kwargs)
class Zoom: """ Instantiate the Zoom class. `Args:` api_key: str A valid Zoom api key. Not required if ``ZOOM_API_KEY`` env variable set. api_secret: str A valid Zoom api secret. Not required if ``ZOOM_API_SECRET`` env variable set. """ def __init__(self, api_key=None, api_secret=None): self.api_key = check_env.check('ZOOM_API_KEY', api_key) self.api_secret = check_env.check('ZOOM_API_SECRET', api_secret) self.client = APIConnector(ZOOM_URI) def refresh_header_token(self): # Generate a token that is valid for 30 seconds and update header. Full documentation # on JWT generation using Zoom API: https://marketplace.zoom.us/docs/guides/auth/jwt payload = {"iss": self.api_key, "exp": int(datetime.datetime.now().timestamp() + 30)} token = jwt.encode(payload, self.api_secret, algorithm='HS256').decode("utf-8") self.client.headers = {'authorization': f"Bearer {token}", 'content-type': "application/json"} def _get_request(self, endpoint, data_key, params=None, **kwargs): # To Do: Consider increasing default page size. self.refresh_header_token() r = self.client.get_request(endpoint, params=params, **kwargs) self.client.data_key = data_key data = self.client.data_parse(r) if not params: params = {} # Return a dict or table if only one item. if 'page_number' not in r.keys(): if isinstance(data, dict): return data if isinstance(data, list): return Table(data) # Else iterate through the pages and return a Table else: while r['page_number'] < r['page_count']: params['page_number'] = int(r['page_number']) + 1 r = self.client.get_request(endpoint, params=params, **kwargs) data.extend(self.client.data_parse(r)) return Table(data) def get_users(self, status='active', role_id=None): """ Get users. `Args:` status: str Filter by the user status. Must be one of following: ``active``, ``inactive``, or ``pending``. role_id: str Filter by the user role. `Returns:` Parsons Table See :ref:`parsons-table` for output options. """ if status not in ['active', 'inactive', 'pending']: raise ValueError('Invalid status type provided.') params = {'status': status, 'role_id': role_id} tbl = self._get_request('users', 'users', params=params) logger.info(f'Retrieved {tbl.num_rows} users.') return tbl def get_meetings(self, user_id, meeting_type='scheduled'): """ Get meetings scheduled by a user. `Args:` user_id: str A user id or email address of the meeting host. meeting_type: str .. list-table:: :widths: 25 50 :header-rows: 1 * - Type - Notes * - ``scheduled`` - This includes all valid past meetings, live meetings and upcoming scheduled meetings. It is the equivalent to the combined list of "Previous Meetings" and "Upcoming Meetings" displayed in the user's Meetings page. * - ``live`` - All the ongoing meetings. * - ``upcoming`` - All upcoming meetings including live meetings. `Returns:` Parsons Table See :ref:`parsons-table` for output options. """ tbl = self._get_request(f'users/{user_id}/meetings', 'meetings') logger.info(f'Retrieved {tbl.num_rows} meetings.') return tbl def get_past_meeting(self, meeting_uuid): """ Get metadata regarding a past meeting. `Args:` meeting_id: str The meeting id `Returns:` Parsons Table See :ref:`parsons-table` for output options. """ tbl = self._get_request(f'past_meetings/{meeting_uuid}', None) logger.info(f'Retrieved meeting {meeting_uuid}.') return tbl def get_past_meeting_participants(self, meeting_id): """ Get past meeting participants `Args:` meeting_id: str The meeting id `Returns:` Parsons Table See :ref:`parsons-table` for output options. """ tbl = self._get_request(f'report/meetings/{meeting_id}/participants', 'participants') logger.info(f'Retrieved {tbl.num_rows} participants.') return tbl
class Mailchimp(): """ Instantiate Mailchimp Class `Args:` api_key: The Mailchimp-provided application key. Not required if ``MAILCHIMP_API_KEY`` env variable set. `Returns:` Mailchimp Class """ def __init__(self, api_key=None): self.api_key = check_env.check('MAILCHIMP_API_KEY', api_key) self.domain = re.findall("(?<=-).+$", self.api_key)[0] self.uri = f'https://{self.domain}.api.mailchimp.com/3.0/' self.client = APIConnector(self.uri, auth=('x', self.api_key)) def get_lists(self, fields=None, exclude_fields=None, count=None, offset=None, before_date_created=None, since_date_created=None, before_campaign_last_sent=None, since_campaign_last_sent=None, email=None, sort_field=None, sort_dir=None): """ Get a table of lists under the account based on query parameters. Note that argument descriptions here are sourced from Mailchimp's official API documentation. `Args:` fields: list of strings A comma-separated list of fields to return. Reference parameters of sub-objects with dot notation. exclude_fields: list of strings A comma-separated list of fields to exclude. Reference parameters of sub-objects with dot notation. count: int The number of records to return. Default value is 10. Maximum value is 1000. offset: int The number of records from a collection to skip. Iterating over large collections with this parameter can be slow. Default value is 0. before_date_created: string Restrict response to lists created before the set date. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. since_date_created: string Restrict results to lists created after the set date. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. before_campaign_last_sent: string Restrict results to lists created before the last campaign send date. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. since_campaign_last_sent: string Restrict results to lists created after the last campaign send date. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. email: string Restrict results to lists that include a specific subscriber's email address. sort_field: string, can only be 'date_created' or None Returns files sorted by the specified field. sort_dir: string, can only be 'ASC', 'DESC', or None Determines the order direction for sorted results. `Returns:` Table Class """ params = { 'fields': fields, 'exclude_fields': exclude_fields, 'count': count, 'offset': offset, 'before_date_created': before_date_created, 'since_date_created': since_date_created, 'before_campaign_last_sent': before_campaign_last_sent, 'since_campaign_last_sent': since_campaign_last_sent, 'email': email, 'sort_field': sort_field, 'sort_dir': sort_dir } response = self.client.get_request('lists', params=params) tbl = Table(response['lists']) logger.info(f'Found {tbl.num_rows} lists.') if tbl.num_rows > 0: return tbl else: return Table() def get_campaigns(self, fields=None, exclude_fields=None, count=None, offset=None, type=None, status=None, before_send_time=None, since_send_time=None, before_create_time=None, since_create_time=None, list_id=None, folder_id=None, member_id=None, sort_field=None, sort_dir=None): """ Get a table of campaigns under the account based on query parameters. Note that argument descriptions here are sourced from Mailchimp's official API documentation. `Args:` fields: list of strings A comma-separated list of fields to return. Reference parameters of sub-objects with dot notation. exclude_fields: list of strings A comma-separated list of fields to exclude. Reference parameters of sub-objects with dot notation. count: int The number of records to return. Default value is 10. Maximum value is 1000. offset: int The number of records from a collection to skip. Iterating over large collections with this parameter can be slow. Default value is 0. type: string, can only be 'regular', 'plaintext', 'absplit', 'rss', 'variate', or None The campaign type. status: string, can only be 'save', 'paused', 'schedule', 'sending', 'sent', or None The status of the campaign. before_send_time: string Restrict the response to campaigns sent before the set time. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. since_send_time: string Restrict the response to campaigns sent after the set time. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. before_create_time: string Restrict the response to campaigns created before the set time. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. since_create_time: string Restrict the response to campaigns created after the set time. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. list_id: string The unique id for the list. folder_id: string The unique folder id. member_id: string Retrieve campaigns sent to a particular list member. Member ID is The MD5 hash of the lowercase version of the list member’s email address. sort_field: string, can only be 'create_time', 'send_time', or None Returns files sorted by the specified field. sort_dir: string, can only be 'ASC', 'DESC', or None Determines the order direction for sorted results. `Returns:` Table Class """ params = { 'fields': fields, 'exclude_fields': exclude_fields, 'count': count, 'offset': offset, 'type': type, 'status': status, 'before_send_time': before_send_time, 'since_send_time': since_send_time, 'before_create_time': before_create_time, 'since_create_time': since_create_time, 'list_id': list_id, 'folder_id': folder_id, 'member_id': member_id, 'sort_field': sort_field, 'sort_dir': sort_dir } response = self.client.get_request('campaigns', params=params) tbl = Table(response['campaigns']) logger.info(f'Found {tbl.num_rows} campaigns.') if tbl.num_rows > 0: return tbl else: return Table() def get_members(self, list_id, fields=None, exclude_fields=None, count=None, offset=None, email_type=None, status=None, since_timestamp_opt=None, before_timestamp_opt=None, since_last_changed=None, before_last_changed=None, unique_email_id=None, vip_only=False, interest_category_id=None, interest_ids=None, interest_match=None, sort_field=None, sort_dir=None, since_last_campaign=None, unsubscribed_since=None): """ Get a table of members in a list based on query parameters. Note that argument descriptions here are sourced from Mailchimp's official API documentation. `Args:` list_id: string The unique ID of the list to fetch members from. fields: list of strings A comma-separated list of fields to return. Reference parameters of sub-objects with dot notation. exclude_fields: list of fields as strings A comma-separated list of fields to exclude. Reference parameters of sub-objects with dot notation. count: int The number of records to return. Default value is 10. Maximum value is 1000. offset: int The number of records from a collection to skip. Iterating over large collections with this parameter can be slow. Default value is 0. email_type: string The email type. status: string, can only be 'subscribed', 'unsubscribed', 'cleaned', 'pending', 'transactional', 'archived', or None The subscriber's status. since_timestamp_opt: string Restrict results to subscribers who opted-in after the set timeframe. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. before_timestamp_opt: string Restrict results to subscribers who opted-in before the set timeframe. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. since_last_changed: string Restrict results to subscribers whose information changed after the set timeframe. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. before_last_changed: string Restrict results to subscribers whose information changed before the set timeframe. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. unique_email_id: string A unique identifier for the email address across all Mailchimp lists. This parameter can be found in any links with Ecommerce Tracking enabled. vip_only: boolean A filter to return only the list's VIP members. Passing true will restrict results to VIP list members, passing false will return all list members. interest_category_id: string The unique id for the interest category. interest_ids: list of strings Used to filter list members by interests. Must be accompanied by interest_category_id and interest_match. The value must be a comma separated list of interest ids present for any supplied interest categories. interest_match: string, can only be 'any', 'all', 'none', or None Used to filter list members by interests. Must be accompanied by interest_category_id and interest_ids. "any" will match a member with any of the interest supplied, "all" will only match members with every interest supplied, and "none" will match members without any of the interest supplied. sort_field: string, can only be 'timestamp_opt', 'timestamp_signup', 'last_changed', or None Returns files sorted by the specified field. sort_dir: string, can only be 'ASC', 'DESC', or None Determines the order direction for sorted results. since_last_campaign: string Filter subscribers by those subscribed/unsubscribed/pending/cleaned since last email campaign send. Member status is required to use this filter. unsubscribed_since: string Filter subscribers by those unsubscribed since a specific date. Using any status other than unsubscribed with this filter will result in an error. `Returns:` Table Class """ params = { 'fields': fields, 'exclude_fields': exclude_fields, 'count': count, 'offset': offset, 'email_type': email_type, 'status': status, 'since_timestamp_opt': since_timestamp_opt, 'before_timestamp_opt': before_timestamp_opt, 'since_last_changed': since_last_changed, 'before_last_changed': before_last_changed, 'unqiue_email_id': unique_email_id, 'vip_only': vip_only, 'interest_category_id': interest_category_id, 'interest_ids': interest_ids, 'interest_match': interest_match, 'sort_field': sort_field, 'sort_dir': sort_dir, 'since_last_campaign': since_last_campaign, 'unsubscribed_since': unsubscribed_since } response = self.client.get_request(f'lists/{list_id}/members', params=params) tbl = Table(response['members']) logger.info(f'Found {tbl.num_rows} members.') if tbl.num_rows > 0: return tbl else: return Table() def get_campaign_emails(self, campaign_id, fields=None, exclude_fields=None, count=None, offset=None, since=None): """ Get a table of individual emails from a campaign based on query parameters. Note that argument descriptions here are sourced from Mailchimp's official API documentation. `Args:` campaign_id: string The unique ID of the campaign to fetch emails from. fields: list of strings A comma-separated list of fields to return. Reference parameters of sub-objects with dot notation. exclude_fields: list of strings A comma-separated list of fields to exclude. Reference parameters of sub-objects with dot notation. count: int The number of records to return. Default value is 10. Maximum value is 1000. offset: int The number of records from a collection to skip. Iterating over large collections with this parameter can be slow. Default value is 0. since: string Restrict results to email activity events that occur after a specific time. We recommend ISO 8601 time format: 2015-10-21T15:41:36+00:00. `Returns:` Table Class """ params = { 'fields': fields, 'exclude_fields': exclude_fields, 'count': count, 'offset': offset, 'since': since } response = self.client.get_request( f'reports/{campaign_id}/email-activity', params=params) tbl = Table(response['emails']) if tbl.num_rows > 0: return tbl else: return Table() def get_unsubscribes(self, campaign_id, fields=None, exclude_fields=None, count=None, offset=None): """ Get a table of unsubscribes associated with a campaign based on query parameters. Note that argument descriptions here are sourced from Mailchimp's official API documentation. `Args:` campaign_id: string The unique ID of the campaign to fetch unsubscribes from. fields: list of strings A comma-separated list of fields to return. Reference parameters of sub-objects with dot notation. exclude_fields: list of strings A comma-separated list of fields to exclude. Reference parameters of sub-objects with dot notation. count: int The number of records to return. Default value is 10. Maximum value is 1000. offset: int The number of records from a collection to skip. Iterating over large collections with this parameter can be slow. Default value is 0. `Returns:` Table Class """ params = { 'fields': fields, 'exclude_fields': exclude_fields, 'count': count, 'offset': offset } response = self.client.get_request( f'reports/{campaign_id}/unsubscribed', params=params) tbl = Table(response['unsubscribes']) logger.info(f'Found {tbl.num_rows} unsubscribes for {campaign_id}.') if tbl.num_rows > 0: return tbl else: return Table()
class ActionNetwork(object): """ `Args:` api_token: str The OSDI API token """ def __init__(self, api_token=None): self.api_token = check_env.check('AN_API_TOKEN', api_token) self.headers = { "Content-Type": "application/json", "OSDI-API-Token": self.api_token } self.api_url = API_URL self.api = APIConnector(self.api_url, headers=self.headers) def _get_page(self, object_name, page, per_page=25): # returns data from one page of results if per_page > 25: per_page = 25 logger.info( "Action Network's API will not return more than 25 entries per page. \ Changing per_page parameter to 25.") page_url = f"{object_name}?page={page}&per_page={per_page}" return self.api.get_request(url=page_url) def _get_entry_list(self, object_name, limit=None, per_page=25): # returns a list of entries for a given object, such as people, tags, or actions count = 0 page = 1 return_list = [] while True: response = self._get_page(object_name, page, per_page) page = page + 1 response_list = response['_embedded'][f"osdi:{object_name}"] if not response_list: return Table(return_list) return_list.extend(response_list) count = count + len(response_list) if limit: if count >= limit: return Table(return_list[0:limit]) def get_people(self, limit=None, per_page=25, page=None): """ `Args:` limit: The number of entries to return. When None, returns all entries. per_page The number of entries per page to return. 25 maximum. page Which page of results to return `Returns:` A list of JSONs of people stored in Action Network. """ if page: self._get_page("people", page, per_page) return self._get_entry_list("people", limit, per_page) def get_person(self, person_id): """ `Args:` person_id: Id of the person. `Returns:` A JSON of the entry. If the entry doesn't exist, Action Network returns ``{'error': 'Couldn't find person with id = <id>'}``. """ return self.api.get_request(url=f"people/{person_id}") def add_person(self, email_address=None, given_name=None, family_name=None, tags=None, languages_spoken=None, postal_addresses=None, mobile_number=None, mobile_status='subscribed', **kwargs): """ `Args:` email_address: Either email_address or mobile_number are required. Can be any of the following - a string with the person's email - a list of strings with a person's emails - a dictionary with the following fields - email_address (REQUIRED) - primary (OPTIONAL): Boolean indicating the user's primary email address - status (OPTIONAL): can taken on any of these values - "subscribed" - "unsubscribed" - "bouncing" - "previous bounce" - "spam complaint" - "previous spam complaint" given_name: The person's given name family_name: The person's family name tags: Any tags to be applied to the person languages_spoken: Optional field. A list of strings of the languages spoken by the person postal_addresses: Optional field. A list of dictionaries. For details, see Action Network's documentation: https://actionnetwork.org/docs/v2/person_signup_helper mobile_number: Either email_address or mobile_number are required. Can be any of the following - a string with the person's cell phone number - an integer with the person's cell phone number - a list of strings with the person's cell phone numbers - a list of integers with the person's cell phone numbers - a dictionary with the following fields - number (REQUIRED) - primary (OPTIONAL): Boolean indicating the user's primary mobile number - status (OPTIONAL): can taken on any of these values - "subscribed" - "unsubscribed" mobile_status: 'subscribed' or 'unsubscribed' **kwargs: Any additional fields to store about the person. Action Network allows any custom field. Adds a person to Action Network """ email_addresses_field = None if type(email_address) == str: email_addresses_field = [{"address": email_address}] elif type(email_address) == list: if type(email_address[0]) == str: email_addresses_field = [{ "address": email } for email in email_address] email_addresses_field[0]['primary'] = True if type(email_address[0]) == dict: email_addresses_field = email_address mobile_numbers_field = None if type(mobile_number) == str: mobile_numbers_field = [{ "number": re.sub('[^0-9]', "", mobile_number), "status": mobile_status }] elif type(mobile_number) == int: mobile_numbers_field = [{ "number": str(mobile_number), "status": mobile_status }] elif type(mobile_number) == list: if len(mobile_number) > 1: raise ( 'Action Network allows only 1 phone number per activist') if type(mobile_number[0]) == str: mobile_numbers_field = [{ "number": re.sub('[^0-9]', "", cell), "status": mobile_status } for cell in mobile_number] mobile_numbers_field[0]['primary'] = True if type(mobile_number[0]) == int: mobile_numbers_field = [{ "number": cell, "status": mobile_status } for cell in mobile_number] mobile_numbers_field[0]['primary'] = True if type(mobile_number[0]) == dict: mobile_numbers_field = mobile_number if not email_addresses_field and not mobile_numbers_field: raise ( "Either email_address or mobile_number is required and can be formatted " "as a string, list of strings, a dictionary, a list of dictionaries, or " "(for mobile_number only) an integer or list of integers") data = {"person": {}} if email_addresses_field is not None: data["person"]["email_addresses"] = email_addresses_field if mobile_numbers_field is not None: data["person"]["phone_numbers"] = mobile_numbers_field if given_name is not None: data["person"]["given_name"] = given_name if family_name is not None: data["person"]["family_name"] = family_name if languages_spoken is not None: data["person"]["languages_spoken"] = languages_spoken if postal_addresses is not None: data["person"]["postal_address"] = postal_addresses if tags is not None: data["add_tags"] = tags data["person"]["custom_fields"] = {**kwargs} response = self.api.post_request(url=f"{self.api_url}/people", data=json.dumps(data)) identifiers = response['identifiers'] person_id = [ entry_id.split(':')[1] for entry_id in identifiers if 'action_network:' in entry_id ][0] logger.info(f"Entry {person_id} successfully added to people.") return response def update_person(self, entry_id, **kwargs): """ `Args:` entry_id: The person's Action Network id **kwargs: Fields to be updated. The possible fields are email_address: Can be any of the following - a string with the person's email - a dictionary with the following fields - email_address (REQUIRED) - primary (OPTIONAL): Boolean indicating the user's primary email address - status (OPTIONAL): can taken on any of these values - "subscribed" - "unsubscribed" - "bouncing" - "previous bounce" - "spam complaint" - "previous spam complaint" given_name: The person's given name family_name: The person's family name tags: Any tags to be applied to the person languages_spoken: Optional field. A list of strings of the languages spoken by the person postal_addresses: Optional field. A list of dictionaries. For details, see Action Network's documentation: https://actionnetwork.org/docs/v2/people#put custom_fields: A dictionary of any other fields to store about the person. Updates a person's data in Action Network """ data = {**kwargs} response = self.api.put_request( url=f"{self.api_url}/people/{entry_id}", json=json.dumps(data), success_codes=[204, 201, 200]) logger.info(f"Person {entry_id} successfully updated") return response def get_tags(self, limit=None, per_page=25, page=None): """ `Args:` limit: The number of entries to return. When None, returns all entries. per_page The number of entries per page to return. 25 maximum. page Which page of results to return `Returns:` A list of JSONs of tags in Action Network. """ if page: self.get_page("tags", page, per_page) return self._get_entry_list("tags", limit, per_page) def get_tag(self, tag_id): """ `Args:` tag_id: Id of the tag. `Returns:` A JSON of the entry. If the entry doesn't exist, Action Network returns "{'error': 'Couldn't find tag with id = <id>'}" """ return self.api.get_request(url=f"tags/{tag_id}") def add_tag(self, name): """ `Args:` name: The tag's name. This is the ONLY editable field Adds a tag to Action Network. Once created, tags CANNOT be edited or deleted. """ data = {"name": name} response = self.api.post_request(url=f"{self.api_url}/tags", data=json.dumps(data)) identifiers = response['identifiers'] person_id = [ entry_id.split(':')[1] for entry_id in identifiers if 'action_network:' in entry_id ][0] logger.info(f"Tag {person_id} successfully added to tags.") return response def create_event(self, title, start_date=None, location=None): """ Create an event in Action Network `Args:` title: str The public title of the event start_date: str OR datetime OPTIONAL: The starting date & time. If a string, use format "YYYY-MM-DD HH:MM:SS" (hint: the default format you get when you use `str()` on a datetime) location: dict OPTIONAL: A dict of location details. Can include any combination of the types of values in the following example: .. code-block:: python my_location = { "venue": "White House", "address_lines": [ "1600 Pennsylvania Ave" ], "locality": "Washington", "region": "DC", "postal_code": "20009", "country": "US" } `Returns:` Dict of Action Network Event data. """ data = {"title": title} if start_date: start_date = str(start_date) data["start_date"] = start_date if isinstance(location, dict): data["location"] = location event_dict = self.api.post_request(url=f"{self.api_url}/events", data=json.dumps(data)) an_event_id = event_dict["_links"]["self"]["href"].split('/')[-1] event_dict["event_id"] = an_event_id return event_dict
class VANConnector(object): def __init__(self, api_key=None, auth_name='default', db=None): self.api_key = check_env.check('VAN_API_KEY', api_key) if db == 'MyVoters': self.db_code = 0 elif db in ['MyMembers', 'MyCampaign', 'EveryAction']: self.db_code = 1 else: raise KeyError('Invalid database type specified. Pick one of:' ' MyVoters, MyCampaign, MyMembers, EveryAction.') self.uri = URI self.db = db self.auth_name = auth_name self.pagination_key = 'nextPageLink' self.auth = (self.auth_name, self.api_key + '|' + str(self.db_code)) self.api = APIConnector(self.uri, auth=self.auth, data_key='items', pagination_key=self.pagination_key) # We will not create the SOAP client unless we need to as this triggers checking for # valid credentials. As not all API keys are provisioned for SOAP, this keeps it from # raising a permission exception when creating the class. self._soap_client = None @property def api_key_profile(self): """ Returns the API key profile with includes permissions and other metadata. """ return self.get_request('apiKeyProfiles')[0] @property def soap_client(self): if not self._soap_client: # Create the SOAP client soap_auth = {'Header': {'DatabaseMode': self.soap_client_db(), 'APIKey': self.api_key}} self._soap_client = Client(SOAP_URI, soapheaders=soap_auth) return self._soap_client def soap_client_db(self): """ Parse the REST database name to the accepted SOAP format """ if self.db == 'MyVoters': return 'MyVoterFile' if self.db == 'EveryAction': return 'MyCampaign' else: return self.db def get_request(self, endpoint, **kwargs): r = self.api.get_request(self.uri + endpoint, **kwargs) data = self.api.data_parse(r) # Paginate while isinstance(r, dict) and self.api.next_page_check_url(r): if endpoint == 'savedLists' and not r['items']: break r = self.api.get_request(r[self.pagination_key], **kwargs) data.extend(self.api.data_parse(r)) return data def post_request(self, endpoint, **kwargs): return self.api.post_request(endpoint, **kwargs) def delete_request(self, endpoint, **kwargs): return self.api.delete_request(endpoint, **kwargs) def patch_request(self, endpoint, **kwargs): return self.api.patch_request(endpoint, **kwargs) def put_request(self, endpoint, **kwargs): return self.api.put_request(endpoint, **kwargs)
class Phone2Action(object): """ Instantiate Phone2Action Class `Args:` app_id: str The Phone2Action provided application id. Not required if ``PHONE2ACTION_APP_ID`` env variable set. app_key: str The Phone2Action provided application key. Not required if ``PHONE2ACTION_APP_KEY`` env variable set. `Returns:` Phone2Action Class """ def __init__(self, app_id=None, app_key=None): self.app_id = check_env.check('PHONE2ACTION_APP_ID', app_id) self.app_key = check_env.check('PHONE2ACTION_APP_KEY', app_key) self.auth = HTTPBasicAuth(self.app_id, self.app_key) self.client = APIConnector(PHONE2ACTION_URI, auth=self.auth) def _paginate_request(self, url, args=None, page=None): # Internal pagination method if page is not None: args['page'] = page r = self.client.get_request(url, params=args) json = r['data'] if page is not None: return json # If count of items is less than the total allowed per page, paginate while r['pagination']['count'] == r['pagination']['per_page']: r = self.client.get_request(r['pagination']['next_url'], args) json.extend(r['data']) return json def get_advocates(self, state=None, campaign_id=None, updated_since=None, page=None): """ Return advocates (person records). If no page is specified, the method will automatically paginate through the available advocates. `Args:` state: str Filter by US postal abbreviation for a state or territory e.g., "CA" "NY" or "DC" campaign_id: int Filter to specific campaign updated_since: str or int or datetime Fetch all advocates updated since the date provided; this can be a datetime object, a UNIX timestamp, or a date string (ex. '2014-01-05 23:59:43') page: int Page number of data to fetch; if this is specified, call will only return one page. `Returns:` A dict of parsons tables: * emails * phones * memberships * tags * ids * fields * advocates """ # Convert the passed in updated_since into a Unix timestamp (which is what the API wants) updated_since = date_to_timestamp(updated_since) args = { 'state': state, 'campaignid': campaign_id, 'updatedSince': updated_since } logger.info('Retrieving advocates...') json = self._paginate_request('advocates', args=args, page=page) return self._advocates_tables(Table(json)) def _advocates_tables(self, tbl): # Convert the advocates nested table into multiple tables tbls = { 'advocates': tbl, 'emails': Table(), 'phones': Table(), 'memberships': Table(), 'tags': Table(), 'ids': Table(), 'fields': Table(), } if not tbl: return tbls logger.info(f'Retrieved {tbl.num_rows} advocates...') # Unpack all of the single objects # The Phone2Action API docs says that created_at and updated_at are dictionaries, but # the data returned from the server is a ISO8601 timestamp. - EHS, 05/21/2020 for c in ['address', 'districts']: tbl.unpack_dict(c) # Unpack all of the arrays child_tables = [child for child in tbls.keys() if child != 'advocates'] for c in child_tables: tbls[c] = tbl.long_table(['id'], c, key_rename={'id': 'advocate_id'}) return tbls def get_campaigns(self, state=None, zip=None, include_generic=False, include_private=False, include_content=True): """ Returns a list of campaigns `Args:` state: str Filter by US postal abbreviation for a state or territory e.g., "CA" "NY" or "DC" zip: int Filter by 5 digit zip code include_generic: boolean When filtering by state or ZIP code, include unrestricted campaigns include_private: boolean If true, will include private campaigns in results include_content: boolean If true, include campaign content fields, which may vary. This may cause sync errors. `Returns:` Parsons Table See :ref:`parsons-table` for output options. """ args = { 'state': state, 'zip': zip, 'includeGeneric': str(include_generic), 'includePrivate': str(include_private) } tbl = Table(self.client.get_request('campaigns', params=args)) if tbl: tbl.unpack_dict('updated_at') if include_content: tbl.unpack_dict('content') return tbl def create_advocate(self, campaigns, first_name=None, last_name=None, email=None, phone=None, address1=None, address2=None, city=None, state=None, zip5=None, sms_optin=None, email_optin=None, sms_optout=None, email_optout=None, **kwargs): """ Create an advocate. If you want to opt an advocate into or out of SMS / email campaigns, you must provide the email address or phone number (accordingly). The list of arguments only partially covers the fields that can be set on the advocate. For a complete list of fields that can be updated, see `the Phone2Action API documentation <https://docs.phone2action.com/#calls-create>`_. `Args:` campaigns: list The ID(s) of campaigns to add the advocate to first_name: str `Optional`; The first name of the advocate last_name: str `Optional`; The last name of the advocate email: str `Optional`; An email address to add for the advocate. One of ``email`` or ``phone`` is required. phone: str `Optional`; An phone # to add for the advocate. One of ``email`` or ``phone`` is required. address1: str `Optional`; The first line of the advocates' address address2: str `Optional`; The second line of the advocates' address city: str `Optional`; The city of the advocates address state: str `Optional`; The state of the advocates address zip5: str `Optional`; The 5 digit Zip code of the advocate sms_optin: boolean `Optional`; Whether to opt the advocate into receiving text messages; an SMS confirmation text message will be sent. You must provide values for the ``phone`` and ``campaigns`` arguments. email_optin: boolean `Optional`; Whether to opt the advocate into receiving emails. You must provide values for the ``email`` and ``campaigns`` arguments. sms_optout: boolean `Optional`; Whether to opt the advocate out of receiving text messages. You must provide values for the ``phone`` and ``campaigns`` arguments. Once an advocate is opted out, they cannot be opted back in. email_optout: boolean `Optional`; Whether to opt the advocate out of receiving emails. You must provide values for the ``email`` and ``campaigns`` arguments. Once an advocate is opted out, they cannot be opted back in. **kwargs: Additional fields on the advocate to update `Returns:` The int ID of the created advocate. """ # Validate the passed in arguments if not campaigns: raise ValueError( 'When creating an advocate, you must specify one or more campaigns.' ) if not email and not phone: raise ValueError( 'When creating an advocate, you must provide an email address or a phone number.' ) if (sms_optin or sms_optout) and not phone: raise ValueError( 'When opting an advocate in or out of SMS messages, you must specify a valid ' 'phone and one or more campaigns') if (email_optin or email_optout) and not email: raise ValueError( 'When opting an advocate in or out of email messages, you must specify a valid ' 'email address and one or more campaigns') # Align our arguments with the expected parameters for the API payload = { 'email': email, 'phone': phone, 'firstname': first_name, 'lastname': last_name, 'address1': address1, 'address2': address2, 'city': city, 'state': state, 'zip5': zip5, 'smsOptin': 1 if sms_optin else None, 'emailOptin': 1 if email_optin else None, 'smsOptout': 1 if sms_optout else None, 'emailOptout': 1 if email_optout else None, } # Clean up any keys that have a "None" value payload = {key: val for key, val in payload.items() if val is not None} # Merge in any kwargs payload.update(kwargs) # Turn into a list of items so we can append multiple campaigns campaign_keys = [('campaigns[]', val) for val in campaigns] data = [(key, value) for key, value in payload.items()] + campaign_keys # Call into the Phone2Action API response = self.client.post_request('advocates', data=data) return response['advocateid'] def update_advocate(self, advocate_id, campaigns=None, email=None, phone=None, sms_optin=None, email_optin=None, sms_optout=None, email_optout=None, **kwargs): """ Update the fields of an advocate. If you want to opt an advocate into or out of SMS / email campaigns, you must provide the email address or phone number along with a list of campaigns. The list of arguments only partially covers the fields that can be updated on the advocate. For a complete list of fields that can be updated, see `the Phone2Action API documentation <https://docs.phone2action.com/#calls-create>`_. `Args:` advocate_id: integer The ID of the advocate being updates campaigns: list `Optional`; The ID(s) of campaigns to add the user to email: str `Optional`; An email address to add for the advocate (or to use when opting in/out) phone: str `Optional`; An phone # to add for the advocate (or to use when opting in/out) sms_optin: boolean `Optional`; Whether to opt the advocate into receiving text messages; an SMS confirmation text message will be sent. You must provide values for the ``phone`` and ``campaigns`` arguments. email_optin: boolean `Optional`; Whether to opt the advocate into receiving emails. You must provide values for the ``email`` and ``campaigns`` arguments. sms_optout: boolean `Optional`; Whether to opt the advocate out of receiving text messages. You must provide values for the ``phone`` and ``campaigns`` arguments. Once an advocate is opted out, they cannot be opted back in. email_optout: boolean `Optional`; Whether to opt the advocate out of receiving emails. You must provide values for the ``email`` and ``campaigns`` arguments. Once an advocate is opted out, they cannot be opted back in. **kwargs: Additional fields on the advocate to update """ # Validate the passed in arguments if (sms_optin or sms_optout) and not (phone and campaigns): raise ValueError( 'When opting an advocate in or out of SMS messages, you must specify a valid ' 'phone and one or more campaigns') if (email_optin or email_optout) and not (email and campaigns): raise ValueError( 'When opting an advocate in or out of email messages, you must specify a valid ' 'email address and one or more campaigns') # Align our arguments with the expected parameters for the API payload = { 'advocateid': advocate_id, 'campaigns': campaigns, 'email': email, 'phone': phone, 'smsOptin': 1 if sms_optin else None, 'emailOptin': 1 if email_optin else None, 'smsOptout': 1 if sms_optout else None, 'emailOptout': 1 if email_optout else None, # remap first_name / last_name to be consistent with updated_advocates 'firstname': kwargs.pop('first_name', None), 'lastname': kwargs.pop('last_name', None), } # Clean up any keys that have a "None" value payload = {key: val for key, val in payload.items() if val is not None} # Merge in any kwargs payload.update(kwargs) # Turn into a list of items so we can append multiple campaigns campaigns = campaigns or [] campaign_keys = [('campaigns[]', val) for val in campaigns] data = [(key, value) for key, value in payload.items()] + campaign_keys # Call into the Phone2Action API self.client.post_request('advocates', data=data)
class ActBlue(object): """ Instantiate class. `Args:` actblue_client_uuid: str The ActBlue provided Client UUID. Not required if ``ACTBLUE_CLIENT_UUID`` env variable set. actblue_client_secret: str The ActBlue provided Client Secret. Not required if ``ACTBLUE_CLIENT_SECRET`` env variable set. actblue_uri: str The URI to access the CSV API. Not required, default is https://secure.actblue.com/api/v1. You can set an ``ACTBLUE_URI`` env variable or use this URI parameter if a different endpoint is necessary - for example, when running this code in a test environment where you don't want to hit the actual API. For instructions on how to generate a Client UUID and Client Secret set, visit https://secure.actblue.com/docs/csv_api#authentication. """ def __init__(self, actblue_client_uuid=None, actblue_client_secret=None, actblue_uri=None): self.actblue_client_uuid = check_env.check('ACTBLUE_CLIENT_UUID', actblue_client_uuid) self.actblue_client_secret = check_env.check('ACTBLUE_CLIENT_SECRET', actblue_client_secret) self.uri = check_env.check( 'ACTBLUE_URI', actblue_uri, optional=True ) or ACTBLUE_API_ENDPOINT self.headers = { "accept": "application/json", } self.client = APIConnector(self.uri, auth=(self.actblue_client_uuid, self.actblue_client_secret), headers=self.headers) def post_request(self, csv_type=None, date_range_start=None, date_range_end=None): """ POST request to ActBlue API to begin generating the CSV. `Args:` csv_type: str Type of CSV you are requesting. Options: 'paid_contributions': contains paid, non-refunded contributions to the entity (campaign or organization) you created the credential for, during the specified date range. 'refunded_contributions': contributions to your entity that were refunded, during the specified date range. 'managed_form_contributions': contributions made through any form that is managed by your entity, during the specified date range - including contributions to other entities via that form if it is a tandem form. date_range_start: str Start of date range to withdraw contribution data (inclusive). Ex: '2020-01-01' date_range_end: str End of date range to withdraw contribution data (exclusive). Ex: '2020-02-01' `Returns:` Response of POST request; a successful response includes 'id', a unique identifier for the CSV being generated. """ body = { "csv_type": csv_type, "date_range_start": date_range_start, "date_range_end": date_range_end } logger.info(f'Requesting {csv_type} from {date_range_start} up to {date_range_end}.') response = self.client.post_request(url="csvs", json=body) return response def get_download_url(self, csv_id=None): """ GET request to retrieve download_url for generated CSV. `Args:` csv_id: str Unique identifier of the CSV you requested. `Returns:` While CSV is being generated, 'None' is returned. When CSV is ready, the method returns the download_url. """ response = self.client.get_request(url=f"csvs/{csv_id}") return response['download_url'] def poll_for_download_url(self, csv_id): """ Poll the GET request method to check whether CSV generation has finished, signified by the presence of a download_url. `Args:` csv_id: str Unique identifier of the CSV you requested. `Returns:` Download URL from which you can download the generated CSV, valid for 10 minutes after retrieval. Null until CSV has finished generating. Keep this URL secure because until it expires, it could be used by anyone to download the CSV. """ logger.info('Request received. Please wait while ActBlue generates this data.') download_url = None while download_url is None: download_url = self.get_download_url(csv_id) time.sleep(POLLING_DELAY) logger.info('Completed data generation.') logger.info('Beginning conversion to Parsons Table.') return download_url def get_contributions(self, csv_type, date_range_start, date_range_end): """ Get specified contribution data from CSV API as Parsons table. `Args:` csv_type: str Type of CSV you are requesting. Options: 'paid_contributions': contains paid, non-refunded contributions to the entity (campaign or organization) you created the credential for, during the specified date range. 'refunded_contributions': contributions to your entity that were refunded, during the specified date range. 'managed_form_contributions': contributions made through any form that is managed by your entity, during the specified date range - including contributions to other entities via that form if it is a tandem form. date_range_start: str Start of date range to withdraw contribution data (inclusive). Ex: '2020-01-01' date_range_end: str End of date range to withdraw contribution data (exclusive). Ex: '2020-02-01' `Returns:` Contents of the generated contribution CSV as a Parsons table. """ post_request_response = self.post_request(csv_type, date_range_start, date_range_end) csv_id = post_request_response["id"] download_url = self.poll_for_download_url(csv_id) table = Table.from_csv(download_url) logger.info('Completed conversion to Parsons Table.') return table
class ActionNetwork(object): """ `Args:` api_token: str The OSDI API token api_url: The end point url """ def __init__(self, api_token=None, api_url=None): self.api_token = check_env.check('AN_API_TOKEN', api_token) self.headers = { "Content-Type": "application/json", "OSDI-API-Token": self.api_token } self.api_url = check_env.check('AN_API_URL', api_url) self.api = APIConnector(self.api_url, headers=self.headers) def _get_page(self, object_name, page, per_page=25): # returns data from one page of results if per_page > 25: per_page = 25 logger.info("Action Network's API will not return more than 25 entries per page. \ Changing per_page parameter to 25.") page_url = f"{object_name}?page={page}&per_page={per_page}" return self.api.get_request(url=page_url) def _get_entry_list(self, object_name, limit=None, per_page=25): # returns a list of entries for a given object, such as people, tags, or actions count = 0 page = 1 return_list = [] while True: response = self._get_page(object_name, page, per_page) page = page + 1 response_list = response['_embedded'][f"osdi:{object_name}"] if not response_list: return Table(return_list) return_list.extend(response_list) count = count + len(response_list) if limit: if count >= limit: return Table(return_list[0:limit]) def get_people(self, limit=None, per_page=25, page=None): """ `Args:` limit: The number of entries to return. When None, returns all entries. per_page The number of entries per page to return. 25 maximum. page Which page of results to return `Returns:` A list of JSONs of people stored in Action Network. """ if page: self._get_page("people", page, per_page) return self._get_entry_list("people", limit, per_page) def get_person(self, person_id): """ `Args:` person_id: Id of the person. `Returns:` A JSON of the entry. If the entry doesn't exist, Action Network returns ``{'error': 'Couldn't find person with id = <id>'}``. """ return self.api.get_request(url=f"people/{person_id}") def add_person(self, email_address, given_name=None, family_name=None, tags=[], languages_spoken=[], postal_addresses=[], **kwargs): """ `Args:` email_address: Can be any of the following - a string with the person's email - a dictionary with the following fields - email_address (REQUIRED) - primary (OPTIONAL): Boolean indicating the user's primary email address - status (OPTIONAL): can taken on any of these values - "subscribed" - "unsubscribed" - "bouncing" - "previous bounce" - "spam complaint" - "previous spam complaint" given_name: The person's given name family_name: The person's family name tags: Any tags to be applied to the person languages_spoken: Optional field. A list of strings of the languages spoken by the person postal_addresses: Optional field. A list of dictionaries. For details, see Action Network's documentation: https://actionnetwork.org/docs/v2/people#put **kwargs: Any additional fields to store about the person. Action Network allows any custom field. Adds a person to Action Network """ email_addresses_field = None if type(email_address) == str: email_addresses_field = [{"address": email_address}] elif type(email_address == list): if type(email_address[0]) == str: email_addresses_field = [{"address": email} for email in email_address] email_addresses_field[0]['primary'] = True if type(email_address[0]) == dict: email_addresses_field = email_address if not email_addresses_field: raise("email_address must be a string, list of strings, or list of dictionaries") data = { "person": { "email_addresses": email_addresses_field, "given_name": given_name, "family_name": family_name, "languages_spoken": languages_spoken, "postal_addresses": postal_addresses, "custom_fields": {**kwargs} }, "add_tags": tags } response = self.api.post_request(url=f"{self.api_url}/people", data=json.dumps(data)) identifiers = response['identifiers'] person_id = [entry_id.split(':')[1] for entry_id in identifiers if 'action_network:' in entry_id][0] logger.info(f"Entry {person_id} successfully added to people.") return response def update_person(self, entry_id, **kwargs): """ `Args:` entry_id: The person's Action Network id **kwargs: Fields to be updated. The possible fields are email_address: Can be any of the following - a string with the person's email - a dictionary with the following fields - email_address (REQUIRED) - primary (OPTIONAL): Boolean indicating the user's primary email address - status (OPTIONAL): can taken on any of these values - "subscribed" - "unsubscribed" - "bouncing" - "previous bounce" - "spam complaint" - "previous spam complaint" given_name: The person's given name family_name: The person's family name tags: Any tags to be applied to the person languages_spoken: Optional field. A list of strings of the languages spoken by the person postal_addresses: Optional field. A list of dictionaries. For details, see Action Network's documentation: https://actionnetwork.org/docs/v2/people#put custom_fields: A dictionary of any other fields to store about the person. Updates a person's data in Action Network """ data = {**kwargs} response = self.api.put_request(url=f"{self.api_url}/people/{entry_id}", json=json.dumps(data), success_codes=[204, 201, 200]) logger.info(f"Person {entry_id} successfully updated") return response def get_tags(self, limit=None, per_page=25, page=None): """ `Args:` limit: The number of entries to return. When None, returns all entries. per_page The number of entries per page to return. 25 maximum. page Which page of results to return `Returns:` A list of JSONs of tags in Action Network. """ if page: self.get_page("tags", page, per_page) return self._get_entry_list("tags", limit, per_page) def get_tag(self, tag_id): """ `Args:` tag_id: Id of the tag. `Returns:` A JSON of the entry. If the entry doesn't exist, Action Network returns "{'error': 'Couldn't find tag with id = <id>'}" """ return self.api.get_request(url=f"tags/{tag_id}") def add_tag(self, name): """ `Args:` name: The tag's name. This is the ONLY editable field Adds a tag to Action Network. Once created, tags CANNOT be edited or deleted. """ data = { "name": name } response = self.api.post_request(url=f"{self.api_url}/tags", data=json.dumps(data)) identifiers = response['identifiers'] person_id = [entry_id.split(':')[1] for entry_id in identifiers if 'action_network:' in entry_id][0] logger.info(f"Tag {person_id} successfully added to tags.") return response