source: stamper/stamper/stamper.py@ 63:76140cb24ebd

Last change on this file since 63:76140cb24ebd was 63:76140cb24ebd, checked in by Borja Lopez <borja@…>, 3 years ago

Refactored date-based filters to their own module.

Added a new date-based filter to look for stamps N days/weeks/months/years
*after* a given date, for example:

"2017-06-29+4d" == "four days after the 29th june 2017"
"2017-06-29+1m" == "1 month after the 29th june 2017"

and so on.

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