source: stamper/stamper/stamper.py@ 60:ec70c7dc68b6

Last change on this file since 60:ec70c7dc68b6 was 60:ec70c7dc68b6, checked in by Borja Lopez <borja@…>, 9 years ago

Improvements on the code to push stamps to a remote collector server:

  • The collector server returns now a list of exposed api methods/urls after logging in. That means we do not need to set the url to the listener anymore (stamper can learn from the collector's response)
  • The collector handles fine now push actions to add/append stamps to its database, returning the number of stamps that are added, updated or deleted to/from the database. That means we can show a bit more information to the user after the push
  • The responses from the collector are json-encoded, so now we try to de-serialize the data, showing an error if that is not posible (badly-formatted json, error/exception, etc).
File size: 19.3 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
[26]182 def totals(self, filter_from=None, filter_to=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
[8]198 if customer not in totals:
199 totals[customer] = 0
200 totals[customer] += self.worktime(stamp['start'], stamp['end'])
201 return totals
202
[26]203 def details(self, filter_customer=None, filter_from=None, filter_to=None):
[19]204 details = OrderedDict()
205 totals = OrderedDict()
[23]206 total_customer = OrderedDict()
[0]207 for stamp in self.stamps:
[26]208 customer = stamp['customer']
209 if customer:
210 if filter_customer and customer != filter_customer:
211 # we are getting the details for only one customer, if this
212 # stamp is not for that customer, simply move on and ignore
213 # it
214 continue
[58]215 start = datetime.strptime(stamp['start'], self.datetime_format)
[26]216 start_day = start.strftime('%Y-%m-%d')
[58]217 end = datetime.strptime(stamp['end'], self.datetime_format)
[26]218 if filter_from and start < filter_from:
219 # if there is a filter setting a starting date for the
220 # report and the current stamp is from an earlier date, do
221 # not add it to the totals
222 continue
223 if filter_to and start > filter_to:
224 # similar for the end date
225 continue
[8]226 # avoid "start" stamps
227 if start_day not in details:
228 details[start_day] = []
229 if start_day not in totals:
230 totals[start_day] = 0
231 worktime = self.worktime(stamp['start'], stamp['end'])
232 details[start_day].append(
[39]233 '%(worktime)s %(customer)s %(action)s' % {
[8]234 'worktime': str(timedelta(seconds=worktime)),
[26]235 'customer': customer,
[8]236 'action': stamp['action']
237 })
238 totals[start_day] += worktime
[23]239 if start_day not in total_customer:
240 total_customer[start_day] = {}
241 if customer not in total_customer[start_day]:
242 total_customer[start_day][customer] = 0
243 total_customer[start_day][customer] += worktime
[8]244 for day in totals:
245 totals[day] = str(timedelta(seconds=totals[day]))
[23]246 return details, totals, total_customer
[0]247
[40]248 def timeline(self, customer=None, stamp_filter=None):
[38]249 filter_from, filter_to = self.validate_filter(stamp_filter)
[28]250 for stamp in self.stamps:
[58]251 start = datetime.strptime(stamp['start'], self.datetime_format)
[38]252 start_day = start.strftime('%Y-%m-%d')
253 if filter_from and start < filter_from:
254 # if there is a filter setting a starting date for the
255 # report and the current stamp is from an earlier date, do
256 # not add it to the totals
257 continue
258 if filter_to and start > filter_to:
259 # similar for the end date
260 continue
[40]261
[28]262 if not stamp['customer']:
[40]263 if customer is None:
264 print(stamp['start'] + ' start')
[28]265 else:
[40]266 if customer and customer != stamp['customer']:
267 continue
268 if customer:
269 print(stamp['start'] + ' start')
[28]270 print(' '.join([stamp['end'],
271 stamp['customer'],
272 stamp['action']]))
273
[30]274 def graph_stamps(self, customer=None, stamp_filter=None):
275 """
276 Generate charts with information from the stamps
277 """
278 filter_from, filter_to = self.validate_filter(stamp_filter)
279 chart = pygal.Bar(title='Work hours per day',
[58]280 range=(0, self.hours_day),
[30]281 x_title='Days',
282 y_title='Work hours',
283 x_label_rotation=45)
284
285 details, totals, totals_customers = self.details(customer,
286 filter_from,
287 filter_to)
288 days = []
289 values = {}
290 for c in self.customers:
291 values[c] = []
292
293 found = []
294
295 for day in details:
296 for c in values:
297 seconds = totals_customers[day].get(c, 0)
298 if seconds and c not in found:
299 found.append(c)
300 human = timedelta(seconds=seconds).__str__()
301 values[c].append({'value': seconds/60.00/60.00,
302 'label': day + ': ' + human})
303 days.append(day)
304 chart.x_labels = map(str, days)
305
306 if customer:
307 chart.add(customer, values[customer])
308 else:
309 for c in found:
310 chart.add(c, values[c])
311
312 chart_name = 'chart-%s.svg' % datetime.today().strftime(
313 '%Y-%m-%d_%H%M%S')
314 chart_symlink = 'chart-latest.svg'
[43]315 chart_path = os.path.join(self.charts_dir, chart_name)
316 chart_symlink_path = os.path.join(self.charts_dir, chart_symlink)
317
318 chart.render_to_file(chart_path)
319 print('Rendered chart: ' + chart_path)
320 if os.path.islink(chart_symlink_path):
321 remove(chart_symlink_path)
322 symlink(chart_name, chart_symlink_path)
323 print('Updated latest chart: ' + chart_symlink_path)
[30]324
[22]325 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
[30]326 sum=False):
[26]327 filter_from, filter_to = self.validate_filter(stamp_filter)
[9]328
[26]329 # If the user asks for verbose information, show it before the
330 # totals (mimicing what the original stamp tool does)
331 if verbose:
332 details, totals, total_customer = self.details(customer,
333 filter_from,
334 filter_to)
335 for day in details:
336 print('------ %(day)s ------' % {'day': day})
337 for line in details[day]:
338 print(line)
[39]339 customer_day_totals = []
340 for tc in total_customer[day]:
341 tc_total = str(timedelta(seconds=total_customer[day][tc]))
342 customer_day_totals.append(tc+': '+tc_total)
343 print(', '.join(customer_day_totals))
344 if len(customer_day_totals) > 1:
345 # if there are multiple customers in the report, show the
346 # daily totals
347 print('daily total: %(total)s' % {'total': totals[day]})
[26]348 print '-'*79
[9]349
[26]350 # now calculate the totals and show them
351 totals = self.totals(filter_from, filter_to)
[9]352 if customer:
[22]353 seconds=totals.get(customer, 0)
[23]354 total = timedelta(seconds=totals.get(customer, 0))
[9]355 print(' %(customer)s: %(total)s' % {'customer': customer,
356 'total': total})
357 else:
358 for c in totals:
[22]359 seconds=totals[c]
[23]360 total = timedelta(seconds=totals[c])
[9]361 print(' %(customer)s: %(total)s' % {'customer': c,
362 'total': total})
363
[22]364 if sum:
365 sum_tot = ''
366 if totals:
367 print('------ Totals ------' % {'day': day})
368 for day, tot in totals.iteritems():
369 print(' %(day)s: %(total)s' % {'day': day, 'total': tot})
370 sum_tot = "%(total)s %(new)s" % {
371 'total': sum_tot,
372 'new': total
373 }
374 totalSecs, sec = divmod(seconds, 60)
375 hr, min = divmod(totalSecs, 60)
[58]376 totalDays, remaining = divmod(seconds,
377 (self.hours_day * 60 * 60))
[22]378 remainingMin, remainingSec = divmod(remaining, (60))
379 remainingHr, remainingMin = divmod(remainingMin, (60))
380 print('----- %d:%02d:%02d -----' % (hr, min, sec))
381 print('--- %d days, remaining: %d:%02d (%d hours/day) ---' % (
[58]382 totalDays, remainingHr, remainingMin, self.hours_day
[22]383 ))
[32]384
385 def remove_stamps(self, n=1):
386 """
387 Remove up to n stamps back, asking for confirmation before delete
388 """
389 for i in range(n):
390 stamp = self.last_stamp()
391 if not stamp['customer']:
392 print(stamp['start'] + ' start')
393 else:
394 print(' '.join([stamp['end'],
395 stamp['customer'],
396 stamp['action']]))
397 confirm = ''
398 while confirm.lower() not in ['y', 'n']:
[59]399 confirm = raw_input('delete stamp? (y/n) ')
[32]400 confirm = confirm.lower()
401 if confirm == 'y':
402 self.stamps.pop()
403 else:
404 # if the user says no to the removal of an stamp, we cannot
405 # keep deleting stamps after that one, as that could leave the
406 # stamps in an inconsistent state.
407 print('Aborting removal of stamps')
408 break
409 self.save_stamps()
[37]410
411 def import_stamps(self, filename):
412 """
413 Import the stamps from the given file into the main stamps list,
414 merging them into the list (removing duplicated entries)
415 """
[43]416 if not os.path.exists(filename):
[37]417 print('[error] ' + filename + 'does not exist')
418 return
[43]419 if os.path.isdir(filename):
[37]420 print('[error] ' + filename + 'is a directory')
421 return
422 stamps = self.__json_load(filename)
423 if not stamps:
424 print('[warning] no stamps can be imported from ' + filename)
425 return
426 self.stamps.extend(stamps)
427 self.remove_duplicates()
428 self.sort_stamps()
429 self.save_stamps()
430 print('[warning] ' + str(len(stamps)) + ' stamps merged')
431 print('[warning] remember to review the resulting stamps file')
[52]432
[54]433 def push_stamps(self, customer=None, stamp_filter=None):
434 filter_from, filter_to = self.validate_filter(stamp_filter)
435
[52]436 stamps = []
437 for stamp in self.stamps:
438 if stamp['customer']:
439 if customer and customer != stamp['customer']:
440 continue
[58]441 start = datetime.strptime(stamp['start'], self.datetime_format)
[52]442 start_day = start.strftime('%Y-%m-%d')
[58]443 end = datetime.strptime(stamp['end'], self.datetime_format)
[52]444 if filter_from and start < filter_from:
445 # if there is a filter setting a starting date for the
446 # report and the current stamp is from an earlier date, do
447 # not add it to the totals
448 continue
449 if filter_to and start > filter_to:
450 # similar for the end date
451 continue
452 stamps.append(stamp)
[54]453
[52]454 stamps = json.dumps(stamps, indent=4)
[58]455 http_client = HTTPClient(self.collector['base_url'])
[60]456 response = http_client.post(self.collector['login_path'],
457 {'username': self.collector['user'],
458 'password': self.collector['password']})
459 # response is a json-encoded list of end-points from the collector api
460 try:
461 api = json.loads(response)
462 except ValueError:
463 print('[error] No valid api end points can be retrieved')
464 return
465
466 response = http_client.post(api['push'], {'stamps': stamps})
467
468 # response is a json-encoded dict, containing lists of added, updated
469 # and deleted stamps (on the other side)
470 try:
471 results = json.loads(response)
472 except ValueError:
473 print('[error] stamps pushed, can not retrieve results information')
474 return
475
476 # display information
477 for k in results.keys():
478 print('%(stamps)d stamps %(action)s' % {'stamps': len(results[k]),
479 'action': k})
Note: See TracBrowser for help on using the repository browser.