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 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
class Bluelink: """ Instantiate a Bluelink connector. Allows for a simple method of inserting person data to Bluelink via a webhook. # see: https://bluelinkdata.github.io/docs/BluelinkApiGuide#webhook `Args:`: user: str Bluelink webhook user name. password: str Bluelink webhook password. """ def __init__(self, user=None, password=None): self.user = check_env.check('BLUELINK_WEBHOOK_USER', user) self.password = check_env.check('BLUELINK_WEBHOOK_PASSWORD', password) self.headers = { "Content-Type": "application/json", } self.api_url = API_URL self.api = APIConnector(self.api_url, auth=(self.user, self.password), headers=self.headers) def upsert_person(self, source, person=None): """ Upsert a BluelinkPerson object into Bluelink. Rows will update, as opposed to being inserted, if an existing person record in Bluelink has a matching BluelinkIdentifier (same source and id) as the BluelinkPerson object passed into this function. `Args:` source: str String to identify that the data came from your system. For example, your company name. person: BluelinkPerson A BluelinkPerson object. Will be inserted to Bluelink, or updated if a matching record is found. `Returns:` int An http status code from the http post request to the Bluelink webhook. """ data = {'source': source, 'person': person} jdata = json.dumps( data, default=lambda o: {k: v for k, v in o.__dict__.items() if v is not None}) resp = self.api.post_request(url=self.api_url, data=jdata) return resp def bulk_upsert_person(self, source, tbl, row_to_person): """ Upsert all rows into Bluelink, using the row_to_person function to transform rows to BluelinkPerson objects. `Args:` source: str String to identify that the data came from your system. For example, your company name. tbl: Table A parsons Table that represents people data. row_to_person: Callable[[dict],BluelinkPerson] A function that takes a dict representation of a row from the passed in tbl and returns a BluelinkPerson object. `Returns:` list[int] A list of https response status codes, one response for each row in the table. """ people = BluelinkPerson.from_table(tbl, row_to_person) responses = [] for person in people: response = self.upsert_person(source, person) responses.append(response) return responses