diff --git a/docs/openfield.rst b/docs/openfield.rst new file mode 100644 index 0000000000..1de563bb76 --- /dev/null +++ b/docs/openfield.rst @@ -0,0 +1,69 @@ +OpenField +========= + +******** +Overview +******** + +`OpenField `_ is a canvassing and VPB tool for organizing and election campaigns. +`OpenField REST API `_ + +.. note:: + Authentication + OpenField requires `HTTP Basic Auth `_. + Clients with an OpenField account can obtain the domain, username, and password needed + to access the OpenField API. + +********** +Quickstart +********** + +To instantiate the OpenField class, you can either store your OpenField API +domain, username, and password as environmental variables (``OPENFIELD_DOMAIN``, +``OPENFIELD_USERNAME``, and ``OPENFIELD_PASSWORD``, respectively) or pass in your +domain, username, and password as arguments: + +.. code-block:: python + + from parsons import OpenField + + # First approach: Use API credentials via environmental variables + openfield = OpenField() + + # Second approach: Pass API credentials as arguments + openfield = OpenField(domain='myorg.openfield.ai', username='my_name', password='1234') + +You can then call various endpoints: + +.. code-block:: python + + # Create a new person + person = { + "first_name": 'John', + "last_name": 'Smith', + "prov_city": 'Boston', + "prov_state": 'MA', + "prov_zip_5": '02108' + "email1": 'john@email.com', + "phone1": '2345678901', + } + openfield.create_person(person=person) + + # Fetch person + person = openfield.retrieve_person(person_id=123) + + # Update person fields + data= { + "phone1": '5558765432', + } + updated_person = openfield.update_person(person_id=123, data=data) + + # Delete person + openfield.destroy_person(person_id=123) + +*** +API +*** + +.. autoclass :: parsons.OpenField + :inherited-members: diff --git a/parsons/__init__.py b/parsons/__init__.py index ebc743e37f..4321a6dd64 100644 --- a/parsons/__init__.py +++ b/parsons/__init__.py @@ -71,6 +71,7 @@ ("parsons.mobilize_america.ma", "MobilizeAmerica"), ("parsons.nation_builder.nation_builder", "NationBuilder"), ("parsons.newmode.newmode", "Newmode"), + ("parsons.openfield.openfield", "OpenField"), ("parsons.ngpvan.van", "VAN"), ("parsons.notifications.gmail", "Gmail"), ("parsons.notifications.slack", "Slack"), diff --git a/parsons/openfield/__init__.py b/parsons/openfield/__init__.py new file mode 100644 index 0000000000..db4b5de1c7 --- /dev/null +++ b/parsons/openfield/__init__.py @@ -0,0 +1,3 @@ +from parsons.openfield.openfield import OpenField + +__all__ = ["OpenField"] diff --git a/parsons/openfield/openfield.py b/parsons/openfield/openfield.py new file mode 100644 index 0000000000..8f3ba7ca06 --- /dev/null +++ b/parsons/openfield/openfield.py @@ -0,0 +1,543 @@ +import json +import logging +import requests + +from parsons.etl.table import Table +from parsons.utilities import check_env + +logger = logging.getLogger(__name__) + + +class FailureException(Exception): + def __init__(self, resp, message="Error in request"): + self.status_code = resp.status_code + message = f"{resp.status_code} - {message}" + + try: + self.json = resp.json() + except ValueError: + self.json = None + if resp.text: + message = f"{message}\n{resp.text}" + + super(FailureException, self).__init__(message) + + +class OpenField: + """ + Instantiate the OpenField class + + `Args:` + domain: str + The OpenField domain (e.g. ``org-name.openfield.ai``) + Not required if ``OPENFIELD_DOMAIN`` env variable set. + username: str + The authorized OpenField username. + Not required if ``OPENFIELD_USERNAME`` env variable set. + password: str + The authorized OpenField user password. + Not required if ``OPENFIELD_PASSWORD`` env variable set. + """ + + _default_headers = { + "content-type": "application/json", + "accepts": "application/json", + } + + def __init__(self, domain=None, username=None, password=None): + self.domain = check_env.check("OPENFIELD_DOMAIN", domain) + self.username = check_env.check("OPENFIELD_USERNAME", username) + self.password = check_env.check("OPENFIELD_PASSWORD", password) + self.conn = self._conn() + + def _conn(self, default_headers=None): + if default_headers is None: + default_headers = self._default_headers + client = requests.Session() + client.auth = (self.username, self.password) + client.headers.update(default_headers) + return client + + def _base_endpoint(self, endpoint, entity_id=None): + # Create the base endpoint URL + + url = f"https://{self.domain}.openfield.ai/api/v1/{endpoint}/" + + if entity_id: + return f"{url}{entity_id}/" + return url + + def _base_get( + self, + endpoint, + entity_id=None, + exception_message=None, + params=None, + ): + # Make a general GET request + + resp = self.conn.get( + self._base_endpoint(endpoint, entity_id), + params=params, + ) + if resp.status_code >= 400: + raise FailureException(resp, exception_message) + + return resp.json() + + def _base_post(self, endpoint, data, exception_message=None): + # Make a general POST request + + resp = self.conn.post( + self._base_endpoint(endpoint), + data=json.dumps(data), + ) + + if resp.status_code >= 400: + raise FailureException(resp, exception_message) + + # Not all responses return a json + try: + return resp.json() + + except ValueError: + return None + + def _base_put( + self, + endpoint, + entity_id=None, + data=None, + params=None, + exception_message=None, + ): + # Make a general PUT request + + endpoint = self._base_endpoint(endpoint, entity_id) + + resp = self.conn.put(endpoint, data=json.dumps(data), params=params) + + if resp.status_code >= 400: + raise FailureException(resp, exception_message) + + # Not all responses return a json + try: + return resp.json() + + except ValueError: + return None + + def _base_patch( + self, + endpoint, + entity_id=None, + data=None, + params=None, + exception_message=None, + ): + # Make a general PATCH request + + endpoint = self._base_endpoint(endpoint, entity_id) + + resp = self.conn.patch( + endpoint, + data=json.dumps(data), + params=params, + ) + + if resp.status_code >= 400: + raise FailureException(resp, exception_message) + + # Not all responses return a json + try: + return resp.json() + + except ValueError: + return None + + def _base_delete( + self, + endpoint, + entity_id=None, + data=None, + exception_message=None, + ): + # Make a general DELETE request + + endpoint = self._base_endpoint(endpoint, entity_id) + + resp = self.conn.delete( + endpoint, + data=json.dumps(data) if data else None, + ) + + if resp.status_code >= 400: + raise FailureException(resp, exception_message) + + # Not all responses return a json + try: + return resp.json() + + except ValueError: + return None + + def retrieve_person(self, person_id): + """ + Get a person. + + `Args:` + person_id: int + The id of the record. + `Returns`: + JSON object + """ + + return self._base_get( + endpoint="people", + entity_id=person_id, + exception_message="Person not found", + ) + + def list_people( + self, + page=1, + page_size=100, + search=None, + ordering=None, + **kwargs, + ): + """ + List people + + `Args:` + page: integer + A page number within the paginated result set. + page_size: integer + Number of results to return per page. + Defaults to 100 + search: string + A search term. + ordering: string + Which field to use when ordering the results. + **kwargs: + Optional arguments to pass to the client. A full list can be + found in the `OpenField API docs + + + `Returns:` + Parsons.Table + The people data. + """ + + res = self._base_get( + endpoint="people", + params={ + "page": page, + "page_size": page_size, + "search": search, + "ordering": ordering, + **kwargs, + }, + ) + + return Table(res["results"]) + + def create_person(self, person): + """ + Create a person. + + `Args:` + person: dict + Shape of the record + `Full list of fields + `_ + `Returns:` + JSON object + """ + + return self._base_post( + endpoint="people", + data=person, + exception_message="Could not create person", + ) + + def bulk_upsert_people(self, people): + """ + Given a list of objects, tries the match the object with a provided ID. + Otherwise, creates a new record for a person without an ID match. + + `Args:` + people: list of dicts + List containing the records + `Full list of fields + `_ + `Returns:` + Parsons.Table + The people data. + If there is an error in any of the rows' columns, you will get + Exception.status_code == 400, and you can use Exception.json attr + to map back to the list you provided and fix any issues in the + columns it has flagged. + """ + + res = self._base_post( + endpoint="people/bulk-upsert", + data=people, + exception_message="Failed to upsert people, check Exception.json", + ) + + return Table(res) + + def update_person(self, person_id, data): + """ + Updates a person. + + `Args:` + person_id: int + The id of the record. + data: dict + Person data to update + `Full list of fields + `_ + `Returns:` + JSON object + """ + + return self._base_put( + endpoint="people", + entity_id=person_id, + data=data, + ) + + def destroy_person(self, person_id): + """ + Delete a person. + + `Args:` + person_id: int + The id of the record. + `Returns`: + None + """ + + return self._base_delete( + endpoint="people", + entity_id=person_id, + exception_message="Person not found", + ) + + def retrieve_label(self, label_id): + """ + Get a label + + `Args:` + label_id: int + The id of the record. + `Returns`: + JSON object + """ + + return self._base_get( + endpoint="labels", + entity_id=label_id, + exception_message="Label not found", + ) + + def list_labels( + self, + page=1, + page_size=100, + search=None, + ordering=None, + **kwargs, + ): + """ + List labels + + `Args:` + page: integer + A page number within the paginated result set. + page_size: integer + Number of results to return per page. + Defaults to 100 + search: string + A search term. + ordering: string + Which field to use when ordering the results. + **kwargs: + Optional arguments to pass to the client. A full list can be + found in the `OpenField API docs + `_ + + `Returns:` + Parsons.Table + The labels data. + """ + + res = self._base_get( + endpoint="labels", + params={ + "page": page, + "page_size": page_size, + "search": search, + "ordering": ordering, + **kwargs, + }, + ) + + return Table(res["results"]) + + def create_label(self, name, description): + """ + Create a label. + + `Args:` + name: string <= 100 characters + label name + description: string <= 255 characters + label description + `Returns:` + JSON object + """ + + return self._base_post( + endpoint="labels", + data={ + "name": name, + "description": description, + }, + exception_message="Could not create label", + ) + + def apply_person_label(self, person_id, label_id): + """ + Apply a label to a person. + + `Args:` + person_id: int + ID of the person + label_id: int + ID of the label + `Returns:` + JSON object + """ + + return self._base_post( + endpoint="people-labels", + data={"person_id": person_id, "label_id": label_id}, + ) + + def bulk_apply_people_labels(self, data): + """ + Bulk apply labels to people. + + `Args:` + data: list of dicts with keys `people`: int and `label`: int + `Returns:` + JSON object + """ + + return self._base_post( + endpoint="people-labels/bulk-upsert", + data=data, + ) + + def remove_person_label(self, junction_id): + """ + Remove a label from a person. + + `Args:` + junction_id: int + Primary Key ID of the `people_labels` junction table + `Returns:` + JSON object + """ + + return self._base_delete( + endpoint="people-labels", + entity_id=junction_id, + ) + + def create_conversation_code(self, conversation_code): + """ + Create a conversation code. + + `Args:` + conversation_code: dict + `Full list of fields + `_ + `Returns:` + JSON object + """ + + return self._base_post( + endpoint="conversation-codes", + data=conversation_code, + exception_message="Could not create conversation code", + ) + + def update_conversation_code(self, conversation_code_id, data): + """ + Update a conversation code. + + `Args:` + person_id: int + The id of the record. + data: dict + Conversation code data to update + `Full list of fields + `_ + `Returns:` + JSON object + """ + + return self._base_put( + endpoint="conversation-codes", + entity_id=conversation_code_id, + data=data, + exception_message="Could not update conversation code", + ) + + def add_people_to_conversation_code( + self, + conversation_code_id, + people_ids, + ): + """ + Adds people to a conversation code. + + `Args:` + conversation_code_id: int + ID of the conversation code + people_ids: list of ints + List of people IDs to add to the conversation code + `Returns:` + JSON object + """ + + return self._base_patch( + endpoint=f"conversation-codes/{conversation_code_id}/people", + data={"people_ids": people_ids}, + ) + + def remove_people_from_conversation_code( + self, + conversation_code_id, + people_ids, + ): + """ + Removes people from a conversation code. + + `Args:` + conversation_code_id: int + ID of the conversation code + people_ids: list of ints + List of people IDs to remove from the conversation code + `Returns:` + JSON object + """ + + return self._base_delete( + endpoint=f"conversation-codes/{conversation_code_id}/people", + data={"people_ids": people_ids}, + )