source: stamper/stamper/stamper.py@ 53:ec3de0325406

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

Authenticate the user before actually push the stamps to the server.
Also, cleaned up a bit the http client, no need to pass and store
there the user and password auth credentials.

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