source: stamper/stamper/stamper.py@ 54:4a88af83eca6

Last change on this file since 54:4a88af83eca6 was 54:4a88af83eca6, checked in by Borja Lopez <borja@…>, 6 years ago

Proper date filtering for the push_stamps() method

File size: 18.3 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'
22# IMPORTANT: keep the trailing / on these _URL variables
23REMOTE_LOGIN_URL = '/stamper/login/'
24REMOTE_PUSH_URL = '/stamper/listen/'
25REMOTE_USER = None
26REMOTE_PASSWORD = None
27
28
29class Stamper(object):
30
31 def __init__(self, stamps_file=STAMPS_FILE, charts_dir=CHARTS_DIR):
32 self.stamps_file = stamps_file
33 self.charts_dir = charts_dir
34 self.ensure_stamps_file()
35 self.ensure_charts_dir()
36 self.stamps = []
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
58 def ensure_stamps_file(self):
59 if not os.path.exists(self.stamps_file):
60 with open(self.stamps_file, 'w') as stamps_file:
61 stamps_file.write('')
62
63 def ensure_charts_dir(self):
64 if not os.path.exists(self.charts_dir):
65 makedirs(self.charts_dir)
66
67 def load_stamps(self):
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'))
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
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 """
93 if not self.stamps:
94 return None
95 return self.stamps[-n]
96
97 def worktime(self, start, end):
98 worktime = (datetime.strptime(end, DATETIME_FORMAT) -
99 datetime.strptime(start, DATETIME_FORMAT))
100 return worktime.seconds
101
102 def validate_filter(self, stamp_filter):
103 """
104 Validate a given filter. Filters can have the following notation:
105
106 - %Y-%m-%d: Times recorded at a given date
107
108 - %Y-%m-%d--%Y-%m-%d: Times recorded between two dates
109
110 - *%Y-%m-%d: Times recorded up to a given date
111
112 - %Y-%m-%d*: Times recorded from a given date
113
114 - N...N[d|w|m|y]: Times recorded N...N days/weeks/months/years ago
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
119 """
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)
144
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)
150
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
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)
176
177 return filter_from, filter_to
178
179 @property
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
188 def totals(self, filter_from=None, filter_to=None):
189 totals = {}
190 for stamp in self.stamps:
191 customer = stamp['customer']
192 # customer will be None for "start" stamps, having no end time
193 if customer:
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
204 if customer not in totals:
205 totals[customer] = 0
206 totals[customer] += self.worktime(stamp['start'], stamp['end'])
207 return totals
208
209 def details(self, filter_customer=None, filter_from=None, filter_to=None):
210 details = OrderedDict()
211 totals = OrderedDict()
212 total_customer = OrderedDict()
213 for stamp in self.stamps:
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
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(
239 '%(worktime)s %(customer)s %(action)s' % {
240 'worktime': str(timedelta(seconds=worktime)),
241 'customer': customer,
242 'action': stamp['action']
243 })
244 totals[start_day] += worktime
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
250 for day in totals:
251 totals[day] = str(timedelta(seconds=totals[day]))
252 return details, totals, total_customer
253
254 def timeline(self, customer=None, stamp_filter=None):
255 filter_from, filter_to = self.validate_filter(stamp_filter)
256 for stamp in self.stamps:
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
267
268 if not stamp['customer']:
269 if customer is None:
270 print(stamp['start'] + ' start')
271 else:
272 if customer and customer != stamp['customer']:
273 continue
274 if customer:
275 print(stamp['start'] + ' start')
276 print(' '.join([stamp['end'],
277 stamp['customer'],
278 stamp['action']]))
279
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'
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)
330
331 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
332 sum=False):
333 filter_from, filter_to = self.validate_filter(stamp_filter)
334
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)
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]})
354 print '-'*79
355
356 # now calculate the totals and show them
357 totals = self.totals(filter_from, filter_to)
358 if customer:
359 seconds=totals.get(customer, 0)
360 total = timedelta(seconds=totals.get(customer, 0))
361 print(' %(customer)s: %(total)s' % {'customer': customer,
362 'total': total})
363 else:
364 for c in totals:
365 seconds=totals[c]
366 total = timedelta(seconds=totals[c])
367 print(' %(customer)s: %(total)s' % {'customer': c,
368 'total': total})
369
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 ))
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()
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 """
421 if not os.path.exists(filename):
422 print('[error] ' + filename + 'does not exist')
423 return
424 if os.path.isdir(filename):
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')
437
438 def push_stamps(self, customer=None, stamp_filter=None):
439 filter_from, filter_to = self.validate_filter(stamp_filter)
440
441 stamps = []
442 for stamp in self.stamps:
443 if stamp['customer']:
444 if customer and customer != stamp['customer']:
445 continue
446 start = datetime.strptime(stamp['start'], DATETIME_FORMAT)
447 start_day = start.strftime('%Y-%m-%d')
448 end = datetime.strptime(stamp['end'], DATETIME_FORMAT)
449 if filter_from and start < filter_from:
450 # if there is a filter setting a starting date for the
451 # report and the current stamp is from an earlier date, do
452 # not add it to the totals
453 continue
454 if filter_to and start > filter_to:
455 # similar for the end date
456 continue
457 stamps.append(stamp)
458
459 stamps = json.dumps(stamps, indent=4)
460 http_client = HTTPClient(REMOTE_BASE_URL)
461 http_client.post(REMOTE_LOGIN_URL, {'username': REMOTE_USER,
462 'password': REMOTE_PASSWORD})
463 http_client.post(REMOTE_PUSH_URL, {'stamps': stamps})
Note: See TracBrowser for help on using the repository browser.