1- from base64 import b64encode
21from datetime import datetime
32import io
43import os
54import sys
65from typing import TextIO
76
87import pandas as pd
9- import requests
108
11- from compiler_admin import __version__
9+ from compiler_admin . api . toggl import Toggl
1210from compiler_admin .services .google import user_info as google_user_info
1311import compiler_admin .services .files as files
1412
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-
20- # cache of previously seen project information, keyed on Toggl project name
21- PROJECT_INFO = {}
22-
2313# cache of previously seen user information, keyed on email
24- USER_INFO = {}
14+ USER_INFO = files . JsonFileCache ( "TOGGL_USER_INFO" )
2515NOT_FOUND = "NOT FOUND"
2616
2717# input CSV columns needed for conversion
3121OUTPUT_COLUMNS = ["Date" , "Client" , "Project" , "Task" , "Notes" , "Hours" , "First name" , "Last name" ]
3222
3323
34- def _harvest_client_name ():
35- """Gets the value of the HARVEST_CLIENT_NAME env var."""
36- return os .environ .get ("HARVEST_CLIENT_NAME" )
37-
38-
39- def _get_info (obj : dict , key : str , env_key : str ):
40- """Read key from obj, populating obj once from a file path at env_key."""
41- if obj == {}:
42- file_path = os .environ .get (env_key )
43- if file_path :
44- file_info = files .read_json (file_path )
45- obj .update (file_info )
46- return obj .get (key )
47-
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-
93- def _toggl_project_info (project : str ):
94- """Return the cached project for the given project key."""
95- return _get_info (PROJECT_INFO , project , "TOGGL_PROJECT_INFO" )
96-
97-
98- def _toggl_user_info (email : str ):
99- """Return the cached user for the given email."""
100- return _get_info (USER_INFO , email , "TOGGL_USER_INFO" )
101-
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-
10824def _get_first_name (email : str ) -> str :
10925 """Get cached first name or derive from email."""
110- user = _toggl_user_info (email )
26+ user = USER_INFO . get (email )
11127 first_name = user .get ("First Name" ) if user else None
11228 if first_name is None :
11329 parts = email .split ("@" )
@@ -122,7 +38,7 @@ def _get_first_name(email: str) -> str:
12238
12339def _get_last_name (email : str ):
12440 """Get cached last name or query from Google."""
125- user = _toggl_user_info (email )
41+ user = USER_INFO . get (email )
12642 last_name = user .get ("Last Name" ) if user else None
12743 if last_name is None :
12844 user = google_user_info (email )
@@ -134,7 +50,7 @@ def _get_last_name(email: str):
13450 return last_name
13551
13652
137- def _str_timedelta (td ):
53+ def _str_timedelta (td : str ):
13854 """Convert a string formatted duration (e.g. 01:30) to a timedelta."""
13955 return pd .to_timedelta (pd .to_datetime (td , format = "%H:%M:%S" ).strftime ("%H:%M:%S" ))
14056
@@ -160,7 +76,7 @@ def convert_to_harvest(
16076 None. Either prints the resulting CSV data or writes to output_path.
16177 """
16278 if client_name is None :
163- client_name = _harvest_client_name ( )
79+ client_name = os . environ . get ( "HARVEST_CLIENT_NAME" )
16480
16581 # read CSV file, parsing dates and times
16682 source = files .read_csv (source_path , usecols = INPUT_COLUMNS , parse_dates = ["Start date" ], cache_dates = True )
@@ -175,8 +91,9 @@ def convert_to_harvest(
17591 source ["Client" ] = client_name
17692 source ["Task" ] = "Project Consulting"
17793
178- # get cached project name if any
179- source ["Project" ] = source ["Project" ].apply (lambda x : _toggl_project_info (x ) or x )
94+ # get cached project name if any, keyed on Toggl project name
95+ project_info = files .JsonFileCache ("TOGGL_PROJECT_INFO" )
96+ source ["Project" ] = source ["Project" ].apply (lambda x : project_info .get (key = x , default = x ))
18097
18198 # assign First and Last name
18299 source ["First name" ] = source ["Email" ].apply (_get_first_name )
@@ -208,42 +125,20 @@ def download_time_entries(
208125
209126 Extra kwargs are passed along in the POST request body.
210127
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-
219128 Returns:
220129 None. Either prints the resulting CSV data or writes to output_path.
221130 """
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 ()
131+ env_client_id = os .environ .get ("TOGGL_CLIENT_ID" )
132+ if env_client_id :
133+ env_client_id = int (env_client_id )
134+ if ("client_ids" not in kwargs or not kwargs ["client_ids" ]) and isinstance (env_client_id , int ):
135+ kwargs ["client_ids" ] = [env_client_id ]
136+
137+ token = os .environ .get ("TOGGL_API_TOKEN" )
138+ workspace = os .environ .get ("TOGGL_WORKSPACE_ID" )
139+ toggl = Toggl (token , workspace )
246140
141+ response = toggl .detailed_time_entries (start_date , end_date , ** kwargs )
247142 # the raw response has these initial 3 bytes:
248143 #
249144 # b"\xef\xbb\xbfUser,Email,Client..."
0 commit comments