source: stamper/stamper/stamper.py@ 67:4303a45e8a2f

Last change on this file since 67:4303a45e8a2f was 67:4303a45e8a2f, checked in by Borja Lopez <borja@…>, 6 years ago

Added a new config parameter, wants_seconds, that tells stamper if it should
show seconds in the reports or not. Defaults to False (do not show seconds)

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