Skip to content

Commit 10ad569

Browse files
committed
refactor(toggl): WIP create an API class
1 parent 7272291 commit 10ad569

File tree

1 file changed

+106
-65
lines changed

1 file changed

+106
-65
lines changed

compiler_admin/services/toggl.py

Lines changed: 106 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@
1212
from compiler_admin.services.google import user_info as google_user_info
1313
import compiler_admin.services.files as files
1414

15-
# Toggl API config
16-
API_BASE_URL = "https://api.track.toggl.com"
17-
API_REPORTS_BASE_URL = "reports/api/v3"
18-
API_WORKSPACE = "workspace/{}"
19-
2015
# cache of previously seen project information, keyed on Toggl project name
2116
PROJECT_INFO = {}
2217

@@ -31,6 +26,108 @@
3126
OUTPUT_COLUMNS = ["Date", "Client", "Project", "Task", "Notes", "Hours", "First name", "Last name"]
3227

3328

29+
class Toggl:
30+
"""Toggl API Client.
31+
32+
See https://engineering.toggl.com/docs/.
33+
"""
34+
35+
API_BASE_URL = "https://api.track.toggl.com"
36+
API_REPORTS_BASE_URL = "reports/api/v3"
37+
API_WORKSPACE = "workspace/{}"
38+
API_HEADERS = {"Content-Type": "application/json", "User-Agent": "compilerla/compiler-admin:{}".format(__version__)}
39+
40+
def __init__(self, api_token: str, workspace_id: int, **kwargs):
41+
self._token = api_token
42+
self.workspace_id = workspace_id
43+
44+
self.headers = dict(self.API_HEADERS)
45+
self.headers.update(self._authorization_header())
46+
47+
self.timeout = int(kwargs.get("timeout", 5))
48+
49+
def _authorization_header(self):
50+
"""Gets an `Authorization: Basic xyz` header using the Toggl API token.
51+
52+
See https://engineering.toggl.com/docs/authentication.
53+
"""
54+
creds = f"{self._token}:api_token"
55+
creds64 = b64encode(bytes(creds, "utf-8")).decode("utf-8")
56+
return {"Authorization": "Basic {}".format(creds64)}
57+
58+
def _make_report_url(self, endpoint: str):
59+
"""Get a fully formed URL for the Toggl Reports API v3 endpoint.
60+
61+
See https://engineering.toggl.com/docs/reports_start.
62+
"""
63+
return "/".join((self.API_BASE_URL, self.API_REPORTS_BASE_URL, self.workspace_url_fragment, endpoint))
64+
65+
@property
66+
def workspace_url_fragment(self):
67+
"""The workspace portion of an API URL."""
68+
return self.API_WORKSPACE.format(self.workspace_id)
69+
70+
def post_reports(self, endpoint: str, **kwargs) -> requests.Response:
71+
"""Send a POST request to the Reports v3 `endpoint`.
72+
73+
Extra `kwargs` are passed through as a POST json body.
74+
75+
Will raise for non-200 status codes.
76+
77+
See https://engineering.toggl.com/docs/reports_start.
78+
"""
79+
url = self._make_report_url(endpoint)
80+
81+
response = requests.post(url, json=kwargs, headers=self.headers, timeout=self.timeout)
82+
response.raise_for_status()
83+
84+
return response
85+
86+
def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwargs):
87+
"""Request a CSV report from Toggl of detailed time entries for the given date range.
88+
89+
Args:
90+
start_date (datetime): The beginning of the reporting period.
91+
92+
end_date (str): The end of the reporting period.
93+
94+
Extra `kwargs` are passed through as a POST json body.
95+
96+
By default, requests a report with the following configuration:
97+
* `billable=True`
98+
* `rounding=1` (True, but this is an int param)
99+
* `rounding_minutes=15`
100+
101+
See https://engineering.toggl.com/docs/reports/detailed_reports#post-export-detailed-report.
102+
103+
Returns:
104+
response (requests.Response): The HTTP response.
105+
"""
106+
start = start_date.strftime("%Y-%m-%d")
107+
end = end_date.strftime("%Y-%m-%d")
108+
109+
# calculate a timeout based on the size of the reporting period in days
110+
# approximately 5 seconds per month of query size, with a minimum of 5 seconds
111+
range_days = (end_date - start_date).days
112+
current_timeout = self.timeout
113+
dynamic_timeout = int((max(30, range_days) / 30.0) * 5)
114+
self.timeout = max(current_timeout, dynamic_timeout)
115+
116+
params = dict(
117+
billable=True,
118+
start_date=start,
119+
end_date=end,
120+
rounding=1,
121+
rounding_minutes=15,
122+
)
123+
params.update(kwargs)
124+
125+
response = self.post_reports("search/time_entries.csv", **params)
126+
self.timeout = current_timeout
127+
128+
return response
129+
130+
34131
def _harvest_client_name():
35132
"""Gets the value of the HARVEST_CLIENT_NAME env var."""
36133
return os.environ.get("HARVEST_CLIENT_NAME")
@@ -46,37 +143,6 @@ def _get_info(obj: dict, key: str, env_key: str):
46143
return obj.get(key)
47144

