source: stamper/stamper/stamper.py@ 30:b8003f616519

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

Refactored the graphs/charts code:

  • Fixed a bug introduced when we added the date-based filtering
  • Moved the graph/chart code into a separate method, calling only that method when the user wants to generate a graph.
  • Integrated the code a bit better with the new details method.
  • The svg files are generated using the current date and time, adding a -latest.svg symlink so finding the latest one is easier.

Some examples:

  • generate a chart of all the times recorded for all customers:

stamps -g

  • generates a chart of all the times recorded for all customers for the past week:

stamps -g 1w

  • generates a chart of all the times for a given customer since the first day of 2014:

stamps -g customer 2014-01-01*

(see the filters code to learn more about them)

Still some work needed here though, and I've disabled the nice stacking
bars, which should be back with some form of chart types selector
(so users can set on runtime which type of chart they want)

File size: 12.3 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):
51 if not self.stamps:
52 return None
53 return self.stamps[-1]
54
55 def worktime(self, start, end):
56 worktime = (datetime.strptime(end, DATETIME_FORMAT) -
57 datetime.strptime(start, DATETIME_FORMAT))
58 return worktime.seconds
59
60 def validate_filter(self, stamp_filter):
61 """
62 Validate a given filter. Filters can have the following notation:
63
64 - %Y-%m-%d: Times recorded at a given date
65
66 - %Y-%m-%d--%Y-%m-%d: Times recorded between two dates
67
68 - *%Y-%m-%d: Times recorded up to a given date
69
70 - %Y-%m-%d*: Times recorded from a given date
71
72 - N...N[d|w|m|y]: Times recorded N...N days/weeks/months/years ago
73
74 Important: all date comparisons are made on datetime objects, using
75 00:00 as the time (first second of the given day). This means that
76 for range filters, the first day is included, but the second day is not
77 """
78 filter_from = None
79 filter_to = None
80
81 if stamp_filter is None:
82 return filter_from, filter_to
83
84 if '--' in stamp_filter:
85 filter_from, filter_to = stamp_filter.split('--')
86 filter_from = datetime.strptime(filter_from, DATE_FORMAT)
87 filter_to = datetime.strptime(filter_to, DATE_FORMAT)
88
89 elif stamp_filter.startswith('*'):
90 filter_to = datetime.strptime(stamp_filter, '*'+DATE_FORMAT)
91 filter_to = filter_to.replace(hour=0, minute=0, second=0)
92
93 elif stamp_filter.endswith('*'):
94 filter_from = datetime.strptime(stamp_filter, DATE_FORMAT+'*')
95 filter_from = filter_from.replace(hour=0, minute=0, second=0)
96
97 elif re.search(r'(\d+[dD]{1})', stamp_filter):
98 number = int(stamp_filter.lower().replace('d', ''))
99 delta = timedelta(days=number)
100 filter_from = datetime.today() - delta
101 filter_from = filter_from.replace(hour=0, minute=0, second=0)
102
103 elif re.search(r'(\d+[wW]{1})', stamp_filter):
104 number = int(stamp_filter.lower().replace('w', '')) * 7
105 delta = timedelta(days=number)
106 filter_from = datetime.today() - delta
107 filter_from = filter_from.replace(hour=0, minute=0, second=0)
108
109 elif re.search(r'(\d+[mM]{1})', stamp_filter):
110 number = int(stamp_filter.lower().replace('m', ''))
111 past = date.today()
112 # start travelling in time, back to N months ago
113 for n in range(number):
114 past = past.replace(day=1) - timedelta(days=1)
115 # Now use the year/month from the past + the current day to set
116 # the proper date
117 filter_from = datetime(past.year, past.month, date.today().day)
118
119 elif re.search(r'(\d+[yY]{1})', stamp_filter):
120 number = int(stamp_filter.lower().replace('y', ''))
121 today = date.today()
122 filter_from = datetime(today.year - number, today.month, today.day)
123
124 else:
125 # maybe they are giving us a fixed date
126 filter_from = datetime.strptime(stamp_filter, DATE_FORMAT)
127 filter_from = filter_from.replace(hour=0, minute=0, second=0)
128 filter_to = filter_from + timedelta(days=1)
129
130 return filter_from, filter_to
131
132 @property
133 def customers(self):
134 customers = []
135 for stamp in self.stamps:
136 if stamp['customer'] not in customers:
137 customers.append(stamp['customer'])
138 customers.remove(None)
139 return customers
140
141 def totals(self, filter_from=None, filter_to=None):
142 totals = {}
143 for stamp in self.stamps:
144 customer = stamp['customer']
145 # customer will be None for "start" stamps, having no end time
146 if customer:
147 start = datetime.strptime(stamp['start'], DATETIME_FORMAT)
148 end = datetime.strptime(stamp['end'], DATETIME_FORMAT)
149 if filter_from and start < filter_from:
150 # if there is a filter setting a starting date for the
151 # report and the current stamp is from an earlier date, do
152 # not add it to the totals
153 continue
154 if filter_to and start > filter_to:
155 # similar for the end date
156 continue
157 if customer not in totals:
158 totals[customer] = 0
159 totals[customer] += self.worktime(stamp['start'], stamp['end'])
160 return totals
161
162 def details(self, filter_customer=None, filter_from=None, filter_to=None):
163 details = OrderedDict()
164 totals = OrderedDict()
165 total_customer = OrderedDict()
166 for stamp in self.stamps:
167 customer = stamp['customer']
168 if customer:
169 if filter_customer and customer != filter_customer:
170 # we are getting the details for only one customer, if this
171 # stamp is not for that customer, simply move on and ignore
172 # it
173 continue
174 start = datetime.strptime(stamp['start'], DATETIME_FORMAT)
175 start_day = start.strftime('%Y-%m-%d')
176 end = datetime.strptime(stamp['end'], DATETIME_FORMAT)
177 if filter_from and start < filter_from:
178 # if there is a filter setting a starting date for the
179 # report and the current stamp is from an earlier date, do
180 # not add it to the totals
181 continue
182 if filter_to and start > filter_to:
183 # similar for the end date
184 continue
185 # avoid "start" stamps
186 if start_day not in details:
187 details[start_day] = []
188 if start_day not in totals:
189 totals[start_day] = 0
190 worktime = self.worktime(stamp['start'], stamp['end'])
191 details[start_day].append(
192 ' -> %(worktime)s %(customer)s %(action)s' % {
193 'worktime': str(timedelta(seconds=worktime)),
194 'customer': customer,
195 'action': stamp['action']
196 })
197 totals[start_day] += worktime
198 if start_day not in total_customer:
199 total_customer[start_day] = {}
200 if customer not in total_customer[start_day]:
201 total_customer[start_day][customer] = 0
202 total_customer[start_day][customer] += worktime
203 for day in totals:
204 totals[day] = str(timedelta(seconds=totals[day]))
205 return details, totals, total_customer
206
207 def timeline(self):
208 for stamp in self.stamps:
209 customer = stamp['customer']
210 if not stamp['customer']:
211 print(stamp['start'] + ' start')
212 else:
213 print(' '.join([stamp['end'],
214 stamp['customer'],
215 stamp['action']]))
216
217 def graph_stamps(self, customer=None, stamp_filter=None):
218 """
219 Generate charts with information from the stamps
220 """
221 filter_from, filter_to = self.validate_filter(stamp_filter)
222 chart = pygal.Bar(title='Work hours per day',
223 range=(0, HOURS_DAY),
224 x_title='Days',
225 y_title='Work hours',
226 x_label_rotation=45)
227
228 details, totals, totals_customers = self.details(customer,
229 filter_from,
230 filter_to)
231 days = []
232 values = {}
233 for c in self.customers:
234 values[c] = []
235
236 found = []
237
238 for day in details:
239 for c in values:
240 seconds = totals_customers[day].get(c, 0)
241 if seconds and c not in found:
242 found.append(c)
243 human = timedelta(seconds=seconds).__str__()
244 values[c].append({'value': seconds/60.00/60.00,
245 'label': day + ': ' + human})
246 days.append(day)
247 chart.x_labels = map(str, days)
248
249 if customer:
250 chart.add(customer, values[customer])
251 else:
252 for c in found:
253 chart.add(c, values[c])
254
255 chart_name = 'chart-%s.svg' % datetime.today().strftime(
256 '%Y-%m-%d_%H%M%S')
257 chart_symlink = 'chart-latest.svg'
258 chart.render_to_file('graphs/' + chart_name)
259 if islink('graphs/'+ chart_symlink):
260 remove('graphs/'+ chart_symlink)
261 symlink(chart_name, 'graphs/'+ chart_symlink)
262
263 def show_stamps(self, customer=None, stamp_filter=None, verbose=False,
264 sum=False):
265 filter_from, filter_to = self.validate_filter(stamp_filter)
266
267 # If the user asks for verbose information, show it before the
268 # totals (mimicing what the original stamp tool does)
269 if verbose:
270 details, totals, total_customer = self.details(customer,
271 filter_from,
272 filter_to)
273 for day in details:
274 print('------ %(day)s ------' % {'day': day})
275 for line in details[day]:
276 print(line)
277 print(' Total: %(total)s' % {'total': totals[day]})
278 print '-'*79
279
280 # now calculate the totals and show them
281 totals = self.totals(filter_from, filter_to)
282 if customer:
283 seconds=totals.get(customer, 0)
284 total = timedelta(seconds=totals.get(customer, 0))
285 print(' %(customer)s: %(total)s' % {'customer': customer,
286 'total': total})
287 else:
288 for c in totals:
289 seconds=totals[c]
290 total = timedelta(seconds=totals[c])
291 print(' %(customer)s: %(total)s' % {'customer': c,
292 'total': total})
293
294 if sum:
295 sum_tot = ''
296 if totals:
297 print('------ Totals ------' % {'day': day})
298 for day, tot in totals.iteritems():
299 print(' %(day)s: %(total)s' % {'day': day, 'total': tot})
300 sum_tot = "%(total)s %(new)s" % {
301 'total': sum_tot,
302 'new': total
303 }
304 totalSecs, sec = divmod(seconds, 60)
305 hr, min = divmod(totalSecs, 60)
306 totalDays, remaining = divmod(seconds, SECS_DAY)
307 remainingMin, remainingSec = divmod(remaining, (60))
308 remainingHr, remainingMin = divmod(remainingMin, (60))
309 print('----- %d:%02d:%02d -----' % (hr, min, sec))
310 print('--- %d days, remaining: %d:%02d (%d hours/day) ---' % (
311 totalDays, remainingHr, remainingMin, HOURS_DAY
312 ))
Note: See TracBrowser for help on using the repository browser.