source: stamper/stamper/stamper.py@ 41:dfe43abd388a

Last change on this file since 41:dfe43abd388a was 40:5d592e2950a1, checked in by Borja Lopez <borja@…>, 9 years ago

Added customer filtering support to the timeline feature:

  • Get the timeline for the customer customA for the last week:

stamps -t customA 1w

  • Get the timeline for the customer customA for august, 2013:

stamps -t customA 2013-08-01--2013-09-01

  • Get all timeline for customer customA:

stamps -t customA

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