Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add newmode v2 #1227

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 298 additions & 7 deletions parsons/newmode/newmode.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
from Newmode import Client
from parsons.utilities.oauth_api_connector import OAuth2APIConnector
from parsons.utilities import check_env
from parsons.etl import Table
import logging

logger = logging.getLogger(__name__)

V2_API_URL = "https://base.newmode.net/api/"
V2_API_AUTH_URL = "https://base.newmode.net/oauth/token/"
V2_API_CAMPAIGNS_URL = "https://base.newmode.net/"
V2_API_CAMPAIGNS_VERSION = "jsonapi"
V2_API_CAMPAIGNS_HEADERS = {
"content-type": "application/vnd.api+json",
"accept": "application/vnd.api+json",
"authorization": "Bearer 1234567890",
}

class Newmode:

class NewmodeV1:
def __init__(self, api_user=None, api_password=None, api_version=None):
"""
Args:
Expand All @@ -22,14 +33,12 @@ def __init__(self, api_user=None, api_password=None, api_version=None):
Returns:
Newmode class
"""
logger.warning(
"Newmode V1 API will be sunset in Feburary 2025. To use V2, set api_version=v2.1"
)
Comment on lines +36 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and just to confirm - v1 is cutoff entirely in Feb? I'm just worried about how little notice we're giving folks

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the comment above, we're still ~a month out

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yes, forgot to update this comment after that update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put Feburary first bc I didn't know the exact date.

self.api_user = check_env.check("NEWMODE_API_USER", api_user)
self.api_password = check_env.check("NEWMODE_API_PASSWORD", api_password)

if api_version is None:
api_version = "v1.0"

self.api_version = check_env.check("NEWMODE_API_VERSION", api_version)

self.api_version = api_version
self.client = Client(api_user, api_password, api_version)

def convert_to_table(self, data):
Expand Down Expand Up @@ -323,3 +332,285 @@ def get_outreach(self, outreach_id, params={}):
else:
logging.warning("Empty outreach returned")
return None


class NewmodeV2:
# TODO: Add param definition and requirements once official Newmode docs are published
def __init__(
self,
client_id=None,
client_secret=None,
api_version="v2.1",
):
"""
Instantiate Class
`Args`:
client_id: str
The client id to use for the API requests. Not required if ``NEWMODE_API_CLIENT_ID``
env variable set.
client_secret: str
The client secret to use for the API requests. Not required if ``NEWMODE_API_CLIENT_SECRET``
env variable set.
api_version: str
The api version to use. Defaults to v2.1
Returns:
NewMode Class
"""
self.api_version = api_version
self.base_url = V2_API_URL
self.client_id = check_env.check("NEWMODE_API_CLIENT_ID", client_id)
self.client_secret = check_env.check("NEWMODE_API_CLIENT_SECRET", client_secret)
self.headers = {"content-type": "application/json"}
self.default_client = OAuth2APIConnector(
uri=self.base_url,
auto_refresh_url=V2_API_AUTH_URL,
client_id=self.client_id,
client_secret=self.client_secret,
headers=self.headers,
token_url=V2_API_AUTH_URL,
grant_type="client_credentials",
)

def base_request(
self,
method,
endpoint,
client,
data=None,
json=None,
data_key=None,
params={},
supports_version=True,
override_api_version=None,
):
"""
Internal method to instantiate OAuth2APIConnector class,
make a call to Newmode API, and validate the response.
"""
api_version = override_api_version if override_api_version else self.api_version
url = f"{api_version}/{endpoint}" if supports_version else endpoint
response = client.request(url=url, req_type=method, json=json, data=data, params=params)
response.raise_for_status()
success_codes = [200, 201, 202, 204]
client.validate_response(response)
if response.status_code in success_codes:
response_json = response.json() if client.json_check(response) else None
return response_json[data_key] if data_key and response_json else response_json
raise Exception(f"API request encountered an error. Response: {response}")

def converted_request(
self,
endpoint,
method,
supports_version=True,
data=None,
json=None,
params={},
convert_to_table=True,
data_key=None,
client=None,
override_api_version=None,
):
"""Internal method to make a call to the Newmode API and convert the result to a Parsons table."""

client = client if client else self.default_client
response = self.base_request(
method=method,
json=json,
data=data,
params=params,
data_key=data_key,
supports_version=supports_version,
endpoint=endpoint,
client=client,
override_api_version=override_api_version,
)

if convert_to_table:
return client.convert_to_table(data=response)
else:
return response

def get_campaign(self, campaign_id, params={}):
"""
Retrieve a specific campaign by ID.

In v2, a campaign is equivalent to Tools or Actions in V1.
`Args:`
campaign_id: str
The ID of the campaign to retrieve.
params: dict
Query parameters to include in the request.
`Returns:`
Parsons Table containing campaign data.
"""
endpoint = f"/campaign/{campaign_id}/form"
data = self.converted_request(
endpoint=endpoint,
method="GET",
params=params,
)
return data

