summaryrefslogtreecommitdiff
path: root/overlays/worktime/worktime.py
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2023-04-04 10:23:20 +0200
committerGregor Kleen <gkleen@yggdrasil.li>2023-04-04 10:23:20 +0200
commit7e7656e22ced47bec5ea5bae1da08e3ef48d2e42 (patch)
treea4a1d4abda4092d69866dbaf6d281c93150f266f /overlays/worktime/worktime.py
parent47f8d03ecb9efe39045630a1ebdcbc1c5a8f424e (diff)
downloadnixos-7e7656e22ced47bec5ea5bae1da08e3ef48d2e42.tar
nixos-7e7656e22ced47bec5ea5bae1da08e3ef48d2e42.tar.gz
nixos-7e7656e22ced47bec5ea5bae1da08e3ef48d2e42.tar.bz2
nixos-7e7656e22ced47bec5ea5bae1da08e3ef48d2e42.tar.xz
nixos-7e7656e22ced47bec5ea5bae1da08e3ef48d2e42.zip
worktime...
Diffstat (limited to 'overlays/worktime/worktime.py')
-rwxr-xr-xoverlays/worktime/worktime.py619
1 files changed, 0 insertions, 619 deletions
diff --git a/overlays/worktime/worktime.py b/overlays/worktime/worktime.py
deleted file mode 100755
index 46197b6e..00000000
--- a/overlays/worktime/worktime.py
+++ /dev/null
@@ -1,619 +0,0 @@
1#!@python@/bin/python
2
3import requests
4from requests.exceptions import HTTPError
5from requests.auth import HTTPBasicAuth
6from datetime import *
7from xdg import (BaseDirectory)
8import configparser
9from uritools import uricompose
10
11from dateutil.easter import *
12from dateutil.tz import *
13from dateutil.parser import isoparse
14
15from enum import Enum
16
17from math import (copysign, ceil, floor)
18
19import calendar
20
21import argparse
22
23from copy import deepcopy
24
25import sys
26from sys import stderr
27
28from tabulate import tabulate
29
30from itertools import groupby
31from functools import cache
32
33import backoff
34
35
36class TogglAPISection(Enum):
37 TOGGL = '/api/v8'
38 REPORTS = '/reports/api/v2'
39
40class TogglAPIError(Exception):
41 def __init__(self, http_error, response):
42 self.http_error = http_error
43 self.response = response
44
45 def __str__(self):
46 if not self.http_error is None:
47 return str(self.http_error)
48 else:
49 return self.response.text
50
51class TogglAPI(object):
52 def __init__(self, api_token, workspace_id, client_ids):
53 self._api_token = api_token
54 self._workspace_id = workspace_id
55 self._client_ids = set(map(int, client_ids.split(','))) if client_ids else None
56
57 def _make_url(self, api=TogglAPISection.TOGGL, section=['time_entries', 'current'], params={}):
58 if api is TogglAPISection.REPORTS:
59 params.update({'user_agent': 'worktime', 'workspace_id': self._workspace_id})
60
61 api_path = api.value
62 section_path = '/'.join(section)
63 uri = uricompose(scheme='https', host='api.track.toggl.com', path=f"{api_path}/{section_path}", query=params)
64
65 return uri
66
67 def _query(self, url, method):
68 response = self._raw_query(url, method)
69 response.raise_for_status()
70 return response
71
72 @backoff.on_predicate(
73 backoff.expo,
74 factor=0.1, max_value=2,
75 predicate=lambda r: r.status_code == 429,
76 max_time=10,
77 )
78 def _raw_query(self, url, method):
79 headers = {'content-type': 'application/json'}
80 response = None
81
82 if method == 'GET':
83 response = requests.get(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token'))
84 elif method == 'POST':
85 response = requests.post(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token'))
86 else:
87 raise ValueError(f"Undefined HTTP method “{method}”")
88
89 return response
90
91 def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False):
92 billable_acc = timedelta(milliseconds = 0)
93 step = timedelta(days = 365)
94
95 for req_start in [start_date + x * step for x in range(0, ceil((end_date - start_date) / step))]:
96 req_end = end_date
97 if end_date > req_start + step:
98 req_end = datetime.combine((req_start + step).astimezone(timezone.utc).date(), time(tzinfo=timezone.utc))
99 elif req_start > start_date:
100 req_start = datetime.combine(req_start.astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) + timedelta(days = 1)
101
102 def get_report(client_ids = self._client_ids):
103 nonlocal req_start, req_end, rounding, self
104
105 if client_ids is not None and not client_ids:
106 return timedelta(milliseconds = 0)
107
108 params = { 'since': req_start.astimezone(timezone.utc).isoformat(),
109 'until': req_end.astimezone(timezone.utc).isoformat(),
110 'rounding': rounding,
111 'billable': 'yes'
112 }
113 if client_ids is not None:
114 params |= { 'client_ids': ','.join(map(str, client_ids)) }
115 url = self._make_url(api = TogglAPISection.REPORTS, section = ['summary'], params = params)
116 r = self._query(url = url, method='GET')
117 if not r or not r.json():
118 raise TogglAPIError(r)
119 res = timedelta(milliseconds=r.json()['total_billable']) if r.json()['total_billable'] else timedelta(milliseconds=0)
120 return res
121
122 if 0 in self._client_ids:
123 url = self._make_url(api = TogglAPISection.TOGGL, section = ['workspaces', self._workspace_id, 'clients'])
124 r = self._query(url = url, method = 'GET')
125 if not r or not r.json():
126 raise TogglAPIError(r)
127
128 billable_acc += get_report(None) - get_report(set(map(lambda c: c['id'], r.json())))
129
130 billable_acc += get_report(self._client_ids - {0})
131
132 return billable_acc
133
134 def get_running_clock(self, now=datetime.now(timezone.utc)):
135 url = self._make_url(api = TogglAPISection.TOGGL, section = ['time_entries', 'current'])
136 r = self._query(url = url, method='GET')
137
138 if not r or not r.json():
139 raise TogglAPIError(r)
140
141 if not r.json()['data'] or not r.json()['data']['billable']:
142 return None
143
144 if self._client_ids is not None:
145 if 'pid' in r.json()['data'] and r.json()['data']['pid']:
146 url = self._make_url(api = TogglAPISection.TOGGL, section = ['projects', str(r.json()['data']['pid'])])
147 pr = self._query(url = url, method = 'GET')
148 if not pr or not pr.json():
149 raise TogglAPIError(pr)
150
151 if not pr.json()['data']:
152 return None
153
154 if 'cid' in pr.json()['data'] and pr.json()['data']['cid']:
155 if pr.json()['data']['cid'] not in self._client_ids:
156 return None
157 elif 0 not in self._client_ids:
158 return None
159 elif 0 not in self._client_ids:
160 return None
161
162 start = isoparse(r.json()['data']['start'])
163
164 return now - start if start <= now else None
165
166class Worktime(object):
167 time_worked = timedelta()
168 running_entry = None
169 now = datetime.now(tzlocal())
170 time_pulled_forward = timedelta()
171 is_workday = False
172 include_running = True
173 time_to_work = None
174 force_day_to_work = True
175 leave_days = set()
176 leave_budget = dict()
177 time_per_day = None
178 workdays = None
179
180 @staticmethod
181 @cache
182 def holidays(year):
183 holidays = dict()
184
185 y_easter = datetime.combine(easter(year), time(), tzinfo=tzlocal())
186
187 # Legal holidays in munich, bavaria
188 holidays[datetime(year, 1, 1, tzinfo=tzlocal()).date()] = 1
189 holidays[datetime(year, 1, 6, tzinfo=tzlocal()).date()] = 1
190 holidays[(y_easter+timedelta(days=-2)).date()] = 1
191 holidays[(y_easter+timedelta(days=+1)).date()] = 1
192 holidays[datetime(year, 5, 1, tzinfo=tzlocal()).date()] = 1
193 holidays[(y_easter+timedelta(days=+39)).date()] = 1
194 holidays[(y_easter+timedelta(days=+50)).date()] = 1
195 holidays[(y_easter+timedelta(days=+60)).date()] = 1
196 holidays[datetime(year, 8, 15, tzinfo=tzlocal()).date()] = 1
197 holidays[datetime(year, 10, 3, tzinfo=tzlocal()).date()] = 1
198 holidays[datetime(year, 11, 1, tzinfo=tzlocal()).date()] = 1
199 holidays[datetime(year, 12, 25, tzinfo=tzlocal()).date()] = 1
200 holidays[datetime(year, 12, 26, tzinfo=tzlocal()).date()] = 1
201
202 return holidays
203
204 @staticmethod
205 def config():
206 config = configparser.ConfigParser()
207 config_dir = BaseDirectory.load_first_config('worktime')
208 config.read(f"{config_dir}/worktime.ini")
209 return config
210
211 def ordinal_workday(self, date):
212 start_date = datetime(date.year, 1, 1, tzinfo=tzlocal()).date()
213 return len([1 for offset in range(0, (date - start_date).days + 1) if self.would_be_workday(start_date + timedelta(days = offset))])
214
215 def would_be_workday(self, date):
216 return date.isoweekday() in self.workdays and date not in set(day for (day, val) in Worktime.holidays(date.year).items() if val >= 1)
217
218 def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs):
219 self.include_running = include_running
220 self.force_day_to_work = force_day_to_work
221
222 if now:
223 self.now = now
224
225 config = Worktime.config()
226 config_dir = BaseDirectory.load_first_config('worktime')
227 api = TogglAPI(api_token=config['TOGGL']['ApiToken'], workspace_id=config['TOGGL']['Workspace'], client_ids=config.get('TOGGL', 'ClientIds', fallback=None))
228 date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d')
229
230 start_date = start_datetime or datetime.strptime(config['WORKTIME']['StartDate'], date_format).replace(tzinfo=tzlocal())
231 end_date = end_datetime or self.now
232
233 try:
234 with open(f"{config_dir}/reset", 'r') as reset:
235 for line in reset:
236 stripped_line = line.strip()
237 reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal())
238
239 if reset_date > start_date and reset_date <= end_date:
240 start_date = reset_date
241 except IOError as e:
242 if e.errno != 2:
243 raise e
244
245
246 hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40))
247 self.workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')])
248 self.time_per_day = timedelta(hours = hours_per_week) / len(self.workdays)
249
250 holidays = dict()
251
252 leave_per_year = int(config.get('WORKTIME', 'LeavePerYear', fallback=30))
253 for year in range(start_date.year, end_date.year + 1):
254 holidays |= {k: v * self.time_per_day for k, v in Worktime.holidays(year).items()}
255 leave_frac = 1
256 if date(year, 1, 1) < start_date.date():
257 leave_frac = (date(year + 1, 1, 1) - start_date.date()) / (date(year + 1, 1, 1) - date(year, 1, 1))
258 self.leave_budget |= {year: floor(leave_per_year * leave_frac)}
259
260 try:
261 with open(f"{config_dir}/reset-leave", 'r') as excused:
262 for line in excused:
263 stripped_line = line.strip()
264 if stripped_line:
265 [datestr, count] = stripped_line.split(' ')
266 day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date()
267 if day != start_date.date():
268 continue
269
270 self.leave_budget[day.year] = (self.leave_budget[day.year] if day.year in self.leave_budget else 0) + int(count)
271 except IOError as e:
272 if e.errno != 2:
273 raise e
274
275
276 for excused_kind in {'excused', 'leave'}:
277 try:
278 with open(f"{config_dir}/{excused_kind}", 'r') as excused:
279 for line in excused:
280 stripped_line = line.strip()
281 if stripped_line:
282 splitLine = stripped_line.split(' ')
283 fromDay = toDay = None
284 def parse_datestr(datestr):
285 nonlocal fromDay, toDay
286 def parse_single(singlestr):
287 return datetime.strptime(singlestr, date_format).replace(tzinfo=tzlocal()).date()
288 if '--' in datestr:
289 [fromDay,toDay] = datestr.split('--')
290 fromDay = parse_single(fromDay)
291 toDay = parse_single(toDay)
292 else:
293 fromDay = toDay = parse_single(datestr)
294 time = self.time_per_day
295 if len(splitLine) == 2:
296 [hours, datestr] = splitLine
297 time = timedelta(hours = float(hours))
298 parse_datestr(datestr)
299 else:
300 parse_datestr(stripped_line)
301
302 for day in [fromDay + timedelta(days = x) for x in range(0, (toDay - fromDay).days + 1)]:
303 if end_date.date() < day or day < start_date.date():
304 continue
305
306 if excused_kind == 'leave' and self.would_be_workday(day):
307 self.leave_days.add(day)
308 holidays[day] = time
309 except IOError as e:
310 if e.errno != 2:
311 raise e
312
313 pull_forward = dict()
314
315 start_day = start_date.date()
316 end_day = end_date.date()
317
318 try:
319 with open(f"{config_dir}/pull-forward", 'r') as excused:
320 for line in excused:
321 stripped_line = line.strip()
322 if stripped_line:
323 [hours, datestr] = stripped_line.split(' ')
324 constr = datestr.split(',')
325 for d in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1 + int(timedelta(hours = float(hours)).total_seconds() / 60 * (7 / len(self.workdays)) * 2))]:
326 for c in constr:
327 if c in calendar.day_abbr:
328 if not d.strftime('%a') == c: break
329 elif "--" in c:
330 [fromDay,toDay] = c.split('--')
331 if fromDay != "":
332 fromDay = datetime.strptime(fromDay, date_format).replace(tzinfo=tzlocal()).date()
333 if not fromDay <= d: break
334 if toDay != "":
335 toDay = datetime.strptime(toDay, date_format).replace(tzinfo=tzlocal()).date()
336 if not d <= toDay: break
337 else:
338 if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break
339 else:
340 if d >= end_date.date():
341 pull_forward[d] = min(timedelta(hours = float(hours)), self.time_per_day - (holidays[d] if d in holidays else timedelta()))
342 except IOError as e:
343 if e.errno != 2:
344 raise e
345
346 days_to_work = dict()
347
348 if pull_forward:
349 end_day = max(end_day, max(list(pull_forward)))
350
351 for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]:
352 if day.isoweekday() in self.workdays:
353 time_to_work = self.time_per_day
354 if day in holidays.keys():
355 time_to_work -= holidays[day]
356 if time_to_work > timedelta():
357 days_to_work[day] = time_to_work
358
359 extra_days_to_work = dict()
360
361 try:
362 with open(f"{config_dir}/days-to-work", 'r') as extra_days_to_work_file:
363 for line in extra_days_to_work_file:
364 stripped_line = line.strip()
365 if stripped_line:
366 splitLine = stripped_line.split(' ')
367 if len(splitLine) == 2:
368 [hours, datestr] = splitLine
369 day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date()
370 extra_days_to_work[day] = timedelta(hours = float(hours))
371 else:
372 extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = self.time_per_day
373 except IOError as e:
374 if e.errno != 2:
375 raise e
376
377
378 self.is_workday = self.now.date() in days_to_work or self.now.date() in extra_days_to_work
379
380 self.time_worked = timedelta()
381
382 if self.include_running:
383 self.running_entry = api.get_running_clock(self.now)
384
385 if self.running_entry:
386 self.time_worked += self.running_entry
387
388 if self.running_entry and self.include_running and self.force_day_to_work and not (self.now.date() in days_to_work or self.now.date() in extra_days_to_work):
389 extra_days_to_work[self.now.date()] = timedelta()
390
391 self.time_to_work = sum([days_to_work[day] for day in days_to_work.keys() if day <= end_date.date()], timedelta())
392 for day in [d for d in list(pull_forward) if d > end_date.date()]:
393 days_forward = set([d for d in days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())])
394 extra_days_forward = set([d for d in extra_days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())])
395 days_forward = days_forward.union(extra_days_forward)
396
397 extra_day_time_left = timedelta()
398 for extra_day in extra_days_forward:
399 day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day])
400 extra_day_time_left += day_time
401 extra_day_time = min(extra_day_time_left, pull_forward[day])
402 time_forward = pull_forward[day] - extra_day_time
403 if extra_day_time_left > timedelta():
404 for extra_day in extra_days_forward:
405 day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day])
406 extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left)
407
408 hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta()
409 days_forward.discard(end_date.date())
410
411 self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward)
412
413 if end_date.date() in extra_days_to_work:
414 self.time_pulled_forward += extra_days_to_work[end_date.date()]
415
416 self.time_to_work += self.time_pulled_forward
417
418 self.time_worked += api.get_billable_hours(start_date, self.now, rounding = config.getboolean('WORKTIME', 'rounding', fallback=True))
419
420def worktime(**args):
421 worktime = Worktime(**args)
422
423 def format_worktime(worktime):
424 def difference_string(difference):
425 total_minutes_difference = round(difference / timedelta(minutes = 1))
426 (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60)
427 sign = '' if total_minutes_difference >= 0 else '-'
428
429 difference_string = f"{sign}"
430 if hours_difference != 0:
431 difference_string += f"{hours_difference}h"
432 if hours_difference == 0 or minutes_difference != 0:
433 difference_string += f"{minutes_difference}m"
434
435 return difference_string
436
437 difference = worktime.time_to_work - worktime.time_worked
438 total_minutes_difference = 5 * ceil(difference / timedelta(minutes = 5))
439
440 if worktime.running_entry and abs(difference) < timedelta(days = 1) and (total_minutes_difference > 0 or abs(worktime.running_entry) >= abs(difference)) :
441 clockout_time = worktime.now + difference
442 clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1)
443 clockout_time = clockout_time.replace(second = 0, microsecond = 0)
444
445 if total_minutes_difference >= 0:
446 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1))
447 return f"{difference_string}/{clockout_time:%H:%M}"
448 else:
449 difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1))
450 return f"{clockout_time:%H:%M}/{difference_string}"
451 else:
452 if worktime.running_entry:
453 difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1))
454 indicator = '↓' if total_minutes_difference >= 0 else '↑' # '\u25b6'
455
456 return f"{indicator}{difference_string}"
457 else:
458 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1))
459 if worktime.is_workday:
460 return difference_string
461 else:
462 return f"({difference_string})"
463
464 if worktime.time_pulled_forward >= timedelta(minutes = 15):
465 worktime_no_pulled_forward = deepcopy(worktime)
466 worktime_no_pulled_forward.time_to_work -= worktime_no_pulled_forward.time_pulled_forward
467 worktime_no_pulled_forward.time_pulled_forward = timedelta()
468
469 difference_string = format_worktime(worktime)
470 difference_string_no_pulled_forward = format_worktime(worktime_no_pulled_forward)
471
472 print(f"{difference_string_no_pulled_forward}…{difference_string}")
473 else:
474 print(format_worktime(worktime))
475
476def time_worked(now, **args):
477 then = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0)
478 if now.time() == time():
479 now = now + timedelta(days = 1)
480
481 then = Worktime(**dict(args, now = then))
482 now = Worktime(**dict(args, now = now))
483
484 worked = now.time_worked - then.time_worked
485
486 if args['do_round']:
487 total_minutes_difference = 5 * ceil(worked / timedelta(minutes = 5))
488 (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60)
489 sign = '' if total_minutes_difference >= 0 else '-'
490
491 difference_string = f"{sign}"
492 if hours_difference != 0:
493 difference_string += f"{hours_difference}h"
494 if hours_difference == 0 or minutes_difference != 0:
495 difference_string += f"{minutes_difference}m"
496
497 clockout_time = None
498 clockout_difference = None
499 if then.is_workday or now.is_workday:
500 target_time = max(then.time_per_day, now.time_per_day) if then.time_per_day and now.time_per_day else (then.time_per_day if then.time_per_day else now.time_per_day);
501 difference = target_time - worked
502 clockout_difference = 5 * ceil(difference / timedelta(minutes = 5))
503 clockout_time = now.now + difference
504 clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1)
505 clockout_time = clockout_time.replace(second = 0, microsecond = 0)
506
507 if now.running_entry and clockout_time and clockout_difference >= 0:
508 print(f"{difference_string}/{clockout_time:%H:%M}")
509 else:
510 print(difference_string)
511 else:
512 print(worked)
513
514def diff(now, **args):
515 now = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0)
516 then = now - timedelta.resolution
517
518 then = Worktime(**dict(args, now = then, include_running = False))
519 now = Worktime(**dict(args, now = now, include_running = False))
520
521 print(now.time_to_work - then.time_to_work)
522
523def holidays(year, **args):
524 config = Worktime.config()
525 date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d')
526
527 table_data = []
528
529 holidays = Worktime.holidays(year)
530 for k, v in holidays.items():
531 kstr = k.strftime(date_format)
532 table_data += [[kstr, v]]
533 print(tabulate(table_data, tablefmt="plain"))
534
535def leave(year, table, **args):
536 def_year = datetime.now(tzlocal()).year
537 worktime = Worktime(**dict(**args, end_datetime = datetime(year = (year if year else def_year) + 1, month = 1, day = 1, tzinfo=tzlocal()) - timedelta(microseconds=1)))
538 config = Worktime.config()
539 date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d')
540 leave_expires = config.get('WORKTIME', 'LeaveExpires', fallback=None)
541 if leave_expires:
542 leave_expires = datetime.strptime(leave_expires, '%m-%d').date()
543
544 leave_budget = deepcopy(worktime.leave_budget)
545 year_leave_budget = deepcopy(worktime.leave_budget) if year else None
546 years = sorted(leave_budget.keys())
547 for day in sorted(worktime.leave_days):
548 for iyear in years:
549 if day > leave_expires.replace(year = iyear + 1):
550 continue
551 if leave_budget[iyear] <= 0:
552 continue
553
554 leave_budget[iyear] -= 1
555 if year_leave_budget and day.year < year:
556 year_leave_budget[iyear] -= 1
557 break
558 else:
559 print(f'Unaccounted leave: {day}', file=stderr)
560
561 if table and year:
562 table_data = []
563 leave_days = sorted([day for day in worktime.leave_days if day.year == year and worktime.would_be_workday(day)])
564
565 count = 0
566 for _, group in groupby(enumerate(leave_days), lambda kv: kv[0] - worktime.ordinal_workday(kv[1])):
567 group = list(map(lambda kv: kv[1], group))
568
569 for day in group:
570 for iyear in years:
571 if day > leave_expires.replace(year = iyear + 1):
572 continue
573 if year_leave_budget[iyear] <= 0:
574 continue
575
576 year_leave_budget[iyear] -= 1
577 break
578
579 next_count = count + len(group)
580 if len(group) > 1:
581 table_data.append([count, group[0].strftime('%m-%d') + '--' + group[-1].strftime('%m-%d'), len(group), sum(year_leave_budget.values())])
582 else:
583 table_data.append([count, group[0].strftime('%m-%d'), len(group), sum(year_leave_budget.values())])
584 count = next_count
585 print(tabulate(table_data, tablefmt="plain"))
586 elif table:
587 table_data = []
588 for year, days in leave_budget.items():
589 leave_days = sorted([day for day in worktime.leave_days if day.year == year])
590 table_data += [[year, days, ','.join(map(lambda d: d.strftime('%m-%d'), leave_days))]]
591 print(tabulate(table_data, tablefmt="plain"))
592 else:
593 print(leave_budget[year if year else def_year])
594
595def main():
596 parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API')
597 parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = lambda s: datetime.fromisoformat(s).replace(tzinfo=tzlocal()), help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal()))
598 parser.add_argument('--no-running', dest = 'include_running', action = 'store_false')
599 parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false')
600 subparsers = parser.add_subparsers(help = 'Subcommands')
601 parser.set_defaults(cmd = worktime)
602 time_worked_parser = subparsers.add_parser('time_worked', aliases = ['time', 'worked', 'today'])
603 time_worked_parser.add_argument('--no-round', dest = 'do_round', action = 'store_false')
604 time_worked_parser.set_defaults(cmd = time_worked)
605 diff_parser = subparsers.add_parser('diff')
606 diff_parser.set_defaults(cmd = diff)
607 holidays_parser = subparsers.add_parser('holidays')
608 holidays_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate holidays for (default: current year)', default = datetime.now(tzlocal()).year, nargs='?')
609 holidays_parser.set_defaults(cmd = holidays)
610 leave_parser = subparsers.add_parser('leave')
611 leave_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate leave days for (default: current year)', default = None, nargs='?')
612 leave_parser.add_argument('--table', action = 'store_true')
613 leave_parser.set_defaults(cmd = leave)
614 args = parser.parse_args()
615
616 args.cmd(**vars(args))
617
618if __name__ == "__main__":
619 sys.exit(main())