source: stamper/stamper/stamper.py@ 61:8fe64b5932b9

Last change on this file since 61:8fe64b5932b9 was 61:8fe64b5932b9, checked in by Borja Lopez <borja@…>, 6 years ago

Filter stamps by text in their description/action.

Examples:

  • Returns all stamps for customer A that contain the word "updated":

stamps -t -desc updated A

  • Returns the sum of times for the work on the release of a project for customer B (if you added RELEASE as a text on those stamps):

stamps -vs -desc RELEASE B

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