def get_campaign_ids(self, params={}):
"""
Retrieve all campaigns
In v2, a campaign is equivalent to Tools or Actions in V1.
`Args:`
organization_id: str
ID of organization
params: dict
Query parameters to include in the request.
`Returns:`
List containing all campaign ids.
"""
endpoint = "node/action"
campaigns_client = OAuth2APIConnector(
uri=V2_API_CAMPAIGNS_URL,
auto_refresh_url=V2_API_AUTH_URL,
client_id=self.client_id,
client_secret=self.client_secret,
headers=V2_API_CAMPAIGNS_HEADERS,
token_url=V2_API_AUTH_URL,
grant_type="client_credentials",
)

data = self.converted_request(
endpoint=endpoint,
method="GET",
params=params,
data_key="data",
client=campaigns_client,
override_api_version=V2_API_CAMPAIGNS_VERSION,
)
return data["id"]

def get_recipient(
self,
campaign_id,
street_address=None,
city=None,
postal_code=None,
region=None,
params={},
):
"""
Retrieve a specific recipient by ID
`Args:`
campaign_id: str
The ID of the campaign to retrieve.
street_address: str
Street address of recipient
city: str
City of recipient
postal_code: str
Postal code of recipient
region: str
Region (i.e. state/province abbreviation) of recipient
params: dict
Query parameters to include in the request.
`Returns:`
Parsons Table containing recipient data.
"""
address_params = {
"street_address": street_address,
"city": city,
"postal_code": postal_code,
"region": region,
}
all_address_params_are_missing = all(x is None for x in address_params.values())
if all_address_params_are_missing:
raise ValueError(
"Incomplete Request. Please specify a street address, city, postal code, and/or region."
)

params = {f"address[value][{key}]": value for key, value in address_params.items() if value}
sharinetmc marked this conversation as resolved.
Show resolved Hide resolved
response = self.converted_request(
endpoint=f"campaign/{campaign_id}/target",
method="GET",
params=params,
)
return response

def run_submit(self, campaign_id, json=None, data=None, params={}):
"""
Pass a submission from a supporter to a campaign
that ultimately fills in a petition,
sends an email or triggers a phone call
depending on your campaign type

`Args:`
campaign_id: str
The ID of the campaign to retrieve.
params: dict
Query parameters to include in the request.
`Returns:`
Parsons Table containing submit data.
"""

response = self.converted_request(
endpoint=f"campaign/{campaign_id}/submit",
method="POST",
data=data,
json=json,
params=params,
sharinetmc marked this conversation as resolved.
Show resolved Hide resolved
convert_to_table=False,
)
return response

def get_submissions(self, campaign_id, params={}):
"""
Retrieve and sort submissions and contact data
for a specified campaign using a range of filters
that include campaign id, data range and submission status

`Args:`
params: dict
Query parameters to include in the request.
`Returns:`
Parsons Table containing submit data.
"""
params = {"action": campaign_id}
response = self.converted_request(endpoint="submission", method="GET", params=params)
return response


class Newmode:
def __new__(
cls,
client_id=None,
client_secret=None,
api_user=None,
api_password=None,
api_version="v1.0",
):
"""
Create and return Newmode instance based on chosen version (V1 or V2)

`Args`:
api_user: str
The Newmode api user. Not required if ``NEWMODE_API_USER`` env variable is
passed. Needed for V1.
api_password: str
The Newmode api password. Not required if ``NEWMODE_API_PASSWORD`` env variable is
passed. Needed for V1.
client_id: str
The client id to use for the API requests. Not required if ``NEWMODE_API_CLIENT_ID``
env variable set. Needed for V2.
client_secret: str
The client secret to use for the API requests. Not required if ``NEWMODE_API_CLIENT_SECRET``
env variable set. Needed for V2.
api_version: str
The api version to use. Defaults to v1.0.

Returns:
NewMode Class
"""
api_version = check_env.check("NEWMODE_API_VERSION", api_version)
if api_version.startswith("v2"):
return NewmodeV2(
client_id=client_id, client_secret=client_secret, api_version=api_version
)
if api_version.startswith("v1"):
return NewmodeV1(api_user=api_user, api_password=api_password, api_version=api_version)
raise ValueError(f"{api_version} not supported.")
11 changes: 11 additions & 0 deletions parsons/utilities/api_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import urllib.parse
from simplejson.errors import JSONDecodeError
from parsons import Table

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -306,3 +307,13 @@ def json_check(self, resp):
return True
except JSONDecodeError:
return False

def convert_to_table(self, data):
"""Internal method to create a Parsons table from a data element."""
table = None
if type(data) is list:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think isinstance(data, list) is more conventional

table = Table(data)
else:
table = Table([data])

return table
Loading
Loading