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.
Files changed (49) hide show
  1. scriptplan/__init__.py +22 -0
  2. scriptplan/cli/__init__.py +7 -0
  3. scriptplan/cli/main.py +546 -0
  4. scriptplan/core/__init__.py +0 -0
  5. scriptplan/core/account.py +125 -0
  6. scriptplan/core/allocation.py +69 -0
  7. scriptplan/core/booking.py +39 -0
  8. scriptplan/core/journal.py +377 -0
  9. scriptplan/core/leave.py +14 -0
  10. scriptplan/core/limits.py +354 -0
  11. scriptplan/core/project.py +924 -0
  12. scriptplan/core/property.py +1290 -0
  13. scriptplan/core/resource.py +198 -0
  14. scriptplan/core/resource_scenario.py +711 -0
  15. scriptplan/core/scenario.py +5 -0
  16. scriptplan/core/scenario_data.py +39 -0
  17. scriptplan/core/shift.py +71 -0
  18. scriptplan/core/task.py +77 -0
  19. scriptplan/core/task_scenario.py +1515 -0
  20. scriptplan/core/timesheet.py +457 -0
  21. scriptplan/core/working_hours.py +231 -0
  22. scriptplan/parser/__init__.py +0 -0
  23. scriptplan/parser/macro_processor.py +264 -0
  24. scriptplan/parser/tjp.lark +412 -0
  25. scriptplan/parser/tjp_parser.py +1904 -0
  26. scriptplan/py.typed +0 -0
  27. scriptplan/report/__init__.py +75 -0
  28. scriptplan/report/html_generator.py +477 -0
  29. scriptplan/report/report.py +466 -0
  30. scriptplan/report/report_base.py +397 -0
  31. scriptplan/report/report_context.py +248 -0
  32. scriptplan/report/resource_report.py +341 -0
  33. scriptplan/report/table_report.py +693 -0
  34. scriptplan/report/task_report.py +362 -0
  35. scriptplan/report/text_report.py +172 -0
  36. scriptplan/scheduler/__init__.py +0 -0
  37. scriptplan/scheduler/batch_processor.py +238 -0
  38. scriptplan/scheduler/scoreboard.py +120 -0
  39. scriptplan/utils/__init__.py +0 -0
  40. scriptplan/utils/data_cache.py +46 -0
  41. scriptplan/utils/logger.py +243 -0
  42. scriptplan/utils/message_handler.py +515 -0
  43. scriptplan/utils/time.py +195 -0
  44. scriptplan-0.9.0.dist-info/METADATA +161 -0
  45. scriptplan-0.9.0.dist-info/RECORD +49 -0
  46. scriptplan-0.9.0.dist-info/WHEEL +5 -0
  47. scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
  48. scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
  49. 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