source: stamper/stamper/stamper.py@ 58:b24ccde3e229

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

Use a config file for the setup of certain parameters, instead of
hardcoded global variables (ugh!)

File size: 18.5 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
12from .config import Config
13
14
15class Stamper(object):
16
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')
28 self.ensure_stamps_file()
29 self.ensure_charts_dir()
30 self.stamps = []
31
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
52 def ensure_stamps_file(self):
53 if not os.path.exists(self.stamps_file):
54 with open(self.stamps_file, 'w') as stamps_file:
55 stamps_file.write('')
56
57 def ensure_charts_dir(self):
58 if not os.path.exists(self.charts_dir):
59 makedirs(self.charts_dir)
60
61 def load_stamps(self):
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'))
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
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 """
87 if not self.stamps:
88 return None
89 return self.stamps[-n]
90
91 def worktime(self, start, end):
92 worktime = (datetime.strptime(end, self.datetime_format) -
93 datetime.strptime(start, self.datetime_format))
94 return worktime.seconds
95
96 def validate_filter(self, stamp_filter):
97 """
98 Validate a given filter. Filters can have the following notation:
99
100 - %Y-%m-%d: Times recorded at a given date
101
102 - %Y-%m-%d--%Y-%m-%d: Times recorded between two dates
103
104 - *%Y-%m-%d: Times recorded up to a given date
105
106 - %Y-%m-%d*: Times recorded from a given date
107
108 - N...N[d|w|m|y]: Times recorded N...N days/weeks/months/years ago
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
113 """
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('--')
122 filter_from = datetime.strptime(filter_from, self.date_format)
123 filter_to = datetime.strptime(filter_to, self.date_format)
124
125 elif stamp_filter.startswith('*'):
126 filter_to = datetime.strptime(stamp_filter, '*'+self.date_format)
127 filter_to = filter_to.replace(hour=0, minute=0, second=0)
128
129 elif stamp_filter.endswith('*'):
130 filter_from = datetime.strptime(stamp_filter, self.date_format+'*')
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)
138
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)
144
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
162 try:
163 filter_from = datetime.strptime(stamp_filter, self.date_format)
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)
170
171 return filter_from, filter_to
172
173 @property
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
182 def totals(self, filter_from=None, filter_to=None):
183 totals = {}
184 for stamp in self.stamps:
185 customer = stamp['customer']
186 # customer will be None for "start" stamps, having no end time
187 if customer:
188 start = datetime.strptime(stamp['start'], self.datetime_format)
189 end = datetime.strptime(stamp['end'], self.datetime_format)
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
198 if customer not in totals:
199 totals[customer] = 0
200 totals[customer] += self.worktime(stamp['start'], stamp['end'])
201 return totals
202
203 def details(self, filter_customer=None, filter_from=None, filter_to=None):
204 details = OrderedDict()
205 totals = OrderedDict()
206 total_customer = OrderedDict()
207 for stamp in self.stamps:
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
215 start = datetime.strptime(stamp['start'], self.datetime_format)
216 start_day = start.strftime('%Y-%m-%d')
217 end = datetime.strptime(stamp['end'], self.datetime_format)
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
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(
233 '%(worktime)s %(customer)s %(action)s' % {
234 'worktime': str(timedelta(seconds=worktime)),
235 'customer': customer,
236 'action': stamp['action']
237 })
238 totals[start_day] += worktime
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
244 for day in totals:
245 totals[day] = str(timedelta(seconds=totals[day]))
246 return details, totals, total_customer
247
248 def timeline(self, customer=None, stamp_filter=None):
249 filter_from, filter_to = self.validate_filter(stamp_filter)
250 for stamp in self.stamps:
251 start = datetime.strptime(stamp['start'], self.datetime_format)
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
261
262 if not stamp['customer']:
263 if customer is None:
264 print(stamp['start'] + ' start')
265 else:
266 if customer and customer != stamp['customer']:
267 continue
268 if customer:
269 print(stamp['start'] + ' start')
270 print(' '.join([stamp['end'],
271 stamp['customer'],
272 stamp['action']]))
273
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',
280 range=(0, self.hours_day),
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'
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)
324
325 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
326 sum=False):
327 filter_from, filter_to = self.validate_filter(stamp_filter)
328
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)
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]})
348 print '-'*79
349
350 # now calculate the totals and show them
351 totals = self.totals(filter_from, filter_to)
352 if customer:
353 seconds=totals.get(customer, 0)
354 total = timedelta(seconds=totals.get(customer, 0))
355 print(' %(customer)s: %(total)s' % {'customer': customer,
356 'total': total})
357 else:
358 for c in totals:
359 seconds=totals[c]
360 total = timedelta(seconds=totals[c])
361 print(' %(customer)s: %(total)s' % {'customer': c,
362 'total': total})
363
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)
376 totalDays, remaining = divmod(seconds,
377 (self.hours_day * 60 * 60))
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) ---' % (
382 totalDays, remainingHr, remainingMin, self.hours_day
383 ))
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']:
399 confirm = input('delete stamp? (y/n) ')
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()
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 """
416 if not os.path.exists(filename):
417 print('[error] ' + filename + 'does not exist')
418 return
419 if os.path.isdir(filename):
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')
432
433 def push_stamps(self, customer=None, stamp_filter=None):
434 filter_from, filter_to = self.validate_filter(stamp_filter)
435
436 stamps = []
437 for stamp in self.stamps:
438 if stamp['customer']:
439 if customer and customer != stamp['customer']:
440 continue
441 start = datetime.strptime(stamp['start'], self.datetime_format)
442 start_day = start.strftime('%Y-%m-%d')
443 end = datetime.strptime(stamp['end'], self.datetime_format)
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)
453
454 stamps = json.dumps(stamps, indent=4)
455 http_client = HTTPClient(self.collector['base_url'])
456 http_client.post(self.collector['login_path'],
457 {'username': self.collector['user'],
458 'password': self.collector['password']})
459 http_client.post(self.collector['listen_path'], {'stamps': stamps})
Note: See TracBrowser for help on using the repository browser.