48145

49-
def _toggl_api_authorization_header():
50-
"""Gets an `Authorization: Basic xyz` header using the Toggl API token.
51-
52-
See https://engineering.toggl.com/docs/authentication.
53-
"""
54-
token = _toggl_api_token()
55-
creds = f"{token}:api_token"
56-
creds64 = b64encode(bytes(creds, "utf-8")).decode("utf-8")
57-
return {"Authorization": "Basic {}".format(creds64)}
58-
59-
60-
def _toggl_api_headers():
61-
"""Gets a dict of headers for Toggl API requests.
62-
63-
See https://engineering.toggl.com/docs/.
64-
"""
65-
headers = {"Content-Type": "application/json"}
66-
headers.update({"User-Agent": "compilerla/compiler-admin:{}".format(__version__)})
67-
headers.update(_toggl_api_authorization_header())
68-
return headers
69-
70-
71-
def _toggl_api_report_url(endpoint: str):
72-
"""Get a fully formed URL for the Toggl Reports API v3 endpoint.
73-
74-
See https://engineering.toggl.com/docs/reports_start.
75-
"""
76-
workspace_id = _toggl_workspace()
77-
return "/".join((API_BASE_URL, API_REPORTS_BASE_URL, API_WORKSPACE.format(workspace_id), endpoint))
78-
79-
80146
def _toggl_api_token():
81147
"""Gets the value of the TOGGL_API_TOKEN env var."""
82148
return os.environ.get("TOGGL_API_TOKEN")
@@ -208,42 +274,17 @@ def download_time_entries(
208274
209275
Extra kwargs are passed along in the POST request body.
210276
211-
By default, requests a report with the following configuration:
212-
* `billable=True`
213-
* `client_ids=[$TOGGL_CLIENT_ID]`
214-
* `rounding=1` (True, but this is an int param)
215-
* `rounding_minutes=15`
216-
217-
See https://engineering.toggl.com/docs/reports/detailed_reports#post-export-detailed-report.
218-
219277
Returns:
220278
None. Either prints the resulting CSV data or writes to output_path.
221279
"""
222-
start = start_date.strftime("%Y-%m-%d")
223-
end = end_date.strftime("%Y-%m-%d")
224-
# calculate a timeout based on the size of the reporting period in days
225-
# approximately 5 seconds per month of query size, with a minimum of 5 seconds
226-
range_days = (end_date - start_date).days
227-
timeout = int((max(30, range_days) / 30.0) * 5)
228-
229280
if ("client_ids" not in kwargs or not kwargs["client_ids"]) and isinstance(_toggl_client_id(), int):
230281
kwargs["client_ids"] = [_toggl_client_id()]
231282

232-
params = dict(
233-
billable=True,
234-
start_date=start,
235-
end_date=end,
236-
rounding=1,
237-
rounding_minutes=15,
238-
)
239-
params.update(kwargs)
240-
241-
headers = _toggl_api_headers()
242-
url = _toggl_api_report_url("search/time_entries.csv")
243-
244-
response = requests.post(url, json=params, headers=headers, timeout=timeout)
245-
response.raise_for_status()
283+
token = _toggl_api_token()
284+
workspace = _toggl_workspace()
285+
toggl = Toggl(token, workspace)
246286

287+
response = toggl.detailed_time_entries(start_date, end_date, **kwargs)
247288
# the raw response has these initial 3 bytes:
248289
#
249290
# b"\xef\xbb\xbfUser,Email,Client..."

0 commit comments

Comments
 (0)