Skip to content

Commit 981137c

Browse files
committed
refactor(toggl): WIP create an API class
1 parent f0c5bf8 commit 981137c

File tree

2 files changed

+200
-100
lines changed

2 files changed

+200
-100
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..."

tests/services/test_toggl.py

Lines changed: 94 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pandas as pd
88
import pytest
99

10+
from compiler_admin import __version__
1011
import compiler_admin.services.toggl
1112
from compiler_admin.services.toggl import (
1213
__name__ as MODULE,
@@ -15,6 +16,7 @@
1516
OUTPUT_COLUMNS,
1617
PROJECT_INFO,
1718
USER_INFO,
19+
Toggl,
1820
_harvest_client_name,
1921
_get_info,
2022
_toggl_project_info,
@@ -60,28 +62,111 @@ def mock_google_user_info(mocker):
6062

6163

6264
@pytest.fixture
63-
def mock_api_env(monkeypatch):
65+
def mock_requests(mocker):
66+
return mocker.patch(f"{MODULE}.requests")
67+
68+
69+
@pytest.fixture
70+
def mock_toggl_api(mocker):
71+
Toggl = mocker.patch(f"{MODULE}.Toggl")
72+
return Toggl.return_value
73+
74+
75+
@pytest.fixture
76+
def mock_toggl_api_env(monkeypatch):
6477
monkeypatch.setenv("TOGGL_API_TOKEN", "token")
6578
monkeypatch.setenv("TOGGL_CLIENT_ID", "1234")
6679
monkeypatch.setenv("TOGGL_WORKSPACE_ID", "workspace")
6780

6881

6982
@pytest.fixture
70-
def mock_requests(mocker):
71-
return mocker.patch(f"{MODULE}.requests")
83+
def toggl():
84+
return Toggl("token", 1234)
7285

7386

7487
@pytest.fixture
75-
def mock_api_post(mocker, mock_requests, toggl_file):
88+
def toggl_mock_post_reports(mocker, toggl, toggl_file):
7689
# setup a mock response to a requests.post call
7790
mock_csv_bytes = Path(toggl_file).read_bytes()
7891
mock_post_response = mocker.Mock()
7992
mock_post_response.raise_for_status.return_value = None
8093
# prepend the BOM to the mock content
8194
mock_post_response.content = b"\xef\xbb\xbf" + mock_csv_bytes
8295
# override the requests.post call to return the mock response
83-
mock_requests.post.return_value = mock_post_response
84-
return mock_requests
96+
mocker.patch.object(toggl, "post_reports", return_value=mock_post_response)
97+
return toggl
98+
99+
100+
@pytest.fixture
101+
def toggl_mock_detailed_time_entries(mock_toggl_api, toggl_file):
102+
mock_csv_bytes = Path(toggl_file).read_bytes()
103+
mock_toggl_api.detailed_time_entries.return_value.content = mock_csv_bytes
104+
return mock_toggl_api
105+
106+
107+
def test_toggl_init(toggl):
108+
token64 = "dG9rZW46YXBpX3Rva2Vu"
109+
110+
assert toggl._token == "token"
111+
assert toggl.workspace_id == 1234
112+
assert toggl.workspace_url_fragment == "workspace/1234"
113+
114+
assert toggl.headers["Content-Type"] == "application/json"
115+
116+
user_agent = toggl.headers["User-Agent"]
117+
assert "compilerla/compiler-admin" in user_agent
118+
assert __version__ in user_agent
119+
120+
assert toggl.headers["Authorization"] == f"Basic {token64}"
121+
122+
assert toggl.timeout == 5
123+
124+
125+
def test_toggl_make_report_url(toggl):
126+
url = toggl._make_report_url("endpoint")
127+
128+
assert url.startswith(toggl.API_BASE_URL)
129+
assert toggl.API_REPORTS_BASE_URL in url
130+
assert toggl.workspace_url_fragment in url
131+
assert "/endpoint" in url
132+
133+
134+
def test_toggl_post_reports(mock_requests, toggl):
135+
url = toggl._make_report_url("endpoint")
136+
response = toggl.post_reports("endpoint", kwarg1=1, kwarg2="two")
137+
138+
response.raise_for_status.assert_called_once()
139+
140+
mock_requests.post.assert_called_once_with(
141+
url, json=dict(kwarg1=1, kwarg2="two"), headers=toggl.headers, timeout=toggl.timeout
142+
)
143+
144+
145+
def test_toggl_detailed_time_entries(toggl_mock_post_reports):
146+
dt = datetime(2024, 9, 25)
147+
toggl_mock_post_reports.detailed_time_entries(dt, dt, kwarg1=1, kwarg2="two")
148+
149+
toggl_mock_post_reports.post_reports.assert_called_once_with(
150+
"search/time_entries.csv",
151+
billable=True,
152+
start_date="2024-09-25",
153+
end_date="2024-09-25",
154+
rounding=1,
155+
rounding_minutes=15,
156+
kwarg1=1,
157+
kwarg2="two",
158+
)
159+
160+
161+
def test_toggl_detailed_time_entries_dynamic_timeout(mock_requests, toggl):
162+
# range of 6 months
163+
# timeout should be 6 * 5 = 30
164+
start = datetime(2024, 1, 1)
165+
end = datetime(2024, 6, 30)
166+
toggl.detailed_time_entries(start, end)
167+
168+
mock_requests.post.assert_called_once()
169+
assert mock_requests.post.call_args.kwargs["timeout"] == 30
85170

86171

87172
def test_harvest_client_name(monkeypatch):
@@ -241,27 +326,13 @@ def test_convert_to_harvest_sample(toggl_file, harvest_file, mock_google_user_in
241326
assert output_df["Client"].eq("Test Client 123").all()
242327

243328

244-
@pytest.mark.usefixtures("mock_api_env")
245-
def test_download_time_entries(toggl_file, mock_api_post):
329+
@pytest.mark.usefixtures("mock_toggl_api_env", "toggl_mock_detailed_time_entries")
330+
def test_download_time_entries(toggl_file):
246331
dt = datetime.now()
247332
mock_csv_bytes = Path(toggl_file).read_bytes()
248333

249334
with NamedTemporaryFile("w") as temp:
250-
download_time_entries(dt, dt, temp.name, extra_1=1, extra_2="two")
251-
252-
json_params = mock_api_post.post.call_args.kwargs["json"]
253-
assert isinstance(json_params, dict)
254-
assert json_params["billable"] is True
255-
assert json_params["client_ids"] == [1234]
256-
assert json_params["end_date"] == dt.strftime("%Y-%m-%d")
257-
assert json_params["extra_1"] == 1
258-
assert json_params["extra_2"] == "two"
259-
assert json_params["rounding"] == 1
260-
assert json_params["rounding_minutes"] == 15
261-
assert json_params["start_date"] == dt.strftime("%Y-%m-%d")
262-
263-
assert mock_api_post.post.call_args.kwargs["timeout"] == 5
264-
335+
download_time_entries(dt, dt, temp.name)
265336
temp.flush()
266337
response_csv_bytes = Path(temp.name).read_bytes()
267338

@@ -276,15 +347,3 @@ def test_download_time_entries(toggl_file, mock_api_post):
276347
# as corresponding column values from the mock DataFrame
277348
for col in response_df.columns:
278349
assert response_df[col].equals(mock_df[col])
279-
280-
281-
@pytest.mark.usefixtures("mock_api_env")
282-
def test_download_time_entries_dynamic_timeout(mock_api_post):
283-
# range of 6 months
284-
# timeout should be 6 * 5 = 30
285-
start = datetime(2024, 1, 1)
286-
end = datetime(2024, 6, 30)
287-
288-
download_time_entries(start, end)
289-
290-
assert mock_api_post.post.call_args.kwargs["timeout"] == 30

0 commit comments

Comments
 (0)