source: stamper/stamper/stamper.py@ 32:80a51180155e

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

Added support to delete up to N stamps from the list:

  • Delete the last recorded stamp:

stamp -d 1

  • Delete the last 10 stamps:

stamp -d 10

We ask for confirmation before proceeding with every stamp, in case
the user asks for the removal of multiple stamps, as soon as he does
not remove one of them, the following stamps could not be deleted.

File size: 13.4 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 filter_from = datetime.strptime(stamp_filter, DATE_FORMAT)
131 filter_from = filter_from.replace(hour=0, minute=0, second=0)
132 filter_to = filter_from + timedelta(days=1)
133
134 return filter_from, filter_to
135
136 @property
137 def customers(self):
138 customers = []
139 for stamp in self.stamps:
140 if stamp['customer'] not in customers:
141 customers.append(stamp['customer'])
142 customers.remove(None)
143 return customers
144
145 def totals(self, filter_from=None, filter_to=None):
146 totals = {}
147 for stamp in self.stamps:
148 customer = stamp['customer']
149 # customer will be None for "start" stamps, having no end time
150 if customer:
151 start = datetime.strptime(stamp['start'], DATETIME_FORMAT)
152 end = datetime.strptime(stamp['end'], DATETIME_FORMAT)
153 if filter_from and start < filter_from:
154 # if there is a filter setting a starting date for the
155 # report and the current stamp is from an earlier date, do
156 # not add it to the totals
157 continue
158 if filter_to and start > filter_to:
159 # similar for the end date
160 continue
161 if customer not in totals:
162 totals[customer] = 0
163 totals[customer] += self.worktime(stamp['start'], stamp['end'])
164 return totals
165
166 def details(self, filter_customer=None, filter_from=None, filter_to=None):
167 details = OrderedDict()
168 totals = OrderedDict()
169 total_customer = OrderedDict()
170 for stamp in self.stamps:
171 customer = stamp['customer']
172 if customer:
173 if filter_customer and customer != filter_customer:
174 # we are getting the details for only one customer, if this
175 # stamp is not for that customer, simply move on and ignore
176 # it
177 continue
178 start = datetime.strptime(stamp['start'], DATETIME_FORMAT)
179 start_day = start.strftime('%Y-%m-%d')
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 # avoid "start" stamps
190 if start_day not in details:
191 details[start_day] = []
192 if start_day not in totals:
193 totals[start_day] = 0
194 worktime = self.worktime(stamp['start'], stamp['end'])
195 details[start_day].append(
196 ' -> %(worktime)s %(customer)s %(action)s' % {
197 'worktime': str(timedelta(seconds=worktime)),
198 'customer': customer,
199 'action': stamp['action']
200 })
201 totals[start_day] += worktime
202 if start_day not in total_customer:
203 total_customer[start_day] = {}
204 if customer not in total_customer[start_day]:
205 total_customer[start_day][customer] = 0
206 total_customer[start_day][customer] += worktime
207 for day in totals:
208 totals[day] = str(timedelta(seconds=totals[day]))
209 return details, totals, total_customer
210
211 def timeline(self):
212 for stamp in self.stamps:
213 customer = stamp['customer']
214 if not stamp['customer']:
215 print(stamp['start'] + ' start')
216 else:
217 print(' '.join([stamp['end'],
218 stamp['customer'],
219 stamp['action']]))
220
221 def graph_stamps(self, customer=None, stamp_filter=None):
222 """
223 Generate charts with information from the stamps
224 """
225 filter_from, filter_to = self.validate_filter(stamp_filter)
226 chart = pygal.Bar(title='Work hours per day',
227 range=(0, HOURS_DAY),
228 x_title='Days',
229 y_title='Work hours',
230 x_label_rotation=45)
231
232 details, totals, totals_customers = self.details(customer,
233 filter_from,
234 filter_to)
235 days = []
236 values = {}
237 for c in self.customers:
238 values[c] = []
239
240 found = []
241
242 for day in details:
243 for c in values:
244 seconds = totals_customers[day].get(c, 0)
245 if seconds and c not in found:
246 found.append(c)
247 human = timedelta(seconds=seconds).__str__()
248 values[c].append({'value': seconds/60.00/60.00,
249 'label': day + ': ' + human})
250 days.append(day)
251 chart.x_labels = map(str, days)
252
253 if customer:
254 chart.add(customer, values[customer])
255 else:
256 for c in found:
257 chart.add(c, values[c])
258
259 chart_name = 'chart-%s.svg' % datetime.today().strftime(
260 '%Y-%m-%d_%H%M%S')
261 chart_symlink = 'chart-latest.svg'
262 chart.render_to_file('graphs/' + chart_name)
263 if islink('graphs/'+ chart_symlink):
264 remove('graphs/'+ chart_symlink)
265 symlink(chart_name, 'graphs/'+ chart_symlink)
266
267 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
268 sum=False):
269 filter_from, filter_to = self.validate_filter(stamp_filter)
270
271 # If the user asks for verbose information, show it before the
272 # totals (mimicing what the original stamp tool does)
273 if verbose:
274 details, totals, total_customer = self.details(customer,
275 filter_from,
276 filter_to)
277 for day in details:
278 print('------ %(day)s ------' % {'day': day})
279 for line in details[day]:
280 print(line)
281 print(' Total: %(total)s' % {'total': totals[day]})
282 print '-'*79
283
284 # now calculate the totals and show them
285 totals = self.totals(filter_from, filter_to)
286 if customer:
287 seconds=totals.get(customer, 0)
288 total = timedelta(seconds=totals.get(customer, 0))
289 print(' %(customer)s: %(total)s' % {'customer': customer,
290 'total': total})
291 else:
292 for c in totals:
293 seconds=totals[c]
294 total = timedelta(seconds=totals[c])
295 print(' %(customer)s: %(total)s' % {'customer': c,
296 'total': total})
297
298 if sum:
299 sum_tot = ''
300 if totals:
301 print('------ Totals ------' % {'day': day})
302 for day, tot in totals.iteritems():
303 print(' %(day)s: %(total)s' % {'day': day, 'total': tot})
304 sum_tot = "%(total)s %(new)s" % {
305 'total': sum_tot,
306 'new': total
307 }
308 totalSecs, sec = divmod(seconds, 60)
309 hr, min = divmod(totalSecs, 60)
310 totalDays, remaining = divmod(seconds, SECS_DAY)
311 remainingMin, remainingSec = divmod(remaining, (60))
312 remainingHr, remainingMin = divmod(remainingMin, (60))
313 print('----- %d:%02d:%02d -----' % (hr, min, sec))
314 print('--- %d days, remaining: %d:%02d (%d hours/day) ---' % (
315 totalDays, remainingHr, remainingMin, HOURS_DAY
316 ))
317
318 def remove_stamps(self, n=1):
319 """
320 Remove up to n stamps back, asking for confirmation before delete
321 """
322 for i in range(n):
323 stamp = self.last_stamp()
324 if not stamp['customer']:
325 print(stamp['start'] + ' start')
326 else:
327 print(' '.join([stamp['end'],
328 stamp['customer'],
329 stamp['action']]))
330 confirm = ''
331 while confirm.lower() not in ['y', 'n']:
332 confirm = raw_input('delete stamp? (y/n) ')
333 confirm = confirm.lower()
334 if confirm == 'y':
335 self.stamps.pop()
336 else:
337 # if the user says no to the removal of an stamp, we cannot
338 # keep deleting stamps after that one, as that could leave the
339 # stamps in an inconsistent state.
340 print('Aborting removal of stamps')
341 break
342 self.save_stamps()
Note: See TracBrowser for help on using the repository browser.