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,924 @@
1
+ import logging
2
+ from scriptplan.utils.message_handler import MessageHandler
3
+ from scriptplan.utils.time import TjTime, TimeInterval
4
+ from scriptplan.utils.data_cache import DataCache, FileList
5
+ from scriptplan.core.property import (
6
+ PropertySet, PropertyList, AttributeDefinition, AttributeBase,
7
+ AlertLevelDefinitions, LeaveList, RealFormat, KeywordArray,
8
+ StringAttribute, IntegerAttribute, DateAttribute, BooleanAttribute,
9
+ ListAttribute, FloatAttribute,
10
+ ResourceListAttribute, ShiftAssignmentsAttribute, TaskDepListAttribute,
11
+ LogicalExpressionListAttribute, PropertyAttribute, RichTextAttribute,
12
+ ColumnListAttribute, AccountAttribute, DefinitionListAttribute,
13
+ FlagListAttribute, FormatListAttribute, LogicalExpressionAttribute,
14
+ SymbolListAttribute, SymbolAttribute, NodeListAttribute, ScenarioListAttribute,
15
+ SortListAttribute, JournalSortListAttribute, RealFormatAttribute,
16
+ LeaveListAttribute
17
+ )
18
+ from scriptplan.core.journal import Journal
19
+ from scriptplan.core.scenario import Scenario
20
+ from scriptplan.core.timesheet import TimeSheets
21
+ from scriptplan.core.working_hours import WorkingHours
22
+ from scriptplan.scheduler.scoreboard import Scoreboard
23
+
24
+ class Project(MessageHandler):
25
+ """
26
+ This class implements objects that hold all project properties. Project
27
+ generally consist of resources, tasks and a number of other optional
28
+ properties.
29
+ """
30
+
31
+ def __init__(self, id, name, version):
32
+ self.id = id
33
+ self.name = name
34
+ self.version = version
35
+
36
+ if hasattr(AttributeBase, 'setMode'):
37
+ AttributeBase.setMode(0)
38
+
39
+ self.attributes = {
40
+ 'alertLevels': AlertLevelDefinitions(),
41
+ 'auxdir': '',
42
+ 'copyright': None,
43
+ 'costaccount': None,
44
+ 'currency': "EUR",
45
+ 'currencyFormat': RealFormat(['-', '', '', ',', 2]),
46
+ 'dailyworkinghours': 8.0,
47
+ 'end': None,
48
+ 'markdate': None,
49
+ 'flags': [],
50
+ 'journal': Journal(self),
51
+ 'limits': None,
52
+ 'leaves': LeaveList(),
53
+ 'loadUnit': 'days',
54
+ 'name': name,
55
+ 'navigators': {},
56
+ 'now': TjTime().align(3600),
57
+ 'numberFormat': RealFormat(['-', '', '', '.', 1]),
58
+ 'priority': 500,
59
+ 'projectid': id or "prj",
60
+ 'projectids': [id] if id else ["prj"],
61
+ 'rate': 0.0,
62
+ 'revenueaccount': None,
63
+ 'scheduleGranularity': self.maxScheduleGranularity(),
64
+ 'shortTimeFormat': "%H:%M",
65
+ 'start': None,
66
+ 'timeFormat': "%Y-%m-%d",
67
+ 'timeOffId': None,
68
+ 'timeOffName': None,
69
+ 'timingresolution': 60 * 60,
70
+ 'timezone': TjTime.timeZone(),
71
+ 'trackingscenario': None,
72
+ 'version': version,
73
+ 'weekStartsMonday': False,
74
+ 'workinghours': None,
75
+ 'yearlyworkingdays': 260.714,
76
+ 'yresolution': 1
77
+ }
78
+
79
+ self.accounts = PropertySet(self, True)
80
+ self._define_account_attributes()
81
+
82
+ self.shifts = PropertySet(self, True)
83
+ self._define_shift_attributes()
84
+
85
+ self.resources = PropertySet(self, False)
86
+ self._define_resource_attributes()
87
+
88
+ self.tasks = PropertySet(self, False)
89
+ self._define_task_attributes()
90
+
91
+ self.reports = PropertySet(self, False)
92
+ self._define_report_attributes()
93
+
94
+ self.scenarios = PropertySet(self, True)
95
+ self._define_scenario_attributes()
96
+
97
+ # Scenario needs to be added AFTER attributes are defined
98
+ Scenario(self, 'plan', 'Plan Scenario', None)
99
+
100
+ self.inputFiles = FileList()
101
+ self.timeSheets = TimeSheets()
102
+
103
+ self.scoreboard = None
104
+ self.scoreboardNoLeaves = None
105
+
106
+ self.reportContexts = []
107
+ self.outputDir = './'
108
+ self.warnTsDeltas = False
109
+
110
+ def _define_scenario_attributes(self):
111
+ attrs = [
112
+ ['active', 'Enabled', BooleanAttribute, True, False, False, True],
113
+ ['ownbookings', 'Own Bookings', BooleanAttribute, False, False, False, True],
114
+ ['projection', 'Projection Mode', BooleanAttribute, True, False, False, False],
115
+ ]
116
+ for a in attrs:
117
+ self.scenarios.addAttributeType(AttributeDefinition(*a))
118
+
119
+ def _define_account_attributes(self):
120
+ attrs = [
121
+ # ID Name Type Inh InhPrj Scen Default
122
+ ['aggregate', 'Aggregate', SymbolAttribute, True, False, False, 'tasks'],
123
+ ['bsi', 'BSI', StringAttribute, False, False, False, ''],
124
+ ['credits', 'Credits', ListAttribute, False, False, True, []],
125
+ ['index', 'Index', IntegerAttribute, False, False, False, -1],
126
+ ['flags', 'Flags', FlagListAttribute, True, False, True, []],
127
+ ['tree', 'Tree Index', StringAttribute, False, False, False, ''],
128
+ ]
129
+ for a in attrs:
130
+ self.accounts.addAttributeType(AttributeDefinition(*a))
131
+
132
+ def _define_shift_attributes(self):
133
+ attrs = [
134
+ # ID Name Type Inh InhPrj Scen Default
135
+ ['bsi', 'BSI', StringAttribute, False, False, False, ''],
136
+ ['index', 'Index', IntegerAttribute, False, False, False, -1],
137
+ ['leaves', 'Leaves', LeaveListAttribute, True, True, True, []],
138
+ ['replace', 'Replace', BooleanAttribute, True, False, True, False],
139
+ ['timezone', 'Time Zone', StringAttribute, True, True, True, TjTime.timeZone()],
140
+ ['tree', 'Tree Index', StringAttribute, False, False, False, ''],
141
+ ['workinghours', 'Working Hours', ShiftAssignmentsAttribute, True, True, True, None],
142
+ ]
143
+ for a in attrs:
144
+ self.shifts.addAttributeType(AttributeDefinition(*a))
145
+
146
+ def _define_resource_attributes(self):
147
+ # Add attributes required by ResourceScenario
148
+ attrs = [
149
+ ['alloctdeffort', 'Allocated Effort', IntegerAttribute, True, False, True, 0],
150
+ ['booking', 'Booking', ResourceListAttribute, True, False, True, []],
151
+ ['bsi', 'BSI', StringAttribute, False, False, False, ""],
152
+ ['email', 'Email', StringAttribute, False, False, False, ""],
153
+ ['index', 'Index', IntegerAttribute, False, False, False, -1],
154
+ ['chargeset', 'Charge Set', StringAttribute, True, False, True, []],
155
+ ['criticalness', 'Criticalness', FloatAttribute, False, False, True, 0.0],
156
+ ['directreports', 'Direct Reports', ResourceListAttribute, True, False, True, []],
157
+ ['duties', 'Duties', TaskDepListAttribute, True, False, True, []],
158
+ ['efficiency', 'Efficiency', FloatAttribute, True, False, True, 1.0],
159
+ ['effort', 'Effort', IntegerAttribute, True, False, True, 0],
160
+ ['flags', 'Flags', FlagListAttribute, True, False, True, []],
161
+ ['leaves', 'Leaves', LeaveListAttribute, True, False, True, []], # Changed to LeaveListAttribute
162
+ ['leaveallowances', 'Leave Allowances', AttributeBase, True, False, True, None], # Placeholder
163
+ ['limits', 'Limits', AttributeBase, True, False, True, None], # Placeholder
164
+ ['managers', 'Managers', ResourceListAttribute, True, False, True, []],
165
+ ['rate', 'Rate', FloatAttribute, True, False, True, 0.0],
166
+ ['reports', 'Reports', ResourceListAttribute, True, False, True, []],
167
+ ['shifts', 'Shifts', ShiftAssignmentsAttribute, True, False, True, None],
168
+ ['timezone', 'Time Zone', StringAttribute, True, False, True, None],
169
+ ['workinghours', 'Working Hours', AttributeBase, True, False, True, None]
170
+ ]
171
+ for a in attrs:
172
+ self.resources.addAttributeType(AttributeDefinition(*a))
173
+
174
+ def _define_task_attributes(self):
175
+ attrs = [
176
+ ['allocate', 'Allocate', ListAttribute, True, False, True, []],
177
+ ['assignedresources', 'Assigned Resources', ListAttribute, False, False, True, []],
178
+ ['booking', 'Booking', ResourceListAttribute, True, False, True, []],
179
+ ['bsi', 'BSI', StringAttribute, False, False, False, ""],
180
+ ['charge', 'Charge', FloatAttribute, True, False, True, 0.0],
181
+ ['chargeset', 'Charge Set', StringAttribute, True, False, True, []],
182
+ ['complete', 'Complete', FloatAttribute, True, False, True, 0.0],
183
+ ['competitors', 'Competitors', ListAttribute, True, False, True, []],
184
+ ['criticalness', 'Criticalness', FloatAttribute, False, False, True, 0.0],
185
+ ['depends', 'Dependencies', TaskDepListAttribute, True, False, True, []],
186
+ ['duration', 'Duration', IntegerAttribute, True, False, True, 0],
187
+ ['effort', 'Effort', IntegerAttribute, True, False, True, 0],
188
+ ['effortdone', 'Effort Done', IntegerAttribute, True, False, True, 0],
189
+ ['effortleft', 'Effort Left', IntegerAttribute, True, False, True, 0],
190
+ ['end', 'End', DateAttribute, False, False, True, None],
191
+ ['flags', 'Flags', FlagListAttribute, True, False, True, []],
192
+ ['forward', 'Forward', BooleanAttribute, True, False, True, True],
193
+ ['gauge', 'Gauge', StringAttribute, True, False, True, None],
194
+ ['index', 'Index', IntegerAttribute, False, False, False, -1],
195
+ ['length', 'Length', IntegerAttribute, True, False, True, 0],
196
+ ['limits', 'Limits', PropertyAttribute, True, False, True, None],
197
+ ['maxend', 'Max End', DateAttribute, True, False, True, None],
198
+ ['maxstart', 'Max Start', DateAttribute, True, False, True, None],
199
+ ['minend', 'Min End', DateAttribute, True, False, True, None],
200
+ ['minstart', 'Min Start', DateAttribute, True, False, True, None],
201
+ ['milestone', 'Milestone', BooleanAttribute, True, False, True, False],
202
+ ['pathcriticalness', 'Path Criticalness', FloatAttribute, False, False, True, 0.0],
203
+ ['precedes', 'Precedes', TaskDepListAttribute, True, False, True, []],
204
+ ['priority', 'Priority', IntegerAttribute, True, False, True, 500],
205
+ ['projectionmode', 'Projection Mode', BooleanAttribute, True, False, True, False],
206
+ ['responsible', 'Responsible', ListAttribute, True, False, True, []],
207
+ ['scheduled', 'Scheduled', BooleanAttribute, True, False, True, False],
208
+ ['shifts', 'Shifts', ShiftAssignmentsAttribute, True, False, True, None],
209
+ ['start', 'Start', DateAttribute, True, False, True, None],
210
+ ['status', 'Status', StringAttribute, True, False, True, ""],
211
+ ]
212
+ for a in attrs:
213
+ self.tasks.addAttributeType(AttributeDefinition(*a))
214
+
215
+ def _define_report_attributes(self):
216
+ attrs = [
217
+ # ID Name Type Inh InhPrj Scen Default
218
+ ['accountRoot', 'Account Root', StringAttribute, True, False, False, None],
219
+ ['auxDir', 'Aux Directory', StringAttribute, True, True, False, ''],
220
+ ['balance', 'Balance', ListAttribute, True, False, False, []],
221
+ ['caption', 'Caption', RichTextAttribute, True, False, False, None],
222
+ ['center', 'Center', RichTextAttribute, True, False, False, None],
223
+ ['columns', 'Columns', ColumnListAttribute, True, False, False, []],
224
+ ['currencyFormat', 'Currency Format', StringAttribute, True, True, False, None],
225
+ ['end', 'End Date', DateAttribute, True, True, False, None],
226
+ ['epilog', 'Epilog', RichTextAttribute, True, False, False, None],
227
+ ['flags', 'Flags', FlagListAttribute, True, False, False, []],
228
+ ['footer', 'Footer', RichTextAttribute, True, False, False, None],
229
+ ['formats', 'Formats', FormatListAttribute, True, False, False, []],
230
+ ['header', 'Header', RichTextAttribute, True, False, False, None],
231
+ ['headline', 'Headline', RichTextAttribute, True, False, False, None],
232
+ ['hideAccount', 'Hide Account', LogicalExpressionAttribute, True, False, False, None],
233
+ ['hideResource', 'Hide Resource', LogicalExpressionAttribute, True, False, False, None],
234
+ ['hideTask', 'Hide Task', LogicalExpressionAttribute, True, False, False, None],
235
+ ['interactive', 'Interactive', BooleanAttribute, True, False, False, False],
236
+ ['journalAttributes', 'Journal Attributes', SymbolListAttribute, True, False, False, []],
237
+ ['journalMode', 'Journal Mode', SymbolAttribute, True, False, False, None],
238
+ ['leafTasksOnly', 'Leaf Tasks Only', BooleanAttribute, True, False, False, False],
239
+ ['left', 'Left', RichTextAttribute, True, False, False, None],
240
+ ['loadUnit', 'Load Unit', SymbolAttribute, True, True, False, 'days'],
241
+ ['numberFormat', 'Number Format', StringAttribute, True, True, False, None],
242
+ ['openNodes', 'Open Nodes', ListAttribute, True, False, False, []],
243
+ ['period', 'Period', StringAttribute, True, True, False, None],
244
+ ['prolog', 'Prolog', RichTextAttribute, True, False, False, None],
245
+ ['rawHtmlHead', 'Raw HTML Head', RichTextAttribute, True, False, False, None],
246
+ ['resourceRoot', 'Resource Root', StringAttribute, True, False, False, None],
247
+ ['right', 'Right', RichTextAttribute, True, False, False, None],
248
+ ['rollupAccount', 'Rollup Account', LogicalExpressionAttribute, True, False, False, None],
249
+ ['rollupResource', 'Rollup Resource', LogicalExpressionAttribute, True, False, False, None],
250
+ ['rollupTask', 'Rollup Task', LogicalExpressionAttribute, True, False, False, None],
251
+ ['scenarios', 'Scenarios', ScenarioListAttribute, True, True, False, []],
252
+ ['selfContained', 'Self Contained', BooleanAttribute, True, False, False, False],
253
+ ['showResources', 'Show Resources', BooleanAttribute, True, False, False, False],
254
+ ['showTasks', 'Show Tasks', BooleanAttribute, True, False, False, False],
255
+ ['sort', 'Sort', SortListAttribute, True, False, False, []],
256
+ ['sortAccounts', 'Sort Accounts', SortListAttribute, True, False, False, []],
257
+ ['sortResources', 'Sort Resources', SortListAttribute, True, False, False, []],
258
+ ['sortTasks', 'Sort Tasks', SortListAttribute, True, False, False, []],
259
+ ['start', 'Start Date', DateAttribute, True, True, False, None],
260
+ ['taskRoot', 'Task Root', StringAttribute, True, False, False, None],
261
+ ['timeFormat', 'Time Format', StringAttribute, True, True, False, '%Y-%m-%d'],
262
+ ['timeZone', 'Time Zone', StringAttribute, True, True, False, None],
263
+ ['title', 'Title', StringAttribute, True, False, False, None],
264
+ ['width', 'Width', IntegerAttribute, True, False, False, None],
265
+ ]
266
+ for a in attrs:
267
+ self.reports.addAttributeType(AttributeDefinition(*a))
268
+
269
+ def scenarioCount(self):
270
+ return self.scenarios.items()
271
+
272
+ def scenario(self, arg):
273
+ if isinstance(arg, int):
274
+ for sc in self.scenarios:
275
+ if sc.sequenceNo - 1 == arg:
276
+ return sc
277
+ else:
278
+ return self.scenarios[arg]
279
+ return None
280
+
281
+ @staticmethod
282
+ def maxScheduleGranularity():
283
+ return 60 * 60
284
+
285
+ def schedule(self):
286
+ # Extend project end if tasks require more time
287
+ self._extendProjectEndIfNeeded()
288
+
289
+ self.initScoreboards()
290
+
291
+ for p in [self.accounts, self.shifts, self.resources, self.tasks]:
292
+ p.index()
293
+
294
+ if self.tasks.empty():
295
+ # No tasks to schedule - just return
296
+ return True
297
+
298
+ for sc in self.scenarios:
299
+ # Skip disabled scenarios if 'active' is false (default true if not set)
300
+ if not sc.get('active') and sc.get('active') is not None:
301
+ continue
302
+
303
+ scIdx = sc.sequenceNo - 1
304
+
305
+ # Propagate inherited values
306
+ AttributeBase.setMode(1)
307
+ self.prepareScenario(scIdx)
308
+
309
+ # Schedule
310
+ AttributeBase.setMode(2)
311
+ self.scheduleScenario(scIdx)
312
+
313
+ # Finish
314
+ self.finishScenario(scIdx)
315
+
316
+ return True
317
+
318
+ def prepareScenario(self, scIdx):
319
+ # Simplified preparation
320
+ # In Ruby: computes criticalness, propagates initial values, checks loops
321
+
322
+ # Apply project-level scheduling mode (alap/asap) to all tasks
323
+ # Note: task-level 'scheduling asap/alap' overrides project-level
324
+ # We track which tasks have explicit scheduling via _explicit_scheduling attr
325
+ project_scheduling = self.attributes.get('scheduling')
326
+ if project_scheduling == 'alap':
327
+ for task in self.tasks:
328
+ if task.leaf():
329
+ # Only override if task doesn't have explicit scheduling attribute
330
+ if not getattr(task, '_explicit_scheduling', False):
331
+ task[('forward', scIdx)] = False # ALAP mode
332
+
333
+ # Propagate container end dates to leaf children for ALAP mode
334
+ # In ALAP, container end dates act as constraints for children
335
+ self._propagateContainerEndDates(scIdx)
336
+
337
+ # We need to ensure tasks are ready
338
+ for task in self.tasks:
339
+ task.prepareScheduling(scIdx)
340
+
341
+ for resource in self.resources:
342
+ resource.prepareScheduling(scIdx)
343
+
344
+ def _propagateContainerEndDates(self, scIdx):
345
+ """
346
+ Propagate container task end dates to their leaf children as constraints.
347
+
348
+ In ALAP mode, if a container has an end date, only the "terminal" tasks
349
+ (those with no successors within the container) should get the end constraint.
350
+ Other tasks will get their end constraints from their successors.
351
+
352
+ Special handling for `onstart` dependencies in ALAP mode:
353
+ - `A depends B { onstart }` means A.start >= B.start
354
+ - In ALAP, this translates to: A must END before B can START
355
+ - So B is the anchor (terminal), not A
356
+ - A derives its end from B's start
357
+ """
358
+ # First, identify which tasks have successors via normal (finish-to-start) dependencies
359
+ # For onstart dependencies in ALAP, the dependent task (A) derives its END from
360
+ # predecessor's START, so the predecessor (B) is the terminal task
361
+ has_fs_successor = set() # Tasks that are predecessors in finish-to-start deps
362
+ has_onstart_dep = set() # Tasks that have onstart dependencies (not terminal)
363
+
364
+ for task in self.tasks:
365
+ if not task.leaf():
366
+ continue
367
+ deps = task.get('depends', scIdx) or []
368
+ for dep in deps:
369
+ if isinstance(dep, dict):
370
+ pred = dep.get('task')
371
+ onstart = dep.get('onstart', False)
372
+ elif hasattr(dep, 'task'):
373
+ pred = dep.task
374
+ onstart = getattr(dep, 'onstart', False)
375
+ else:
376
+ pred = dep
377
+ onstart = False
378
+
379
+ if pred and hasattr(pred, 'fullId'):
380
+ if onstart:
381
+ # For onstart deps in ALAP: the dependent task (this task)
382
+ # derives END from predecessor's START, so this task is NOT terminal
383
+ has_onstart_dep.add(task.fullId if hasattr(task, 'fullId') else None)
384
+ else:
385
+ # Normal finish-to-start: predecessor has a successor
386
+ has_fs_successor.add(pred.fullId)
387
+
388
+ def propagate_end_to_children(task, container_end):
389
+ """Recursively propagate end constraint down the task tree."""
390
+ task_end = task.get('end', scIdx)
391
+ # Use the most restrictive (earliest) end date
392
+ effective_end = task_end if task_end else container_end
393
+
394
+ if task.leaf():
395
+ # Leaf task - apply the constraint if ALAP, no explicit end,
396
+ # AND the task is terminal
397
+ forward = task.get('forward', scIdx)
398
+ task_id = task.fullId if hasattr(task, 'fullId') else None
399
+
400
+ # A task is terminal if:
401
+ # 1. No finish-to-start successors (nothing depends on its END), AND
402
+ # 2. No onstart dependencies (doesn't derive END from another task's START)
403
+ is_terminal = (task_id not in has_fs_successor) and (task_id not in has_onstart_dep)
404
+
405
+ if forward is False and not task_end and container_end and is_terminal:
406
+ task[('end', scIdx)] = container_end
407
+ else:
408
+ # Container - propagate to children
409
+ for child in task.children:
410
+ propagate_end_to_children(child, effective_end)
411
+
412
+ # Start from root tasks (no parent)
413
+ for task in self.tasks:
414
+ if task.parent is None:
415
+ task_end = task.get('end', scIdx)
416
+ if task_end:
417
+ propagate_end_to_children(task, task_end)
418
+
419
+ def finishScenario(self, scIdx):
420
+ for task in self.tasks:
421
+ if not task.parent:
422
+ task.finishScheduling(scIdx)
423
+
424
+ for resource in self.resources:
425
+ if not resource.parent:
426
+ resource.finishScheduling(scIdx)
427
+
428
+ def scheduleScenario(self, scIdx):
429
+ all_tasks = list(self.tasks)
430
+
431
+ # First, handle milestones - they just need end=start (or start=end)
432
+ # A milestone is either:
433
+ # 1. Explicitly marked with milestone attribute, or
434
+ # 2. Has start or end set, but no effort/duration/length (implicit milestone)
435
+ for task in all_tasks:
436
+ if not task.leaf():
437
+ continue
438
+
439
+ is_explicit_milestone = task.get('milestone', scIdx)
440
+ effort = task.get('effort', scIdx) or 0
441
+ duration = task.get('duration', scIdx) or 0
442
+ length = task.get('length', scIdx) or 0
443
+ start = task.get('start', scIdx)
444
+ end = task.get('end', scIdx)
445
+
446
+ # Implicit milestone: has start/end but no duration metrics
447
+ is_implicit_milestone = (start or end) and effort == 0 and duration == 0 and length == 0
448
+
449
+ if is_explicit_milestone or is_implicit_milestone:
450
+ # Only mark as scheduled if we can set both dates
451
+ # Milestones with dependencies but no dates need to go through normal scheduling
452
+ if start and not end:
453
+ task[('end', scIdx)] = start
454
+ task[('scheduled', scIdx)] = True
455
+ elif end and not start:
456
+ task[('start', scIdx)] = end
457
+ task[('scheduled', scIdx)] = True
458
+ elif start and end:
459
+ task[('scheduled', scIdx)] = True
460
+ # else: milestone with no dates - let it be scheduled by the main loop
461
+
462
+ # Propagate ALAP mode through dependency chains
463
+ # If task B depends on task A, and B is ALAP with fixed end,
464
+ # then A should also be ALAP (scheduled as late as possible)
465
+ self._propagateALAPMode(scIdx)
466
+
467
+ # Only care about leaf tasks that aren't scheduled already
468
+ tasks = [t for t in all_tasks if t.leaf() and not t.get('scheduled', scIdx)]
469
+
470
+ # Sorting
471
+ # Primary: priority (desc), Secondary: pathcriticalness (desc), Tertiary: seqno (asc)
472
+ # Note: attributes might return None, need safe access for sorting
473
+ def sort_key(t):
474
+ prio = t.get('priority', scIdx) or 500
475
+ crit = t.get('pathcriticalness', scIdx) or 0.0
476
+ seq = t.get('seqno') or 0
477
+ return (-prio, -crit, seq)
478
+
479
+ tasks.sort(key=sort_key)
480
+
481
+ failedTasks = []
482
+
483
+ while tasks:
484
+ taskToRemove = None
485
+ for task in tasks:
486
+ # Task not ready? Ignore it.
487
+ if not task.readyForScheduling(scIdx):
488
+ continue
489
+
490
+ if not task.schedule(scIdx):
491
+ failedTasks.append(task)
492
+
493
+ taskToRemove = task
494
+ break
495
+
496
+ if taskToRemove:
497
+ tasks.remove(taskToRemove)
498
+ # After scheduling a leaf, check if any container tasks should be marked scheduled
499
+ self._updateContainerTaskStatus(scIdx)
500
+ elif tasks and not failedTasks:
501
+ # If we have tasks but none are ready and no failures yet, it's a deadlock
502
+ # (Unless readyForScheduling logic waits for something else?)
503
+ self.warning('deadlock', 'Deadlock detected in scheduling')
504
+ failedTasks.extend(tasks)
505
+ break
506
+ else:
507
+ # If tasks is not empty but we didn't remove any, we break to avoid infinite loop
508
+ # likely deadlock or all failed
509
+ break
510
+
511
+ if failedTasks:
512
+ self.warning('unscheduled_tasks', f"{len(failedTasks)} tasks could not be scheduled")
513
+ return False
514
+
515
+ return True
516
+
517
+ def _updateContainerTaskStatus(self, scIdx):
518
+ """Mark container tasks as scheduled when all their children are scheduled.
519
+
520
+ Also compute start/end dates for container tasks based on children.
521
+ """
522
+ for task in self.tasks:
523
+ if task.leaf():
524
+ continue # Skip leaf tasks
525
+
526
+ if task.get('scheduled', scIdx):
527
+ continue # Already scheduled
528
+
529
+ # Check if all children are scheduled
530
+ children = task.children
531
+ if not children:
532
+ continue
533
+
534
+ all_scheduled = all(child.get('scheduled', scIdx) for child in children)
535
+ if not all_scheduled:
536
+ continue
537
+
538
+ # All children scheduled - mark container as scheduled
539
+ # Compute start/end from children
540
+ min_start = None
541
+ max_end = None
542
+ for child in children:
543
+ child_start = child.get('start', scIdx)
544
+ child_end = child.get('end', scIdx)
545
+ if child_start and (min_start is None or child_start < min_start):
546
+ min_start = child_start
547
+ if child_end and (max_end is None or child_end > max_end):
548
+ max_end = child_end
549
+
550
+ if min_start:
551
+ task[('start', scIdx)] = min_start
552
+ if max_end:
553
+ task[('end', scIdx)] = max_end
554
+ task[('scheduled', scIdx)] = True
555
+
556
+ def _propagateALAPMode(self, scIdx):
557
+ """
558
+ Propagate ALAP scheduling mode backward through dependency chains.
559
+
560
+ When task B depends on task A, and B is ALAP with a fixed end date,
561
+ task A should also be scheduled ALAP (as late as possible) to allow
562
+ B to meet its deadline.
563
+
564
+ This implements "backward propagation" of ALAP constraints:
565
+ 1. Find all ALAP tasks with fixed end dates (anchor tasks)
566
+ 2. For each anchor, traverse its dependencies backward
567
+ 3. Mark predecessor tasks as ALAP and set their end constraint
568
+ to the dependent task's calculated start
569
+ """
570
+ # Build reverse dependency map: task -> list of tasks that depend on it
571
+ reverse_deps = {} # predecessor_id -> [successor tasks]
572
+ for task in self.tasks:
573
+ if not task.leaf():
574
+ continue
575
+ deps = task.get('depends', scIdx) or []
576
+ for dep in deps:
577
+ # Extract the predecessor task from dependency
578
+ if isinstance(dep, dict):
579
+ pred = dep.get('task')
580
+ elif hasattr(dep, 'task'):
581
+ pred = dep.task
582
+ else:
583
+ pred = dep
584
+
585
+ if pred:
586
+ pred_id = pred.fullId if hasattr(pred, 'fullId') else id(pred)
587
+ if pred_id not in reverse_deps:
588
+ reverse_deps[pred_id] = []
589
+ reverse_deps[pred_id].append(task)
590
+
591
+ # Find ALAP anchor tasks (ALAP with fixed end)
592
+ alap_anchors = []
593
+ for task in self.tasks:
594
+ if not task.leaf():
595
+ continue
596
+ forward = task.get('forward', scIdx)
597
+ end = task.get('end', scIdx)
598
+ if forward is False and end: # ALAP with fixed end
599
+ alap_anchors.append(task)
600
+
601
+ # Propagate ALAP backward from each anchor
602
+ # Use BFS to traverse dependency chains
603
+ processed = set()
604
+ for anchor in alap_anchors:
605
+ anchor_id = anchor.fullId if hasattr(anchor, 'fullId') else id(anchor)
606
+ processed.add(anchor_id)
607
+
608
+ # Get dependencies of the anchor (tasks that must finish before anchor starts)
609
+ deps = anchor.get('depends', scIdx) or []
610
+ for dep in deps:
611
+ if isinstance(dep, dict):
612
+ pred = dep.get('task')
613
+ elif hasattr(dep, 'task'):
614
+ pred = dep.task
615
+ else:
616
+ pred = dep
617
+
618
+ if not pred:
619
+ continue
620
+
621
+ pred_id = pred.fullId if hasattr(pred, 'fullId') else id(pred)
622
+ if pred_id in processed:
623
+ continue
624
+
625
+ # Mark predecessor as ALAP
626
+ # It should finish as late as possible while still allowing the anchor to start
627
+ self._markTaskALAP(pred, scIdx, processed, reverse_deps)
628
+
629
+ def _markTaskALAP(self, task, scIdx, processed, reverse_deps):
630
+ """
631
+ Mark a task as ALAP and propagate to its predecessors.
632
+
633
+ Args:
634
+ task: The task to mark as ALAP
635
+ scIdx: Scenario index
636
+ processed: Set of already processed task IDs
637
+ reverse_deps: Map of task ID -> list of successor tasks
638
+ """
639
+ task_id = task.fullId if hasattr(task, 'fullId') else id(task)
640
+ if task_id in processed:
641
+ return
642
+ processed.add(task_id)
643
+
644
+ # Only process leaf tasks
645
+ if not task.leaf():
646
+ return
647
+
648
+ # Check if task is already explicitly ASAP with a fixed start
649
+ # In that case, don't override
650
+ forward = task.get('forward', scIdx)
651
+ start = task.get('start', scIdx)
652
+ if forward is True and start:
653
+ # Explicitly ASAP with start date - don't change
654
+ return
655
+
656
+ # Mark as ALAP (forward=False)
657
+ task[('forward', scIdx)] = False
658
+
659
+ # Now propagate to predecessors of this task
660
+ deps = task.get('depends', scIdx) or []
661
+ for dep in deps:
662
+ if isinstance(dep, dict):
663
+ pred = dep.get('task')
664
+ elif hasattr(dep, 'task'):
665
+ pred = dep.task
666
+ else:
667
+ pred = dep
668
+
669
+ if pred:
670
+ self._markTaskALAP(pred, scIdx, processed, reverse_deps)
671
+
672
+ def _extendProjectEndIfNeeded(self):
673
+ """
674
+ Extend project end date if tasks require more time than the specified duration.
675
+ This prevents tasks from being truncated at the project boundary.
676
+ """
677
+ from datetime import timedelta
678
+
679
+ if not self.attributes.get('start') or not self.attributes.get('end'):
680
+ return
681
+
682
+ # Calculate total effort and gaps needed
683
+ total_effort_seconds = 0
684
+ total_gap_seconds = 0
685
+ task_count = 0
686
+
687
+ for task in self.tasks:
688
+ if task.leaf():
689
+ task_count += 1
690
+ # Get effort - stored in hours, convert to seconds
691
+ try:
692
+ effort = task.get('effort', 0)
693
+ if effort:
694
+ if isinstance(effort, (int, float)):
695
+ # Effort is in hours, convert to seconds
696
+ total_effort_seconds += effort * 3600
697
+ elif hasattr(effort, 'total_seconds'):
698
+ total_effort_seconds += effort.total_seconds()
699
+ except Exception:
700
+ pass
701
+
702
+ # Account for dependency gaps - use task.get() with scenario index 0
703
+ try:
704
+ deps = task.get('depends', 0) or []
705
+ for dep in deps:
706
+ gap = None
707
+ if isinstance(dep, dict):
708
+ gap = dep.get('gapduration')
709
+ elif hasattr(dep, 'gapduration'):
710
+ gap = dep.gapduration
711
+ if gap:
712
+ if isinstance(gap, (int, float)):
713
+ total_gap_seconds += gap
714
+ elif isinstance(gap, str):
715
+ # Parse duration string like '29min', '1h', '2d'
716
+ import re
717
+ match = re.match(r'(\d+)(min|h|d|w|m|y|s)', gap)
718
+ if match:
719
+ val = int(match.group(1))
720
+ unit = match.group(2)
721
+ if unit == 's':
722
+ total_gap_seconds += val
723
+ elif unit == 'min':
724
+ total_gap_seconds += val * 60
725
+ elif unit == 'h':
726
+ total_gap_seconds += val * 3600
727
+ elif unit == 'd':
728
+ total_gap_seconds += val * 86400
729
+ elif unit == 'w':
730
+ total_gap_seconds += val * 86400 * 7
731
+ elif hasattr(gap, 'total_seconds'):
732
+ total_gap_seconds += gap.total_seconds()
733
+ except Exception:
734
+ pass
735
+
736
+ if task_count == 0:
737
+ return
738
+
739
+ # Estimate daily working capacity (conservative: 6 hours/day to account for breaks)
740
+ daily_capacity_seconds = 6 * 3600
741
+ # Estimate days needed for effort
742
+ work_days_needed = total_effort_seconds / daily_capacity_seconds if daily_capacity_seconds > 0 else 0
743
+ # Add gap time (calendar days)
744
+ gap_days = total_gap_seconds / 86400
745
+ # Total calendar days (with 50% buffer for weekends/non-working days)
746
+ total_days_needed = int((work_days_needed + gap_days) * 1.5) + 7
747
+
748
+ # Calculate minimum required end date
749
+ min_end_date = self.attributes['start'] + timedelta(days=total_days_needed)
750
+
751
+ # Extend project end if needed
752
+ if min_end_date > self.attributes['end']:
753
+ self.attributes['end'] = min_end_date
754
+
755
+ def initScoreboards(self):
756
+ if not self.attributes['start'] or not self.attributes['end']:
757
+ return
758
+
759
+ self.scoreboard = Scoreboard(
760
+ self.attributes['start'],
761
+ self.attributes['end'],
762
+ self.attributes['scheduleGranularity'],
763
+ 2
764
+ )
765
+ self.scoreboardNoLeaves = Scoreboard(
766
+ self.attributes['start'],
767
+ self.attributes['end'],
768
+ self.attributes['scheduleGranularity'],
769
+ 2
770
+ )
771
+
772
+ # Initialize working time slots - mark working hours as None
773
+ # Default working hours: Mon-Fri, 9am-5pm
774
+ from datetime import timedelta
775
+ size = self.scoreboardSize()
776
+ granularity = self.attributes['scheduleGranularity']
777
+
778
+ for i in range(size):
779
+ date = self.idxToDate(i)
780
+ if self._isDefaultWorkingTime(date):
781
+ self.scoreboard[i] = None
782
+ self.scoreboardNoLeaves[i] = None
783
+
784
+ def _isDefaultWorkingTime(self, date):
785
+ """Check if a date/time falls within default working hours."""
786
+ if date is None:
787
+ return False
788
+ # Check global vacations
789
+ vacations = self.attributes.get('vacations', [])
790
+ for vac in vacations:
791
+ if hasattr(vac, 'interval') and vac.interval:
792
+ if vac.interval.start <= date < vac.interval.end:
793
+ return False
794
+ elif hasattr(vac, 'contains') and vac.contains(date):
795
+ return False
796
+ weekday = date.weekday()
797
+ if weekday >= 5: # Saturday or Sunday
798
+ return False
799
+ hour = date.hour
800
+ if hour < 9 or hour >= 17: # Outside 9am-5pm
801
+ return False
802
+ return True
803
+
804
+ def isWorkingTime(self, sbIdx):
805
+ """Check if a scoreboard slot is working time."""
806
+ if self.scoreboard is None:
807
+ return self._isDefaultWorkingTime(self.idxToDate(sbIdx))
808
+ return self.scoreboard[sbIdx] is None
809
+
810
+ def scoreboardSize(self):
811
+ if self.scoreboard:
812
+ return self.scoreboard.size
813
+ if self.attributes['start'] and self.attributes['end']:
814
+ try:
815
+ diff = (self.attributes['end'] - self.attributes['start']).total_seconds()
816
+ except AttributeError:
817
+ diff = self.attributes['end'] - self.attributes['start']
818
+ return int(diff / self.attributes['scheduleGranularity']) + 1
819
+ return 0
820
+
821
+ def dateToIdx(self, date, forceIntoProject=True):
822
+ if not self.attributes['start']:
823
+ return 0
824
+ try:
825
+ diff = (date - self.attributes['start']).total_seconds()
826
+ except AttributeError:
827
+ diff = date - self.attributes['start']
828
+
829
+ idx = int(diff / self.attributes['scheduleGranularity'])
830
+ return idx
831
+
832
+ def idxToDate(self, idx):
833
+ if not self.attributes['start']:
834
+ return None
835
+
836
+ from datetime import timedelta
837
+ # Assuming idx is integer steps of scheduleGranularity from start
838
+ seconds = idx * self.attributes['scheduleGranularity']
839
+ return self.attributes['start'] + timedelta(seconds=seconds)
840
+
841
+ def addReport(self, report):
842
+ """
843
+ Add a report to the project's report list.
844
+ This is called automatically by Report.__init__.
845
+
846
+ Args:
847
+ report: The Report object to add
848
+ """
849
+ # Report is already added via PropertySet in PropertyTreeNode.__init__
850
+ # This method exists for compatibility with TaskJuggler's API
851
+ pass
852
+
853
+ def addAccount(self, account):
854
+ """
855
+ Add an account to the project's account list.
856
+ This is called automatically by Account.__init__.
857
+
858
+ Args:
859
+ account: The Account object to add
860
+ """
861
+ # Account is already added via PropertySet in PropertyTreeNode.__init__
862
+ # This method exists for compatibility with TaskJuggler's API
863
+ pass
864
+
865
+ def addShift(self, shift):
866
+ """
867
+ Add a shift to the project's shift list.
868
+ This is called automatically by Shift.__init__.
869
+
870
+ Args:
871
+ shift: The Shift object to add
872
+ """
873
+ # Shift is already added via PropertySet in PropertyTreeNode.__init__
874
+ # This method exists for compatibility with TaskJuggler's API
875
+ pass
876
+
877
+ def addResource(self, resource):
878
+ """
879
+ Add a resource to the project's resource list.
880
+ This is called automatically by Resource.__init__.
881
+
882
+ Args:
883
+ resource: The Resource object to add
884
+ """
885
+ # Resource is already added via PropertySet in PropertyTreeNode.__init__
886
+ # This method exists for compatibility with TaskJuggler's API
887
+ pass
888
+
889
+ def addTask(self, task):
890
+ """
891
+ Add a task to the project's task list.
892
+ This is called automatically by Task.__init__.
893
+
894
+ Args:
895
+ task: The Task object to add
896
+ """
897
+ # Task is already added via PropertySet in PropertyTreeNode.__init__
898
+ # This method exists for compatibility with TaskJuggler's API
899
+ pass
900
+
901
+ def __getitem__(self, key):
902
+ """
903
+ Get a project attribute.
904
+
905
+ Args:
906
+ key: Attribute name
907
+
908
+ Returns:
909
+ Attribute value or None
910
+ """
911
+ return self.attributes.get(key)
912
+
913
+ def __setitem__(self, key, value):
914
+ """
915
+ Set a project attribute.
916
+
917
+ Args:
918
+ key: Attribute name
919
+ value: Attribute value
920
+ """
921
+ self.attributes[key] = value
922
+ # When timingresolution is set, also update scheduleGranularity
923
+ if key == 'timingresolution':
924
+ self.attributes['scheduleGranularity'] = value