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

Last change on this file since 61:8fe64b5932b9 was 61:8fe64b5932b9, checked in by Borja Lopez <borja@…>, 9 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
RevLine 
[0]1
2import json
[7]3import re
[43]4import os.path
[26]5from datetime import datetime, date, timedelta
[43]6from os import symlink, remove, makedirs
[19]7from collections import OrderedDict
[37]8from operator import itemgetter
[43]9import pygal
[0]10
[56]11from .http import HTTPClient
[58]12from .config import Config
[0]13
14
15class Stamper(object):
16
[58]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')
[0]28 self.ensure_stamps_file()
[43]29 self.ensure_charts_dir()
[0]30 self.stamps = []
31
[37]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
[0]52 def ensure_stamps_file(self):
[43]53 if not os.path.exists(self.stamps_file):
[0]54 with open(self.stamps_file, 'w') as stamps_file:
55 stamps_file.write('')
56
[43]57 def ensure_charts_dir(self):
58 if not os.path.exists(self.charts_dir):
59 makedirs(self.charts_dir)
60
[0]61 def load_stamps(self):
[37]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'))
[0]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
[32]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 """
[0]87 if not self.stamps:
88 return None
[32]89 return self.stamps[-n]
[0]90
91 def worktime(self, start, end):
[58]92 worktime = (datetime.strptime(end, self.datetime_format) -
93 datetime.strptime(start, self.datetime_format))
[0]94 return worktime.seconds
95
[7]96 def validate_filter(self, stamp_filter):
97 """
98 Validate a given filter. Filters can have the following notation:
99
[26]100 - %Y-%m-%d: Times recorded at a given date
101
[7]102 - %Y-%m-%d--%Y-%m-%d: Times recorded between two dates
103
[26]104 - *%Y-%m-%d: Times recorded up to a given date
[7]105
[26]106 - %Y-%m-%d*: Times recorded from a given date
[7]107
108 - N...N[d|w|m|y]: Times recorded N...N days/weeks/months/years ago
[26]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
[7]113 """
[26]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('--')
[58]122 filter_from = datetime.strptime(filter_from, self.date_format)
123 filter_to = datetime.strptime(filter_to, self.date_format)
[26]124
125 elif stamp_filter.startswith('*'):
[58]126 filter_to = datetime.strptime(stamp_filter, '*'+self.date_format)
[26]127 filter_to = filter_to.replace(hour=0, minute=0, second=0)
128
129 elif stamp_filter.endswith('*'):
[58]130 filter_from = datetime.strptime(stamp_filter, self.date_format+'*')
[26]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)
[7]138
[26]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)
[7]144
[26]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
[36]162 try:
[58]163 filter_from = datetime.strptime(stamp_filter, self.date_format)
[36]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)
[26]170
171 return filter_from, filter_to
[7]172
[30]173 @property
[0]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
[61]182 def totals(self, filter_from=None, filter_to=None, filter_descr=None):
[8]183 totals = {}
184 for stamp in self.stamps:
185 customer = stamp['customer']
[26]186 # customer will be None for "start" stamps, having no end time
[8]187 if customer:
[58]188 start = datetime.strptime(stamp['start'], self.datetime_format)
189 end = datetime.strptime(stamp['end'], self.datetime_format)
[26]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
[61]198 if filter_descr and filter_descr not in stamp['action']:
199 continue
[8]200 if customer not in totals:
201 totals[customer] = 0
202 totals[customer] += self.worktime(stamp['start'], stamp['end'])
203 return totals
204
[61]205 def details(self, filter_customer=None, filter_from=None, filter_to=None,
206 filter_descr=None):
[19]207 details = OrderedDict()
208 totals = OrderedDict()
[23]209 total_customer = OrderedDict()
[0]210 for stamp in self.stamps:
[26]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
[58]218 start = datetime.strptime(stamp['start'], self.datetime_format)
[26]219 start_day = start.strftime('%Y-%m-%d')
[58]220 end = datetime.strptime(stamp['end'], self.datetime_format)
[26]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
[61]229 if filter_descr and filter_descr not in stamp['action']:
230 continue
[8]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(
[39]238 '%(worktime)s %(customer)s %(action)s' % {
[8]239 'worktime': str(timedelta(seconds=worktime)),
[26]240 'customer': customer,
[8]241 'action': stamp['action']
242 })
243 totals[start_day] += worktime
[23]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
[8]249 for day in totals:
250 totals[day] = str(timedelta(seconds=totals[day]))
[23]251 return details, totals, total_customer
[0]252
[61]253 def timeline(self, customer=None, stamp_filter=None, filter_descr=None):
[38]254 filter_from, filter_to = self.validate_filter(stamp_filter)
[28]255 for stamp in self.stamps:
[58]256 start = datetime.strptime(stamp['start'], self.datetime_format)
[38]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
[61]266 if filter_descr and stamp['action']:
267 if filter_descr not in stamp['action']:
268 continue
[28]269 if not stamp['customer']:
[40]270 if customer is None:
271 print(stamp['start'] + ' start')
[28]272 else:
[40]273 if customer and customer != stamp['customer']:
274 continue
275 if customer:
276 print(stamp['start'] + ' start')
[28]277 print(' '.join([stamp['end'],
278 stamp['customer'],
279 stamp['action']]))
280
[61]281 def graph_stamps(self, customer=None, stamp_filter=None, filter_descr=None):
[30]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',
[58]287 range=(0, self.hours_day),
[30]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,
[61]294 filter_to,
295 filter_descr)
[30]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'
[43]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)
[30]332
[22]333 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
[61]334 sum=False, filter_descr=None):
[26]335 filter_from, filter_to = self.validate_filter(stamp_filter)
[9]336
[26]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,
[61]342 filter_to,
343 filter_descr)
[26]344 for day in details:
345 print('------ %(day)s ------' % {'day': day})
346 for line in details[day]:
347 print(line)
[39]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]})
[26]357 print '-'*79
[9]358
[26]359 # now calculate the totals and show them
[61]360 totals = self.totals(filter_from, filter_to, filter_descr)
[9]361 if customer:
[22]362 seconds=totals.get(customer, 0)
[23]363 total = timedelta(seconds=totals.get(customer, 0))
[9]364 print(' %(customer)s: %(total)s' % {'customer': customer,
365 'total': total})
366 else:
367 for c in totals:
[22]368 seconds=totals[c]
[23]369 total = timedelta(seconds=totals[c])
[9]370 print(' %(customer)s: %(total)s' % {'customer': c,
371 'total': total})
372
[22]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)
[58]385 totalDays, remaining = divmod(seconds,
386 (self.hours_day * 60 * 60))
[22]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) ---' % (
[58]391 totalDays, remainingHr, remainingMin, self.hours_day
[22]392 ))
[32]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']:
[59]408 confirm = raw_input('delete stamp? (y/n) ')
[32]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()
[37]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 """
[43]425 if not os.path.exists(filename):
[37]426 print('[error] ' + filename + 'does not exist')
427 return
[43]428 if os.path.isdir(filename):
[37]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')
[52]441
[61]442 def push_stamps(self, customer=None, stamp_filter=None, filter_descr=None):
[54]443 filter_from, filter_to = self.validate_filter(stamp_filter)
444
[52]445 stamps = []
446 for stamp in self.stamps:
447 if stamp['customer']:
448 if customer and customer != stamp['customer']:
449 continue
[58]450 start = datetime.strptime(stamp['start'], self.datetime_format)
[52]451 start_day = start.strftime('%Y-%m-%d')
[58]452 end = datetime.strptime(stamp['end'], self.datetime_format)
[52]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
[61]461 if filter_descr and filter_descr not in stamp['action']:
462 continue
[52]463 stamps.append(stamp)
[54]464
[52]465 stamps = json.dumps(stamps, indent=4)
[58]466 http_client = HTTPClient(self.collector['base_url'])
[60]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.