source: stamper/stamper/stamper.py@ 74:2290b564b51e

Last change on this file since 74:2290b564b51e was 74:2290b564b51e, checked in by Borja Lopez <borja@…>, 5 years ago

Replaced python-2 raw_input() with python-3 input()

File size: 18.2 KB
RevLine 
[0]1
2import json
[43]3import os.path
[63]4from datetime import datetime, timedelta
[43]5from os import symlink, remove, makedirs
[19]6from collections import OrderedDict
[37]7from operator import itemgetter
[43]8import pygal
[0]9
[56]10from .http import HTTPClient
[58]11from .config import Config
[63]12from .filters import DateFilter
[0]13
14
15class Stamper(object):
16
[58]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')
[67]26 self.wants_seconds = self.config.get('stamps', 'wants_seconds')
[58]27 self.hours_day = int(self.config.get('sum', 'hours_day'))
28 self.collector = self.config.get('collector')
[0]29 self.ensure_stamps_file()
[43]30 self.ensure_charts_dir()
[0]31 self.stamps = []
[63]32 self.date_filter = DateFilter(self.date_format)
[0]33
[37]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
[0]54 def ensure_stamps_file(self):
[43]55 if not os.path.exists(self.stamps_file):
[0]56 with open(self.stamps_file, 'w') as stamps_file:
57 stamps_file.write('')
58
[43]59 def ensure_charts_dir(self):
60 if not os.path.exists(self.charts_dir):
61 makedirs(self.charts_dir)
62
[0]63 def load_stamps(self):
[37]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'))
[0]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
[32]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 """
[0]89 if not self.stamps:
90 return None
[32]91 return self.stamps[-n]
[0]92
93 def worktime(self, start, end):
[58]94 worktime = (datetime.strptime(end, self.datetime_format) -
95 datetime.strptime(start, self.datetime_format))
[0]96 return worktime.seconds
97
[30]98 @property
[0]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
[61]107 def totals(self, filter_from=None, filter_to=None, filter_descr=None):
[8]108 totals = {}
109 for stamp in self.stamps:
110 customer = stamp['customer']
[26]111 # customer will be None for "start" stamps, having no end time
[8]112 if customer:
[58]113 start = datetime.strptime(stamp['start'], self.datetime_format)
114 end = datetime.strptime(stamp['end'], self.datetime_format)
[26]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
[61]123 if filter_descr and filter_descr not in stamp['action']:
124 continue
[8]125 if customer not in totals:
126 totals[customer] = 0
127 totals[customer] += self.worktime(stamp['start'], stamp['end'])
128 return totals
129
[61]130 def details(self, filter_customer=None, filter_from=None, filter_to=None,
131 filter_descr=None):
[19]132 details = OrderedDict()
133 totals = OrderedDict()
[23]134 total_customer = OrderedDict()
[0]135 for stamp in self.stamps:
[26]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
[58]143 start = datetime.strptime(stamp['start'], self.datetime_format)
[26]144 start_day = start.strftime('%Y-%m-%d')
[58]145 end = datetime.strptime(stamp['end'], self.datetime_format)
[26]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
[61]154 if filter_descr and filter_descr not in stamp['action']:
155 continue
[8]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'])
[67]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])
[8]166 details[start_day].append(
[39]167 '%(worktime)s %(customer)s %(action)s' % {
[67]168 'worktime': str_worktime,
[26]169 'customer': customer,
[8]170 'action': stamp['action']
171 })
172 totals[start_day] += worktime
[23]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
[8]178 for day in totals:
179 totals[day] = str(timedelta(seconds=totals[day]))
[67]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])
[23]183 return details, totals, total_customer
[0]184
[61]185 def timeline(self, customer=None, stamp_filter=None, filter_descr=None):
[63]186 filter_from, filter_to = self.date_filter.validate(stamp_filter)
[28]187 for stamp in self.stamps:
[58]188 start = datetime.strptime(stamp['start'], self.datetime_format)
[38]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
[61]198 if filter_descr and stamp['action']:
199 if filter_descr not in stamp['action']:
200 continue
[28]201 if not stamp['customer']:
[40]202 if customer is None:
203 print(stamp['start'] + ' start')
[28]204 else:
[40]205 if customer and customer != stamp['customer']:
206 continue
207 if customer:
208 print(stamp['start'] + ' start')
[28]209 print(' '.join([stamp['end'],
210 stamp['customer'],
211 stamp['action']]))
212
[61]213 def graph_stamps(self, customer=None, stamp_filter=None, filter_descr=None):
[30]214 """
215 Generate charts with information from the stamps
216 """
[63]217 filter_from, filter_to = self.date_filter.validate(stamp_filter)
[30]218 chart = pygal.Bar(title='Work hours per day',
[58]219 range=(0, self.hours_day),
[30]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,
[61]226 filter_to,
227 filter_descr)
[30]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'
[43]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)
[30]264
[22]265 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
[61]266 sum=False, filter_descr=None):
[63]267 filter_from, filter_to = self.date_filter.validate(stamp_filter)
[9]268
[26]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,
[61]274 filter_to,
275 filter_descr)
[26]276 for day in details:
277 print('------ %(day)s ------' % {'day': day})
278 for line in details[day]:
279 print(line)
[39]280 customer_day_totals = []
281 for tc in total_customer[day]:
282 tc_total = str(timedelta(seconds=total_customer[day][tc]))
[67]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])
[69]286 # ensure hours show double digits
287 tc_total = tc_total.zfill(5)
[39]288 customer_day_totals.append(tc+': '+tc_total)
289 print(', '.join(customer_day_totals))
290 if len(customer_day_totals) > 1:
291 # if there are multiple customers in the report, show the
292 # daily totals
293 print('daily total: %(total)s' % {'total': totals[day]})
[71]294 print('-'*79)
[9]295
[26]296 # now calculate the totals and show them
[61]297 totals = self.totals(filter_from, filter_to, filter_descr)
[9]298 if customer:
[22]299 seconds=totals.get(customer, 0)
[23]300 total = timedelta(seconds=totals.get(customer, 0))
[67]301 if not self.wants_seconds or self.wants_seconds == 'false':
302 # remove the seconds part from the string representation
303 total = ':'.join(str(total).split(':')[:-1])
[9]304 print(' %(customer)s: %(total)s' % {'customer': customer,
305 'total': total})
306 else:
307 for c in totals:
[22]308 seconds=totals[c]
[23]309 total = timedelta(seconds=totals[c])
[67]310 if not self.wants_seconds or self.wants_seconds == 'false':
311 # remove the seconds part from the string representation
312 total = ':'.join(str(total).split(':')[:-1])
[9]313 print(' %(customer)s: %(total)s' % {'customer': c,
314 'total': total})
315
[22]316 if sum:
317 sum_tot = ''
318 if totals:
319 print('------ Totals ------' % {'day': day})
[71]320 for day, tot in totals.items():
[22]321 print(' %(day)s: %(total)s' % {'day': day, 'total': tot})
322 sum_tot = "%(total)s %(new)s" % {
323 'total': sum_tot,
324 'new': total
325 }
326 totalSecs, sec = divmod(seconds, 60)
327 hr, min = divmod(totalSecs, 60)
[58]328 totalDays, remaining = divmod(seconds,
329 (self.hours_day * 60 * 60))
[22]330 remainingMin, remainingSec = divmod(remaining, (60))
331 remainingHr, remainingMin = divmod(remainingMin, (60))
332 print('----- %d:%02d:%02d -----' % (hr, min, sec))
333 print('--- %d days, remaining: %d:%02d (%d hours/day) ---' % (
[58]334 totalDays, remainingHr, remainingMin, self.hours_day
[22]335 ))
[32]336
337 def remove_stamps(self, n=1):
338 """
339 Remove up to n stamps back, asking for confirmation before delete
340 """
341 for i in range(n):
342 stamp = self.last_stamp()
343 if not stamp['customer']:
344 print(stamp['start'] + ' start')
345 else:
346 print(' '.join([stamp['end'],
347 stamp['customer'],
348 stamp['action']]))
349 confirm = ''
350 while confirm.lower() not in ['y', 'n']:
[74]351 confirm = input('delete stamp? (y/n) ')
[32]352 confirm = confirm.lower()
353 if confirm == 'y':
354 self.stamps.pop()
355 else:
356 # if the user says no to the removal of an stamp, we cannot
357 # keep deleting stamps after that one, as that could leave the
358 # stamps in an inconsistent state.
359 print('Aborting removal of stamps')
360 break
361 self.save_stamps()
[37]362
363 def import_stamps(self, filename):
364 """
365 Import the stamps from the given file into the main stamps list,
366 merging them into the list (removing duplicated entries)
367 """
[43]368 if not os.path.exists(filename):
[37]369 print('[error] ' + filename + 'does not exist')
370 return
[43]371 if os.path.isdir(filename):
[37]372 print('[error] ' + filename + 'is a directory')
373 return
374 stamps = self.__json_load(filename)
375 if not stamps:
376 print('[warning] no stamps can be imported from ' + filename)
377 return
378 self.stamps.extend(stamps)
379 self.remove_duplicates()
380 self.sort_stamps()
381 self.save_stamps()
382 print('[warning] ' + str(len(stamps)) + ' stamps merged')
383 print('[warning] remember to review the resulting stamps file')
[52]384
[61]385 def push_stamps(self, customer=None, stamp_filter=None, filter_descr=None):
[63]386 filter_from, filter_to = self.date_filter.validate(stamp_filter)
[54]387
[52]388 stamps = []
389 for stamp in self.stamps:
390 if stamp['customer']:
391 if customer and customer != stamp['customer']:
392 continue
[58]393 start = datetime.strptime(stamp['start'], self.datetime_format)
[52]394 start_day = start.strftime('%Y-%m-%d')
[58]395 end = datetime.strptime(stamp['end'], self.datetime_format)
[52]396 if filter_from and start < filter_from:
397 # if there is a filter setting a starting date for the
398 # report and the current stamp is from an earlier date, do
399 # not add it to the totals
400 continue
401 if filter_to and start > filter_to:
402 # similar for the end date
403 continue
[61]404 if filter_descr and filter_descr not in stamp['action']:
405 continue
[52]406 stamps.append(stamp)
[54]407
[52]408 stamps = json.dumps(stamps, indent=4)
[58]409 http_client = HTTPClient(self.collector['base_url'])
[60]410 response = http_client.post(self.collector['login_path'],
411 {'username': self.collector['user'],
412 'password': self.collector['password']})
413 # response is a json-encoded list of end-points from the collector api
414 try:
415 api = json.loads(response)
416 except ValueError:
417 print('[error] No valid api end points can be retrieved')
418 return
419
420 response = http_client.post(api['push'], {'stamps': stamps})
421
422 # response is a json-encoded dict, containing lists of added, updated
423 # and deleted stamps (on the other side)
424 try:
425 results = json.loads(response)
426 except ValueError:
427 print('[error] stamps pushed, can not retrieve results information')
428 return
429
430 # display information
431 for k in results.keys():
432 print('%(stamps)d stamps %(action)s' % {'stamps': len(results[k]),
433 'action': k})
Note: See TracBrowser for help on using the repository browser.