generated from MITLibraries/python-cli-template
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Why these changes are being introduced: One of the primary functions of this app is to load data into Quickbase via its API. The API has some awkward edges, like the need to upsert data with Field IDs vs Field names, or the need to use Table IDs instead of names. Therefore to upsert data, you might need 2-3 API calls in advance just to map the Table and Field IDs to names. How this addresses that need: * Creates new QBClient class * QBClient has method for making API calls with authorization * QBClient caches API calls when the call signature is identical * QBClient has convenience methods for common API calls like getting Table or Field information * QBClient has some methods to map data * QBClient will be the workhorse of most Load Tasks that get built Side effects of this change: * None Relevant ticket(s): * https://mitlibraries.atlassian.net/browse/HRQB-12
- Loading branch information
Showing
13 changed files
with
776 additions
and
65 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
"""hrqb.exceptions""" | ||
|
||
|
||
class QBFieldNotFoundError(ValueError): | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
"""utils.quickbase""" | ||
|
||
import json | ||
import logging | ||
from collections.abc import Callable | ||
|
||
import pandas as pd | ||
import requests | ||
from attrs import define, field | ||
|
||
from hrqb.config import Config | ||
from hrqb.exceptions import QBFieldNotFoundError | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
RequestsMethod = Callable[..., requests.Response] | ||
|
||
|
||
@define | ||
class QBClient: | ||
api_base: str = field(default="https://api.quickbase.com/v1") | ||
cache_results: bool = field(default=True) | ||
_cache: dict = field(factory=dict, repr=False) | ||
|
||
@property | ||
def request_headers(self) -> dict: | ||
return { | ||
"Authorization": f"QB-USER-TOKEN {Config().QUICKBASE_API_TOKEN}", | ||
"QB-Realm-Hostname": "mit.quickbase.com", | ||
} | ||
|
||
@property | ||
def app_id(self) -> str: | ||
return Config().QUICKBASE_APP_ID | ||
|
||
def make_request( | ||
self, requests_method: RequestsMethod, path: str, **kwargs: dict | ||
) -> dict: | ||
"""Make an API request to Quickbase API. | ||
This method caches request responses, such that data from informational requests | ||
may be reused in later operations. | ||
""" | ||
# hash the request to cache the response | ||
request_hash = (path, json.dumps(kwargs, sort_keys=True)) | ||
if self.cache_results and request_hash in self._cache: | ||
message = f"Using cached result for path: {path}" | ||
logger.debug(message) | ||
return self._cache[request_hash] | ||
|
||
# make API call | ||
results = requests_method( | ||
f"{self.api_base}/{path.removeprefix('/')}", | ||
headers=self.request_headers, | ||
**kwargs, | ||
).json() | ||
if self.cache_results: | ||
self._cache[request_hash] = results | ||
|
||
return results | ||
|
||
def get_app_info(self) -> dict: | ||
"""Retrieve information about the QB app. | ||
https://developer.quickbase.com/operation/getApp | ||
""" | ||
return self.make_request(requests.get, f"apps/{self.app_id}") | ||
|
||
def get_tables(self) -> pd.DataFrame: | ||
"""Get all QB Tables as a Dataframe. | ||
https://developer.quickbase.com/operation/getAppTables | ||
""" | ||
tables = self.make_request(requests.get, f"tables?appId={self.app_id}") | ||
return pd.DataFrame(tables) | ||
|
||
def get_table_id(self, name: str) -> str: | ||
"""Get Table ID from Dataframe of Tables.""" | ||
tables_df = self.get_tables() | ||
return tables_df[tables_df.name == name].iloc[0].id | ||
|
||
def get_table_fields(self, table_id: str) -> pd.DataFrame: | ||
"""Get all QB Table Fields as a Dataframe. | ||
https://developer.quickbase.com/operation/getFields | ||
""" | ||
fields = self.make_request(requests.get, f"fields?tableId={table_id}") | ||
return pd.DataFrame(fields) | ||
|
||
def get_table_fields_name_to_id(self, table_id: str) -> dict: | ||
"""Get Field name-to-id map for a Table. | ||
This method is particularly helpful for upserting data via the QB API, where | ||
Field IDs are required instead of Field names. | ||
""" | ||
fields_df = self.get_table_fields(table_id) | ||
return {f["label"]: f["id"] for _, f in fields_df.iterrows()} | ||
|
||
def upsert_records(self, upsert_payload: dict) -> dict: | ||
"""Upsert Records into a Table. | ||
https://developer.quickbase.com/operation/upsert | ||
""" | ||
return self.make_request(requests.post, "records", json=upsert_payload) | ||
|
||
def prepare_upsert_payload( | ||
self, | ||
table_id: str, | ||
records: list[dict], | ||
merge_field: str | None = None, | ||
) -> dict: | ||
"""Prepare an API payload for upsert. | ||
https://developer.quickbase.com/operation/upsert | ||
This method expects a list of dictionaries, one dictionary per record, with a | ||
{Field Name:Value} structure. This method will first retrieve a mapping of | ||
Field name-to-ID, then remap the data to a {Field ID:Value} structure. | ||
Then, return a dictionary payload suitable for the QB upsert API call. | ||
""" | ||
field_map = self.get_table_fields_name_to_id(table_id) | ||
mapped_records = [] | ||
for record in records: | ||
mapped_record = {} | ||
for field_name, field_value in record.items(): | ||
if field_id := field_map.get(field_name): | ||
mapped_record[str(field_id)] = {"value": field_value} | ||
else: | ||
message = ( | ||
f"Field name '{field_name}' not found for Table ID '{table_id}'" | ||
) | ||
raise QBFieldNotFoundError(message) | ||
mapped_records.append(mapped_record) | ||
|
||
upsert_payload = { | ||
"to": table_id, | ||
"data": mapped_records, | ||
"fieldsToReturn": list(field_map.values()), | ||
} | ||
if merge_field: | ||
upsert_payload["mergeFieldId"] = field_map[merge_field] | ||
|
||
return upsert_payload |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
{ | ||
"created": "2020-03-27T18:34:12Z", | ||
"dateFormat": "MM-DD-YYYY", | ||
"description": "My testing app", | ||
"hasEveryoneOnTheInternet": true, | ||
"id": "bpqe82s1", | ||
"name": "Testing App", | ||
"securityProperties": { | ||
"allowClone": false, | ||
"allowExport": false, | ||
"enableAppTokens": false, | ||
"hideFromPublic": false, | ||
"mustBeRealmApproved": true, | ||
"useIPFilter": true | ||
}, | ||
"timeZone": "(UTC-08:00) Pacific Time (US & Canada)", | ||
"updated": "2020-04-03T19:12:20Z", | ||
"dataClassification": "Confidential" | ||
} |
Oops, something went wrong.