source: stamper/stamper/stamper.py@ 69:522b15adbbcd

Last change on this file since 69:522b15adbbcd was 69:522b15adbbcd, checked in by Borja Lopez <borja@…>, 7 years ago

Ensure the daily totals per customer have double digits in the hours value
when showing verbose details of the stamps

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