Source code for parsons.targetsmart.targetsmart_api

"""
Routines for interacting with TargetSmart's developer API.

https://docs.targetsmart.com/developers/tsapis/v2/index.html
"""

import logging

import petl
import requests
from parsons.etl.table import Table
from parsons.utilities import check_env

from .targetsmart_smartmatch import SmartMatch

URI = "https://api.targetsmart.com/"

logger = logging.getLogger(__name__)


class TargetSmartConnector:
    def __init__(self, api_key):
        self.uri = URI
        self.api_key = check_env.check("TS_API_KEY", api_key)
        self.headers = {"x-api-key": self.api_key}

    def request(self, url, args=None, raw=False):
        r = requests.get(url, headers=self.headers, params=args)

        # This allows me to deal with data that needs to be munged.
        if raw:
            return r.json()

        return Table(r.json()["output"])


class Person:
    def __init__(self):
        return None

    def data_enhance(self, search_id, search_id_type="voterbase", state=None):
        """
        Searches for a record based on an id or phone or email address

        `Args:`
            search_id: str
                The primary key or email address or phone number
            search_id_type: str
                One of ``voterbase``, ``exacttrack``, ``phone``,
                ``email``, ``smartvan``, ``votebuilder``, ``voter``, ``household``.
            state: str
                Two character state code. Required if ``search_id_type`` of ``smartvan``,
                ``votebuilder`` or ``voter``.
        `Returns`
            Parsons Table
                See :ref:`parsons-table` for output options.
        """

        if search_id_type in ["smartvan", "votebuilder", "voter"] and state is None:
            raise KeyError("Search ID type '{}' requires state kwarg".format(search_id_type))

        if search_id_type not in (
            "voterbase",
            "exacttrack",
            "phone",
            "email",
            "smartvan",
            "votebuilder",
            "voter",
            "household",
        ):
            raise ValueError("Search_id_type is not valid")

        url = self.connection.uri + "person/data-enhance"

        args = {
            "search_id": search_id,
            "search_id_type": search_id_type,
            "state": state,
        }

        return self.connection.request(url, args=args)

    def radius_search(
        self,
        first_name,
        last_name,
        middle_name=None,
        name_suffix=None,
        latitude=None,
        longitude=None,
        address=None,
        radius_size=10,
        radius_unit="miles",
        max_results=10,
        gender="a",
        age_min=None,
        age_max=None,
        composite_score_min=1,
        composite_score_max=100,
        last_name_exact=True,
        last_name_is_prefix=False,
        last_name_prefix_length=10,
        address_type="reg",
    ):
        """
        Search for a person based on a specified radius

        `Args`:
            first_name: str
                One or more alpha characters. Required
            last_name: str
                One or more alpha characters. Required
            middle_name: str
                One or more alpha characters
            name_suffix: str
                One or more alpha characters
            latitude: float
                Floating point number (e.g. 33.738987255507)
            longitude: float
                Floating point number (e.g. -116.40833849559)
            address: str
                Any geocode-able address
            address_type: str
                ``reg`` for registration (default) or ``tsmart`` for TargetSmart
            radius_size: int
                A positive integer where combined with ``radius_unit`` does not exceed 120 miles
            radius_unit: str
                One of ``meters``, ``feet``, ``miles`` (default), or ``kilometers``.
            max_results: int
                Default of ``10``. An integer in range [0 - 100]
            gender: str
                Default of ``a``. One of ``m``, ``f``, ``u``, ``a``.
            age_min: int
                A positive integer
            age_max: int
                A positive integer
            composite_score_min: int
                An integer in range [1 - 100]. Filter out results with composite score
                less than this value.
            composite_score_max: int
                An integer in range [1 - 100]. Filter out results with composite score
                greater than this value.
            last_name_exact: boolean
                By default, the full last name is used for finding matches if the length of the
                last name is not longer than 10 characters. As an example, “anders” is less likely
                to match to “anderson” with this enabled. Disable this option if you are using
                either ``last_name_is_prefix`` or ``last_name_prefix_length``.
            last_name_is_prefix: boolean
                By default, the full last name is used for finding matches. Enable this parameter
                if your search last name is truncated. This can be common for some client
                applications that for various reasons do not have full last names. Use this
                parameter along with ``last_name_prefix_length`` to configure the length of the last
                name prefix. This parameter is ignored if ``last_name_exact`` is enabled.
            last_name_prefix_length: int
                By default, up to the first 10 characters of the search last name are used for
                finding relative matches. This value must be between 3 and 10. This parameter is
                ignored if last_name_exact is enabled.
        `Returns`
            Parsons Table
                See :ref:`parsons-table` for output options.
        """

        if (latitude is None or longitude is None) and address is None:
            raise ValueError("Lat/Long or Address required")

        if not first_name:
            raise ValueError("First name is required")

        if not last_name:
            raise ValueError("Last name is required")

        # Convert booleans
        for a in [last_name_exact, last_name_is_prefix]:
            a = str(a)

        url = self.connection.uri + "person/radius-search"

        args = {
            "first_name": first_name,
            "last_name": last_name,
            "middle_name": middle_name,
            "name_suffix": name_suffix,
            "latitude": latitude,
            "longitude": longitude,
            "address": address,
            "address_type": address_type,
            "radius_size": radius_size,
            "radius_unit": radius_unit,
            "max_results": max_results,
            "gender": gender,
            "age_min": age_min,
            "age_max": age_max,
            "composite_score_min": composite_score_min,
            "composite_score_max": composite_score_max,
            "last_name_exact": last_name_exact,
            "last_name_is_prefix": last_name_is_prefix,
            "last_name_prefix_length": last_name_prefix_length,
        }

        r = self.connection.request(url, args=args, raw=True)
        return Table([itm for itm in r["output"]]).unpack_dict("data_fields", prepend=False)

    def phone(self, table):
        """
        Match based on a list of 500 phones numbers. Table
        can contain up to 500 phone numbers to match

        `Args:`
            table: parsons table
                See :ref:`parsons-table`. One row per phone number,
                up to 500 phone numbers.
        `Returns:`
            See :ref:`parsons-table` for output options.
        """

        url = self.connection.uri + "person/phone-search"

        args = {"phones": list(petl.values(table.table, 0))}

        return Table(self.connection.request(url, args=args, raw=True)["result"])


