scriptplan 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scriptplan/__init__.py +22 -0
- scriptplan/cli/__init__.py +7 -0
- scriptplan/cli/main.py +546 -0
- scriptplan/core/__init__.py +0 -0
- scriptplan/core/account.py +125 -0
- scriptplan/core/allocation.py +69 -0
- scriptplan/core/booking.py +39 -0
- scriptplan/core/journal.py +377 -0
- scriptplan/core/leave.py +14 -0
- scriptplan/core/limits.py +354 -0
- scriptplan/core/project.py +924 -0
- scriptplan/core/property.py +1290 -0
- scriptplan/core/resource.py +198 -0
- scriptplan/core/resource_scenario.py +711 -0
- scriptplan/core/scenario.py +5 -0
- scriptplan/core/scenario_data.py +39 -0
- scriptplan/core/shift.py +71 -0
- scriptplan/core/task.py +77 -0
- scriptplan/core/task_scenario.py +1515 -0
- scriptplan/core/timesheet.py +457 -0
- scriptplan/core/working_hours.py +231 -0
- scriptplan/parser/__init__.py +0 -0
- scriptplan/parser/macro_processor.py +264 -0
- scriptplan/parser/tjp.lark +412 -0
- scriptplan/parser/tjp_parser.py +1904 -0
- scriptplan/py.typed +0 -0
- scriptplan/report/__init__.py +75 -0
- scriptplan/report/html_generator.py +477 -0
- scriptplan/report/report.py +466 -0
- scriptplan/report/report_base.py +397 -0
- scriptplan/report/report_context.py +248 -0
- scriptplan/report/resource_report.py +341 -0
- scriptplan/report/table_report.py +693 -0
- scriptplan/report/task_report.py +362 -0
- scriptplan/report/text_report.py +172 -0
- scriptplan/scheduler/__init__.py +0 -0
- scriptplan/scheduler/batch_processor.py +238 -0
- scriptplan/scheduler/scoreboard.py +120 -0
- scriptplan/utils/__init__.py +0 -0
- scriptplan/utils/data_cache.py +46 -0
- scriptplan/utils/logger.py +243 -0
- scriptplan/utils/message_handler.py +515 -0
- scriptplan/utils/time.py +195 -0
- scriptplan-0.9.0.dist-info/METADATA +161 -0
- scriptplan-0.9.0.dist-info/RECORD +49 -0
- scriptplan-0.9.0.dist-info/WHEEL +5 -0
- scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
- scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
- scriptplan-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""TimeSheets module for tracking work reports.
|
|
2
|
+
|
|
3
|
+
Contains TimeSheetRecord, TimeSheet, and TimeSheets classes for
|
|
4
|
+
managing time tracking and work reporting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional, Union, Any
|
|
8
|
+
from scriptplan.utils.message_handler import MessageHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TimeSheetRecord(MessageHandler):
|
|
12
|
+
"""Holds work-related bits of a time sheet specific to a single Task.
|
|
13
|
+
|
|
14
|
+
For effort-based tasks, stores the remaining effort.
|
|
15
|
+
For other tasks, stores the expected end date.
|
|
16
|
+
For all tasks, stores the completed work during the reporting time frame.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, time_sheet: 'TimeSheet', task):
|
|
20
|
+
"""Create a new TimeSheetRecord.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
time_sheet: The TimeSheet this record belongs to.
|
|
24
|
+
task: Task object for existing tasks or ID string for new tasks.
|
|
25
|
+
"""
|
|
26
|
+
self._task = task
|
|
27
|
+
self._time_sheet = time_sheet
|
|
28
|
+
time_sheet.add_record(self)
|
|
29
|
+
|
|
30
|
+
self._work: Optional[int] = None # Measured in time slots
|
|
31
|
+
self._remaining: Optional[int] = None # Measured in time slots
|
|
32
|
+
self._expected_end = None
|
|
33
|
+
self._name: Optional[str] = None # For new tasks
|
|
34
|
+
self._status = None # JournalEntry reference
|
|
35
|
+
self._priority: int = 0
|
|
36
|
+
self.sourceFileInfo = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def task(self):
|
|
40
|
+
return self._task
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def work(self) -> Optional[int]:
|
|
44
|
+
return self._work
|
|
45
|
+
|
|
46
|
+
@work.setter
|
|
47
|
+
def work(self, value):
|
|
48
|
+
"""Set work value. Integer is slots, Float is percentage (0.0-1.0)."""
|
|
49
|
+
if isinstance(value, int):
|
|
50
|
+
self._work = value
|
|
51
|
+
else:
|
|
52
|
+
# Percentage value
|
|
53
|
+
self._work = self._time_sheet.percentToSlots(value)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def remaining(self) -> Optional[int]:
|
|
57
|
+
return self._remaining
|
|
58
|
+
|
|
59
|
+
@remaining.setter
|
|
60
|
+
def remaining(self, value):
|
|
61
|
+
self._remaining = value
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def expectedEnd(self):
|
|
65
|
+
return self._expected_end
|
|
66
|
+
|
|
67
|
+
@expectedEnd.setter
|
|
68
|
+
def expectedEnd(self, value):
|
|
69
|
+
self._expected_end = value
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def status(self):
|
|
73
|
+
return self._status
|
|
74
|
+
|
|
75
|
+
@status.setter
|
|
76
|
+
def status(self, value):
|
|
77
|
+
self._status = value
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def priority(self) -> int:
|
|
81
|
+
return self._priority
|
|
82
|
+
|
|
83
|
+
@priority.setter
|
|
84
|
+
def priority(self, value):
|
|
85
|
+
self._priority = value
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def name(self) -> Optional[str]:
|
|
89
|
+
return self._name
|
|
90
|
+
|
|
91
|
+
@name.setter
|
|
92
|
+
def name(self, value):
|
|
93
|
+
self._name = value
|
|
94
|
+
|
|
95
|
+
def check(self):
|
|
96
|
+
"""Perform consistency checks on the record."""
|
|
97
|
+
scIdx = self._time_sheet.scenarioIdx
|
|
98
|
+
taskId = self.taskId
|
|
99
|
+
|
|
100
|
+
# All records must have a 'work' attribute
|
|
101
|
+
if self._work is None:
|
|
102
|
+
self.error('ts_no_work',
|
|
103
|
+
f"The time sheet record for task {taskId} must "
|
|
104
|
+
"have a 'work' attribute to specify how much was done "
|
|
105
|
+
"for this task during the reported period.")
|
|
106
|
+
|
|
107
|
+
# Check if task is an existing Task object or a string ID
|
|
108
|
+
if hasattr(self._task, 'fullId'):
|
|
109
|
+
# Existing task
|
|
110
|
+
effort = self._task.get('effort', scIdx) if hasattr(self._task, 'get') else 0
|
|
111
|
+
if effort and effort > 0:
|
|
112
|
+
if not self._remaining:
|
|
113
|
+
self.error('ts_no_remaining',
|
|
114
|
+
f"The time sheet record for task {taskId} must "
|
|
115
|
+
"have a 'remaining' attribute to specify how much "
|
|
116
|
+
"effort is left for this task.")
|
|
117
|
+
else:
|
|
118
|
+
if not self._expected_end:
|
|
119
|
+
self.error('ts_no_expected_end',
|
|
120
|
+
f"The time sheet record for task {taskId} must "
|
|
121
|
+
"have an 'end' attribute to specify the expected end "
|
|
122
|
+
"of this task.")
|
|
123
|
+
else:
|
|
124
|
+
# New task
|
|
125
|
+
if self._remaining is None and self._expected_end is None:
|
|
126
|
+
self.error('ts_no_rem_or_end',
|
|
127
|
+
f"New task {taskId} requires either a 'remaining' or a "
|
|
128
|
+
"'end' attribute.")
|
|
129
|
+
|
|
130
|
+
if self._work and self._work >= self._time_sheet.daysToSlots(1) and self._status is None:
|
|
131
|
+
self.error('ts_no_status_work',
|
|
132
|
+
f"You must specify a status for task {taskId}. It was worked "
|
|
133
|
+
"on for a day or more.")
|
|
134
|
+
|
|
135
|
+
if self._status:
|
|
136
|
+
if hasattr(self._status, 'headline') and not self._status.headline:
|
|
137
|
+
self.error('ts_no_headline',
|
|
138
|
+
f"You must provide a headline for the status of "
|
|
139
|
+
f"task {taskId}")
|
|
140
|
+
|
|
141
|
+
def warnOnDelta(self, startIdx: int, endIdx: int):
|
|
142
|
+
"""Warn about differences between planned and actual work."""
|
|
143
|
+
if self._task is None:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
resource = self._time_sheet.resource
|
|
147
|
+
project = resource.project if hasattr(resource, 'project') else None
|
|
148
|
+
if not project:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if isinstance(self._task, str):
|
|
152
|
+
# New task request
|
|
153
|
+
remaining_str = (f"Remaining: {self._time_sheet.slotsToDays(self._remaining)}d"
|
|
154
|
+
if self._remaining else f"End: {self._expected_end}")
|
|
155
|
+
self.warning('ts_res_new_task',
|
|
156
|
+
f"{resource.name} is requesting a new task:\n"
|
|
157
|
+
f" ID: {self._task}\n"
|
|
158
|
+
f" Name: {self._name}\n"
|
|
159
|
+
f" Work: {self._time_sheet.slotsToDays(self._work)}d "
|
|
160
|
+
f"{remaining_str}")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Compare actual vs planned work
|
|
164
|
+
scenarioIdx = self._time_sheet.scenarioIdx
|
|
165
|
+
if hasattr(self._task, 'getEffectiveWork'):
|
|
166
|
+
plannedWork = self._task.getEffectiveWork(scenarioIdx, startIdx, endIdx, resource)
|
|
167
|
+
scheduleGranularity = project.get('scheduleGranularity', 3600)
|
|
168
|
+
work = project.convertToDailyLoad(self._work * scheduleGranularity) if hasattr(project, 'convertToDailyLoad') else self._work
|
|
169
|
+
|
|
170
|
+
if work != plannedWork:
|
|
171
|
+
direction = 'less' if work < plannedWork else 'more'
|
|
172
|
+
self.warning('ts_res_work_delta',
|
|
173
|
+
f"{resource.name} worked {direction} on {self._task.fullId}\n"
|
|
174
|
+
f"{work}d instead of {plannedWork}d")
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def taskId(self) -> str:
|
|
178
|
+
"""Return the task ID."""
|
|
179
|
+
if hasattr(self._task, 'fullId'):
|
|
180
|
+
return self._task.fullId
|
|
181
|
+
return str(self._task)
|
|
182
|
+
|
|
183
|
+
def actualWorkPercent(self) -> float:
|
|
184
|
+
"""Return reported work as percentage (0.0 - 100.0) of average working time."""
|
|
185
|
+
if self._work is None:
|
|
186
|
+
return 0.0
|
|
187
|
+
total = self._time_sheet.totalGrossWorkingSlots
|
|
188
|
+
if total == 0:
|
|
189
|
+
return 0.0
|
|
190
|
+
return (float(self._work) / total) * 100.0
|
|
191
|
+
|
|
192
|
+
def planWorkPercent(self) -> float:
|
|
193
|
+
"""Return planned work as percentage (0.0 - 100.0) of average working time."""
|
|
194
|
+
resource = self._time_sheet.resource
|
|
195
|
+
if not hasattr(resource, 'project'):
|
|
196
|
+
return 0.0
|
|
197
|
+
|
|
198
|
+
project = resource.project
|
|
199
|
+
scenarioIdx = self._time_sheet.scenarioIdx
|
|
200
|
+
interval = self._time_sheet.interval
|
|
201
|
+
|
|
202
|
+
if hasattr(project, 'dateToIdx'):
|
|
203
|
+
startIdx = project.dateToIdx(interval.start)
|
|
204
|
+
endIdx = project.dateToIdx(interval.end)
|
|
205
|
+
else:
|
|
206
|
+
return 0.0
|
|
207
|
+
|
|
208
|
+
if hasattr(resource, 'getAllocatedSlots'):
|
|
209
|
+
allocated = resource.getAllocatedSlots(scenarioIdx, startIdx, endIdx, self._task)
|
|
210
|
+
total = self._time_sheet.totalGrossWorkingSlots
|
|
211
|
+
if total == 0:
|
|
212
|
+
return 0.0
|
|
213
|
+
return (float(allocated) / total) * 100.0
|
|
214
|
+
return 0.0
|
|
215
|
+
|
|
216
|
+
def actualRemaining(self) -> float:
|
|
217
|
+
"""Return reported remaining effort in days."""
|
|
218
|
+
if self._remaining is None:
|
|
219
|
+
return 0.0
|
|
220
|
+
resource = self._time_sheet.resource
|
|
221
|
+
if not hasattr(resource, 'project'):
|
|
222
|
+
return float(self._remaining)
|
|
223
|
+
|
|
224
|
+
project = resource.project
|
|
225
|
+
scheduleGranularity = project.get('scheduleGranularity', 3600) if hasattr(project, 'get') else 3600
|
|
226
|
+
if hasattr(project, 'convertToDailyLoad'):
|
|
227
|
+
return project.convertToDailyLoad(self._remaining * scheduleGranularity)
|
|
228
|
+
return float(self._remaining)
|
|
229
|
+
|
|
230
|
+
def planRemaining(self) -> float:
|
|
231
|
+
"""Return remaining effort according to plan."""
|
|
232
|
+
resource = self._time_sheet.resource
|
|
233
|
+
if not hasattr(resource, 'project') or not hasattr(self._task, 'getEffectiveWork'):
|
|
234
|
+
return 0.0
|
|
235
|
+
|
|
236
|
+
project = resource.project
|
|
237
|
+
scenarioIdx = self._time_sheet.scenarioIdx
|
|
238
|
+
|
|
239
|
+
if hasattr(project, 'dateToIdx'):
|
|
240
|
+
startIdx = project.dateToIdx(project.get('now'))
|
|
241
|
+
endIdx = project.dateToIdx(self._task.get('end', scenarioIdx))
|
|
242
|
+
return self._task.getEffectiveWork(scenarioIdx, startIdx, endIdx, resource)
|
|
243
|
+
return 0.0
|
|
244
|
+
|
|
245
|
+
def actualEnd(self):
|
|
246
|
+
"""Return reported expected end of task."""
|
|
247
|
+
return self._expected_end
|
|
248
|
+
|
|
249
|
+
def planEnd(self):
|
|
250
|
+
"""Return planned end of task."""
|
|
251
|
+
if hasattr(self._task, 'get'):
|
|
252
|
+
return self._task.get('end', self._time_sheet.scenarioIdx)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TimeSheet(MessageHandler):
|
|
257
|
+
"""Stores work-related bits of a time sheet.
|
|
258
|
+
|
|
259
|
+
Holds TimeSheetRecord objects for each task.
|
|
260
|
+
Always bound to an existing Resource.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def __init__(self, resource, interval, scenarioIdx: int):
|
|
264
|
+
"""Create a new TimeSheet.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
resource: The Resource this time sheet belongs to.
|
|
268
|
+
interval: The time interval covered by this time sheet.
|
|
269
|
+
scenarioIdx: The scenario index.
|
|
270
|
+
"""
|
|
271
|
+
if not resource:
|
|
272
|
+
raise ValueError("Illegal resource")
|
|
273
|
+
self._resource = resource
|
|
274
|
+
|
|
275
|
+
if interval is None:
|
|
276
|
+
raise ValueError("Interval undefined")
|
|
277
|
+
self._interval = interval
|
|
278
|
+
|
|
279
|
+
if scenarioIdx is None:
|
|
280
|
+
raise ValueError("Scenario index undefined")
|
|
281
|
+
self._scenarioIdx = scenarioIdx
|
|
282
|
+
|
|
283
|
+
self.sourceFileInfo = None
|
|
284
|
+
self._percentageUsed = False
|
|
285
|
+
self._records: List[TimeSheetRecord] = []
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def resource(self):
|
|
289
|
+
return self._resource
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def interval(self):
|
|
293
|
+
return self._interval
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def scenarioIdx(self) -> int:
|
|
297
|
+
return self._scenarioIdx
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def records(self) -> List[TimeSheetRecord]:
|
|
301
|
+
return self._records
|
|
302
|
+
|
|
303
|
+
def add_record(self, record: TimeSheetRecord):
|
|
304
|
+
"""Add a TimeSheetRecord to this time sheet."""
|
|
305
|
+
for r in self._records:
|
|
306
|
+
if r.task == record.task:
|
|
307
|
+
self.error('ts_duplicate_task',
|
|
308
|
+
f"Duplicate records for task {r.taskId}")
|
|
309
|
+
self._records.append(record)
|
|
310
|
+
|
|
311
|
+
def __lshift__(self, record: TimeSheetRecord):
|
|
312
|
+
"""Add a record using << operator."""
|
|
313
|
+
self.add_record(record)
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def check(self):
|
|
317
|
+
"""Perform consistency checks on all records."""
|
|
318
|
+
totalSlots = 0
|
|
319
|
+
for record in self._records:
|
|
320
|
+
record.check()
|
|
321
|
+
if record.work:
|
|
322
|
+
totalSlots += record.work
|
|
323
|
+
|
|
324
|
+
project = self._resource.project if hasattr(self._resource, 'project') else None
|
|
325
|
+
if not project:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
trackingScenarioIdx = project.get('trackingScenarioIdx') if hasattr(project, 'get') else None
|
|
329
|
+
if not trackingScenarioIdx:
|
|
330
|
+
self.error('ts_no_tracking_scenario',
|
|
331
|
+
'No trackingscenario has been defined.')
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
efficiency = self._resource.get('efficiency', self._scenarioIdx) if hasattr(self._resource, 'get') else 1.0
|
|
335
|
+
if efficiency and efficiency > 0.0:
|
|
336
|
+
targetSlots = self.totalNetWorkingSlots
|
|
337
|
+
delta = 1 # Acceptable rounding error
|
|
338
|
+
|
|
339
|
+
if totalSlots < (targetSlots - delta):
|
|
340
|
+
self.error('ts_work_too_low',
|
|
341
|
+
f"The total work to be reported for this time sheet "
|
|
342
|
+
f"is {self._workWithUnit(targetSlots)} but only "
|
|
343
|
+
f"{self._workWithUnit(totalSlots)} were reported.")
|
|
344
|
+
|
|
345
|
+
if totalSlots > (targetSlots + delta):
|
|
346
|
+
self.error('ts_work_too_high',
|
|
347
|
+
f"The total work to be reported for this time sheet "
|
|
348
|
+
f"is {self._workWithUnit(targetSlots)} but "
|
|
349
|
+
f"{self._workWithUnit(totalSlots)} were reported.")
|
|
350
|
+
else:
|
|
351
|
+
if totalSlots > 0:
|
|
352
|
+
self.error('ts_work_not_null',
|
|
353
|
+
"The reported work for non-working resources must be 0.")
|
|
354
|
+
|
|
355
|
+
def warnOnDelta(self):
|
|
356
|
+
"""Warn about all delta differences in records."""
|
|
357
|
+
project = self._resource.project if hasattr(self._resource, 'project') else None
|
|
358
|
+
if not project or not hasattr(project, 'dateToIdx'):
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
startIdx = project.dateToIdx(self._interval.start)
|
|
362
|
+
endIdx = project.dateToIdx(self._interval.end)
|
|
363
|
+
|
|
364
|
+
for record in self._records:
|
|
365
|
+
record.warnOnDelta(startIdx, endIdx)
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def totalGrossWorkingSlots(self) -> int:
|
|
369
|
+
"""Compute total potential working time slots during report period."""
|
|
370
|
+
project = self._resource.project if hasattr(self._resource, 'project') else None
|
|
371
|
+
if not project:
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
# Calculate weeks in report
|
|
375
|
+
duration = self._interval.end - self._interval.start
|
|
376
|
+
if hasattr(duration, 'total_seconds'):
|
|
377
|
+
weeksToReport = duration.total_seconds() / (60 * 60 * 24 * 7)
|
|
378
|
+
else:
|
|
379
|
+
weeksToReport = float(duration) / (60 * 60 * 24 * 7)
|
|
380
|
+
|
|
381
|
+
weeklyWorkingDays = getattr(project, 'weeklyWorkingDays', 5)
|
|
382
|
+
return self.daysToSlots(int(weeklyWorkingDays * weeksToReport))
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def totalNetWorkingSlots(self) -> int:
|
|
386
|
+
"""Compute total actual working time slots of the Resource."""
|
|
387
|
+
project = self._resource.project if hasattr(self._resource, 'project') else None
|
|
388
|
+
if not project or not hasattr(project, 'dateToIdx'):
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
startIdx = project.dateToIdx(self._interval.start)
|
|
392
|
+
endIdx = project.dateToIdx(self._interval.end)
|
|
393
|
+
|
|
394
|
+
allocated = 0
|
|
395
|
+
free = 0
|
|
396
|
+
if hasattr(self._resource, 'getAllocatedSlots'):
|
|
397
|
+
allocated = self._resource.getAllocatedSlots(self._scenarioIdx, startIdx, endIdx, None)
|
|
398
|
+
if hasattr(self._resource, 'getFreeSlots'):
|
|
399
|
+
free = self._resource.getFreeSlots(self._scenarioIdx, startIdx, endIdx)
|
|
400
|
+
|
|
401
|
+
return allocated + free
|
|
402
|
+
|
|
403
|
+
def percentToSlots(self, value: float) -> int:
|
|
404
|
+
"""Convert allocation percentage to time slots."""
|
|
405
|
+
self._percentageUsed = True
|
|
406
|
+
return int(self.totalGrossWorkingSlots * value)
|
|
407
|
+
|
|
408
|
+
def slotsToPercent(self, slots: int) -> float:
|
|
409
|
+
"""Compute what percent the slots are of total working slots."""
|
|
410
|
+
total = self.totalGrossWorkingSlots
|
|
411
|
+
if total == 0:
|
|
412
|
+
return 0.0
|
|
413
|
+
return float(slots) / total
|
|
414
|
+
|
|
415
|
+
def slotsToDays(self, slots: int) -> float:
|
|
416
|
+
"""Convert slots to days."""
|
|
417
|
+
project = self._resource.project if hasattr(self._resource, 'project') else None
|
|
418
|
+
if not project:
|
|
419
|
+
return float(slots)
|
|
420
|
+
|
|
421
|
+
scheduleGranularity = project.get('scheduleGranularity', 3600) if hasattr(project, 'get') else 3600
|
|
422
|
+
dailyWorkingHours = getattr(project, 'dailyWorkingHours', 8)
|
|
423
|
+
return slots * scheduleGranularity / (60 * 60 * dailyWorkingHours)
|
|
424
|
+
|
|
425
|
+
def daysToSlots(self, days: int) -> int:
|
|
426
|
+
"""Convert days to slots."""
|
|
427
|
+
project = self._resource.project if hasattr(self._resource, 'project') else None
|
|
428
|
+
if not project:
|
|
429
|
+
return days
|
|
430
|
+
|
|
431
|
+
dailyWorkingHours = getattr(project, 'dailyWorkingHours', 8)
|
|
432
|
+
scheduleGranularity = project.get('scheduleGranularity', 3600) if hasattr(project, 'get') else 3600
|
|
433
|
+
return int((days * 60 * 60 * dailyWorkingHours) / scheduleGranularity)
|
|
434
|
+
|
|
435
|
+
def _workWithUnit(self, slots: int) -> str:
|
|
436
|
+
"""Format work with appropriate unit."""
|
|
437
|
+
if self._percentageUsed:
|
|
438
|
+
return f"{int(self.slotsToPercent(slots) * 100.0)}%"
|
|
439
|
+
else:
|
|
440
|
+
return f"{self.slotsToDays(slots)} days"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class TimeSheets(list):
|
|
444
|
+
"""Collection of all time sheets for a project."""
|
|
445
|
+
|
|
446
|
+
def __init__(self):
|
|
447
|
+
super().__init__()
|
|
448
|
+
|
|
449
|
+
def check(self):
|
|
450
|
+
"""Check all time sheets."""
|
|
451
|
+
for sheet in self:
|
|
452
|
+
sheet.check()
|
|
453
|
+
|
|
454
|
+
def warnOnDelta(self):
|
|
455
|
+
"""Warn about deltas in all time sheets."""
|
|
456
|
+
for sheet in self:
|
|
457
|
+
sheet.warnOnDelta()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WorkingHours class for managing per-resource working hour schedules.
|
|
3
|
+
|
|
4
|
+
This class handles irregular working hours like "08:15 - 11:45, 13:15 - 16:30"
|
|
5
|
+
for specific days of the week.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, time, timedelta
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import zoneinfo
|
|
12
|
+
HAS_ZONEINFO = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
HAS_ZONEINFO = False
|
|
15
|
+
try:
|
|
16
|
+
import pytz
|
|
17
|
+
HAS_PYTZ = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
HAS_PYTZ = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WorkingHours:
|
|
23
|
+
"""
|
|
24
|
+
Manages working hours for a resource.
|
|
25
|
+
|
|
26
|
+
Working hours are defined as time intervals for each day of the week.
|
|
27
|
+
For example:
|
|
28
|
+
Mon, Wed, Fri: 08:15 - 11:45, 13:15 - 16:30
|
|
29
|
+
Tue, Thu: 09:00 - 10:30, 14:45 - 16:00
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Map day names to weekday numbers (0=Monday, 6=Sunday)
|
|
33
|
+
DAY_MAP = {
|
|
34
|
+
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def __init__(self, project):
|
|
38
|
+
"""
|
|
39
|
+
Initialize working hours.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
project: Project reference for time conversion
|
|
43
|
+
"""
|
|
44
|
+
self.project = project
|
|
45
|
+
# Dict mapping weekday (0-6) to list of (start_time, end_time) tuples
|
|
46
|
+
# Times are stored as (hour, minute) tuples
|
|
47
|
+
self._hours = {}
|
|
48
|
+
# Start with empty hours - will be populated by set_hours()
|
|
49
|
+
# If no hours are set, onShift will fall back to project default
|
|
50
|
+
self._custom_hours_set = False
|
|
51
|
+
|
|
52
|
+
def set_hours(self, days, ranges):
|
|
53
|
+
"""
|
|
54
|
+
Set working hours for specific days.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
days: List of day names like ['mon', 'wed', 'fri']
|
|
58
|
+
ranges: List of (start_time, end_time) tuples like [('08:15', '11:45')]
|
|
59
|
+
"""
|
|
60
|
+
self._custom_hours_set = True
|
|
61
|
+
|
|
62
|
+
# Convert day names to weekday numbers
|
|
63
|
+
day_nums = []
|
|
64
|
+
for day in days:
|
|
65
|
+
day_lower = day.lower()
|
|
66
|
+
if day_lower in self.DAY_MAP:
|
|
67
|
+
day_nums.append(self.DAY_MAP[day_lower])
|
|
68
|
+
|
|
69
|
+
# Handle day ranges like "mon - fri"
|
|
70
|
+
if len(day_nums) == 0:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Parse time ranges to (hour, minute) tuples
|
|
74
|
+
time_intervals = []
|
|
75
|
+
for start_str, end_str in ranges:
|
|
76
|
+
start_h, start_m = self._parse_time(start_str)
|
|
77
|
+
end_h, end_m = self._parse_time(end_str)
|
|
78
|
+
time_intervals.append(((start_h, start_m), (end_h, end_m)))
|
|
79
|
+
|
|
80
|
+
# Set hours for each day
|
|
81
|
+
# NOTE: Multiple workinghours directives for the same resource will
|
|
82
|
+
# result in multiple calls. The behavior depends on whether days overlap:
|
|
83
|
+
# - Different days: each gets its own time intervals
|
|
84
|
+
# - Same day called multiple times: extends (adds more intervals)
|
|
85
|
+
for day_num in day_nums:
|
|
86
|
+
if day_num not in self._hours:
|
|
87
|
+
self._hours[day_num] = []
|
|
88
|
+
# Extend with new intervals (allows multiple non-contiguous ranges per day)
|
|
89
|
+
self._hours[day_num].extend(time_intervals)
|
|
90
|
+
|
|
91
|
+
def _parse_time(self, time_str):
|
|
92
|
+
"""Parse a time string like '08:15' to (hour, minute) tuple."""
|
|
93
|
+
parts = str(time_str).split(':')
|
|
94
|
+
hour = int(parts[0])
|
|
95
|
+
minute = int(parts[1]) if len(parts) > 1 else 0
|
|
96
|
+
return (hour, minute)
|
|
97
|
+
|
|
98
|
+
def onShift(self, slot_idx, timezone=None):
|
|
99
|
+
"""
|
|
100
|
+
Check if a slot index is within working hours.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
slot_idx: Scoreboard slot index
|
|
104
|
+
timezone: Optional timezone string (e.g., "Asia/Tokyo"). If provided,
|
|
105
|
+
the UTC slot time is converted to local time before checking
|
|
106
|
+
working hours. This enables resources in different timezones
|
|
107
|
+
to have their shifts defined in local time.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True if the slot is within working hours
|
|
111
|
+
"""
|
|
112
|
+
# If no custom hours set, fall back to project default
|
|
113
|
+
if not self._custom_hours_set:
|
|
114
|
+
return self.project.isWorkingTime(slot_idx)
|
|
115
|
+
|
|
116
|
+
# Get datetime for this slot (in UTC)
|
|
117
|
+
dt = self.project.idxToDate(slot_idx)
|
|
118
|
+
if dt is None:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
# Convert UTC time to resource's local timezone if specified
|
|
122
|
+
if timezone:
|
|
123
|
+
dt = self._convert_to_timezone(dt, timezone)
|
|
124
|
+
if dt is None:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
weekday = dt.weekday()
|
|
128
|
+
|
|
129
|
+
# Check if this day has working hours defined
|
|
130
|
+
if weekday not in self._hours or not self._hours[weekday]:
|
|
131
|
+
# No working hours defined for this day = not working
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
slot_time = (dt.hour, dt.minute)
|
|
135
|
+
slot_minutes = slot_time[0] * 60 + slot_time[1]
|
|
136
|
+
|
|
137
|
+
# Check if slot falls within any working interval
|
|
138
|
+
for (start_h, start_m), (end_h, end_m) in self._hours[weekday]:
|
|
139
|
+
start_minutes = start_h * 60 + start_m
|
|
140
|
+
end_minutes = end_h * 60 + end_m
|
|
141
|
+
|
|
142
|
+
# Check for cross-midnight shift (e.g., 22:00 - 06:00)
|
|
143
|
+
if end_minutes <= start_minutes:
|
|
144
|
+
# This interval crosses midnight
|
|
145
|
+
# Working time is: start_minutes <= slot < 1440 OR 0 <= slot < end_minutes
|
|
146
|
+
if slot_minutes >= start_minutes or slot_minutes < end_minutes:
|
|
147
|
+
return True
|
|
148
|
+
else:
|
|
149
|
+
# Normal interval within same day
|
|
150
|
+
if start_minutes <= slot_minutes < end_minutes:
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
# Also check if we're in the early morning part of a cross-midnight shift from previous day
|
|
154
|
+
prev_weekday = (weekday - 1) % 7
|
|
155
|
+
if prev_weekday in self._hours and self._hours[prev_weekday]:
|
|
156
|
+
for (start_h, start_m), (end_h, end_m) in self._hours[prev_weekday]:
|
|
157
|
+
start_minutes = start_h * 60 + start_m
|
|
158
|
+
end_minutes = end_h * 60 + end_m
|
|
159
|
+
|
|
160
|
+
# If previous day had a cross-midnight shift
|
|
161
|
+
if end_minutes <= start_minutes:
|
|
162
|
+
# Check if current slot is in the morning part (0 <= slot < end)
|
|
163
|
+
if slot_minutes < end_minutes:
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def get_daily_hours(self, weekday):
|
|
169
|
+
"""
|
|
170
|
+
Get total working hours for a specific weekday.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
weekday: Day of week (0=Monday, 6=Sunday)
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Total working hours as float
|
|
177
|
+
"""
|
|
178
|
+
if weekday not in self._hours:
|
|
179
|
+
return 0.0
|
|
180
|
+
|
|
181
|
+
total_minutes = 0
|
|
182
|
+
for (start_h, start_m), (end_h, end_m) in self._hours[weekday]:
|
|
183
|
+
start_minutes = start_h * 60 + start_m
|
|
184
|
+
end_minutes = end_h * 60 + end_m
|
|
185
|
+
total_minutes += (end_minutes - start_minutes)
|
|
186
|
+
|
|
187
|
+
return total_minutes / 60.0
|
|
188
|
+
|
|
189
|
+
def clear_day(self, weekday):
|
|
190
|
+
"""Clear working hours for a specific day."""
|
|
191
|
+
if weekday in self._hours:
|
|
192
|
+
self._hours[weekday] = []
|
|
193
|
+
|
|
194
|
+
def clear_all(self):
|
|
195
|
+
"""Clear all working hours."""
|
|
196
|
+
self._hours = {}
|
|
197
|
+
|
|
198
|
+
def _convert_to_timezone(self, dt, timezone_str):
|
|
199
|
+
"""
|
|
200
|
+
Convert a naive UTC datetime to the specified timezone.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
dt: Naive datetime (assumed to be UTC)
|
|
204
|
+
timezone_str: Timezone string like "Asia/Tokyo" or "America/New_York"
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Datetime in the local timezone, or None if conversion fails
|
|
208
|
+
"""
|
|
209
|
+
if not timezone_str:
|
|
210
|
+
return dt
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
if HAS_ZONEINFO:
|
|
214
|
+
# Python 3.9+ with zoneinfo
|
|
215
|
+
from datetime import timezone as dt_timezone
|
|
216
|
+
utc_dt = dt.replace(tzinfo=dt_timezone.utc)
|
|
217
|
+
tz = zoneinfo.ZoneInfo(timezone_str)
|
|
218
|
+
return utc_dt.astimezone(tz)
|
|
219
|
+
elif HAS_PYTZ:
|
|
220
|
+
# Fallback to pytz
|
|
221
|
+
import pytz
|
|
222
|
+
utc = pytz.UTC
|
|
223
|
+
utc_dt = utc.localize(dt)
|
|
224
|
+
tz = pytz.timezone(timezone_str)
|
|
225
|
+
return utc_dt.astimezone(tz)
|
|
226
|
+
else:
|
|
227
|
+
# No timezone support - return as-is with a warning
|
|
228
|
+
return dt
|
|
229
|
+
except Exception:
|
|
230
|
+
# Invalid timezone - return original datetime
|
|
231
|
+
return dt
|
|
File without changes
|