1+ from base64 import b64encode
2+ from datetime import datetime
3+ import io
14import os
25import sys
36from typing import TextIO
47
58import pandas as pd
9+ import requests
610
11+ from compiler_admin import __version__
712from compiler_admin .services .google import user_info as google_user_info
813import compiler_admin .services .files as files
914
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+
1020# cache of previously seen project information, keyed on Toggl project name
1121PROJECT_INFO = {}
1222
@@ -36,6 +46,50 @@ def _get_info(obj: dict, key: str, env_key: str):
3646 return obj .get (key )
3747
3848
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+
3993def _toggl_project_info (project : str ):
4094 """Return the cached project for the given project key."""
4195 return _get_info (PROJECT_INFO , project , "TOGGL_PROJECT_INFO" )
@@ -46,6 +100,11 @@ def _toggl_user_info(email: str):
46100 return _get_info (USER_INFO , email , "TOGGL_USER_INFO" )
47101
48102
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+
49108def _get_first_name (email : str ) -> str :
50109 """Get cached first name or derive from email."""
51110 user = _toggl_user_info (email )
@@ -127,3 +186,75 @@ def convert_to_harvest(
127186 source ["Hours" ] = (source ["Duration" ].dt .total_seconds () / 3600 ).round (2 )
128187
129188 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