source: stamper/stamper/stamper.py@ 75:957d4b9c06eb

tip
Last change on this file since 75:957d4b9c06eb was 75:957d4b9c06eb, checked in by Borja Lopez <borja@…>, 4 years ago

Ensure totals are shown in hours:minutes:seconds when they are over 24 hours

File size: 18.7 KB
Line 
1
2import json
3import os.path
4from datetime import datetime, timedelta
5from os import symlink, remove, makedirs
6from collections import OrderedDict
7from operator import itemgetter
8import pygal
9
10from .http import HTTPClient
11from .config import Config
12from .filters import DateFilter
13
14
15def timedelta_to_hms(value):
16 """
17 Return hours, minutes, seconds from a timedelta object
18 """
19 hours, remainder = divmod(int(value.total_seconds()), 3600)
20 minutes, seconds = divmod(remainder, 60)
21 return hours, minutes, seconds
22
23
24class Stamper(object):
25
26 def __init__(self, config_file=None):
27 self.config = Config(config_file)
28 self.stamps_file = os.path.expanduser(
29 self.config.get('stamps', 'path'))
30 self.charts_dir = os.path.expanduser(
31 self.config.get('charts', 'path'))
32 self.date_format = self.config.get('stamps', 'date_format')
33 self.time_format = self.config.get('stamps', 'time_format')
34 self.datetime_format = self.config.get('stamps', 'datetime_format')
35 self.wants_seconds = self.config.get('stamps', 'wants_seconds')
36 self.hours_day = int(self.config.get('sum', 'hours_day'))
37 self.collector = self.config.get('collector')
38 self.ensure_stamps_file()
39 self.ensure_charts_dir()
40 self.stamps = []
41 self.date_filter = DateFilter(self.date_format)
42
43 def __json_load(self, filename):
44 """
45 Load the stamps from a file in json format, returning
46 the parsed list.
47 """
48 with open(filename, 'r') as stamps_file:
49 try:
50 stamps = json.load(stamps_file)
51 except ValueError:
52 stamps = []
53 return stamps
54
55 def remove_duplicates(self):
56 """
57 Remove duplicated stamps from the stamps list
58 """
59 stamps = [dict(t) for t in set(
60 [tuple(d.items()) for d in self.stamps])]
61 self.stamps = stamps
62
63 def ensure_stamps_file(self):
64 if not os.path.exists(self.stamps_file):
65 with open(self.stamps_file, 'w') as stamps_file:
66 stamps_file.write('')
67
68 def ensure_charts_dir(self):
69 if not os.path.exists(self.charts_dir):
70 makedirs(self.charts_dir)
71
72 def load_stamps(self):
73 self.stamps = self.__json_load(self.stamps_file)
74
75 def sort_stamps(self):
76 """
77 Sort all the stamps by start and end dates
78 """
79 self.stamps = sorted(self.stamps, key=itemgetter('start', 'end'))
80
81 def save_stamps(self):
82 with open(self.stamps_file, 'w') as stamps_file:
83 json.dump(self.stamps, stamps_file, indent=4)
84
85 def stamp(self, start, end, customer, action):
86 self.stamps.append({
87 'start': start,
88 'end': end,
89 'customer': customer,
90 'action': action,
91 })
92
93 def last_stamp(self, n=1):
94 """
95 return the stamp in position -n, that is, starting from the latest one
96 and going back N positions in the list of stamps
97 """
98 if not self.stamps:
99 return None
100 return self.stamps[-n]
101
102 def worktime(self, start, end):
103 worktime = (datetime.strptime(end, self.datetime_format) -
104 datetime.strptime(start, self.datetime_format))
105 return worktime.seconds
106
107 @property
108 def customers(self):
109 customers = []
110 for stamp in self.stamps:
111 if stamp['customer'] not in customers:
112 customers.append(stamp['customer'])
113 customers.remove(None)
114 return customers
115
116 def totals(self, filter_from=None, filter_to=None, filter_descr=None):
117 totals = {}
118 for stamp in self.stamps:
119 customer = stamp['customer']
120 # customer will be None for "start" stamps, having no end time
121 if customer:
122 start = datetime.strptime(stamp['start'], self.datetime_format)
123 end = datetime.strptime(stamp['end'], self.datetime_format)
124 if filter_from and start < filter_from:
125 # if there is a filter setting a starting date for the
126 # report and the current stamp is from an earlier date, do
127 # not add it to the totals
128 continue
129 if filter_to and start > filter_to:
130 # similar for the end date
131 continue
132 if filter_descr and filter_descr not in stamp['action']:
133 continue
134 if customer not in totals:
135 totals[customer] = 0
136 totals[customer] += self.worktime(stamp['start'], stamp['end'])
137 return totals
138
139 def details(self, filter_customer=None, filter_from=None, filter_to=None,
140 filter_descr=None):
141 details = OrderedDict()
142 totals = OrderedDict()
143 total_customer = OrderedDict()
144 for stamp in self.stamps:
145 customer = stamp['customer']
146 if customer:
147 if filter_customer and customer != filter_customer:
148 # we are getting the details for only one customer, if this
149 # stamp is not for that customer, simply move on and ignore
150 # it
151 continue
152 start = datetime.strptime(stamp['start'], self.datetime_format)
153 start_day = start.strftime('%Y-%m-%d')
154 end = datetime.strptime(stamp['end'], self.datetime_format)
155 if filter_from and start < filter_from:
156 # if there is a filter setting a starting date for the
157 # report and the current stamp is from an earlier date, do
158 # not add it to the totals
159 continue
160 if filter_to and start > filter_to:
161 # similar for the end date
162 continue
163 if filter_descr and filter_descr not in stamp['action']:
164 continue
165 # avoid "start" stamps
166 if start_day not in details:
167 details[start_day] = []
168 if start_day not in totals:
169 totals[start_day] = 0
170 worktime = self.worktime(stamp['start'], stamp['end'])
171 str_worktime = str(timedelta(seconds=worktime))
172 if not self.wants_seconds or self.wants_seconds == 'false':
173 # remove the seconds part from the string representation
174 str_worktime = ':'.join(str_worktime.split(':')[:-1])
175 details[start_day].append(
176 '%(worktime)s %(customer)s %(action)s' % {
177 'worktime': str_worktime,
178 'customer': customer,
179 'action': stamp['action']
180 })
181 totals[start_day] += worktime
182 if start_day not in total_customer:
183 total_customer[start_day] = {}
184 if customer not in total_customer[start_day]:
185 total_customer[start_day][customer] = 0
186 total_customer[start_day][customer] += worktime
187 for day in totals:
188 totals[day] = str(timedelta(seconds=totals[day]))
189 if not self.wants_seconds or self.wants_seconds == 'false':
190 # remove the seconds part from the string representation
191 totals[day] = ':'.join(totals[day].split(':')[:-1])
192 return details, totals, total_customer
193
194 def timeline(self, customer=None, stamp_filter=None, filter_descr=None):
195 filter_from, filter_to = self.date_filter.validate(stamp_filter)
196 for stamp in self.stamps:
197 start = datetime.strptime(stamp['start'], self.datetime_format)
198 start_day = start.strftime('%Y-%m-%d')
199 if filter_from and start < filter_from:
200 # if there is a filter setting a starting date for the
201 # report and the current stamp is from an earlier date, do
202 # not add it to the totals
203 continue
204 if filter_to and start > filter_to:
205 # similar for the end date
206 continue
207 if filter_descr and stamp['action']:
208 if filter_descr not in stamp['action']:
209 continue
210 if not stamp['customer']:
211 if customer is None:
212 print(stamp['start'] + ' start')
213 else:
214 if customer and customer != stamp['customer']:
215 continue
216 if customer:
217 print(stamp['start'] + ' start')
218 print(' '.join([stamp['end'],
219 stamp['customer'],
220 stamp['action']]))
221
222 def graph_stamps(self, customer=None, stamp_filter=None, filter_descr=None):
223 """
224 Generate charts with information from the stamps
225 """
226 filter_from, filter_to = self.date_filter.validate(stamp_filter)
227 chart = pygal.Bar(title='Work hours per day',
228 range=(0, self.hours_day),
229 x_title='Days',
230 y_title='Work hours',
231 x_label_rotation=45)
232
233 details, totals, totals_customers = self.details(customer,
234 filter_from,
235 filter_to,
236 filter_descr)
237 days = []
238 values = {}
239 for c in self.customers:
240 values[c] = []
241
242 found = []
243
244 for day in details:
245 for c in values:
246 seconds = totals_customers[day].get(c, 0)
247 if seconds and c not in found:
248 found.append(c)
249 human = timedelta(seconds=seconds).__str__()
250 values[c].append({'value': seconds/60.00/60.00,
251 'label': day + ': ' + human})
252 days.append(day)
253 chart.x_labels = map(str, days)
254
255 if customer:
256 chart.add(customer, values[customer])
257 else:
258 for c in found:
259 chart.add(c, values[c])
260
261 chart_name = 'chart-%s.svg' % datetime.today().strftime(
262 '%Y-%m-%d_%H%M%S')
263 chart_symlink = 'chart-latest.svg'
264 chart_path = os.path.join(self.charts_dir, chart_name)
265 chart_symlink_path = os.path.join(self.charts_dir, chart_symlink)
266
267 chart.render_to_file(chart_path)
268 print('Rendered chart: ' + chart_path)
269 if os.path.islink(chart_symlink_path):
270 remove(chart_symlink_path)
271 symlink(chart_name, chart_symlink_path)
272 print('Updated latest chart: ' + chart_symlink_path)
273
274 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
275 sum=False, filter_descr=None):
276 filter_from, filter_to = self.date_filter.validate(stamp_filter)
277
278 # If the user asks for verbose information, show it before the
279 # totals (mimicing what the original stamp tool does)
280 if verbose:
281 details, totals, total_customer = self.details(customer,
282 filter_from,
283 filter_to,
284 filter_descr)
285 for day in details:
286 print('------ %(day)s ------' % {'day': day})
287 for line in details[day]:
288 print(line)
289 customer_day_totals = []
290 for tc in total_customer[day]:
291 tc_total = str(timedelta(seconds=total_customer[day][tc]))
292 if not self.wants_seconds or self.wants_seconds == 'false':
293 # remove the seconds part from the string representation
294 tc_total = ':'.join(tc_total.split(':')[:-1])
295 # ensure hours show double digits
296 tc_total = tc_total.zfill(5)
297 customer_day_totals.append(tc+': '+tc_total)
298 print(', '.join(customer_day_totals))
299 if len(customer_day_totals) > 1:
300 # if there are multiple customers in the report, show the
301 # daily totals
302 print('daily total: %(total)s' % {'total': totals[day]})
303 print('-'*79)
304
305 # now calculate the totals and show them
306 totals = self.totals(filter_from, filter_to, filter_descr)
307 if customer:
308 seconds=totals.get(customer, 0)
309 total = timedelta(seconds=totals.get(customer, 0))
310 h, m, s = timedelta_to_hms(total)
311 total = ':'.join(
312 [str(h).zfill(2), str(m).zfill(2), str(s).zfill(2)])
313 if not self.wants_seconds or self.wants_seconds == 'false':
314 # remove the seconds part from the string representation
315 total = ':'.join(str(total).split(':')[:-1])
316 print(' %(customer)s: %(total)s' % {'customer': customer,
317 'total': total})
318 else:
319 for c in totals:
320 seconds=totals[c]
321 total = timedelta(seconds=totals[c])
322 h, m, s = timedelta_to_hms(total)
323 total = ':'.join(
324 [str(h).zfill(2), str(m).zfill(2), str(s).zfill(2)])
325 if not self.wants_seconds or self.wants_seconds == 'false':
326 # remove the seconds part from the string representation
327 total = ':'.join(str(total).split(':')[:-1])
328 print(' %(customer)s: %(total)s' % {'customer': c,
329 'total': total})
330
331 if sum:
332 sum_tot = ''
333 if totals:
334 print('------ Totals ------' % {'day': day})
335 for day, tot in totals.items():
336 print(' %(day)s: %(total)s' % {'day': day, 'total': tot})
337 sum_tot = "%(total)s %(new)s" % {
338 'total': sum_tot,
339 'new': total
340 }
341 totalSecs, sec = divmod(seconds, 60)
342 hr, min = divmod(totalSecs, 60)
343 totalDays, remaining = divmod(seconds,
344 (self.hours_day * 60 * 60))
345 remainingMin, remainingSec = divmod(remaining, (60))
346 remainingHr, remainingMin = divmod(remainingMin, (60))
347 print('----- %d:%02d:%02d -----' % (hr, min, sec))
348 print('--- %d days, remaining: %d:%02d (%d hours/day) ---' % (
349 totalDays, remainingHr, remainingMin, self.hours_day
350 ))
351
352 def remove_stamps(self, n=1):
353 """
354 Remove up to n stamps back, asking for confirmation before delete
355 """
356 for i in range(n):
357 stamp = self.last_stamp()
358 if not stamp['customer']:
359 print(stamp['start'] + ' start')
360 else:
361 print(' '.join([stamp['end'],
362 stamp['customer'],
363 stamp['action']]))
364 confirm = ''
365 while confirm.lower() not in ['y', 'n']:
366 confirm = input('delete stamp? (y/n) ')
367 confirm = confirm.lower()
368 if confirm == 'y':
369 self.stamps.pop()
370 else:
371 # if the user says no to the removal of an stamp, we cannot
372 # keep deleting stamps after that one, as that could leave the
373 # stamps in an inconsistent state.
374 print('Aborting removal of stamps')
375 break
376 self.save_stamps()
377
378 def import_stamps(self, filename):
379 """
380 Import the stamps from the given file into the main stamps list,
381 merging them into the list (removing duplicated entries)
382 """
383 if not os.path.exists(filename):
384 print('[error] ' + filename + 'does not exist')
385 return
386 if os.path.isdir(filename):
387 print('[error] ' + filename + 'is a directory')
388 return
389 stamps = self.__json_load(filename)
390 if not stamps:
391 print('[warning] no stamps can be imported from ' + filename)
392 return
393 self.stamps.extend(stamps)
394 self.remove_duplicates()
395 self.sort_stamps()
396 self.save_stamps()
397 print('[warning] ' + str(len(stamps)) + ' stamps merged')
398 print('[warning] remember to review the resulting stamps file')
399
400 def push_stamps(self, customer=None, stamp_filter=None, filter_descr=None):
401 filter_from, filter_to = self.date_filter.validate(stamp_filter)
402
403 stamps = []
404 for stamp in self.stamps:
405 if stamp['customer']:
406 if customer and customer != stamp['customer']:
407 continue
408 start = datetime.strptime(stamp['start'], self.datetime_format)
409 start_day = start.strftime('%Y-%m-%d')
410 end = datetime.strptime(stamp['end'], self.datetime_format)
411 if filter_from and start < filter_from:
412 # if there is a filter setting a starting date for the
413 # report and the current stamp is from an earlier date, do
414 # not add it to the totals
415 continue
416 if filter_to and start > filter_to:
417 # similar for the end date
418 continue
419 if filter_descr and filter_descr not in stamp['action']:
420 continue
421 stamps.append(stamp)
422
423 stamps = json.dumps(stamps, indent=4)
424 http_client = HTTPClient(self.collector['base_url'])
425 response = http_client.post(self.collector['login_path'],
426 {'username': self.collector['user'],
427 'password': self.collector['password']})
428 # response is a json-encoded list of end-points from the collector api
429 try:
430 api = json.loads(response)
431 except ValueError:
432 print('[error] No valid api end points can be retrieved')
433 return
434
435 response = http_client.post(api['push'], {'stamps': stamps})
436
437 # response is a json-encoded dict, containing lists of added, updated
438 # and deleted stamps (on the other side)
439 try:
440 results = json.loads(response)
441 except ValueError:
442 print('[error] stamps pushed, can not retrieve results information')
443 return
444
445 # display information
446 for k in results.keys():
447 print('%(stamps)d stamps %(action)s' % {'stamps': len(results[k]),
448 'action': k})
Note: See TracBrowser for help on using the repository browser.