Skip to content

Commit 40e8be5

Browse files
committed
refactor(toggl): split API implementation from service
1 parent 9dab066 commit 40e8be5

File tree

5 files changed

+208
-199
lines changed

5 files changed

+208
-199
lines changed

compiler_admin/api/toggl.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from base64 import b64encode
2+
from datetime import datetime
3+
4+
import requests
5+
6+
from compiler_admin import __version__
7+
8+
9+
class Toggl:
10+
"""Toggl API Client.
11+
12+
See https://engineering.toggl.com/docs/.
13+
"""
14+
15+
API_BASE_URL = "https://api.track.toggl.com"
16+
API_REPORTS_BASE_URL = "reports/api/v3"
17+
API_WORKSPACE = "workspace/{}"
18+
API_HEADERS = {"Content-Type": "application/json", "User-Agent": "compilerla/compiler-admin:{}".format(__version__)}
19+
20+
def __init__(self, api_token: str, workspace_id: int, **kwargs):
21+
self._token = api_token
22+
self.workspace_id = workspace_id
23+
24+
self.headers = dict(Toggl.API_HEADERS)
25+
self.headers.update(self._authorization_header())
26+
27+
self.timeout = int(kwargs.get("timeout", 5))
28+
29+
@property
30+
def workspace_url_fragment(self):
31+
"""The workspace portion of an API URL."""
32+
return Toggl.API_WORKSPACE.format(self.workspace_id)
33+
34+
def _authorization_header(self):
35+
"""Gets an `Authorization: Basic xyz` header using the Toggl API token.
36+
37+
See https://engineering.toggl.com/docs/authentication.
38+
"""
39+
creds = f"{self._token}:api_token"
40+
creds64 = b64encode(bytes(creds, "utf-8")).decode("utf-8")
41+
return {"Authorization": "Basic {}".format(creds64)}
42+
43+
def _make_report_url(self, endpoint: str):
44+
"""Get a fully formed URL for the Toggl Reports API v3 endpoint.
45+
46+
See https://engineering.toggl.com/docs/reports_start.
47+
"""
48+
return "/".join((Toggl.API_BASE_URL, Toggl.API_REPORTS_BASE_URL, self.workspace_url_fragment, endpoint))
49+
50+
def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwargs):
51+
"""Request a CSV report from Toggl of detailed time entries for the given date range.
52+
53+
Args:
54+
start_date (datetime): The beginning of the reporting period.
55+
56+
end_date (str): The end of the reporting period.
57+
58+
Extra `kwargs` are passed through as a POST json body.
59+
60+
By default, requests a report with the following configuration:
61+
* `billable=True`
62+
* `rounding=1` (True, but this is an int param)
63+
* `rounding_minutes=15`
64+
65+
See https://engineering.toggl.com/docs/reports/detailed_reports#post-export-detailed-report.
66+
67+
Returns:
68+
response (requests.Response): The HTTP response.
69+
"""
70+
# ensure start_date precedes end_date
71+
start_date, end_date = min(start_date, end_date), max(start_date, end_date)
72+
start = start_date.strftime("%Y-%m-%d")
73+
end = end_date.strftime("%Y-%m-%d")
74+
75+
# calculate a timeout based on the size of the reporting period in days
76+
# approximately 5 seconds per month of query size, with a minimum of 5 seconds
77+
range_days = (end_date - start_date).days
78+
current_timeout = self.timeout
79+
dynamic_timeout = int((max(30, range_days) / 30.0) * 5)
80+
self.timeout = max(current_timeout, dynamic_timeout)
81+
82+
params = dict(
83+
billable=True,
84+
start_date=start,
85+
end_date=end,
86+
rounding=1,
87+
rounding_minutes=15,
88+
)
89+
params.update(kwargs)
90+
91+
response = self.post_reports("search/time_entries.csv", **params)
92+
self.timeout = current_timeout
93+
94+
return response
95+
96+
def post_reports(self, endpoint: str, **kwargs) -> requests.Response:
97+
"""Send a POST request to the Reports v3 `endpoint`.
98+
99+
Extra `kwargs` are passed through as a POST json body.
100+
101+
Will raise for non-200 status codes.
102+
103+
See https://engineering.toggl.com/docs/reports_start.
104+
"""
105+
url = self._make_report_url(endpoint)
106+
107+
response = requests.post(url, json=kwargs, timeout=self.timeout)
108+
response.raise_for_status()
109+
110+
return response

