diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..09029eb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.pythonPath": "venv/bin/python3.6" +} \ No newline at end of file diff --git a/README.md b/README.md index 7495891..8b6371b 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,61 @@ # togglore + Tool for the timetracker [toggle](http://toggl.com/) to calculate the difference between tracked time and the time you should have worked in a given range. +![result](docs/result.jpg) + ## Setup -Create a config file and save it at ~/.togglore. +Create a virtual env using python3 and install the requirements. + +```sh +virtualenv --python=python3 venv +source venv/bin/activate +pip install -r requirements.txt +``` + +Create a config file and save it at `~/.togglore`. + ```sh [Authentication] API_KEY = 5b9f5e3fd7745a022781daf205f62c72 [Work Hours] hours_per_day = 8.4 -excluded_days = 2016.01.01 +excluded_days = 2020.01.01,2020.01.30 [User Info] -id = 1 -workspace = 1 +id = 123123123 +workspace = 123123123 +project = 123123123 ``` +Where to find your information: + +* **API_KEY**: You can find in the section "API Token" in your [profile page](https://toggl.com/app/profile). + + +* **id**, **workspace** and **project**: Open your reports summary in the toggl web app ([link](https://toggl.com/app/reports/summary/)). Then select a filter by Team (select your user) and by project (select the project you want to follow) as showed in the images. Copy the ids showed in the path: `https://toggl.com/app/reports/summary//period/thisWeek/projects//users/` + +![toggl](docs/image0.png) + +![toggl](docs/image1.png) + +## Running notifications + +Open your crontab file (`crontab -e` in a terminal). +Then add the following line in your crontab file: + +```bash +*/10 * * * * eval "export $(egrep -z DBUS_SESSION_BUS_ADDRESS /proc/$(pgrep -u $LOGNAME gnome-session)/environ)" && ~/Documents/GitHub/togglore/venv/bin/python ~/Documents/GitHub/togglore/run.py --notify today +``` + +This will run the script with the notify option for today. The first part of the command is used to allow the use of notifications in gnome env when a script is called by crontab. + +Obs: You should add to the crontab of your user, if you insists and add this to the sudo crontab you have to fix the HOME path. + ## Run + ```sh # show diff for today python3 run.py today @@ -37,8 +75,10 @@ python3 run.py month 08 # show diff from 2016.08.01 until today python3 run.py since 2016.08.01 ``` + The output is something like: -``` + +```text Hours to do: 176.00h (22.00 days) Hours worked: 186.65h (23.33 days) Difference: 10.65h (1.33 days) diff --git a/config_template.txt b/config_template.txt index 18d5663..6b1e508 100644 --- a/config_template.txt +++ b/config_template.txt @@ -3,8 +3,9 @@ API_KEY = 5b9f5e3fd7745a022781daf205f62c72 [Work Hours] hours_per_day = 8.4 -excluded_days = 2016.1.1,2016.12.30 +excluded_days = 2020.01.01,2020.01.30 [User Info] -id = 1 -workspace = 1 \ No newline at end of file +id = 123123123 +workspace = 123123123 +project = 123123123 \ No newline at end of file diff --git a/docs/image0.png b/docs/image0.png new file mode 100644 index 0000000..0731690 Binary files /dev/null and b/docs/image0.png differ diff --git a/docs/image1.png b/docs/image1.png new file mode 100644 index 0000000..d751f74 Binary files /dev/null and b/docs/image1.png differ diff --git a/docs/result.jpg b/docs/result.jpg new file mode 100644 index 0000000..72155b3 Binary files /dev/null and b/docs/result.jpg differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6c6cce0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +get==2019.4.13 +post==2019.4.13 +public==2019.4.13 +query-string==2019.4.13 +request==2019.4.13 +ruamel.yaml==0.16.10 +ruamel.yaml.clib==0.2.0 +vext==0.7.3 +vext.gi==0.7.0 diff --git a/run.py b/run.py index 8e9b08c..b3d0e8d 100644 --- a/run.py +++ b/run.py @@ -1,10 +1,11 @@ import argparse +import datetime import togglore from togglore import utils -if __name__ == '__main__': +def main(): parser = argparse.ArgumentParser(description='Tool for toggle to calculate over/undertime.') subparsers = parser.add_subparsers(dest='command') subparsers.required = True @@ -13,40 +14,271 @@ parser_range.add_argument('from_date', help='startdate, e.g. 30.08.2016') parser_range.add_argument('to_date', help='enddate, e.g. 12.10.2016') + parser_updatecotation = subparsers.add_parser('updatecotation', help='updatecotation help') + parser_year = subparsers.add_parser('thisyear', help='today help') parser_thismonth = subparsers.add_parser('thismonth', help='month help') + parser_lastmonth = subparsers.add_parser('lastmonth', help='last month help') parser_week = subparsers.add_parser('thisweek', help='week help') + parser_lastweek = subparsers.add_parser('lastweek', help='this week help') parser_today = subparsers.add_parser('today', help='day help') parser_month = subparsers.add_parser('month', help='month help') parser_month.add_argument('month', help='month e.g. 08') parser_since = subparsers.add_parser('since', help='since help') parser_since.add_argument('since', help='since e.g. 2016.08.01') + parser.add_argument( + '--untiltoday', + action="store_true", + ) + + parser.add_argument( + '--notify', + action="store_true", + ) + + parser.add_argument( + '--uses_notify_send', + action="store_true", + ) + args = parser.parse_args() client = togglore.Togglore() expected = 0 actual = 0 + running = 0 + + if args.command == 'updatecotation': + client.cfg.update_eur_value(client.config_path) + brl = float(client.cfg.eur_to_brl['value']) + brl_update_date = client.cfg.eur_to_brl['date'] + print(f"* EUR value updated to {brl:.3f} BRL on {brl_update_date}") + return if args.command == 'range': - actual, expected = client.diff(utils.DateRange.parse_from_iso_strings(args.from_date, args.to_date)) + actual, expected, running = client.diff(utils.DateRange.parse_from_iso_strings(args.from_date, args.to_date)) elif args.command == 'thisyear': - actual, expected = client.diff(utils.DateRange.this_year()) + if args.untiltoday: + actual, expected, running = client.diff(utils.DateRange.this_year_until_today(), include_running=True) + else: + actual, expected, running = client.diff(utils.DateRange.this_year(), include_running=True) elif args.command == 'thismonth': - actual, expected = client.diff(utils.DateRange.this_month()) + if args.untiltoday: + actual, expected, running = client.diff(utils.DateRange.this_month_until_today(), include_running=True) + else: + actual, expected, running = client.diff(utils.DateRange.this_month(), include_running=True) + elif args.command == 'lastmonth': + actual, expected, running = client.diff(utils.DateRange.last_month()) elif args.command == 'thisweek': - actual, expected = client.diff(utils.DateRange.this_week()) + if args.untiltoday: + actual, expected, running = client.diff(utils.DateRange.this_week_until_today(), include_running=True) + else: + actual, expected, running = client.diff(utils.DateRange.this_week(), include_running=True) + elif args.command == 'lastweek': + actual, expected, running = client.diff(utils.DateRange.last_week()) elif args.command == 'today': - actual, expected = client.diff(utils.DateRange.today()) + actual, expected, running = client.diff(utils.DateRange.today(), include_running=True) elif args.command == 'month': - actual, expected = client.diff(utils.DateRange.month(int(args.month))) + actual, expected, running = client.diff(utils.DateRange.month(int(args.month))) elif args.command == 'since': - actual, expected = client.diff(utils.DateRange.since(args.since)) + actual, expected, running = client.diff(utils.DateRange.since(args.since)) - print("Hours to do: {0:.2f}h ({1:.2f} days)".format(expected, expected/client.cfg.work_hours_per_day)) - print("Hours worked: {0:.2f}h ({1:.2f} days)".format(actual, actual/client.cfg.work_hours_per_day)) difference = actual-expected - print("Difference: {0:.2f}h ({1:.2f} days)".format(difference, difference/client.cfg.work_hours_per_day)) + brl = float(client.cfg.eur_to_brl['value']) + brl_update_date = client.cfg.eur_to_brl['date'] + actual_eur = actual * client.cfg.hourly_wage + actual_brl = actual_eur * brl + expected_eur = expected * client.cfg.hourly_wage + expected_brl = expected_eur * brl + difference_eur = difference * client.cfg.hourly_wage + difference_brl = difference_eur * brl + + output_result = ( + "Hours to do:\t{0:6.2f}{1} ({2:5.2f} days) -> €{3:7.2f} | R$ {4:4.0f}".format( + expected if abs(expected) >= 1 else expected * 60, + " h" if abs(expected) >= 1 else " m", + expected/client.cfg.work_hours_per_day, + expected_eur, + expected_brl + ) + "\r\n" + + "Hours worked:\t{0:6.2f}{1} ({2:5.2f} days) -> €{3:7.2f} | R$ {4:4.0f}".format( + actual if abs(actual) >= 1 else actual * 60, + " h" if abs(actual) >= 1 else " m", + actual/client.cfg.work_hours_per_day, + actual_eur, + actual_brl + ) + "\r\n" + + "Difference:\t{0:6.2f}{1} ({2:5.2f} days) -> €{3:7.2f} | R$ {4:4.0f}".format( + difference if abs(difference) >= 1 else difference * 60, + " h" if abs(difference) >= 1 else " m", + difference/client.cfg.work_hours_per_day, + abs(difference_eur), + abs(difference_brl) + ) + "\r\n" + + "-" * 60 + "\r\n" + + f"Cotation on {brl_update_date}: 1 EUR = {brl:.3f} BRL" + ) + + if args.command == 'lastmonth': + email_message = ( + f"Bonjour {client.cfg.boss_name}," + "\n" + + "Je vous envoie le total du mois de ." + "\n" + + "Prévu pour le mois: {0:.2f}h ({1:.2f} jours)".format(expected, expected/client.cfg.work_hours_per_day) + "\n" + + "Total pour le mois: {0:.2f}h ({1:.2f} jours)".format(actual, actual/client.cfg.work_hours_per_day) + "\n" + + "Total: {0:.2f} hrs x {1:.1f} = €{2:.2f}".format(actual, client.cfg.hourly_wage, actual * client.cfg.hourly_wage) + "\n" + + "\n" + + "Quel jour de cette semaine vous pouvez fair le virement?" + "\n" + + "Dans le même jour je vais générer le document fiscale en considerant de la cotation du jour forni par transferwise." + "\n" + + "\n" + + "Je vous souhaite une bonne journée." + ) + print("*"*80) + print("Rapport des heures - ") + print("-"*45) + print(email_message) + print("*"*80) + elif args.command == 'lastweek': + expected_end_of_month = client.time_calculator.time_to_work_in_range( + utils.DateRange.this_month() + ) + date_range = utils.DateRange.last_week() + actual_hours = int(actual) + actual_minutes_float = (actual - actual_hours) * 60 + actual_minutes = int(actual_minutes_float) + actual_seconds = int((actual_minutes_float - actual_minutes) * 60) + # Email template + email_message = ( + f"Bonjour {client.cfg.boss_name}," + "\n" + + "Pour info je vous envoie la quantité des heures que j'ai fait la semaine dernière." + "\n\n" + + "Total (Semaine) : {}:{}:{} ({:.2f} hrs)".format( + actual_hours, + actual_minutes, + actual_seconds, + actual, + ) + "\n" + + "Balance (Semaine) : {}:{} ({:.2f} hrs)".format( + int(difference), + int((difference - int(difference)) * 60), + difference, + ) + "\n" + + "Total prévu pour le mois : {0:.2f}h ({1:.0f} jours)".format( + expected_end_of_month, expected_end_of_month/client.cfg.work_hours_per_day + ) + "\n\n" + + "Cordialement,\nItalo Gustavo Sampaio Fernandes" + ) + print("*"*80) + print("Rapport des heures - Semaine {start} à {end}".format( + start=date_range.start.strftime("%d/%m"), + end=date_range.end.strftime("%d/%m") + )) + print("-"*45) + print(email_message) + print("*"*80) + elif args.command == 'thismonth': + expected_end_of_month = client.time_calculator.time_to_work_in_range( + utils.DateRange.this_month() + ) + output_result = output_result + ( + "\r\n" + + "Total: {0:.2f} hrs x {1:.1f}€ (R$ {2:.1f}) = €{3:.2f} | R$ {4:.0f}".format( + expected_end_of_month, + client.cfg.hourly_wage, + client.cfg.hourly_wage * brl, + expected_end_of_month * client.cfg.hourly_wage, + expected_end_of_month * client.cfg.hourly_wage * brl, + ) + ) + if args.untiltoday: + actual_today, expected_today, running_today = client.diff(utils.DateRange.today(), include_running=True) + output_result = output_result + ( + "\r\n" + + "-" * 60 + "\r\n" + ) + if difference <= 0: + finish_prevision = ( + datetime.datetime.now() + + datetime.timedelta(hours=-difference) + ).strftime("%H:%M") + output_result = output_result + ( + f"Finish prevision ({-difference:.2f}h): {finish_prevision}" + "\r\n" + + "-" * 60 + "\r\n" + ) + output_result = output_result + ( + "Today: " + f"{actual_today:.2f}h / {expected-actual+actual_today:.2f}h" + "\r\n" + + "{0:3.0f}%".format(100*actual_today/(expected-actual+actual_today)) + " " + "[" + "=" * int(actual_today*54/(expected-actual+actual_today)) + "-" * int(-(difference)*54/(expected-actual+actual_today)) + "]" + "\r\n" + + "This Month: " + f"{actual/client.cfg.work_hours_per_day:.2f} / {(expected_end_of_month/client.cfg.work_hours_per_day):.0f} days - " + f"R$ {actual_brl:.0f} / R$ {(expected_end_of_month * client.cfg.hourly_wage * brl):.0f}" + "\r\n" + + "{0:3.0f}%".format(100*actual/expected_end_of_month) + " " + "[" + "=" * int(actual*54/expected_end_of_month) + "-" * int(-(actual-expected_end_of_month)*54/expected_end_of_month) + "]" + ) + elif args.command == 'today': + expected_end_of_month = client.time_calculator.time_to_work_in_range( + utils.DateRange.this_month() + ) + output_result = output_result + ( + "\r\n" + + "Total: {0:.2f} hrs x {1:.1f}€ (R$ {2:.1f}) = €{3:.2f} | R$ {4:.0f}".format( + expected_end_of_month, + client.cfg.hourly_wage, + client.cfg.hourly_wage * brl, + expected_end_of_month * client.cfg.hourly_wage, + expected_end_of_month * client.cfg.hourly_wage * brl, + ) + ) + output_result = output_result + ( + "\r\n" + + "-" * 60 + "\r\n" + ) + if difference <= 0: + finish_prevision = ( + datetime.datetime.now() + + datetime.timedelta(hours=-difference) + ).strftime("%H:%M") + output_result = output_result + ( + f"Finish prevision ({-difference:.2f}h): {finish_prevision}" + "\r\n" + + "-" * 60 + "\r\n" + ) + output_result=output_result + ( + "Today: " + f"{actual:.2f}h / {expected:.2f}h" + "\r\n" + + "{0:3.0f}%".format(100*actual/expected) + " " + "[" + "=" * int(actual*54/expected) + "-" * int(-difference*54/expected) + "]" + ) + + + print("*"*60) + print(output_result) + print("*"*60) + + print(f"Working now: {'Yes' if running else 'No'}") + if args.notify and difference >= 0 and running: + from gi import require_version + require_version('Notify', '0.7') + from gi.repository import Notify + Notify.init("Toggle Notifier") + notification=Notify.Notification.new( + 'Time to stop working (+{0:.2f}{1})'.format( + difference if abs(difference) >= 1 else difference * 60, + " h" if abs(difference) >= 1 else " m", + ), + ('-' * 112) + "\r\n" + output_result, + "dialog-information" + ) + notification.set_timeout(Notify.EXPIRES_NEVER) # persist + notification.set_urgency(Notify.Urgency.CRITICAL) # persist + notification.show () + + if args.uses_notify_send and difference >= 0 and running: + import os + title = 'Time to stop working (+{0:.2f}{1})'.format( + difference if abs(difference) >= 1 else difference * 60, + " h" if abs(difference) >= 1 else " m", + ) + os.system( + "notify-send \"" + title + "\" " + " \"" + + output_result + "\"" + ) + + +if __name__ == '__main__': + main() diff --git a/togglore/__init__.py b/togglore/__init__.py index ddeaa62..b802ed9 100644 --- a/togglore/__init__.py +++ b/togglore/__init__.py @@ -7,14 +7,17 @@ class Togglore(object): def __init__(self): - config_path = os.path.join(os.path.expanduser('~'), '.togglore') - self.cfg = config.Config.read_from_file(config_path) + self.config_path = os.path.join(os.path.expanduser('~'), '.togglore') + self.cfg = config.Config.read_from_file(self.config_path) - self.toggle = toggl.TogglClient(self.cfg.api_key, self.cfg.user_id, self.cfg.workspace) + self.toggle = toggl.TogglClient(self.cfg.api_key, self.cfg.user_id, self.cfg.workspace, self.cfg.project) self.time_calculator = utils.WorkTimeCalculator(work_hours_per_day=self.cfg.work_hours_per_day, excluded_days=self.cfg.excluded_days) - def diff(self, date_range): + def diff(self, date_range, include_running=False): actual_hours = utils.sum_time_of_entries(self.toggle.time_entries(date_range)) expected_hours = self.time_calculator.time_to_work_in_range(date_range) + running_time_entry_hours = utils.get_time_of_running_entry(self.toggle.running_time_entry()) + if include_running: + actual_hours = actual_hours + running_time_entry_hours - return actual_hours, expected_hours + return actual_hours, expected_hours, running_time_entry_hours diff --git a/togglore/config.py b/togglore/config.py index 8db02cd..69b22f8 100644 --- a/togglore/config.py +++ b/togglore/config.py @@ -1,14 +1,18 @@ import configparser import datetime - +import requests class Config(object): - def __init__(self, api_key=None, work_hours_per_day=8.4, excluded_days=[], user_id=1, workspace=1): + def __init__(self, api_key=None, work_hours_per_day=8.4, excluded_days=[], user_id=1, workspace=1, project=1, boss_name="Boss", hourly_wage=10.0, eur_to_brl={'value': '5.0', 'date': '30/01/2020'}): self.api_key = api_key self.work_hours_per_day = work_hours_per_day self.excluded_days = excluded_days self.user_id = user_id self.workspace = workspace + self.project = project + self.boss_name = boss_name + self.hourly_wage = hourly_wage + self.eur_to_brl = eur_to_brl def write_to_file(self, path): cfg = configparser.ConfigParser() @@ -18,6 +22,22 @@ def write_to_file(self, path): with open(path, 'w') as configfile: cfg.write(configfile) + + def update_eur_value(self, path): + cfg = configparser.ConfigParser() + cfg.read(path) + + url = "https://api.exchangerate-api.com/v6/latest" + response = requests.get(url) + if response.status_code == 200: + result = response.json() + cfg['EUR to BRL']['value'] = str(result['rates']['BRL'] / result['rates']['EUR']) + cfg['EUR to BRL']['date'] = str(datetime.datetime.fromtimestamp(result['time_last_update_unix']).strftime("%d/%m/%Y %H:%M")) + self.eur_to_brl = cfg['EUR to BRL'] + + with open(path, 'w') as configfile: + cfg.write(configfile) + @classmethod def read_from_file(cls, path): @@ -29,6 +49,11 @@ def read_from_file(cls, path): excluded_days_string = cfg['Work Hours']['excluded_days'] user_id = cfg['User Info']['id'] workspace = cfg['User Info']['workspace'] + project = cfg['User Info']['project'] + boss_name = cfg['Personal Details']['boss_name'] + hourly_wage = float(cfg['Personal Details']['hourly_wage']) + eur_to_brl = cfg['EUR to BRL'] + day_strings = excluded_days_string.split(',') days = [] @@ -37,4 +62,4 @@ def read_from_file(cls, path): days.append(datetime.datetime.strptime(day_string, "%Y.%m.%d").date()) return cls(api_key=api_key, work_hours_per_day=float(work_hours), excluded_days=days, user_id=user_id, - workspace=workspace) + workspace=workspace, project=project, boss_name=boss_name, hourly_wage=hourly_wage, eur_to_brl=eur_to_brl) diff --git a/togglore/toggl.py b/togglore/toggl.py index d6bcd20..4ba167a 100644 --- a/togglore/toggl.py +++ b/togglore/toggl.py @@ -6,10 +6,11 @@ class TogglClient(object): - def __init__(self, api_token, user_id, workspace): + def __init__(self, api_token, user_id, workspace, project): self.api_token = api_token self.user_id = user_id self.workspace = workspace + self.project = project self.headers = {} self.__init_headers() @@ -43,9 +44,15 @@ def time_entries(self, date_range): while current_page * per_page < total: current_page += 1 - response = self.request( - 'https://toggl.com/reports/api/v2/details?workspace_id={}&since={}&until={}&user_agent=togglore&page={}'.format( - self.workspace, date_range.start.isoformat(), date_range.end.isoformat(), current_page)) + url = self._get_toggl_url( + workspace_id=self.workspace, + project_id=self.project, + since=date_range.start.isoformat(), + until=date_range.end.isoformat(), + user_agent='togglore', + page=current_page, + ) + response = self.request(url) total = response['total_count'] per_page = response['per_page'] for time in response['data']: @@ -54,3 +61,27 @@ def time_entries(self, date_range): return entries + def running_time_entry(self): + entry = None + url = "https://www.toggl.com/api/v8/time_entries/current" + response = self.request(url) + if response['data'] == None: + return None + if (str(response['data']['wid']) == self.workspace and + str(response['data']['uid']) == self.user_id and + str(response['data']['pid']) == self.project): + entry = response['data'] + return entry + + + def _get_toggl_url(self, workspace_id, project_id, since, until, user_agent, page): + url = ( + f"https://toggl.com/reports/api/v2/details?" + + f"workspace_id={workspace_id}&" + f"project_ids={project_id}&" + f"since={since}&" + f"until={until}&" + f"user_agent={user_agent}&" + f"page={page}" + ) + return url diff --git a/togglore/utils.py b/togglore/utils.py index 28de140..8925b95 100644 --- a/togglore/utils.py +++ b/togglore/utils.py @@ -10,6 +10,13 @@ def sum_time_of_entries(entries): return ms / 3600000.0 +def get_time_of_running_entry(entry): + if not entry: + return 0.0 + current_time = int(datetime.datetime.now().strftime("%s")) + duration = int(entry['duration']) + return (current_time + duration) / 3600.0 + class WorkTimeCalculator(object): def __init__(self, work_hours_per_day=8.4, excluded_days=[]): @@ -58,10 +65,26 @@ def today(cls): @classmethod def this_week(cls): today = datetime.date.today() - start = today - datetime.timedelta(today.weekday()) + start = today - datetime.timedelta(today.weekday() + 1) end = start + datetime.timedelta(6) return cls(start, end) + + @classmethod + def last_week(cls): + today = datetime.date.today() + end = today - datetime.timedelta(today.weekday() + 1) - datetime.timedelta(1) + start = end - datetime.timedelta(6) + + return cls(start, end) + + @classmethod + def this_week_until_today(cls): + today = datetime.date.today() + start = today - datetime.timedelta(today.weekday()) + end = today + + return cls(start, end) @classmethod def this_month(cls): @@ -71,6 +94,29 @@ def this_month(cls): end = datetime.date(today.year, today.month, end_day) return cls(start, end) + + @classmethod + def last_month(cls): + today = datetime.date.today() + month = today.month - 1 + year = today.year + if month == 0: + month = 12 + year = year - 1 + __, end_day = calendar.monthrange(year, month) + start = datetime.date(year, month, 1) + end = datetime.date(year, month, end_day) + + return cls(start, end) + + @classmethod + def this_month_until_today(cls): + today = datetime.date.today() + __, end_day = calendar.monthrange(today.year, today.month) + start = datetime.date(today.year, today.month, 1) + end = today + + return cls(start, end) @classmethod def this_year(cls): @@ -80,6 +126,14 @@ def this_year(cls): return cls(start, end) + @classmethod + def this_year_until_today(cls): + today = datetime.date.today() + start = datetime.date(today.year, 1, 1) + end = today + + return cls(start, end) + @classmethod def month(cls, month): today = datetime.date.today()