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,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
|