Created
September 3, 2023 23:37
-
-
Save elisium-oat/3c1a3139f98adf990517e23191f57d02 to your computer and use it in GitHub Desktop.
My time tracking and reporting script. Vanilla Python. Can generate CSV for GNU Cash invoices.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from datetime import datetime, time | |
| from zoneinfo import ZoneInfo | |
| import sqlite3 | |
| import csv | |
| import sys | |
| class billit: | |
| def __init__(self, path): | |
| self.db = sqlite3.connect(path) | |
| self.db.execute( | |
| "CREATE TABLE IF NOT EXISTS entries(client, task, started_at, finished_at);") | |
| def clients(self): | |
| res = self.db.execute( | |
| "SELECT client FROM entries GROUP BY client ORDER BY MAX(started_at) DESC;") | |
| return [row[0] for row in res.fetchall()] | |
| def tasks(self, client): | |
| res = self.db.execute(""" | |
| SELECT task FROM entries | |
| WHERE client = ? | |
| GROUP BY task | |
| ORDER BY MAX(started_at) DESC; | |
| """, [client]) | |
| return [row[0] for row in res.fetchall()] | |
| # Entries | |
| def punch(self, client, task, at): | |
| if client == '' or task == '': | |
| return | |
| top = self.db.execute(""" | |
| SELECT rowid | |
| FROM entries | |
| WHERE finished_at IS NULL | |
| AND client = ? | |
| AND task = ? | |
| ORDER BY started_at DESC; | |
| """, [client, task]).fetchone() | |
| if top is None: | |
| # Punch in | |
| self.db.execute("INSERT INTO entries VALUES (?, ?, ?, NULL);", [ | |
| client, task, at]) | |
| else: | |
| # Punch out | |
| self.db.execute( | |
| """UPDATE entries SET finished_at = ? | |
| WHERE rowid = ?;""", [at, top[0]]) | |
| self.db.commit() | |
| def wip(self): | |
| return self.db.execute("SELECT client, task, started_at FROM entries WHERE finished_at IS NULL;").fetchall() | |
| # Reporting | |
| def gnucash_invoice(self, client, rate, gnucash_owner, invoice_id, gnucash_account, tax_table, taxable=False): | |
| cols = ['id', 'date_opened', 'owner_id', 'billingid', 'notes', 'date', | |
| 'desc', 'action', 'account', 'quantity', 'price', 'disc_type', 'disc_how', | |
| 'discount', 'taxable', 'taxincluded', 'tax_table', 'date_posted', | |
| 'due_date', 'account_posted', 'memo_posted', 'accu_splits'] | |
| base_dict = {k: '' for k in cols} | { | |
| 'id': invoice_id, | |
| 'owner_id': gnucash_owner, | |
| 'account': gnucash_account, | |
| 'price': rate, | |
| 'taxable': 'X' if taxable else '', | |
| 'tax_table': tax_table, | |
| } | |
| res = self.db.execute("""SELECT | |
| started_at as date, | |
| task as desc, | |
| 'Heures' as action, | |
| round((cast(finished_at as real)-cast(started_at as real)) / 3600, 3) as quantity | |
| FROM entries | |
| WHERE finished_at IS NOT NULL | |
| AND client = ? | |
| ORDER BY date;""", [client]) | |
| keys = [x[0] for x in res.description] | |
| rows = [base_dict | dict(zip(keys, row)) for row in res.fetchall()] | |
| for row in rows: | |
| # Set timezone and format | |
| row['date'] = datetime.fromtimestamp( | |
| row['date'], tz=ZoneInfo("America/Toronto")).strftime('%Y-%m-%d') | |
| return cols, rows | |
| def dump_gnucash_invoice(self, client_id): | |
| cols, entries = self.gnucash_invoice(client_id) | |
| w = csv.DictWriter(sys.stdout, cols, restval='', | |
| extrasaction='raise', dialect='excel', delimiter=';') | |
| w.writerows(entries) | |
| if __name__ == '__main__': | |
| if len(sys.argv) == 1: | |
| print("Please provide a path for the database.", file=sys.stderr) | |
| exit(1) | |
| a = billit(sys.argv[1]) | |
| match sys.argv[2:]: | |
| case ['report', client]: | |
| a.dump_gnucash_invoice(client) | |
| case ['clients']: | |
| print('\n'.join(a.clients())) | |
| case ['tasks', client]: | |
| print('\n'.join(a.tasks(client))) | |
| case ['punch', client, task, at]: | |
| a.punch(client, task, int(at)) | |
| print() | |
| case ['wip']: | |
| print( | |
| '\n'.join(['|'.join(map(str, x)) for x in a.wip()])) | |
| case _: | |
| print("Options not supported.", file=sys.stderr) | |
| exit(1) | |
| exit(0) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment