source: stamper/stamper/stamper.py@ 43:b086c7d68163

Last change on this file since 43:b086c7d68163 was 43:b086c7d68163, checked in by Borja Lopez <borja@…>, 10 years ago

Put charts into ~/.workstamps-charts by default, ensuring the directory
exists when we create an instance of Stamper.

Cleaned up imports a bit (first imports of std library modules, then
external dependencies)

Use os.path.join to generate the paths to the chart files (instead of
manually generating them concatenating strings)

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