toggl2sheets/toggl2sheets.py
2022-08-28 01:24:49 +02:00

214 lines
7.4 KiB
Python

#!/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
from typing import Dict, List, Tuple, Any
import traceback
import sys
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]
# 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)]
worksheet.insert_rows(last_row, len(d), d)
def get_toggl_entries(
token: str, work_id: int, last_edit: datetime.datetime
) -> 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}"'
)
projects = {p["id"]: p["name"] for p in resp.json()}
def valid_entry(e):
return (
e["duration"] > 0
and e["server_deleted_at"] is None
and e["project_id"] in projects
)
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()
last_entry = next(
filter(
valid_entry,
entries,
),
None,
)
# construct a dict of d["project name"] = [[date, description, duration (hours), wage], ...]
out = defaultdict(list)
for entry in reversed(entries):
if not valid_entry(entry):
continue
start = datetime.datetime.fromisoformat(entry["start"].replace("Z", "+00:00"))
end = datetime.datetime.fromisoformat(entry["stop"].replace("Z", "+00:00"))
if end <= last_edit:
continue # get better accuracy than toggl lets us in their requests
out[projects[entry["project_id"]]].append(
[
start.astimezone().strftime("%-d. %-m. %Y"),
entry["description"],
round(entry["duration"] / 60 / 60, 2),
HOURLY_WAGE,
]
)
return out, last_entry
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")
if val:
return datetime.datetime.fromisoformat(val)
return datetime.datetime.now() - datetime.timedelta(days=7)
def update_last_edit(sh: Spreadsheet, last_entry: Dict[str, Any]):
worksheet: Worksheet = sh.worksheet("title", "_toggl2sheets") # type: ignore
worksheet.update_value("A1", last_entry["stop"].replace("Z", "+00: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)
entries, last_entry = get_toggl_entries(TOGGL_TOKEN, TOGGL_WORKSPACE, last_edit)
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(
0, 1, [["Datum", "Popis", "=SUM(C2:C)", "Hodinovka", "=SUM(E2:E)"]]
)
else:
continue
append_hours(worksheet, val)
if last_entry is not None:
update_last_edit(sh, last_entry)
if __name__ == "__main__":
try:
main()
except Exception as e:
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}")
exit(1)