1
+ from base64 import b64encode
2
+ from datetime import datetime
3
+ import io
1
4
import os
2
5
import sys
3
6
from typing import TextIO
4
7
5
8
import pandas as pd
9
+ import requests
6
10
11
+ from compiler_admin import __version__
7
12
from compiler_admin .services .google import user_info as google_user_info
8
13
import compiler_admin .services .files as files
9
14
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
+
10
20
# cache of previously seen project information, keyed on Toggl project name
11
21
PROJECT_INFO = {}
12
22
@@ -36,6 +46,50 @@ def _get_info(obj: dict, key: str, env_key: str):
36
46
return obj .get (key )
37
47
38
48
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
+
80
+ def _toggl_api_token ():
81
+ """Gets the value of the TOGGL_API_TOKEN env var."""
82
+ return os .environ .get ("TOGGL_API_TOKEN" )
83
+
84
+
85
+ def _toggl_client_id ():
86
+ """Gets the value of the TOGGL_CLIENT_ID env var."""
87
+ client_id = os .environ .get ("TOGGL_CLIENT_ID" )
88
+ if client_id :
89
+ return int (client_id )
90
+ return None
91
+
92
+
39
93
def _toggl_project_info (project : str ):
40
94
"""Return the cached project for the given project key."""
41
95
return _get_info (PROJECT_INFO , project , "TOGGL_PROJECT_INFO" )
@@ -46,6 +100,11 @@ def _toggl_user_info(email: str):
46
100
return _get_info (USER_INFO , email , "TOGGL_USER_INFO" )
47
101
48
102
103
+ def _toggl_workspace ():
104
+ """Gets the value of the TOGGL_WORKSPACE_ID env var."""
105
+ return os .environ .get ("TOGGL_WORKSPACE_ID" )
106
+
107
+
49
108
def _get_first_name (email : str ) -> str :
50
109
"""Get cached first name or derive from email."""
51
110
user = _toggl_user_info (email )
@@ -127,3 +186,75 @@ def convert_to_harvest(
127
186
source ["Hours" ] = (source ["Duration" ].dt .total_seconds () / 3600 ).round (2 )
128
187
129
188
files .write_csv (output_path , source , columns = output_cols )
189
+
190
+
191
+ def download_time_entries (
192
+ start_date : datetime ,
193
+ end_date : datetime ,
194
+ output_path : str | TextIO = sys .stdout ,
195
+ output_cols : list [str ] | None = INPUT_COLUMNS ,
196
+ ** kwargs ,
197
+ ):
198
+ """Download a CSV report from Toggl of detailed time entries for the given date range.
199
+
200
+ Args:
201
+ start_date (datetime): The beginning of the reporting period.
202
+
203
+ end_date (str): The end of the reporting period.
204
+
205
+ output_path: The path to a CSV file where Toggl time entries will be written; or a writeable buffer for the same.
206
+
207
+ output_cols (list[str]): A list of column names for the output.
208
+
209
+ Extra kwargs are passed along in the POST request body.
210
+
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
+
219
+ Returns:
220
+ None. Either prints the resulting CSV data or writes to output_path.
221
+ """
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
+
229
+ if ("client_ids" not in kwargs or not kwargs ["client_ids" ]) and isinstance (_toggl_client_id (), int ):
230
+ kwargs ["client_ids" ] = [_toggl_client_id ()]
231
+
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 ()
246
+
247
+ # the raw response has these initial 3 bytes:
248
+ #
249
+ # b"\xef\xbb\xbfUser,Email,Client..."
250
+ #
251
+ # \xef\xbb\xb is the Byte Order Mark (BOM) sometimes used in unicode text files
252
+ # these 3 bytes indicate a utf-8 encoded text file
253
+ #
254
+ # See more
255
+ # - https://en.wikipedia.org/wiki/Byte_order_mark
256
+ # - https://stackoverflow.com/a/50131187
257
+ csv = response .content .decode ("utf-8-sig" )
258
+
259
+ df = pd .read_csv (io .StringIO (csv ))
260
+ files .write_csv (output_path , df , columns = output_cols )
0 commit comments