compiler_admin/services/toggl.py

Lines changed: 1 addition & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
from base64 import b64encode
21
from datetime import datetime
32
import io
43
import os
54
import sys
65
from typing import TextIO
76

87
import pandas as pd
9-
import requests
108

11-
from compiler_admin import __version__
9+
from compiler_admin.api.toggl import Toggl
1210
from compiler_admin.services.google import user_info as google_user_info
1311
import compiler_admin.services.files as files
1412

@@ -23,110 +21,6 @@
2321
OUTPUT_COLUMNS = ["Date", "Client", "Project", "Task", "Notes", "Hours", "First name", "Last name"]
2422

2523

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

tests/api/__init__.py

Whitespace-only changes.

tests/api/test_toggl.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from datetime import datetime
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from compiler_admin import __version__
7+
from compiler_admin.api.toggl import __name__ as MODULE, Toggl
8+
9+
10+
@pytest.fixture
11+
def mock_requests(mocker):
12+
return mocker.patch(f"{MODULE}.requests")
13+
14+
15+
@pytest.fixture
16+
def toggl():
17+
return Toggl("token", 1234)
18+
19+
20+
@pytest.fixture
21+
def toggl_mock_post_reports(mocker, toggl, toggl_file):
22+
# setup a mock response to a requests.post call
23+
mock_csv_bytes = Path(toggl_file).read_bytes()
24+
mock_post_response = mocker.Mock()
25+
mock_post_response.raise_for_status.return_value = None
26+
# prepend the BOM to the mock content
27+
mock_post_response.content = b"\xef\xbb\xbf" + mock_csv_bytes
28+
# override the requests.post call to return the mock response
29+
mocker.patch.object(toggl, "post_reports", return_value=mock_post_response)
30+
return toggl
31+
32+
33+
def test_toggl_init(toggl):
34+
token64 = "dG9rZW46YXBpX3Rva2Vu"
35+
36+
assert toggl._token == "token"
37+
assert toggl.workspace_id == 1234
38+
assert toggl.workspace_url_fragment == "workspace/1234"
39+
40+
assert toggl.headers["Content-Type"] == "application/json"
41+
42+
user_agent = toggl.headers["User-Agent"]
43+
assert "compilerla/compiler-admin" in user_agent
44+
assert __version__ in user_agent
45+
46+
assert toggl.headers["Authorization"] == f"Basic {token64}"
47+
48+
assert toggl.timeout == 5
49+
50+
51+
def test_toggl_make_report_url(toggl):
52+
url = toggl._make_report_url("endpoint")
53+
54+
assert url.startswith(toggl.API_BASE_URL)
55+
assert toggl.API_REPORTS_BASE_URL in url
56+
assert toggl.workspace_url_fragment in url
57+
assert "/endpoint" in url
58+
59+
60+
def test_toggl_post_reports(mock_requests, toggl):
61+
url = toggl._make_report_url("endpoint")
62+
response = toggl.post_reports("endpoint", kwarg1=1, kwarg2="two")
63+
64+
response.raise_for_status.assert_called_once()
65+
66+
mock_requests.post.assert_called_once_with(
67+
url, json=dict(kwarg1=1, kwarg2="two"), headers=toggl.headers, timeout=toggl.timeout
68+
)
69+
70+
71+
def test_toggl_detailed_time_entries(toggl_mock_post_reports):
72+
dt = datetime(2024, 9, 25)
73+
toggl_mock_post_reports.detailed_time_entries(dt, dt, kwarg1=1, kwarg2="two")
74+
75+
toggl_mock_post_reports.post_reports.assert_called_once_with(
76+
"search/time_entries.csv",
77+
billable=True,
78+
start_date="2024-09-25",
79+
end_date="2024-09-25",
80+
rounding=1,
81+
rounding_minutes=15,
82+
kwarg1=1,
83+
kwarg2="two",
84+
)
85+
86+
87+
def test_toggl_detailed_time_entries_dynamic_timeout(mock_requests, toggl):
88+
# range of 6 months
89+
# timeout should be 6 * 5 = 30
90+
start = datetime(2024, 1, 1)
91+
end = datetime(2024, 6, 30)
92+
toggl.detailed_time_entries(start, end)
93+
94+
mock_requests.post.assert_called_once()
95+
assert mock_requests.post.call_args.kwargs["timeout"] == 30

0 commit comments

Comments
 (0)