1212from compiler_admin .services .google import user_info as google_user_info
1313import 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
2116PROJECT_INFO = {}
2217
3126OUTPUT_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+
34131def _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-
80146def _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