2022-08-11 23:11:21 +02:00
|
|
|
#!/bin/python3
|
|
|
|
"""
|
|
|
|
toggl2sheets is a script that automatically gets time entries from toggl
|
|
|
|
and puts them into a google spreadsheet. toggl2sheets will only add *new*
|
|
|
|
entries (since it last ran), modified entries will not be updated.
|
|
|
|
|
|
|
|
A hidden spreadsheet "_toggl2sheets" will be created to keep track when
|
|
|
|
was the last execution of this script.
|
|
|
|
|
|
|
|
PLEASE FILL OUT ALL THE CONSTANTS JUST UNDER ALL THE IMPORT STATEMENTS
|
|
|
|
OR PROVIDE APPROPRIATE ENVIRONMENT VARIABLES
|
|
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
|
|
|
|
|
|
The script operates based on project names. It will put all entries
|
|
|
|
that are a part of project "A" into a worksheet with with the name "A".
|
|
|
|
It can create new sheets when the project worksheet does not exist, or just
|
|
|
|
ignore it. (IGNORE_PROJECTS_WITHOUT_SHEET option)
|
|
|
|
"""
|
|
|
|
from collections import defaultdict
|
|
|
|
import os
|
2022-08-28 01:02:53 +02:00
|
|
|
from typing import Dict, List, Tuple, Any
|
|
|
|
import traceback
|
|
|
|
import sys
|
2022-08-11 23:11:21 +02:00
|
|
|
|
|
|
|
import datetime
|
|
|
|
import pygsheets
|
|
|
|
from pygsheets.spreadsheet import Spreadsheet
|
|
|
|
from pygsheets.worksheet import Worksheet
|
|
|
|
import requests
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------
|
|
|
|
|
|
|
|
# To obtain a Google service account private key, follow instructions
|
|
|
|
# here: https://pygsheets.readthedocs.io/en/stable/authorization.html#service-account
|
|
|
|
# You will need to:
|
|
|
|
# 1. create a new Google Cloud project (https://console.developers.google.com/)
|
|
|
|
# 2. go to the credentials tab and make a new service account credential
|
|
|
|
# - when creating a new service account, you do not need to add any roles to it
|
|
|
|
# 3. go to the created service account and add a key. Click: create new private key.
|
|
|
|
# You should now have the key downloaded.
|
|
|
|
# 4. point the GOOGLE_SAF variable to that file
|
|
|
|
# 5. got to "Enabled APIs & services" in the Google developer console and enable
|
|
|
|
# "Google Sheets API"
|
|
|
|
GOOGLE_SAF = os.getenv("GOOGLE_SAF", "./service.json")
|
|
|
|
|
|
|
|
# To get your toggl token go to your profile page (https://track.toggl.com/profile),
|
|
|
|
# scroll down, and click reveal on your API key.
|
|
|
|
TOGGL_TOKEN = os.getenv("TOGGL_TOKEN", "9e5c9000000000000000000000fd2291")
|
|
|
|
|
|
|
|
|
|
|
|
# To get the workspace id open a report from it in toggl.
|
|
|
|
# You can see the ID in the URL
|
|
|
|
TOGGL_WORKSPACE = int(os.getenv("TOGGL_WORKSPACE", "42000"))
|
|
|
|
|
|
|
|
# To get your Google sheet ID, open the sheet, and copy this part of the URL:
|
|
|
|
# https://docs.google.com/spreadsheets/d/1Li3djx7xbC00000000000000000iyd2zWv7jjXI_qFA/edit
|
|
|
|
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
|
|
GOOGLE_SHEET = os.getenv("GOOGLE_SHEET", "1Li3djx7xbC000000v7jjXI_qFA")
|
|
|
|
|
|
|
|
# Self explainatory. If set to False, then the script will create a new sheet
|
|
|
|
IGNORE_PROJECTS_WITHOUT_SHEET = bool(
|
|
|
|
int(os.getenv("IGNORE_PROJECTS_WITHOUT_SHEET", "1"))
|
|
|
|
)
|
|
|
|
|
|
|
|
# houry wage
|
|
|
|
HOURLY_WAGE = os.getenv("HOURLY_WAGE", "0,00 Kč")
|
|
|
|
|
|
|
|
# ------------------------------
|
|
|
|
|
|
|
|
today = datetime.datetime.now().date() - datetime.timedelta(days=1)
|
|
|
|
|
|
|
|
|
|
|
|
def append_hours(worksheet: Worksheet, data: List[List[str]]):
|
|
|
|
column = worksheet.get_col(1)
|
|
|
|
|
|
|
|
# get last non-empty row
|
|
|
|
last_row = next(filter(lambda x: not x[1], enumerate(column)))[0]
|
|
|
|
|
2022-08-11 23:38:41 +02:00
|
|
|
# add a calculated hours * wage cell to all rows
|
|
|
|
d = [[*x, f"=C{i}*D{i}"] for i, x in enumerate(data, start=last_row + 1)]
|
|
|
|
|
2022-08-11 23:11:21 +02:00
|
|
|
worksheet.insert_rows(last_row, len(d), d)
|
|
|
|
|
|
|
|
|
|
|
|
def get_toggl_entries(
|
|
|
|
token: str, work_id: int, last_edit: datetime.datetime
|
2022-08-28 01:02:53 +02:00
|
|
|
) -> Tuple[Dict[str, List], Any]:
|
|
|
|
last_edit = last_edit.astimezone(datetime.timezone.utc)
|
|
|
|
|
|
|
|
resp = requests.get(
|
|
|
|
f"https://api.track.toggl.com/api/v9/workspaces/{work_id}/projects",
|
|
|
|
auth=(token, "api_token"),
|
|
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
|
|
raise ValueError(
|
|
|
|
f'Toggl responded with non-200 status code: {resp}, body: "{resp.text}"'
|
2022-08-11 23:11:21 +02:00
|
|
|
)
|
2022-08-28 01:02:53 +02:00
|
|
|
projects = {p["id"]: p["name"] for p in resp.json()}
|
2022-08-11 23:11:21 +02:00
|
|
|
|
2022-08-28 01:18:07 +02:00
|
|
|
def valid_entry(e):
|
|
|
|
return (
|
|
|
|
e["duration"] > 0
|
|
|
|
and e["server_deleted_at"] is None
|
|
|
|
and e["project_id"] in projects
|
|
|
|
)
|
|
|
|
|
2022-08-28 01:02:53 +02:00
|
|
|
entries = []
|
|
|
|
resp = requests.get(
|
|
|
|
f"https://api.track.toggl.com/api/v9/me/time_entries",
|
|
|
|
params={"user_agent": "toggl2sheets", "since": int(last_edit.timestamp())},
|
|
|
|
auth=(token, "api_token"),
|
|
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
|
|
raise ValueError(
|
|
|
|
f'Toggl responded with non-200 status code: {resp}, body: "{resp.text}"'
|
|
|
|
)
|
|
|
|
entries = resp.json()
|
2022-08-28 01:14:24 +02:00
|
|
|
last_entry = next(
|
|
|
|
filter(
|
2022-08-28 01:18:07 +02:00
|
|
|
valid_entry,
|
2022-08-28 01:14:24 +02:00
|
|
|
entries,
|
|
|
|
),
|
|
|
|
None,
|
|
|
|
)
|
2022-08-11 23:11:21 +02:00
|
|
|
|
|
|
|
# construct a dict of d["project name"] = [[date, description, duration (hours), wage], ...]
|
|
|
|
out = defaultdict(list)
|
2022-08-28 01:02:53 +02:00
|
|
|
for entry in reversed(entries):
|
2022-08-28 01:18:07 +02:00
|
|
|
if not valid_entry(entry):
|
2022-08-28 01:05:23 +02:00
|
|
|
continue
|
2022-08-28 01:02:53 +02:00
|
|
|
start = datetime.datetime.fromisoformat(entry["start"].replace("Z", "+00:00"))
|
|
|
|
end = datetime.datetime.fromisoformat(entry["stop"].replace("Z", "+00:00"))
|
|
|
|
if end <= last_edit:
|
2022-08-11 23:11:21 +02:00
|
|
|
continue # get better accuracy than toggl lets us in their requests
|
2022-08-28 01:05:23 +02:00
|
|
|
|
2022-08-28 01:02:53 +02:00
|
|
|
out[projects[entry["project_id"]]].append(
|
2022-08-11 23:11:21 +02:00
|
|
|
[
|
2022-08-28 01:02:53 +02:00
|
|
|
start.astimezone().strftime("%-d. %-m. %Y"),
|
2022-08-11 23:11:21 +02:00
|
|
|
entry["description"],
|
2022-08-28 01:02:53 +02:00
|
|
|
round(entry["duration"] / 60 / 60, 2),
|
2022-08-11 23:11:21 +02:00
|
|
|
HOURLY_WAGE,
|
|
|
|
]
|
|
|
|
)
|
|
|
|
|
2022-08-28 01:02:53 +02:00
|
|
|
return out, last_entry
|
2022-08-11 23:11:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
def get_or_create_last_edit(sh: Spreadsheet) -> datetime.datetime:
|
|
|
|
try:
|
|
|
|
worksheet: Worksheet = sh.worksheet("title", "_toggl2sheets") # type: ignore
|
|
|
|
except pygsheets.WorksheetNotFound:
|
|
|
|
worksheet = sh.add_worksheet("_toggl2sheets")
|
|
|
|
worksheet.hidden = True
|
|
|
|
worksheet.cell("A2").set_text_format("bold", True).value = ( # type: ignore
|
|
|
|
"Internal record of when toggl2sheets last "
|
|
|
|
"updated the spreadsheet, please do not modify"
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
val = worksheet.get_value("A1")
|
2022-08-28 01:02:53 +02:00
|
|
|
if val:
|
|
|
|
return datetime.datetime.fromisoformat(val)
|
|
|
|
return datetime.datetime.now() - datetime.timedelta(days=7)
|
2022-08-11 23:11:21 +02:00
|
|
|
|
|
|
|
|
2022-08-28 01:02:53 +02:00
|
|
|
def update_last_edit(sh: Spreadsheet, last_entry: Dict[str, Any]):
|
2022-08-11 23:11:21 +02:00
|
|
|
worksheet: Worksheet = sh.worksheet("title", "_toggl2sheets") # type: ignore
|
2022-08-28 01:02:53 +02:00
|
|
|
worksheet.update_value("A1", last_entry["stop"].replace("Z", "+00:00"))
|
2022-08-11 23:11:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
gc = pygsheets.authorize(service_file=GOOGLE_SAF)
|
|
|
|
try:
|
|
|
|
sh = gc.open_by_key(GOOGLE_SHEET)
|
|
|
|
except:
|
|
|
|
raise ValueError(
|
|
|
|
f"Cannot open google sheet. Do you have permission? Share sheet with \n{gc.oauth.service_account_email}"
|
|
|
|
)
|
|
|
|
|
|
|
|
# get last edit time
|
|
|
|
last_edit = get_or_create_last_edit(sh)
|
|
|
|
|
2022-08-28 01:02:53 +02:00
|
|
|
entries, last_entry = get_toggl_entries(TOGGL_TOKEN, TOGGL_WORKSPACE, last_edit)
|
2022-08-11 23:11:21 +02:00
|
|
|
|
|
|
|
for key, val in entries.items():
|
|
|
|
try:
|
|
|
|
worksheet: Worksheet = sh.worksheet("title", key) # type: ignore
|
|
|
|
except pygsheets.WorksheetNotFound:
|
|
|
|
if not IGNORE_PROJECTS_WITHOUT_SHEET:
|
|
|
|
# create a new worksheet with a header
|
|
|
|
worksheet = sh.add_worksheet(key)
|
|
|
|
worksheet.insert_rows(
|
2022-08-11 23:47:35 +02:00
|
|
|
0, 1, [["Datum", "Popis", "=SUM(C2:C)", "Hodinovka", "=SUM(E2:E)"]]
|
2022-08-11 23:11:21 +02:00
|
|
|
)
|
|
|
|
else:
|
|
|
|
continue
|
|
|
|
|
|
|
|
append_hours(worksheet, val)
|
|
|
|
|
2022-08-28 01:02:53 +02:00
|
|
|
if last_entry is not None:
|
|
|
|
update_last_edit(sh, last_entry)
|
2022-08-11 23:11:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
try:
|
|
|
|
main()
|
|
|
|
except Exception as e:
|
2022-08-28 01:02:53 +02:00
|
|
|
stk = [
|
|
|
|
f"{s[0].split('/')[-1]}:{s[1]}:{s[2]}"
|
|
|
|
for s in traceback.extract_tb(sys.exc_info()[2])
|
|
|
|
][:5]
|
|
|
|
print(f"ERROR: func stack: {stk}")
|
|
|
|
print(f"ERROR: {e.__class__.__name__}: {e}")
|
2022-08-11 23:11:21 +02:00
|
|
|
exit(1)
|