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)