diff --git a/docs/formstack.rst b/docs/formstack.rst new file mode 100644 index 0000000000..cd08c321c8 --- /dev/null +++ b/docs/formstack.rst @@ -0,0 +1,58 @@ +Formstack +=================== + +******** +Overview +******** + +`Formstack `_ is a service that provides an advanced online form builder. +This connector allows you to load data from the Formstack API. + +.. note:: + Authentication + Formstack uses OAuth2 user access tokens to handle authentication. *"Access tokens are tied to a + Formstack user and follow Formstack (in-app) user permissions."* You can acquire an OAuth2 token + in the `Formstack API overview `_. + + You can pass the token to the ``Formstack`` object as the `api_token` keyword argument, or you + can set the environment variable ``FORMSTACK_API_TOKEN``. + +*********** +Quick Start +*********** + +To instantiate the ``Formstack`` class, you can either store your access token in the ``FORMSTACK_API_TOKEN`` +environment variable or pass it in as an argument. + +.. code-block:: python + + from parsons.formstack import Formstack + + # Instantiate the Formstack class using the FORMSTACK_API_TOKEN env variable + fs = Formstack() + + # Instantiate the Formstack class using the api token directly + fs = Formstack(api_token="") + + # Get all of the folders in our account + folders = fs.get_folders() + + # Find the ID of the "Data" folder + data_folder_id = None + for folder in folders: + if folder["name"] == "Data": + data_folder_id = folder["id"] + break + + # If we found the "Data" folder, get all of the forms in it + if data_folder_id is not None: + forms = fs.get_forms(folder_id=data_folder_id) + + print(forms) + +*** +API +*** + +.. autoclass :: parsons.formstack.Formstack + :inherited-members: \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 4bec939b58..b3cba40fcf 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -201,6 +201,7 @@ Indices and tables databases donorbox facebook_ads + formstack freshdesk github google diff --git a/parsons/__init__.py b/parsons/__init__.py index 72121b2a20..66ebf85d47 100644 --- a/parsons/__init__.py +++ b/parsons/__init__.py @@ -57,6 +57,7 @@ ("parsons.databases.redshift.redshift", "Redshift"), ("parsons.donorbox.donorbox", "Donorbox"), ("parsons.facebook_ads.facebook_ads", "FacebookAds"), + ("parsons.formstack.formstack", "Formstack"), ("parsons.freshdesk.freshdesk", "Freshdesk"), ("parsons.geocode.census_geocoder", "CensusGeocoder"), ("parsons.github.github", "GitHub"), diff --git a/parsons/formstack/__init__.py b/parsons/formstack/__init__.py new file mode 100644 index 0000000000..8783f47458 --- /dev/null +++ b/parsons/formstack/__init__.py @@ -0,0 +1,3 @@ +from parsons.formstack.formstack import Formstack + +__all__ = ["Formstack"] diff --git a/parsons/formstack/formstack.py b/parsons/formstack/formstack.py new file mode 100644 index 0000000000..45d4e8c6e3 --- /dev/null +++ b/parsons/formstack/formstack.py @@ -0,0 +1,199 @@ +import logging +from typing import Optional + +from parsons import Table +from parsons.utilities import check_env +from parsons.utilities.api_connector import APIConnector + +logger = logging.getLogger(__name__) + +API_URI = "https://www.formstack.com/api/v2" + + +class Formstack(object): + """ + Instantiate Formstack class. + + `Args:` + api_token: + API token to access the Formstack API. Not required if the + ``FORMSTACK_API_TOKEN`` env variable is set. + """ + + def __init__(self, api_token: Optional[str] = None): + self.api_token = check_env.check("FORMSTACK_API_TOKEN", api_token) + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.api_token}", + } + self.client = APIConnector(API_URI, headers=headers) + + def _get_paginated_request( + self, url: str, data_key: str, params: dict = {}, large_request: bool = False + ) -> Table: + """ + Make a GET request for any endpoint that returns a list of data. Will check pagination. + + This is suitable for endpoints like `GET /form`, which gets all forms. It is not suitable + for endpoints like `GET /form/:id`, which returns the data for one particular form, + and is never paginated. + + Author's note: I have observed some inconsistency in how Formstack is paginating its + its endpoints. For example, the /form endpoint (mentioned above) is documented as + if it follows Formstack's standard pagination scheme. However, it seems to just + return all of your form data and does not include the pagination keys at all. + So for developing this connector, I'm just experimenting with endpoints as I write + the methods for them and using `_get_paginated_request` as needed. Fortunately using + the connector will remove the need to worry about this from the caller, and we can + udated the methods in this class as needed when/if Formstack changes pagination on + their API. + + `Args:` + url: string + Relative URL (from the Formstack base URL) to make the request. + + data_key: string + JSON key that will hold the data in the response body. + + params: Dictionary, optional + Params to pass to the request. + + large_request: Boolean, optional + If the response is likely to include a large number of pages. Defaults to `False`. + In rare cases the API will return more pages than `parsons.Table` is able to handle. + Pass `True` to enable a workaround for these endpoints. + + `Returns:` + Table Class + A table with the returned data. + """ + data = Table() + page = 1 + pages = None + + while pages is None or page <= pages: + req_params = {**params, "page": page} + response_data = self.client.get_request(url, req_params) + pages = response_data["pages"] + data.concat(Table(response_data[data_key])) + + if large_request: + data.materialize() + + page += 1 + + return data + + def get_folders(self) -> Table: + """ + Get all folders on your account and their subfolders. + + `Returns:` + Table Class + A Table with the folders data. + """ + response_data = self.client.get_request("folder") + logger.debug(response_data) + + # The API returns folders in a tree structure that doesn't fit well + # into a Table. Fortunately, subfolders can't themselves have subfolders. + subfolders = [] + for f in response_data["folders"]: + f_subfolders = f.get("subfolders") + if f_subfolders is not None: + subfolders += f_subfolders + + tbl = Table(response_data["folders"] + subfolders) + logger.debug(f"Found {tbl.num_rows} folders.") + + if tbl.num_rows == 0: + return Table() + + tbl.convert_column("id", int) + tbl.convert_column("parent", lambda p: None if p == "0" else int(p)) + tbl.remove_column("subfolders") + return tbl + + def get_forms( + self, form_name: Optional[str] = None, folder_id: Optional[int] = None + ) -> Table: + """ + Get all forms on your account. + + `Args:` + form_name: string, optional + Search by form name. + folder_id: int, optional + Return forms in the specified folder. + + `Returns:` + Table Class + A table with the forms data. + """ + params = {} + if form_name: + params["search"] = form_name + if folder_id: + params["folder"] = folder_id + response_data = self.client.get_request("form", params) + logger.debug(response_data) + return Table(response_data["forms"]) + + def get_submission(self, id: int) -> dict: + """ + Get the details of the specified submission. + + `Args:` + id: int + ID for the submission to retrieve. + + `Returns:` + Dictionary + Submission data. + """ + response_data = self.client.get_request(f"submission/{id}") + logger.debug(response_data) + return response_data + + def get_form_submissions(self, form_id: int, **query_params) -> Table: + """ + Get all submissions for the specified form. + + Note this only returns the meta-data about the submissions, not the answer data, + by default. To get the responses pass `data=True` as a query param. + + For more useful options, such as how to filter the responses by date, + check the Formstack documentation. + + `Args:` + form_id: int + The form ID for the form of the submissions. + query_params: kwargs + Query arguments to pass to the form/submissions endpoint. + + `Returns:` + Table Class + A Table with the submission data for the form. + """ + tbl = self._get_paginated_request( + f"form/{form_id}/submission", "submissions", query_params, True + ) + logger.debug(tbl) + return tbl + + def get_form_fields(self, form_id: int) -> Table: + """ + Get all fields for the specified form. + + `Args:` + form_id: int + The form ID for the form of the submissions. + + `Returns:` + Table Class + A Table with the fields on the form. + """ + response_data = self.client.get_request(f"form/{form_id}/field") + logger.debug(response_data) + tbl = Table(response_data) + return tbl diff --git a/test/test_formstack/__init__.py b/test/test_formstack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/test_formstack/formstack_json.py b/test/test_formstack/formstack_json.py new file mode 100644 index 0000000000..7e6111e221 --- /dev/null +++ b/test/test_formstack/formstack_json.py @@ -0,0 +1,225 @@ +folder_json = { + "folders": [ + { + "id": "123", + "name": "Voter Events", + "parent": "0", + "permissions": "full", + "subfolders": [ + { + "id": "200", + "name": "Phone Banks", + "parent": "123", + "permissions": "full", + }, + { + "id": "201", + "name": "Text Banks", + "parent": "121", + "permissions": "full", + }, + ], + }, + {"id": "121", "name": "Fundraising", "parent": "0", "permissions": "full"}, + {"id": "122", "name": "Internal", "parent": "0", "permissions": "full"}, + ], + "total": 3, +} +form_json = { + "forms": [ + { + "id": "1001", + "created": "2018-08-01 10:10:00", + "db": "1", + "deleted": "0", + "folder": "123", + "language": "en", + "name": "Test Form 1", + "num_columns": "3", + "progress_meter": "0", + "submissions": "1321", + "submissions_unread": "1", + "updated": "2019-10-10 11:04:53", + "viewkey": "viewkey_test", + "views": "1000", + "submissions_today": 0, + "last_submission_id": "54321", + "last_submission_time": "2020-10-10 10:10:10", + "url": "https://test.formstack.com/forms/test_1", + "encrypted": False, + "thumbnail_url": None, + "submit_button_title": "Submit", + "inactive": True, + "timezone": "US/Eastern", + "should_display_one_question_at_a_time": False, + "can_access_1q_feature": True, + "is_workflow_form": False, + "is_workflow_published": False, + "has_approvers": False, + "edit_url": "https://www.formstack.com/admin/form/builder/1001/build", + "data_url": "", + "summary_url": "", + "rss_url": "", + "permissions": 150, + "can_edit": True, + }, + { + "id": "1002", + "created": "2018-08-01 10:10:00", + "db": "1", + "deleted": "0", + "folder": "123", + "language": "en", + "name": "Test Form 2", + "num_columns": "3", + "progress_meter": "0", + "submissions": "1321", + "submissions_unread": "1", + "updated": "2019-10-10 11:04:53", + "viewkey": "viewkey_test", + "views": "1000", + "submissions_today": 0, + "last_submission_id": "54321", + "last_submission_time": "2020-10-10 10:10:10", + "url": "https://test.formstack.com/forms/test_2", + "encrypted": False, + "thumbnail_url": None, + "submit_button_title": "Submit", + "inactive": True, + "timezone": "US/Eastern", + "should_display_one_question_at_a_time": False, + "can_access_1q_feature": True, + "is_workflow_form": False, + "is_workflow_published": False, + "has_approvers": False, + "edit_url": "https://www.formstack.com/admin/form/builder/1002/build", + "data_url": "", + "summary_url": "", + "rss_url": "", + "permissions": 150, + "can_edit": True, + }, + ], + "total": 3, +} +submission_id = 332525567 +submission_json = { + "id": str(submission_id), + "timestamp": "2017-06-13 17:07:51", + "user_agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", + "remote_addr": "50.232.88.142", + "payment_status": "", + "form": "2691007", + "latitude": "39.955799102783", + "longitude": "-105.16829681396", + "data": [ + {"field": "52404588", "value": "95386.00000"}, + {"field": "52404587", "value": "lisetalbott@gmail.com"}, + ], + "pretty_field_id": "52404587", +} +form_submissions_json = { + "submissions": [ + { + "id": "325343738", + "timestamp": "2017-05-07 21:52:42", + "user_agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36" + " (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36" + ), + "remote_addr": "73.163.190.175", + "payment_status": "", + "latitude": "38.905700683594", + "longitude": "-76.978302001953", + "read": "1", + }, + { + "id": "325512020", + "timestamp": "2017-05-08 15:31:08", + "user_agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.8" + " (KHTML, like Gecko) Version/9.1.3 Safari/601.7.8" + ), + "remote_addr": "65.242.21.250", + "payment_status": "", + "latitude": "37.750999450684", + "longitude": "-97.821998596191", + "read": "1", + }, + { + "id": "325512197", + "timestamp": "2017-05-08 15:31:52", + "user_agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/603.1.30" + " (KHTML, like Gecko) Version/10.1 Safari/603.1.30" + ), + "remote_addr": "65.242.21.250", + "payment_status": "", + "latitude": "37.750999450684", + "longitude": "-97.821998596191", + "read": "1", + }, + ], + "total": 1321, + "pages": 53, + "pretty_field_id": "52404587", +} +form_fields_json = [ + { + "id": "52404588", + "label": "Zip", + "hide_label": "0", + "description": "", + "name": "zip", + "type": "number", + "options": "", + "required": "1", + "uniq": "0", + "hidden": "0", + "readonly": "0", + "colspan": "1", + "sort": "0", + "logic": None, + "calculation": "", + "workflow_access": "write", + "default": "", + "field_one_calculation": 0, + "field_two_calculation": 0, + "calculation_units": "", + "calculation_operator": "", + "calculation_type": "", + "calculation_category": "", + "calculation_allow_negatives": 0, + "text_size": 5, + "min_value": "", + "max_value": "", + "currency": "", + "decimals": "5", + "slider": "0", + "placeholder": "", + }, + { + "id": "52404587", + "label": "Email", + "hide_label": "0", + "description": "", + "name": "email", + "type": "email", + "options": "", + "required": "1", + "uniq": "0", + "hidden": "0", + "readonly": "0", + "colspan": "1", + "sort": "1", + "logic": None, + "calculation": "", + "workflow_access": "write", + "default": "", + "text_size": 50, + "maxlength": 0, + "confirm": 0, + "confirmationText": "", + "placeholder": "", + }, +] diff --git a/test/test_formstack/test_formstack.py b/test/test_formstack/test_formstack.py new file mode 100644 index 0000000000..101e5040f4 --- /dev/null +++ b/test/test_formstack/test_formstack.py @@ -0,0 +1,73 @@ +import unittest +import requests_mock +from parsons.formstack.formstack import Formstack, API_URI +from parsons import Table + +from test.test_formstack.formstack_json import ( + folder_json, + form_json, + submission_id, + submission_json, + form_submissions_json, + form_fields_json, +) + +VALID_RESPONSE_STATUS_CODE = 200 + + +class TestFormstack(unittest.TestCase): + @requests_mock.Mocker() + def test_get_folders(self, m): + fs = Formstack(api_token="token") + m.get( + API_URI + "/folder", + status_code=VALID_RESPONSE_STATUS_CODE, + json=folder_json, + ) + folders_data = fs.get_folders() + self.assertIsInstance(folders_data, Table) + + @requests_mock.Mocker() + def test_get_forms(self, m): + fs = Formstack(api_token="token") + m.get( + API_URI + "/form", + status_code=VALID_RESPONSE_STATUS_CODE, + json=form_json, + ) + forms_data = fs.get_forms() + self.assertIsInstance(forms_data, Table) + + @requests_mock.Mocker() + def test_get_submission(self, m): + fs = Formstack(api_token="token") + m.get( + API_URI + f"/submission/{submission_id}", + status_code=VALID_RESPONSE_STATUS_CODE, + json=submission_json, + ) + submission_data = fs.get_submission(submission_id) + self.assertIsInstance(submission_data, dict) + + @requests_mock.Mocker() + def test_get_form_submissions(self, m): + fs = Formstack(api_token="token") + m.get( + API_URI + f"/form/{submission_id}/submission", + status_code=VALID_RESPONSE_STATUS_CODE, + json=form_submissions_json, + ) + form_submissions_data = fs.get_form_submissions(submission_id) + self.assertIsInstance(form_submissions_data, Table) + + @requests_mock.Mocker() + def test_get_form_fields(self, m): + fs = Formstack(api_token="token") + form_id = 123 + m.get( + API_URI + f"/form/{form_id}/field", + status_code=VALID_RESPONSE_STATUS_CODE, + json=form_fields_json, + ) + form_fields_data = fs.get_form_fields(form_id) + self.assertIsInstance(form_fields_data, Table)