forked from aloisiojr/timesheet-generator
-
Notifications
You must be signed in to change notification settings - Fork 0
/
timesheet-generator.py
executable file
·344 lines (275 loc) · 12.9 KB
/
timesheet-generator.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
#!/usr/bin/python
import sys
import argparse
import re
import random
from datetime import timedelta, datetime, time, date
from random import randint
class TimeOfDay(time):
def __new__(cls, hour=0, minute=0, second=0, microsecond=0):
self = time.__new__(cls, hour, minute, second,
microsecond, None)
return self
def __add__(self, other):
"Add TimeOfDay and timedelta"
if not isinstance(other, timedelta):
return NotImplemented
delta = timedelta(hours=self.hour, minutes=self.minute,
seconds=self.second,
microseconds=self.microsecond)
delta += other
if delta.days != 0:
raise OverflowError("result out of range")
hour, rem = divmod(delta.seconds, 3600)
minute, second = divmod(rem, 60)
return TimeOfDay(hour, minute, second, delta.microseconds)
__radd__ = __add__
def __sub__(self, other):
"Subtract two TimeOfDays or a TimeOfDay and a timedelta"
if isinstance(other, timedelta):
return self + -other
if not isinstance(other, TimeOfDay):
return NotImplemented
if other > self:
raise OverflowError("result out of range")
delta_self = timedelta(hours=self.hour, minutes=self.minute,
seconds=self.second,
microseconds=self.microsecond)
delta_other = timedelta(hours=other.hour, minutes=other.minute,
seconds=other.second,
microseconds=other.microsecond)
return delta_self - delta_other
class Calendar:
def __init__(self, firstday, days, holiday_list):
self._firstday = firstday
self._days = days
self._holiday_list = holiday_list
def is_weekend(self, day):
return day.weekday() == 5 or day.weekday() == 6
def is_holiday(self, day):
return day in self._holiday_list
def worked_days(self):
total = 0
day = self._firstday
for i in range(self._days):
if not self.is_weekend(day) and not self.is_holiday(day):
total += 1
day += timedelta(days=1)
return total
MAX_CLOCK_OUT_TIME = TimeOfDay(hour=22)
MAX_WORKING_TIME_PER_DAY = timedelta(hours=10, minutes=0)
class Timesheet:
_max_clockout = MAX_CLOCK_OUT_TIME
_max_working_time = MAX_WORKING_TIME_PER_DAY
_table = []
def __init__(self, lunch_break, lunch_duration, earlier_clockin,
later_clockin, lunch_variation, daily_worktime, rounded):
self._lunch_break = lunch_break
self._lunch_duration = lunch_duration
self._earlier_clockin = earlier_clockin
self._later_clockin = later_clockin
self._lunch_variation = lunch_variation
self._daily_worktime = daily_worktime
self._rounded = rounded
def _generate_day(self, working_time):
clockin = random_time(self._earlier_clockin, self._later_clockin, self._rounded)
clockout = clockin + self._lunch_duration + working_time
if clockout > self._max_clockout:
delta = clockout - self._max_clockout
clockin -= delta # No problem if clockin < _earlier_clockin
clockout = self._max_clockout
variation = datetime.strptime(self._lunch_variation, "%H:%M")
lunch_break = random_time(self._lunch_break, self._lunch_break+timedelta(minutes=30), self._rounded)
clockin = random_time(self._earlier_clockin, self._later_clockin, self._rounded)
return [clockin, lunch_break, self._lunch_duration, clockout]
def generate(self, worked_days, balance):
worked_time = worked_days * self._daily_worktime + balance
# In order to have an integer average_worked value, we have to remove
# some minutes from worked_time variable. These are the remaining_min.
# They will be added to working time progressively, one per day
remaining_min = (worked_time.total_seconds() / 60) % worked_days
average_worked = ((worked_time - timedelta(minutes=remaining_min)) /
worked_days)
# Warn the user that it's impossible to prevent exceed the max
# working time
overloaded = False
if (worked_time / worked_days) > self._max_working_time:
overloaded = True
print("WARNING: Your average working time is higher than the \n" +
" max allowed working time. Check the spreadsheet!\n")
# The average_worked = (min_working_time + self._max_working_time) / 2
# in order to calculate the second day worked time without exceed the
# max_working_time.
min_working_time = (self._max_working_time -
((self._max_working_time - average_worked) * 2))
# You should not work that much...
if min_working_time > self._max_working_time:
x = min_working_time
min_working_time = self._max_working_time
self._max_working_time = x
# Generate 2 days per iteraction
for i in range((int)(worked_days / 2)):
# The first day has a random working time
worked_day1 = random_time(min_working_time, self._max_working_time, self._rounded)
# The following day has the complement to keep the average
worked_day2 = (2 * average_worked) - worked_day1
if remaining_min and (overloaded or worked_day1 < self._max_working_time):
worked_day1 += timedelta(minutes=1)
remaining_min -= 1
self._table.append(self._generate_day(worked_day1))
if remaining_min and (overloaded or worked_day2 < self._max_working_time):
worked_day2 += timedelta(minutes=1)
remaining_min -= 1
self._table.append(self._generate_day(worked_day2))
# If odd number of days, the last day has the average working time
if worked_days % 2 == 1:
self._table.append(self._generate_day(average_worked))
def pop(self):
return tuple(self._table.pop())
# def print_worked_day(clockin, lunch_break, lunch_break_duration, clockout, ftime="%I:%M:%S %p"):
# TODO: add option to change format
def print_worked_day(clockin, lunch_break, lunch_break_duration, clockout, is_csv=False):
ftime = "%H:%M"
back_from_lunch = lunch_break + lunch_break_duration
if is_csv:
print("%s,%s,%s,%s" % (clockin.strftime(ftime),
lunch_break.strftime(ftime),
back_from_lunch.strftime(ftime),
clockout.strftime(ftime)))
else:
print("%s\t%s\t%s\t%s" % (clockin.strftime(ftime),
lunch_break.strftime(ftime),
back_from_lunch.strftime(ftime),
clockout.strftime(ftime)))
def print_holiday(is_csv):
if is_csv:
print("x,x,x")
else:
print("x\t\t\t")
def print_weekend_day(is_csv=False):
if is_csv:
print(",,,")
else:
print("\t\t\t")
def trunc_to_interval(num, min_, max_):
if num < min_:
num = min_
elif num > max_:
num = max_
return num
def random_time(min_, max_, rounded=False):
delta_max = (max_ - min_).total_seconds() / 60
mu = delta_max / 2
sigma = delta_max / 10
delta = trunc_to_interval(int(random.gauss(mu, sigma)), 0, delta_max)
if rounded:
"""
Rounds all randoms to 5min
"""
mu /= 5
sigma /= 5
delta = trunc_to_interval(int(random.gauss(mu, sigma)), 0, delta_max)
delta *= 5
return min_ + timedelta(minutes=delta)
def parseSignedTimeArg(time):
timeFormat = re.compile('^[pn]\d{1,2}:\d{2}$')
if not timeFormat.match(time):
raise argparse.ArgumentTypeError("Wrong time format!")
return time
def parseTimeArg(time):
timeFormat = re.compile('^\d{1,2}:\d{2}$')
if not timeFormat.match(time):
raise argparse.ArgumentTypeError("Wrong time format!")
return time
def parseDateArg(date):
dateFormat = re.compile('^\d{1,2}/\d{1,2}/\d{2}$')
if not dateFormat.match(date):
raise argparse.ArgumentTypeError("Wrong date format!")
return date
def parseDateListArg(dateList):
for date in dateList.split(','):
parseDateArg(date)
return dateList
def parse_args(args):
if len(args) < 1:
args.append('-h')
parser = argparse.ArgumentParser(description="Generate timesheet table " +
"based on the worked days and the desired balance",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("firstday", metavar="DD/MM/YY", type=parseDateArg,
help="First day on the timesheet table")
parser.add_argument("totaldays", metavar="NUM_DAYS", type=int,
help="Number of days on the timesheet table " +
"including weekends and holidays")
parser.add_argument("--balance", metavar="(p|n)HH:MM",
type=parseSignedTimeArg, help="Desired timesheet " +
"balance. Use prefix 'p' for positive balance and " +
"'n' for negative balance")
parser.add_argument("--holiday-list", metavar="DD/MM/YY[,DD/MM/YY[...]]",
type=parseDateListArg, help="List of holidays")
parser.add_argument("--lunch-break", metavar="HH:MM", type=parseTimeArg,
default="11:30", help="Lunch time")
parser.add_argument("--lunch-variation", metavar="HH:MM", type=parseTimeArg,
default="00:30", help="Lunch start variation")
parser.add_argument("--lunch-duration", metavar="N", type=int, default=60,
help="Lunch duration in minutes")
parser.add_argument("--earlier-clockin-time", metavar="HH:MM",
type=parseTimeArg, default="9:00", help="Earlier " +
"time for clock-in")
parser.add_argument("--later-clockin-time", metavar="HH:MM",
type=parseTimeArg, default="10:00", help="Later time " +
"for clock-in")
parser.add_argument("--daily-worktime", metavar="HH:MM", type=parseTimeArg,
default="8:00", help="Daily Worktime")
parser.add_argument('--csv', dest='csv', action='store_true')
parser.add_argument('--no-csv', dest='csv', action='store_false')
parser.add_argument('--rounded', dest='rounded', action='store_true')
parser.add_argument('--no-rounded', dest='rounded', action='store_false')
parser.set_defaults(csv=False)
parser.set_defaults(rounded=True)
return parser.parse_args(args)
def main():
args = parse_args(sys.argv[1:])
# TODO: refactory below
# Need to extract this code to another area, not the main()
firstday = datetime.strptime(args.firstday, "%d/%m/%y")
totaldays = int(args.totaldays)
holiday_list = []
if args.holiday_list:
for holiday in args.holiday_list.split(','):
holiday_list.append(datetime.strptime(holiday, "%d/%m/%y"))
(h, m) = args.lunch_break.split(':')
lunch_break = TimeOfDay(int(h), int(m))
lunch_break_duration = timedelta(minutes=args.lunch_duration)
(h, m) = args.earlier_clockin_time.split(':')
earlier_clockin = TimeOfDay(int(h), int(m))
(h, m) = args.later_clockin_time.split(':')
later_clockin = TimeOfDay(int(h), int(m))
daily_worktime = datetime.strptime(args.daily_worktime, "%H:%M")
daily_worktime = timedelta(hours=daily_worktime.hour,
minutes=daily_worktime.minute)
balance = timedelta()
if args.balance:
signal = 1 if args.balance[0] == 'p' else -1
(balance_hours, balance_minutes) = args.balance[1:].split(':')
balance = signal * timedelta(hours=int(balance_hours),
minutes=int(balance_minutes))
# TODO: refactory above
calendar = Calendar(firstday, totaldays, holiday_list)
timesheet = Timesheet(lunch_break, lunch_break_duration, earlier_clockin,
later_clockin, args.lunch_variation, daily_worktime, args.rounded)
timesheet.generate(calendar.worked_days(), balance)
# TODO: refactory how timesheet is printed, should be a to_string method
# instead of this
day = firstday
for i in range(totaldays):
if calendar.is_holiday(day):
print_holiday(args.csv)
elif calendar.is_weekend(day):
print_weekend_day(args.csv)
else:
(clockin, lunch, lunch_dur, clockout) = timesheet.pop()
print_worked_day(clockin, lunch, lunch_dur, clockout, args.csv)
day += timedelta(days=1)
if __name__ == "__main__":
main()