diff options
author | Gregor Kleen <gkleen@yggdrasil.li> | 2023-04-04 10:23:20 +0200 |
---|---|---|
committer | Gregor Kleen <gkleen@yggdrasil.li> | 2023-04-04 10:23:20 +0200 |
commit | 7e7656e22ced47bec5ea5bae1da08e3ef48d2e42 (patch) | |
tree | a4a1d4abda4092d69866dbaf6d281c93150f266f /overlays/worktime/worktime.py | |
parent | 47f8d03ecb9efe39045630a1ebdcbc1c5a8f424e (diff) | |
download | nixos-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-x | overlays/worktime/worktime.py | 619 |
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 | |||
3 | import requests | ||
4 | from requests.exceptions import HTTPError | ||
5 | from requests.auth import HTTPBasicAuth | ||
6 | from datetime import * | ||
7 | from xdg import (BaseDirectory) | ||
8 | import configparser | ||
9 | from uritools import uricompose | ||
10 | |||
11 | from dateutil.easter import * | ||
12 | from dateutil.tz import * | ||
13 | from dateutil.parser import isoparse | ||
14 | |||
15 | from enum import Enum | ||
16 | |||
17 | from math import (copysign, ceil, floor) | ||
18 | |||
19 | import calendar | ||
20 | |||
21 | import argparse | ||
22 | |||
23 | from copy import deepcopy | ||
24 | |||
25 | import sys | ||
26 | from sys import stderr | ||
27 | |||
28 | from tabulate import tabulate | ||
29 | |||
30 | from itertools import groupby | ||
31 | from functools import cache | ||
32 | |||
33 | import backoff | ||
34 | |||
35 | |||
36 | class TogglAPISection(Enum): | ||
37 | TOGGL = '/api/v8' | ||
38 | REPORTS = '/reports/api/v2' | ||
39 | |||
40 | class 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 | |||
51 | class 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 | |||
166 | class 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 | |||
420 | def 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 | |||
476 | def 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 | |||
514 | def 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 | |||
523 | def 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 | |||
535 | def 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 | |||
595 | def 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 | |||
618 | if __name__ == "__main__": | ||
619 | sys.exit(main()) | ||