source: stamper/stamper/stamper.py@ 36:b78fa8f898ea

Last change on this file since 36:b78fa8f898ea was 36:b78fa8f898ea, checked in by Borja Lopez <borja@…>, 7 years ago

If a provided date-based filter is not valid, show a warning message
and ignore the filtering, instead of raising an error.

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