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,693 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TableReport - Base class for tabular report content generators.
|
|
3
|
+
|
|
4
|
+
This module provides the TableReport class which is the base for all types
|
|
5
|
+
of tabular reports. All tabular reports are converted to an abstract
|
|
6
|
+
(output independent) intermediate form first, before being turned into
|
|
7
|
+
the requested output format.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Optional, List, Any, Dict, Tuple
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from scriptplan.report.report_base import ReportBase
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from scriptplan.report.report import Report
|
|
17
|
+
from scriptplan.core.property import PropertyList
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Alignment(Enum):
|
|
21
|
+
"""Column alignment options."""
|
|
22
|
+
LEFT = 'left'
|
|
23
|
+
CENTER = 'center'
|
|
24
|
+
RIGHT = 'right'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReportTableCell:
|
|
28
|
+
"""
|
|
29
|
+
Represents a single cell in a report table.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
text: Cell text content
|
|
33
|
+
alignment: Cell alignment
|
|
34
|
+
colspan: Number of columns to span
|
|
35
|
+
rowspan: Number of rows to span
|
|
36
|
+
indent: Indentation level
|
|
37
|
+
is_header: Whether this is a header cell
|
|
38
|
+
css_class: Optional CSS class
|
|
39
|
+
tooltip: Optional tooltip text
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, text: str = '', alignment: Alignment = Alignment.LEFT,
|
|
43
|
+
colspan: int = 1, rowspan: int = 1, indent: int = 0,
|
|
44
|
+
is_header: bool = False, css_class: str = '',
|
|
45
|
+
tooltip: str = ''):
|
|
46
|
+
self.text = text
|
|
47
|
+
self.alignment = alignment
|
|
48
|
+
self.colspan = colspan
|
|
49
|
+
self.rowspan = rowspan
|
|
50
|
+
self.indent = indent
|
|
51
|
+
self.is_header = is_header
|
|
52
|
+
self.css_class = css_class
|
|
53
|
+
self.tooltip = tooltip
|
|
54
|
+
|
|
55
|
+
def to_html(self) -> str:
|
|
56
|
+
"""Convert cell to HTML."""
|
|
57
|
+
tag = 'th' if self.is_header else 'td'
|
|
58
|
+
attrs = []
|
|
59
|
+
|
|
60
|
+
if self.colspan > 1:
|
|
61
|
+
attrs.append(f'colspan="{self.colspan}"')
|
|
62
|
+
if self.rowspan > 1:
|
|
63
|
+
attrs.append(f'rowspan="{self.rowspan}"')
|
|
64
|
+
if self.css_class:
|
|
65
|
+
attrs.append(f'class="{self.css_class}"')
|
|
66
|
+
if self.tooltip:
|
|
67
|
+
attrs.append(f'title="{self.tooltip}"')
|
|
68
|
+
|
|
69
|
+
style_parts = []
|
|
70
|
+
if self.alignment != Alignment.LEFT:
|
|
71
|
+
style_parts.append(f'text-align: {self.alignment.value}')
|
|
72
|
+
if self.indent > 0:
|
|
73
|
+
style_parts.append(f'padding-left: {self.indent * 20}px')
|
|
74
|
+
|
|
75
|
+
if style_parts:
|
|
76
|
+
attrs.append(f'style="{"; ".join(style_parts)}"')
|
|
77
|
+
|
|
78
|
+
attr_str = ' ' + ' '.join(attrs) if attrs else ''
|
|
79
|
+
return f'<{tag}{attr_str}>{self.text}</{tag}>'
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ReportTableLine:
|
|
83
|
+
"""
|
|
84
|
+
Represents a row in a report table.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
cells: List of cells in this row
|
|
88
|
+
property: The property this row represents
|
|
89
|
+
scenario_idx: The scenario index for this row
|
|
90
|
+
is_hidden: Whether this row should be hidden
|
|
91
|
+
css_class: Optional CSS class for the row
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, property_node: Any = None, scenario_idx: int = 0):
|
|
95
|
+
self.cells: List[ReportTableCell] = []
|
|
96
|
+
self.property = property_node
|
|
97
|
+
self.scenario_idx = scenario_idx
|
|
98
|
+
self.is_hidden = False
|
|
99
|
+
self.css_class = ''
|
|
100
|
+
|
|
101
|
+
def add_cell(self, cell: ReportTableCell) -> None:
|
|
102
|
+
"""Add a cell to this row."""
|
|
103
|
+
self.cells.append(cell)
|
|
104
|
+
|
|
105
|
+
def to_html(self) -> str:
|
|
106
|
+
"""Convert row to HTML."""
|
|
107
|
+
if self.is_hidden:
|
|
108
|
+
return ''
|
|
109
|
+
|
|
110
|
+
attrs = []
|
|
111
|
+
if self.css_class:
|
|
112
|
+
attrs.append(f'class="{self.css_class}"')
|
|
113
|
+
|
|
114
|
+
attr_str = ' ' + ' '.join(attrs) if attrs else ''
|
|
115
|
+
cells_html = ''.join(cell.to_html() for cell in self.cells)
|
|
116
|
+
return f'<tr{attr_str}>{cells_html}</tr>'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ReportTableColumn:
|
|
120
|
+
"""
|
|
121
|
+
Stores column-specific computed values.
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
start: Start date for column period
|
|
125
|
+
end: End date for column period
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, start: Any = None, end: Any = None):
|
|
129
|
+
self.start = start
|
|
130
|
+
self.end = end
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ReportTable:
|
|
134
|
+
"""
|
|
135
|
+
Represents a complete report table.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
header_lines: Header rows
|
|
139
|
+
body_lines: Body rows
|
|
140
|
+
footer_lines: Footer rows
|
|
141
|
+
self_contained: Whether resources are embedded
|
|
142
|
+
aux_dir: Auxiliary files directory
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self):
|
|
146
|
+
self.header_lines: List[ReportTableLine] = []
|
|
147
|
+
self.body_lines: List[ReportTableLine] = []
|
|
148
|
+
self.footer_lines: List[ReportTableLine] = []
|
|
149
|
+
self.self_contained = True
|
|
150
|
+
self.aux_dir = ''
|
|
151
|
+
|
|
152
|
+
def add_header_line(self, line: ReportTableLine) -> None:
|
|
153
|
+
"""Add a header row."""
|
|
154
|
+
self.header_lines.append(line)
|
|
155
|
+
|
|
156
|
+
def add_body_line(self, line: ReportTableLine) -> None:
|
|
157
|
+
"""Add a body row."""
|
|
158
|
+
self.body_lines.append(line)
|
|
159
|
+
|
|
160
|
+
def add_footer_line(self, line: ReportTableLine) -> None:
|
|
161
|
+
"""Add a footer row."""
|
|
162
|
+
self.footer_lines.append(line)
|
|
163
|
+
|
|
164
|
+
def to_html(self) -> str:
|
|
165
|
+
"""Convert table to HTML."""
|
|
166
|
+
html = ['<table class="tj_report_table">']
|
|
167
|
+
|
|
168
|
+
if self.header_lines:
|
|
169
|
+
html.append('<thead>')
|
|
170
|
+
for line in self.header_lines:
|
|
171
|
+
html.append(line.to_html())
|
|
172
|
+
html.append('</thead>')
|
|
173
|
+
|
|
174
|
+
if self.body_lines:
|
|
175
|
+
html.append('<tbody>')
|
|
176
|
+
for line in self.body_lines:
|
|
177
|
+
html.append(line.to_html())
|
|
178
|
+
html.append('</tbody>')
|
|
179
|
+
|
|
180
|
+
if self.footer_lines:
|
|
181
|
+
html.append('<tfoot>')
|
|
182
|
+
for line in self.footer_lines:
|
|
183
|
+
html.append(line.to_html())
|
|
184
|
+
html.append('</tfoot>')
|
|
185
|
+
|
|
186
|
+
html.append('</table>')
|
|
187
|
+
return '\n'.join(html)
|
|
188
|
+
|
|
189
|
+
def to_csv(self) -> List[List[str]]:
|
|
190
|
+
"""Convert table to CSV format."""
|
|
191
|
+
rows = []
|
|
192
|
+
|
|
193
|
+
for line in self.header_lines:
|
|
194
|
+
rows.append([cell.text for cell in line.cells])
|
|
195
|
+
|
|
196
|
+
for line in self.body_lines:
|
|
197
|
+
rows.append([cell.text for cell in line.cells])
|
|
198
|
+
|
|
199
|
+
for line in self.footer_lines:
|
|
200
|
+
rows.append([cell.text for cell in line.cells])
|
|
201
|
+
|
|
202
|
+
return rows
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ReportTableLegend:
|
|
206
|
+
"""
|
|
207
|
+
Legend for the report table.
|
|
208
|
+
|
|
209
|
+
Shows explanations for icons, colors, and symbols used in the report.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self):
|
|
213
|
+
self.items: List[Tuple[str, str]] = [] # (symbol, description)
|
|
214
|
+
|
|
215
|
+
def add_item(self, symbol: str, description: str) -> None:
|
|
216
|
+
"""Add a legend item."""
|
|
217
|
+
self.items.append((symbol, description))
|
|
218
|
+
|
|
219
|
+
def to_html(self) -> str:
|
|
220
|
+
"""Convert legend to HTML."""
|
|
221
|
+
if not self.items:
|
|
222
|
+
return ''
|
|
223
|
+
|
|
224
|
+
html = ['<div class="tj_table_legend">']
|
|
225
|
+
html.append('<table>')
|
|
226
|
+
for symbol, description in self.items:
|
|
227
|
+
html.append(f'<tr><td>{symbol}</td><td>{description}</td></tr>')
|
|
228
|
+
html.append('</table>')
|
|
229
|
+
html.append('</div>')
|
|
230
|
+
return '\n'.join(html)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TableReport(ReportBase):
|
|
234
|
+
"""
|
|
235
|
+
Base class for all tabular reports.
|
|
236
|
+
|
|
237
|
+
All tabular reports are converted to an abstract (output independent)
|
|
238
|
+
intermediate form first, before being turned into the requested output
|
|
239
|
+
format (HTML, CSV, etc.).
|
|
240
|
+
|
|
241
|
+
Attributes:
|
|
242
|
+
table: The intermediate table representation
|
|
243
|
+
columns: Column-specific computed values
|
|
244
|
+
legend: Report legend
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
# Column properties: ID -> (Header, Indent, Alignment, ScenarioSpecific)
|
|
248
|
+
PROPERTIES_BY_ID = {
|
|
249
|
+
'activetasks': ('Active Tasks', True, Alignment.RIGHT, True),
|
|
250
|
+
'alert': ('Alert', True, Alignment.LEFT, False),
|
|
251
|
+
'alertmessages': ('Alert Messages', False, Alignment.LEFT, False),
|
|
252
|
+
'alertsummaries': ('Alert Summaries', False, Alignment.LEFT, False),
|
|
253
|
+
'alerttrend': ('Alert Trend', False, Alignment.LEFT, False),
|
|
254
|
+
'bsi': ('BSI', False, Alignment.LEFT, False),
|
|
255
|
+
'children': ('Children', False, Alignment.LEFT, False),
|
|
256
|
+
'closedtasks': ('Closed Tasks', True, Alignment.RIGHT, True),
|
|
257
|
+
'complete': ('Completion', False, Alignment.RIGHT, True),
|
|
258
|
+
'cost': ('Cost', True, Alignment.RIGHT, True),
|
|
259
|
+
'duration': ('Duration', True, Alignment.RIGHT, True),
|
|
260
|
+
'effort': ('Effort', True, Alignment.RIGHT, True),
|
|
261
|
+
'effortdone': ('Effort Done', True, Alignment.RIGHT, True),
|
|
262
|
+
'effortleft': ('Effort Left', True, Alignment.RIGHT, True),
|
|
263
|
+
'end': ('End', True, Alignment.RIGHT, True),
|
|
264
|
+
'followers': ('Followers', False, Alignment.LEFT, True),
|
|
265
|
+
'freetime': ('Free Time', True, Alignment.RIGHT, True),
|
|
266
|
+
'freework': ('Free Work', True, Alignment.RIGHT, True),
|
|
267
|
+
'fte': ('FTE', True, Alignment.RIGHT, True),
|
|
268
|
+
'headcount': ('Headcount', True, Alignment.RIGHT, True),
|
|
269
|
+
'id': ('Id', False, Alignment.LEFT, False),
|
|
270
|
+
'inputs': ('Inputs', False, Alignment.LEFT, True),
|
|
271
|
+
'journal': ('Journal', False, Alignment.LEFT, False),
|
|
272
|
+
'line': ('Line No.', False, Alignment.RIGHT, False),
|
|
273
|
+
'name': ('Name', True, Alignment.LEFT, False),
|
|
274
|
+
'no': ('No.', False, Alignment.RIGHT, False),
|
|
275
|
+
'opentasks': ('Open Tasks', True, Alignment.RIGHT, True),
|
|
276
|
+
'precursors': ('Precursors', False, Alignment.LEFT, True),
|
|
277
|
+
'priority': ('Priority', True, Alignment.RIGHT, True),
|
|
278
|
+
'rate': ('Rate', True, Alignment.RIGHT, True),
|
|
279
|
+
'resources': ('Resources', False, Alignment.LEFT, True),
|
|
280
|
+
'responsible': ('Responsible', False, Alignment.LEFT, True),
|
|
281
|
+
'revenue': ('Revenue', True, Alignment.RIGHT, True),
|
|
282
|
+
'scenario': ('Scenario', False, Alignment.LEFT, True),
|
|
283
|
+
'scheduling': ('Scheduling Mode', True, Alignment.LEFT, True),
|
|
284
|
+
'start': ('Start', True, Alignment.RIGHT, True),
|
|
285
|
+
'status': ('Status', False, Alignment.LEFT, True),
|
|
286
|
+
'targets': ('Targets', False, Alignment.LEFT, True),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
def __init__(self, report: 'Report'):
|
|
290
|
+
"""
|
|
291
|
+
Initialize TableReport.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
report: The parent Report object
|
|
295
|
+
"""
|
|
296
|
+
super().__init__(report)
|
|
297
|
+
self.report.content = self
|
|
298
|
+
self.table: Optional[ReportTable] = None
|
|
299
|
+
self.columns: Dict[Any, ReportTableColumn] = {}
|
|
300
|
+
self.legend = ReportTableLegend()
|
|
301
|
+
|
|
302
|
+
def generate_intermediate_format(self) -> None:
|
|
303
|
+
"""Generate the intermediate table format."""
|
|
304
|
+
super().generate_intermediate_format()
|
|
305
|
+
|
|
306
|
+
def to_html(self) -> Optional[str]:
|
|
307
|
+
"""
|
|
308
|
+
Convert the table report to HTML.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
HTML string or None
|
|
312
|
+
"""
|
|
313
|
+
if not self.table:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
html = []
|
|
317
|
+
|
|
318
|
+
# Add dynamic report ID comment
|
|
319
|
+
if self.project.reportContexts:
|
|
320
|
+
dynamic_id = self.project.reportContexts[-1].dynamic_report_id
|
|
321
|
+
html.append(f'<!-- Dynamic Report ID: {dynamic_id} -->')
|
|
322
|
+
|
|
323
|
+
# Add header RichText if present
|
|
324
|
+
header = self._rich_text_to_html(self.a('header'))
|
|
325
|
+
if header:
|
|
326
|
+
html.append(header)
|
|
327
|
+
|
|
328
|
+
# Generate table frame
|
|
329
|
+
html.append(self._generate_html_table_frame())
|
|
330
|
+
|
|
331
|
+
# Add the actual table
|
|
332
|
+
html.append('<tr><td>')
|
|
333
|
+
html.append(self.table.to_html())
|
|
334
|
+
html.append('</td></tr>')
|
|
335
|
+
|
|
336
|
+
# Add caption if present
|
|
337
|
+
caption = self.a('caption')
|
|
338
|
+
if caption:
|
|
339
|
+
caption_html = self._rich_text_to_html(caption)
|
|
340
|
+
html.append('<tr><td>')
|
|
341
|
+
html.append(f'<div class="tj_table_caption">{caption_html}</div>')
|
|
342
|
+
html.append('</td></tr>')
|
|
343
|
+
|
|
344
|
+
# Add legend
|
|
345
|
+
legend_html = self.legend.to_html()
|
|
346
|
+
if legend_html:
|
|
347
|
+
html.append('<tr><td>')
|
|
348
|
+
html.append(legend_html)
|
|
349
|
+
html.append('</td></tr>')
|
|
350
|
+
|
|
351
|
+
html.append('</table>')
|
|
352
|
+
|
|
353
|
+
# Add footer RichText if present
|
|
354
|
+
footer = self._rich_text_to_html(self.a('footer'))
|
|
355
|
+
if footer:
|
|
356
|
+
html.append(footer)
|
|
357
|
+
|
|
358
|
+
return '\n'.join(html)
|
|
359
|
+
|
|
360
|
+
def to_csv(self) -> Optional[List[List[str]]]:
|
|
361
|
+
"""
|
|
362
|
+
Convert the table report to CSV.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
List of rows or None
|
|
366
|
+
"""
|
|
367
|
+
if not self.table:
|
|
368
|
+
return None
|
|
369
|
+
return self.table.to_csv()
|
|
370
|
+
|
|
371
|
+
@classmethod
|
|
372
|
+
def default_column_title(cls, column_id: str) -> Optional[str]:
|
|
373
|
+
"""
|
|
374
|
+
Get the default column title for a column ID.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
column_id: The column identifier
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Default title or None
|
|
381
|
+
"""
|
|
382
|
+
# Special columns without fixed titles
|
|
383
|
+
if column_id in ('chart', 'hourly', 'daily', 'weekly', 'monthly',
|
|
384
|
+
'quarterly', 'yearly'):
|
|
385
|
+
return ''
|
|
386
|
+
|
|
387
|
+
if column_id in cls.PROPERTIES_BY_ID:
|
|
388
|
+
return cls.PROPERTIES_BY_ID[column_id][0]
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def indent(cls, column_id: str, property_type: Any = None) -> bool:
|
|
393
|
+
"""
|
|
394
|
+
Determine if column values should be indented.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
column_id: The column identifier
|
|
398
|
+
property_type: The property type class
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
True if values should be indented
|
|
402
|
+
"""
|
|
403
|
+
if column_id in cls.PROPERTIES_BY_ID:
|
|
404
|
+
return cls.PROPERTIES_BY_ID[column_id][1]
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def alignment(cls, column_id: str, attribute_type: Any = None) -> Alignment:
|
|
409
|
+
"""
|
|
410
|
+
Get the alignment for a column.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
column_id: The column identifier
|
|
414
|
+
attribute_type: The attribute type class
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Alignment enum value
|
|
418
|
+
"""
|
|
419
|
+
if column_id in cls.PROPERTIES_BY_ID:
|
|
420
|
+
return cls.PROPERTIES_BY_ID[column_id][2]
|
|
421
|
+
return Alignment.CENTER
|
|
422
|
+
|
|
423
|
+
@classmethod
|
|
424
|
+
def is_calculated(cls, column_id: str) -> bool:
|
|
425
|
+
"""
|
|
426
|
+
Check if column values need to be calculated.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
column_id: The column identifier
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
True if values are calculated
|
|
433
|
+
"""
|
|
434
|
+
return column_id in cls.PROPERTIES_BY_ID
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def is_scenario_specific(cls, column_id: str) -> bool:
|
|
438
|
+
"""
|
|
439
|
+
Check if column values are scenario specific.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
column_id: The column identifier
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
True if scenario specific
|
|
446
|
+
"""
|
|
447
|
+
if column_id in cls.PROPERTIES_BY_ID:
|
|
448
|
+
return cls.PROPERTIES_BY_ID[column_id][3]
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
def generate_header_cell(self, column_def: Any) -> ReportTableCell:
|
|
452
|
+
"""
|
|
453
|
+
Generate a header cell for a column.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
column_def: Column definition (can be dict, object with id attr, or string)
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
ReportTableCell for the header
|
|
460
|
+
"""
|
|
461
|
+
# Handle different column_def formats
|
|
462
|
+
if isinstance(column_def, dict):
|
|
463
|
+
column_id = column_def.get('id', str(column_def))
|
|
464
|
+
options = column_def.get('options', {})
|
|
465
|
+
title = options.get('title') if options else None
|
|
466
|
+
elif hasattr(column_def, 'id'):
|
|
467
|
+
column_id = column_def.id
|
|
468
|
+
title = getattr(column_def, 'title', None)
|
|
469
|
+
else:
|
|
470
|
+
column_id = str(column_def)
|
|
471
|
+
title = None
|
|
472
|
+
|
|
473
|
+
if not title:
|
|
474
|
+
title = self.default_column_title(column_id) or column_id
|
|
475
|
+
|
|
476
|
+
return ReportTableCell(
|
|
477
|
+
text=title,
|
|
478
|
+
alignment=self.alignment(column_id),
|
|
479
|
+
is_header=True
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def generate_cell(self, property_node: Any, column_def: Any,
|
|
483
|
+
scenario_idx: int = 0) -> ReportTableCell:
|
|
484
|
+
"""
|
|
485
|
+
Generate a data cell for a property and column.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
property_node: The property (task/resource)
|
|
489
|
+
column_def: Column definition (can be dict, object with id attr, or string)
|
|
490
|
+
scenario_idx: Scenario index
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
ReportTableCell for the data
|
|
494
|
+
"""
|
|
495
|
+
# Handle different column_def formats
|
|
496
|
+
if isinstance(column_def, dict):
|
|
497
|
+
column_id = column_def.get('id', str(column_def))
|
|
498
|
+
elif hasattr(column_def, 'id'):
|
|
499
|
+
column_id = column_def.id
|
|
500
|
+
else:
|
|
501
|
+
column_id = str(column_def)
|
|
502
|
+
|
|
503
|
+
alignment = self.alignment(column_id)
|
|
504
|
+
should_indent = self.indent(column_id)
|
|
505
|
+
indent_level = property_node.level() if should_indent and hasattr(property_node, 'level') else 0
|
|
506
|
+
|
|
507
|
+
# Get the value
|
|
508
|
+
value = self._get_cell_value(property_node, column_id, scenario_idx)
|
|
509
|
+
text = self._format_value(value, column_id)
|
|
510
|
+
|
|
511
|
+
return ReportTableCell(
|
|
512
|
+
text=text,
|
|
513
|
+
alignment=alignment,
|
|
514
|
+
indent=indent_level
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def _get_cell_value(self, property_node: Any, column_id: str,
|
|
518
|
+
scenario_idx: int) -> Any:
|
|
519
|
+
"""
|
|
520
|
+
Get the value for a cell.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
property_node: The property
|
|
524
|
+
column_id: Column identifier
|
|
525
|
+
scenario_idx: Scenario index
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
The cell value
|
|
529
|
+
"""
|
|
530
|
+
try:
|
|
531
|
+
# Handle special computed columns
|
|
532
|
+
if column_id == 'revenue':
|
|
533
|
+
return self._get_revenue_value(property_node, scenario_idx)
|
|
534
|
+
elif column_id == 'cost':
|
|
535
|
+
return self._get_cost_value(property_node, scenario_idx)
|
|
536
|
+
|
|
537
|
+
if self.is_scenario_specific(column_id):
|
|
538
|
+
return property_node.get(column_id, scenario_idx) if hasattr(property_node, 'get') else None
|
|
539
|
+
else:
|
|
540
|
+
return property_node.get(column_id) if hasattr(property_node, 'get') else None
|
|
541
|
+
except (ValueError, KeyError, AttributeError):
|
|
542
|
+
# Unknown attribute - return placeholder
|
|
543
|
+
return '-'
|
|
544
|
+
|
|
545
|
+
def _get_revenue_value(self, property_node: Any, scenario_idx: int) -> Any:
|
|
546
|
+
"""
|
|
547
|
+
Get the revenue value for a task.
|
|
548
|
+
|
|
549
|
+
Revenue is the sum of charges that go to revenue accounts.
|
|
550
|
+
"""
|
|
551
|
+
if not hasattr(property_node, 'get'):
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
charge = property_node.get('charge', scenario_idx)
|
|
555
|
+
if not charge or charge == 0:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
# Check if the chargeset is a revenue account
|
|
559
|
+
chargeset_id = property_node.get('chargeset', scenario_idx)
|
|
560
|
+
if not chargeset_id:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
# Look up the account and check if it's a revenue account
|
|
564
|
+
# For now, use a simple heuristic: if the chargeset is 'rev' or similar
|
|
565
|
+
# A more complete implementation would check the account's properties
|
|
566
|
+
if isinstance(chargeset_id, str):
|
|
567
|
+
# Simple check: 'rev' accounts are revenue accounts
|
|
568
|
+
if 'rev' in chargeset_id.lower() or chargeset_id == 'rev':
|
|
569
|
+
return charge
|
|
570
|
+
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
def _get_cost_value(self, property_node: Any, scenario_idx: int) -> Any:
|
|
574
|
+
"""
|
|
575
|
+
Get the cost value for a task.
|
|
576
|
+
|
|
577
|
+
Cost is calculated as: allocated_time × resource_rate
|
|
578
|
+
Uses the task's getCost() method if available.
|
|
579
|
+
"""
|
|
580
|
+
if not hasattr(property_node, 'data'):
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
# Get the task scenario data
|
|
584
|
+
if not property_node.data or scenario_idx >= len(property_node.data):
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
task_scenario = property_node.data[scenario_idx]
|
|
588
|
+
if task_scenario is None:
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
# Use getCost() method if available
|
|
592
|
+
if hasattr(task_scenario, 'getCost'):
|
|
593
|
+
cost = task_scenario.getCost()
|
|
594
|
+
if cost and cost > 0:
|
|
595
|
+
return cost
|
|
596
|
+
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
def _format_value(self, value: Any, column_id: str) -> str:
|
|
600
|
+
"""
|
|
601
|
+
Format a value for display.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
value: The value to format
|
|
605
|
+
column_id: Column identifier for context
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
Formatted string
|
|
609
|
+
"""
|
|
610
|
+
from datetime import datetime
|
|
611
|
+
if value is None:
|
|
612
|
+
return ''
|
|
613
|
+
if isinstance(value, bool):
|
|
614
|
+
return 'Yes' if value else 'No'
|
|
615
|
+
if isinstance(value, datetime):
|
|
616
|
+
# Use report's timeFormat, falling back to project's timeformat
|
|
617
|
+
timeformat = self.a('timeFormat')
|
|
618
|
+
# Check if it's the default - if so, try project's timeformat
|
|
619
|
+
if timeformat == '%Y-%m-%d':
|
|
620
|
+
project_timeformat = self.project.attributes.get('timeformat')
|
|
621
|
+
if project_timeformat:
|
|
622
|
+
timeformat = project_timeformat
|
|
623
|
+
if timeformat:
|
|
624
|
+
return value.strftime(timeformat)
|
|
625
|
+
return str(value)
|
|
626
|
+
if isinstance(value, float):
|
|
627
|
+
return f'{value:.2f}'
|
|
628
|
+
if isinstance(value, list):
|
|
629
|
+
return ', '.join(str(v) for v in value)
|
|
630
|
+
return str(value)
|
|
631
|
+
|
|
632
|
+
def adjust_column_period(self, column_def: Any,
|
|
633
|
+
tasks: 'PropertyList' = None,
|
|
634
|
+
scenarios: List[int] = None) -> None:
|
|
635
|
+
"""
|
|
636
|
+
Adjust the column period based on task dates.
|
|
637
|
+
|
|
638
|
+
If the user has not specified the report period, try to fit all
|
|
639
|
+
tasks and add extra time at both ends for certain column types.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
column_def: Column definition
|
|
643
|
+
tasks: List of tasks
|
|
644
|
+
scenarios: List of scenario indices
|
|
645
|
+
"""
|
|
646
|
+
# Determine start date
|
|
647
|
+
do_not_adjust_start = False
|
|
648
|
+
do_not_adjust_end = False
|
|
649
|
+
|
|
650
|
+
if hasattr(column_def, 'start') and column_def.start:
|
|
651
|
+
r_start = column_def.start
|
|
652
|
+
do_not_adjust_start = True
|
|
653
|
+
else:
|
|
654
|
+
r_start = self.a('start')
|
|
655
|
+
if r_start != self.project.attributes.get('start'):
|
|
656
|
+
do_not_adjust_start = True
|
|
657
|
+
|
|
658
|
+
if hasattr(column_def, 'end') and column_def.end:
|
|
659
|
+
r_end = column_def.end
|
|
660
|
+
do_not_adjust_end = True
|
|
661
|
+
else:
|
|
662
|
+
r_end = self.a('end')
|
|
663
|
+
if r_end != self.project.attributes.get('end'):
|
|
664
|
+
do_not_adjust_end = True
|
|
665
|
+
|
|
666
|
+
# Store the column info
|
|
667
|
+
self.columns[column_def] = ReportTableColumn(r_start, r_end)
|
|
668
|
+
|
|
669
|
+
# Early exit if no adjustment needed
|
|
670
|
+
if not tasks or not scenarios or (do_not_adjust_start and do_not_adjust_end):
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
# Find task date range
|
|
674
|
+
task_start = None
|
|
675
|
+
task_end = None
|
|
676
|
+
|
|
677
|
+
for scenario_idx in scenarios:
|
|
678
|
+
for task in tasks:
|
|
679
|
+
start = task.get('start', scenario_idx) if hasattr(task, 'get') else None
|
|
680
|
+
end = task.get('end', scenario_idx) if hasattr(task, 'get') else None
|
|
681
|
+
|
|
682
|
+
if start:
|
|
683
|
+
if task_start is None or start < task_start:
|
|
684
|
+
task_start = start
|
|
685
|
+
if end:
|
|
686
|
+
if task_end is None or end > task_end:
|
|
687
|
+
task_end = end
|
|
688
|
+
|
|
689
|
+
# Update column range if found
|
|
690
|
+
if task_start and not do_not_adjust_start:
|
|
691
|
+
self.columns[column_def].start = task_start
|
|
692
|
+
if task_end and not do_not_adjust_end:
|
|
693
|
+
self.columns[column_def].end = task_end
|