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

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

Push stamps to a remote server

File size: 18.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
12
13
14STAMPS_FILE = os.path.expanduser('~/.workstamps.json')
15CHARTS_DIR = os.path.expanduser('~/.workstamps-charts')
16DATE_FORMAT = '%Y-%m-%d'
17TIME_FORMAT = '%H:%M:%S'
18DATETIME_FORMAT = '%Y-%m-%d %H:%M'
19HOURS_DAY = 8
20SECS_DAY = HOURS_DAY * 60 * 60
21REMOTE_BASE_URL = 'https://localhost'
22REMOTE_PUSH_URL = '/stamper/listen/' # IMPORTANT: keep the trailing /
23REMOTE_USER = None
24REMOTE_PASSWORD = None
25
26
27class Stamper(object):
28
29 def __init__(self, stamps_file=STAMPS_FILE, charts_dir=CHARTS_DIR):
30 self.stamps_file = stamps_file
31 self.charts_dir = charts_dir
32 self.ensure_stamps_file()
33 self.ensure_charts_dir()
34 self.stamps = []
35
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
56 def ensure_stamps_file(self):
57 if not os.path.exists(self.stamps_file):
58 with open(self.stamps_file, 'w') as stamps_file:
59 stamps_file.write('')
60
61 def ensure_charts_dir(self):
62 if not os.path.exists(self.charts_dir):
63 makedirs(self.charts_dir)
64
65 def load_stamps(self):
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'))
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
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 """
91 if not self.stamps:
92 return None
93 return self.stamps[-n]
94
95 def worktime(self, start, end):
96 worktime = (datetime.strptime(end, DATETIME_FORMAT) -
97 datetime.strptime(start, DATETIME_FORMAT))
98 return worktime.seconds
99
100 def validate_filter(self, stamp_filter):
101 """
102 Validate a given filter. Filters can have the following notation:
103
104 - %Y-%m-%d: Times recorded at a given date
105
106 - %Y-%m-%d--%Y-%m-%d: Times recorded between two dates
107
108 - *%Y-%m-%d: Times recorded up to a given date
109
110 - %Y-%m-%d*: Times recorded from a given date
111
112 - N...N[d|w|m|y]: Times recorded N...N days/weeks/months/years ago
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
117 """
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)
142
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)
148
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
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)
174
175 return filter_from, filter_to
176
177 @property
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
186 def totals(self, filter_from=None, filter_to=None):
187 totals = {}
188 for stamp in self.stamps:
189 customer = stamp['customer']
190 # customer will be None for "start" stamps, having no end time
191 if customer:
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
202 if customer not in totals:
203 totals[customer] = 0
204 totals[customer] += self.worktime(stamp['start'], stamp['end'])
205 return totals
206
207 def details(self, filter_customer=None, filter_from=None, filter_to=None):
208 details = OrderedDict()
209 totals = OrderedDict()
210 total_customer = OrderedDict()
211 for stamp in self.stamps:
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
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(
237 '%(worktime)s %(customer)s %(action)s' % {
238 'worktime': str(timedelta(seconds=worktime)),
239 'customer': customer,
240 'action': stamp['action']
241 })
242 totals[start_day] += worktime
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
248 for day in totals:
249 totals[day] = str(timedelta(seconds=totals[day]))
250 return details, totals, total_customer
251
252 def timeline(self, customer=None, stamp_filter=None):
253 filter_from, filter_to = self.validate_filter(stamp_filter)
254 for stamp in self.stamps:
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
265
266 if not stamp['customer']:
267 if customer is None:
268 print(stamp['start'] + ' start')
269 else:
270 if customer and customer != stamp['customer']:
271 continue
272 if customer:
273 print(stamp['start'] + ' start')
274 print(' '.join([stamp['end'],
275 stamp['customer'],
276 stamp['action']]))
277
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'
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)
328
329 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
330 sum=False):
331 filter_from, filter_to = self.validate_filter(stamp_filter)
332
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)
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]})
352 print '-'*79
353
354 # now calculate the totals and show them
355 totals = self.totals(filter_from, filter_to)
356 if customer:
357 seconds=totals.get(customer, 0)
358 total = timedelta(seconds=totals.get(customer, 0))
359 print(' %(customer)s: %(total)s' % {'customer': customer,
360 'total': total})
361 else:
362 for c in totals:
363 seconds=totals[c]
364 total = timedelta(seconds=totals[c])
365 print(' %(customer)s: %(total)s' % {'customer': c,
366 'total': total})
367
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 ))
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()
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 """
419 if not os.path.exists(filename):
420 print('[error] ' + filename + 'does not exist')
421 return
422 if os.path.isdir(filename):
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')
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.