source: stamper/stamper/stamper.py@ 52:8d45fe507fa4

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

Push stamps to a remote server

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