예제 #1
0
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)
예제 #2
0
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
예제 #3
0
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)
예제 #4
0
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)
예제 #5
0
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
예제 #6
0
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
예제 #7
0
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