Skip to content

Commit 7272291

Browse files
authored
Feat: download Toggl detailed time reports as CSV (#26)
2 parents 985f55a + d6e7b25 commit 7272291

File tree

14 files changed

+578
-9
lines changed

14 files changed

+578
-9
lines changed

.env.sample

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,9 @@ HARVEST_DATA=data/harvest-sample.csv
44
TOGGL_DATA=data/toggl-sample.csv
55
TOGGL_PROJECT_INFO=data/toggl-project-info-sample.json
66
TOGGL_USER_INFO=data/toggl-user-info-sample.json
7+
8+
TOGGL_API_TOKEN=token
9+
TOGGL_CLIENT_ID=client
10+
TOGGL_WORKSPACE_ID=workspace
11+
12+
TZ_NAME=America/Los_Angeles

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,35 @@ The `time` command provides an interface for working with time entries from Comp
6262

6363
```bash
6464
$ compiler-admin time -h
65-
usage: compiler-admin time [-h] {convert} ...
65+
usage: compiler-admin time [-h] {convert,download} ...
6666
6767
positional arguments:
68-
{convert} The time command to run.
69-
convert Convert a time report from one format into another.
68+
{convert,download} The time command to run.
69+
convert Convert a time report from one format into another.
70+
download Download a Toggl report in CSV format.
7071
7172
options:
72-
-h, --help show this help message and exit
73+
-h, --help show this help message and exit
74+
```
75+
76+
### Downloading a Toggl report
77+
78+
Use this command to download a time report from Toggl in CSV format:
79+
80+
```bash
81+
$ compiler-admin time download -h
82+
usage: compiler-admin time download [-h] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output OUTPUT]
83+
[--client CLIENT_ID] [--project PROJECT_ID] [--task TASK_ID] [--user USER_ID]
84+
85+
options:
86+
-h, --help show this help message and exit
87+
--start YYYY-MM-DD The start date of the reporting period. Defaults to the beginning of the prior month.
88+
--end YYYY-MM-DD The end date of the reporting period. Defaults to the end of the prior month.
89+
--output OUTPUT The path to the file where converted data should be written. Defaults to stdout.
90+
--client CLIENT_ID An ID for a Toggl Client to filter for in reports. Can be supplied more than once.
91+
--project PROJECT_ID An ID for a Toggl Project to filter for in reports. Can be supplied more than once.
92+
--task TASK_ID An ID for a Toggl Project Task to filter for in reports. Can be supplied more than once.
93+
--user USER_ID An ID for a Toggl User to filter for in reports. Can be supplied more than once.
7394
```
7495
7596
### Converting an hours report

compiler_admin/commands/time/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from argparse import Namespace
22

33
from compiler_admin.commands.time.convert import convert # noqa: F401
4+
from compiler_admin.commands.time.download import download # noqa: F401
45

56

67
def time(args: Namespace, *extra):
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from argparse import Namespace
2+
3+
from compiler_admin import RESULT_SUCCESS
4+
from compiler_admin.services.toggl import INPUT_COLUMNS as TOGGL_COLUMNS, download_time_entries
5+
6+
7+
def download(args: Namespace, *extras):
8+
params = dict(start_date=args.start, end_date=args.end, output_path=args.output, output_cols=TOGGL_COLUMNS)
9+
10+
if args.client_ids:
11+
params.update(dict(client_ids=args.client_ids))
12+
if args.project_ids:
13+
params.update(dict(project_ids=args.project_ids))
14+
if args.task_ids:
15+
params.update(dict(task_ids=args.task_ids))
16+
if args.user_ids:
17+
params.update(dict(user_ids=args.user_ids))
18+
19+
download_time_entries(**params)
20+
21+
return RESULT_SUCCESS

compiler_admin/main.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from argparse import ArgumentParser, _SubParsersAction
2+
from datetime import datetime, timedelta
3+
import os
24
import sys
35

6+
from pytz import timezone
7+
48
from compiler_admin import __version__ as version
59
from compiler_admin.commands.info import info
610
from compiler_admin.commands.init import init
@@ -9,6 +13,24 @@
913
from compiler_admin.commands.user.convert import ACCOUNT_TYPE_OU
1014

1115

16+
TZINFO = timezone(os.environ.get("TZ_NAME", "America/Los_Angeles"))
17+
18+
19+
def local_now():
20+
return datetime.now(tz=TZINFO)
21+
22+
23+
def prior_month_end():
24+
now = local_now()
25+
first = now.replace(day=1)
26+
return first - timedelta(days=1)
27+
28+
29+
def prior_month_start():
30+
end = prior_month_end()
31+
return end.replace(day=1)
32+
33+
1234
def add_sub_cmd_parser(parser: ArgumentParser, dest="subcommand", help=None):
1335
"""Helper adds a subparser for the given dest."""
1436
return parser.add_subparsers(dest=dest, help=help)
@@ -54,6 +76,57 @@ def setup_time_command(cmd_parsers: _SubParsersAction):
5476
)
5577
time_convert.add_argument("--client", default=None, help="The name of the client to use in converted data.")
5678

79+
time_download = add_sub_cmd(time_subcmds, "download", help="Download a Toggl report in CSV format.")
80+
time_download.add_argument(
81+
"--start",
82+
metavar="YYYY-MM-DD",
83+
default=prior_month_start(),
84+
type=lambda s: TZINFO.localize(datetime.strptime(s, "%Y-%m-%d")),
85+
help="The start date of the reporting period. Defaults to the beginning of the prior month.",
86+
)
87+
time_download.add_argument(
88+
"--end",
89+
metavar="YYYY-MM-DD",
90+
default=prior_month_end(),
91+
type=lambda s: TZINFO.localize(datetime.strptime(s, "%Y-%m-%d")),
92+
help="The end date of the reporting period. Defaults to the end of the prior month.",
93+
)
94+
time_download.add_argument(
95+
"--output", default=sys.stdout, help="The path to the file where converted data should be written. Defaults to stdout."
96+
)
97+
time_download.add_argument(
98+
"--client",
99+
dest="client_ids",
100+
metavar="CLIENT_ID",
101+
action="append",
102+
type=int,
103+
help="An ID for a Toggl Client to filter for in reports. Can be supplied more than once.",
104+
)
105+
time_download.add_argument(
106+
"--project",
107+
dest="project_ids",
108+
metavar="PROJECT_ID",
109+
action="append",
110+
type=int,
111+
help="An ID for a Toggl Project to filter for in reports. Can be supplied more than once.",
112+
)
113+
time_download.add_argument(
114+
"--task",
115+
dest="task_ids",
116+
metavar="TASK_ID",
117+
action="append",
118+
type=int,
119+
help="An ID for a Toggl Project Task to filter for in reports. Can be supplied more than once.",
120+
)
121+
time_download.add_argument(
122+
"--user",
123+
dest="user_ids",
124+
metavar="USER_ID",
125+
action="append",
126+
type=int,
127+
help="An ID for a Toggl User to filter for in reports. Can be supplied more than once.",
128+
)
129+
57130

58131
def setup_user_command(cmd_parsers: _SubParsersAction):
59132
user_cmd = add_sub_cmd(cmd_parsers, "user", help="Work with users in the Compiler org.")

compiler_admin/services/toggl.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1+
from base64 import b64encode
2+
from datetime import datetime
3+
import io
14
import os
25
import sys
36
from typing import TextIO
47

58
import pandas as pd
9+
import requests
610

11+
from compiler_admin import __version__
712
from compiler_admin.services.google import user_info as google_user_info
813
import 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
1121
PROJECT_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+
3993
def _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+
49108
def _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

Comments
 (0)