class Service:
    def __init__(self):
        return None

    def district(
        self,
        search_type="zip",
        address=None,
        zip5=None,
        zip4=None,
        state=None,
        latitude=None,
        longitude=None,
    ):
        """
        Return district information based on a geographic point. The method allows you to
        search based on the following:

        .. list-table::
            :widths: 30 30 30
            :header-rows: 1

            * - Search Type
              - ``search_type``
              - Required kwarg(s)
            * - Zip Code
              - ``zip``
              - ``zip5``, ``zip4``
            * - Address
              - ``address``
              - ``address``
            * - Point
              - ``point``
              - ``latitude``, ``longitude``

        `Args`:
            search_type: str
                The type of district search to perform. One of ``zip``, ``address``
                or ``point``.
            address: str
                An uparsed full address
            zip5: str
                The USPS Zip5 code
            zip4: str
                The USPS Zip4 code
            state: str
                The two character state code
            latitude: float or str
                Valid latitude floating point
            longitude: float or str
                Valid longitude floating point
        `Returns`:
            Parsons Table
                See :ref:`parsons-table` for output options.
        """

        if search_type == "zip" and None in [zip5, zip4]:
            raise ValueError("Search type 'zip' requires 'zip5' and 'zip4' arguments")

        elif search_type == "point" and None in [latitude, longitude]:
            raise ValueError("Search type 'point' requires 'latitude' and 'longitude' arguments")

        elif search_type == "address" and None in [address]:
            raise ValueError("Search type 'address' requires 'address' argument")

        elif search_type not in ["zip", "point", "address"]:
            raise KeyError("Invalid 'search_type' provided. ")

        else:
            pass

        url = self.connection.uri + "service/district"

        args = {
            "search_type": search_type,
            "address": address,
            "zip5": zip5,
            "zip4": zip4,
            "state": state,
            "latitude": latitude,
            "longitude": longitude,
        }

        return Table([self.connection.request(url, args=args, raw=True)["match_data"]])


class Voter(object):
    def __init__(self, connection):
        self.connection = connection

    def voter_registration_check(
        self,
        first_name=None,
        last_name=None,
        state=None,
        street_number=None,
        street_name=None,
        city=None,
        zip_code=None,
        age=None,
        dob=None,
        phone=None,
        email=None,
        unparsed_full_address=None,
    ):
        """
        Searches for a registered individual, returns matches.

        A search must include the at minimum first name, last name and state.

        `Args:`
            first_name: str
                Required; One or more alpha characters. Trailing wildcard allowed
            last_name: str
                Required; One or more alpha characters. Trailing wildcard allowed
            state: str
                Required; Two character state code (e.g. ``NY``)
            street_number: str
                Optional; One or more alpha characters. Trailing wildcard allowed
            street_name: str
                Optional; One or more alpha characters. Trailing wildcard allowed
            city: str
                Optional; The person's home city
            zip_code: str
                Optional; Numeric characters. Trailing wildcard allowed
            age; int
                Optional; One or more integers. Trailing wildcard allowed
            dob; str
                Optional; Numeric characters in YYYYMMDD format. Trailing wildcard allowed
            phone; str
                Optional; Integer followed by 0 or more * or integers
            email: str
                Optional; Alphanumeric character followed by 0 or more * or legal characters
                (alphanumeric, @, -, .)
            unparsed_full_address: str
                Optional; One or more alphanumeric characters. No wildcards.
        `Returns`
            Parsons Table
                See :ref:`parsons-table` for output options.
        """

        url = self.connection.uri + "voter/voter-registration-check"

        if None in [first_name, last_name, state]:
            raise ValueError(
                """Function must include at least first_name,
                             last_name, and state."""
            )

        args = {
            "first_name": first_name,
            "last_name": last_name,
            "state": state,
            "street_number": street_number,
            "street_name": street_name,
            "city": city,
            "zip_code": zip_code,
            "age": age,
            "dob": dob,
            "phone": phone,
            "email": email,
            "unparsed_full_address": unparsed_full_address,
        }

        return self.connection.request(url, args=args, raw=True)


[docs] class TargetSmartAPI(Voter, Person, Service, SmartMatch): def __init__(self, api_key=None): self.connection = TargetSmartConnector(api_key=api_key)