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,397 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ReportBase - Abstract base class for report content generators.
|
|
3
|
+
|
|
4
|
+
This module provides the ReportBase class which is the abstract base for all
|
|
5
|
+
kinds of report content generators. Derived classes must implement the
|
|
6
|
+
generate_intermediate_format function as well as to_html, to_csv, etc.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import TYPE_CHECKING, Optional, List, Any, Dict
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from scriptplan.report.report import Report
|
|
14
|
+
from scriptplan.core.project import Project
|
|
15
|
+
from scriptplan.core.property import PropertyList
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReportBase(ABC):
|
|
19
|
+
"""
|
|
20
|
+
Abstract base class for all report content generators.
|
|
21
|
+
|
|
22
|
+
This class provides common functionality for filtering property lists
|
|
23
|
+
and generating the intermediate format that can be converted to
|
|
24
|
+
various output formats (HTML, CSV, etc.).
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
report: Reference to the parent Report object
|
|
28
|
+
project: Reference to the Project object
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, report: 'Report'):
|
|
32
|
+
"""
|
|
33
|
+
Initialize the ReportBase.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
report: The parent Report object
|
|
37
|
+
"""
|
|
38
|
+
self.report = report
|
|
39
|
+
self.project = report.project
|
|
40
|
+
|
|
41
|
+
def a(self, attribute: str) -> Any:
|
|
42
|
+
"""
|
|
43
|
+
Convenience function to access a report attribute.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
attribute: Name of the attribute to access
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The attribute value
|
|
50
|
+
"""
|
|
51
|
+
return self.report.get(attribute)
|
|
52
|
+
|
|
53
|
+
def get_scenario_indices(self) -> List[int]:
|
|
54
|
+
"""
|
|
55
|
+
Get scenario indices from the report's scenarios attribute.
|
|
56
|
+
|
|
57
|
+
The 'scenarios' attribute can contain either scenario names (strings)
|
|
58
|
+
or scenario indices (integers). This method resolves all names to
|
|
59
|
+
their corresponding indices.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of scenario indices (integers). Defaults to [0] if not set.
|
|
63
|
+
"""
|
|
64
|
+
scenarios = self.a('scenarios') or []
|
|
65
|
+
if not scenarios:
|
|
66
|
+
return [0]
|
|
67
|
+
|
|
68
|
+
result = []
|
|
69
|
+
for scen in scenarios:
|
|
70
|
+
if isinstance(scen, int):
|
|
71
|
+
result.append(scen)
|
|
72
|
+
elif isinstance(scen, str):
|
|
73
|
+
# Resolve scenario name to index
|
|
74
|
+
for idx, proj_scen in enumerate(self.project.scenarios):
|
|
75
|
+
if proj_scen.id == scen:
|
|
76
|
+
result.append(idx)
|
|
77
|
+
break
|
|
78
|
+
else:
|
|
79
|
+
# Scenario name not found, try to parse as int
|
|
80
|
+
try:
|
|
81
|
+
result.append(int(scen))
|
|
82
|
+
except ValueError:
|
|
83
|
+
# Skip unknown scenarios
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
return result if result else [0]
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def generate_intermediate_format(self) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Generate the intermediate format representation.
|
|
92
|
+
|
|
93
|
+
This method must be implemented by derived classes to generate
|
|
94
|
+
an output-format-agnostic representation of the report data.
|
|
95
|
+
"""
|
|
96
|
+
# Process RichText elements like header, footer, etc.
|
|
97
|
+
query = None
|
|
98
|
+
if self.project.reportContexts:
|
|
99
|
+
query = self.project.reportContexts[-1].query
|
|
100
|
+
|
|
101
|
+
for name in ['header', 'left', 'center', 'right', 'footer',
|
|
102
|
+
'prolog', 'headline', 'caption', 'epilog']:
|
|
103
|
+
text = self.a(name)
|
|
104
|
+
if text and query and hasattr(text, 'setQuery'):
|
|
105
|
+
text.setQuery(query)
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def to_html(self) -> Optional[str]:
|
|
109
|
+
"""
|
|
110
|
+
Convert the intermediate format to HTML.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
HTML string representation or None
|
|
114
|
+
"""
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def to_csv(self) -> Optional[List[List[str]]]:
|
|
118
|
+
"""
|
|
119
|
+
Convert the intermediate format to CSV.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of rows (each row is a list of column values)
|
|
123
|
+
"""
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def filter_account_list(self, account_list: 'PropertyList',
|
|
127
|
+
hide_expr: Any = None,
|
|
128
|
+
rollup_expr: Any = None,
|
|
129
|
+
open_nodes: Optional[List] = None) -> 'PropertyList':
|
|
130
|
+
"""
|
|
131
|
+
Filter an account list based on hide/rollup expressions.
|
|
132
|
+
|
|
133
|
+
Takes the complete account list and removes all accounts that are
|
|
134
|
+
matching the hide expression, the rollup expression or are not a
|
|
135
|
+
descendant of accountroot.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
account_list: List of accounts to filter
|
|
139
|
+
hide_expr: Expression to determine which accounts to hide
|
|
140
|
+
rollup_expr: Expression to determine which accounts to roll up
|
|
141
|
+
open_nodes: List of nodes to keep expanded
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Filtered PropertyList
|
|
145
|
+
"""
|
|
146
|
+
from scriptplan.core.property import PropertyList
|
|
147
|
+
|
|
148
|
+
result = PropertyList(account_list)
|
|
149
|
+
|
|
150
|
+
account_root = self.a('accountRoot')
|
|
151
|
+
if account_root:
|
|
152
|
+
# Remove accounts not descended from accountRoot
|
|
153
|
+
result.delete_if(lambda acc: not self._is_child_of(acc, account_root))
|
|
154
|
+
|
|
155
|
+
return self._standard_filter_ops(result, hide_expr, rollup_expr,
|
|
156
|
+
open_nodes, None, account_root)
|
|
157
|
+
|
|
158
|
+
def filter_task_list(self, task_list: 'PropertyList',
|
|
159
|
+
resource: Any = None,
|
|
160
|
+
hide_expr: Any = None,
|
|
161
|
+
rollup_expr: Any = None,
|
|
162
|
+
open_nodes: Optional[List] = None) -> 'PropertyList':
|
|
163
|
+
"""
|
|
164
|
+
Filter a task list based on hide/rollup expressions.
|
|
165
|
+
|
|
166
|
+
Takes the complete task list and removes all tasks that are matching
|
|
167
|
+
the hide expression, the rollup expression or are not a descendant
|
|
168
|
+
of taskroot. If resource is not None, a task is only included if
|
|
169
|
+
the resource is allocated to it.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
task_list: List of tasks to filter
|
|
173
|
+
resource: Optional resource filter
|
|
174
|
+
hide_expr: Expression to determine which tasks to hide
|
|
175
|
+
rollup_expr: Expression to determine which tasks to roll up
|
|
176
|
+
open_nodes: List of nodes to keep expanded
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Filtered PropertyList
|
|
180
|
+
"""
|
|
181
|
+
from scriptplan.core.property import PropertyList
|
|
182
|
+
|
|
183
|
+
result = PropertyList(task_list)
|
|
184
|
+
|
|
185
|
+
task_root = self.a('taskRoot')
|
|
186
|
+
if task_root:
|
|
187
|
+
result.delete_if(lambda task: not self._is_child_of(task, task_root))
|
|
188
|
+
|
|
189
|
+
if resource:
|
|
190
|
+
# Filter to tasks that have the resource allocated
|
|
191
|
+
scenario_indices = self.get_scenario_indices()
|
|
192
|
+
start = self.a('start')
|
|
193
|
+
end = self.a('end')
|
|
194
|
+
|
|
195
|
+
def has_resource(task):
|
|
196
|
+
for scenario_idx in scenario_indices:
|
|
197
|
+
if hasattr(task, 'hasResourceAllocated'):
|
|
198
|
+
if task.hasResourceAllocated(scenario_idx, (start, end), resource):
|
|
199
|
+
return True
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
result.delete_if(lambda task: not has_resource(task))
|
|
203
|
+
|
|
204
|
+
return self._standard_filter_ops(result, hide_expr, rollup_expr,
|
|
205
|
+
open_nodes, resource, task_root)
|
|
206
|
+
|
|
207
|
+
def filter_resource_list(self, resource_list: 'PropertyList',
|
|
208
|
+
task: Any = None,
|
|
209
|
+
hide_expr: Any = None,
|
|
210
|
+
rollup_expr: Any = None,
|
|
211
|
+
open_nodes: Optional[List] = None) -> 'PropertyList':
|
|
212
|
+
"""
|
|
213
|
+
Filter a resource list based on hide/rollup expressions.
|
|
214
|
+
|
|
215
|
+
Takes the complete resource list and removes all resources that are
|
|
216
|
+
matching the hide expression, the rollup expression or are not a
|
|
217
|
+
descendant of resourceroot. If task is not None, a resource is only
|
|
218
|
+
included if it is assigned to the task.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
resource_list: List of resources to filter
|
|
222
|
+
task: Optional task filter
|
|
223
|
+
hide_expr: Expression to determine which resources to hide
|
|
224
|
+
rollup_expr: Expression to determine which resources to roll up
|
|
225
|
+
open_nodes: List of nodes to keep expanded
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Filtered PropertyList
|
|
229
|
+
"""
|
|
230
|
+
from scriptplan.core.property import PropertyList
|
|
231
|
+
|
|
232
|
+
result = PropertyList(resource_list)
|
|
233
|
+
|
|
234
|
+
resource_root = self.a('resourceRoot')
|
|
235
|
+
if resource_root:
|
|
236
|
+
result.delete_if(lambda res: not self._is_child_of(res, resource_root))
|
|
237
|
+
|
|
238
|
+
if task:
|
|
239
|
+
# Filter to resources assigned to the task
|
|
240
|
+
scenario_indices = self.get_scenario_indices()
|
|
241
|
+
start = self.a('start')
|
|
242
|
+
end = self.a('end')
|
|
243
|
+
|
|
244
|
+
def is_assigned(resource):
|
|
245
|
+
for scenario_idx in scenario_indices:
|
|
246
|
+
if hasattr(task, 'hasResourceAllocated'):
|
|
247
|
+
if task.hasResourceAllocated(scenario_idx, (start, end), resource):
|
|
248
|
+
return True
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
result.delete_if(lambda res: not is_assigned(res))
|
|
252
|
+
|
|
253
|
+
return self._standard_filter_ops(result, hide_expr, rollup_expr,
|
|
254
|
+
open_nodes, task, resource_root)
|
|
255
|
+
|
|
256
|
+
def _standard_filter_ops(self, items: 'PropertyList',
|
|
257
|
+
hide_expr: Any,
|
|
258
|
+
rollup_expr: Any,
|
|
259
|
+
open_nodes: Optional[List],
|
|
260
|
+
scope_property: Any,
|
|
261
|
+
root: Any) -> 'PropertyList':
|
|
262
|
+
"""
|
|
263
|
+
Apply standard filtering operations to a property list.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
items: The property list to filter
|
|
267
|
+
hide_expr: Expression determining what to hide
|
|
268
|
+
rollup_expr: Expression determining what to roll up
|
|
269
|
+
open_nodes: List of explicitly open nodes
|
|
270
|
+
scope_property: The scope property for queries
|
|
271
|
+
root: The root property
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Filtered PropertyList
|
|
275
|
+
"""
|
|
276
|
+
# Get query copy for evaluating expressions
|
|
277
|
+
query = None
|
|
278
|
+
if self.project.reportContexts:
|
|
279
|
+
query = self.project.reportContexts[-1].query.copy()
|
|
280
|
+
query.scope_property = scope_property
|
|
281
|
+
|
|
282
|
+
# Remove hidden properties
|
|
283
|
+
if hide_expr and query:
|
|
284
|
+
def should_hide(prop):
|
|
285
|
+
query.property = prop
|
|
286
|
+
return self._eval_expression(hide_expr, query)
|
|
287
|
+
items.delete_if(should_hide)
|
|
288
|
+
|
|
289
|
+
# Remove children of rolled-up properties
|
|
290
|
+
if rollup_expr or open_nodes:
|
|
291
|
+
def should_remove_child(prop):
|
|
292
|
+
parent = prop.parent
|
|
293
|
+
while parent:
|
|
294
|
+
query.property = parent if query else None
|
|
295
|
+
|
|
296
|
+
if open_nodes:
|
|
297
|
+
# If open_nodes specified, only listed nodes are unrolled
|
|
298
|
+
if [parent, scope_property] not in open_nodes:
|
|
299
|
+
return True
|
|
300
|
+
elif rollup_expr:
|
|
301
|
+
# Roll up based on expression
|
|
302
|
+
if self._eval_expression(rollup_expr, query):
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
parent = parent.parent
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
items.delete_if(should_remove_child)
|
|
309
|
+
|
|
310
|
+
# Re-add parents in tree mode (if applicable)
|
|
311
|
+
if hasattr(items, 'tree_mode') and items.tree_mode():
|
|
312
|
+
parents = []
|
|
313
|
+
for prop in items:
|
|
314
|
+
parent = prop.parent
|
|
315
|
+
while parent:
|
|
316
|
+
if parent not in items and parent not in parents:
|
|
317
|
+
parents.append(parent)
|
|
318
|
+
if parent == root:
|
|
319
|
+
break
|
|
320
|
+
parent = parent.parent
|
|
321
|
+
items.extend(parents)
|
|
322
|
+
|
|
323
|
+
return items
|
|
324
|
+
|
|
325
|
+
def _is_child_of(self, node: Any, parent: Any) -> bool:
|
|
326
|
+
"""
|
|
327
|
+
Check if node is a descendant of parent.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
node: The potential child node
|
|
331
|
+
parent: The potential parent node
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
True if node is a descendant of parent
|
|
335
|
+
"""
|
|
336
|
+
if hasattr(node, 'isChildOf'):
|
|
337
|
+
return node.isChildOf(parent)
|
|
338
|
+
|
|
339
|
+
# Manual check
|
|
340
|
+
current = node
|
|
341
|
+
while current:
|
|
342
|
+
if current == parent:
|
|
343
|
+
return True
|
|
344
|
+
current = current.parent if hasattr(current, 'parent') else None
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
def _eval_expression(self, expr: Any, query: Any) -> bool:
|
|
348
|
+
"""
|
|
349
|
+
Evaluate a logical expression.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
expr: The expression to evaluate
|
|
353
|
+
query: The query context
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Boolean result of expression evaluation
|
|
357
|
+
"""
|
|
358
|
+
if hasattr(expr, 'eval'):
|
|
359
|
+
return expr.eval(query)
|
|
360
|
+
if callable(expr):
|
|
361
|
+
return expr(query)
|
|
362
|
+
return bool(expr)
|
|
363
|
+
|
|
364
|
+
def _generate_html_table_frame(self) -> str:
|
|
365
|
+
"""
|
|
366
|
+
Generate the HTML table frame with headline.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
HTML string for table frame start
|
|
370
|
+
"""
|
|
371
|
+
html = ['<table class="tj_table_frame" cellspacing="1">']
|
|
372
|
+
|
|
373
|
+
# Add headline if present
|
|
374
|
+
headline = self.a('headline')
|
|
375
|
+
if headline:
|
|
376
|
+
headline_html = self._rich_text_to_html(headline)
|
|
377
|
+
html.append('<tr><td>')
|
|
378
|
+
html.append(f'<div class="tj_table_headline">{headline_html}</div>')
|
|
379
|
+
html.append('</td></tr>')
|
|
380
|
+
|
|
381
|
+
return '\n'.join(html)
|
|
382
|
+
|
|
383
|
+
def _rich_text_to_html(self, text: Any) -> str:
|
|
384
|
+
"""
|
|
385
|
+
Convert RichText to HTML.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
text: RichText object or string
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
HTML string
|
|
392
|
+
"""
|
|
393
|
+
if text is None:
|
|
394
|
+
return ''
|
|
395
|
+
if hasattr(text, 'to_html'):
|
|
396
|
+
return text.to_html()
|
|
397
|
+
return str(text)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ReportContext - Manages context and state during report generation.
|
|
3
|
+
|
|
4
|
+
This module provides the ReportContext class which holds settings used during
|
|
5
|
+
report generation. Reports can be nested, so multiple ReportContext objects
|
|
6
|
+
can exist at a time, but there is always one current context accessible via
|
|
7
|
+
Project.reportContexts[-1].
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Optional, List, Any, Dict
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from scriptplan.core.project import Project
|
|
14
|
+
from scriptplan.core.property import PropertyList
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Query:
|
|
18
|
+
"""
|
|
19
|
+
Query object for accessing property attributes during report generation.
|
|
20
|
+
|
|
21
|
+
This class provides a unified interface for querying task/resource attributes
|
|
22
|
+
with proper formatting and scenario handling.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, attrs: Optional[Dict[str, Any]] = None):
|
|
26
|
+
self.project = None
|
|
27
|
+
self.property = None
|
|
28
|
+
self.scope_property = None
|
|
29
|
+
self.scenario_idx = None
|
|
30
|
+
self.attributeId = None # Attribute ID to query
|
|
31
|
+
self.result = None # Result of the query
|
|
32
|
+
self.load_unit = 'days'
|
|
33
|
+
self.number_format = None
|
|
34
|
+
self.time_format = '%Y-%m-%d'
|
|
35
|
+
self.currency_format = None
|
|
36
|
+
self.start = None
|
|
37
|
+
self.end = None
|
|
38
|
+
self.hide_journal_entry = None
|
|
39
|
+
self.journal_mode = None
|
|
40
|
+
self.journal_attributes = None
|
|
41
|
+
self.sort_journal_entries = None
|
|
42
|
+
self.cost_account = None
|
|
43
|
+
self.revenue_account = None
|
|
44
|
+
|
|
45
|
+
if attrs:
|
|
46
|
+
for key, value in attrs.items():
|
|
47
|
+
attr_name = self._camel_to_snake(key)
|
|
48
|
+
if hasattr(self, attr_name):
|
|
49
|
+
setattr(self, attr_name, value)
|
|
50
|
+
|
|
51
|
+
def _camel_to_snake(self, name: str) -> str:
|
|
52
|
+
"""Convert camelCase to snake_case."""
|
|
53
|
+
import re
|
|
54
|
+
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
|
55
|
+
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
|
|
56
|
+
|
|
57
|
+
def copy(self) -> 'Query':
|
|
58
|
+
"""Create a copy of this Query."""
|
|
59
|
+
new_query = Query()
|
|
60
|
+
new_query.project = self.project
|
|
61
|
+
new_query.property = self.property
|
|
62
|
+
new_query.scope_property = self.scope_property
|
|
63
|
+
new_query.scenario_idx = self.scenario_idx
|
|
64
|
+
new_query.attributeId = self.attributeId
|
|
65
|
+
new_query.result = self.result
|
|
66
|
+
new_query.load_unit = self.load_unit
|
|
67
|
+
new_query.number_format = self.number_format
|
|
68
|
+
new_query.time_format = self.time_format
|
|
69
|
+
new_query.currency_format = self.currency_format
|
|
70
|
+
new_query.start = self.start
|
|
71
|
+
new_query.end = self.end
|
|
72
|
+
new_query.hide_journal_entry = self.hide_journal_entry
|
|
73
|
+
new_query.journal_mode = self.journal_mode
|
|
74
|
+
new_query.journal_attributes = self.journal_attributes
|
|
75
|
+
new_query.sort_journal_entries = self.sort_journal_entries
|
|
76
|
+
new_query.cost_account = self.cost_account
|
|
77
|
+
new_query.revenue_account = self.revenue_account
|
|
78
|
+
return new_query
|
|
79
|
+
|
|
80
|
+
def process(self) -> Any:
|
|
81
|
+
"""
|
|
82
|
+
Process the query and return the result.
|
|
83
|
+
|
|
84
|
+
This is the main entry point for executing a query against a property.
|
|
85
|
+
"""
|
|
86
|
+
if not self.property:
|
|
87
|
+
self.result = None
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# If attributeId is set, fetch the value from the property
|
|
91
|
+
if self.attributeId:
|
|
92
|
+
try:
|
|
93
|
+
# Use scenario_idx if available, otherwise just get the attribute
|
|
94
|
+
if self.scenario_idx is not None:
|
|
95
|
+
# Ensure property supports scenario indexing
|
|
96
|
+
# We pass scenario_idx as a tuple key if the property supports it
|
|
97
|
+
# But property.get() usually takes (name, scIdx) or just name
|
|
98
|
+
# Let's look at how PropertyTreeNode.get is implemented
|
|
99
|
+
# It usually takes (attribute_name, scenario_idx)
|
|
100
|
+
|
|
101
|
+
# Note: property.py logic sets self.scenarioIdx which maps to self.scenario_idx here
|
|
102
|
+
# property.py calls self._query.process()
|
|
103
|
+
|
|
104
|
+
val = self.property.get(self.attributeId, self.scenario_idx)
|
|
105
|
+
else:
|
|
106
|
+
val = self.property.get(self.attributeId)
|
|
107
|
+
|
|
108
|
+
self.result = val
|
|
109
|
+
except Exception:
|
|
110
|
+
self.result = None
|
|
111
|
+
|
|
112
|
+
return self.result
|
|
113
|
+
|
|
114
|
+
def to_sort(self) -> Any:
|
|
115
|
+
"""
|
|
116
|
+
Return a sortable representation of the query result.
|
|
117
|
+
"""
|
|
118
|
+
return self.result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ReportContext:
|
|
122
|
+
"""
|
|
123
|
+
Context object for report generation.
|
|
124
|
+
|
|
125
|
+
The ReportContext holds settings and state used during report generation.
|
|
126
|
+
Reports can be nested, creating a stack of contexts. The current context
|
|
127
|
+
is always accessible via Project.reportContexts[-1].
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
project: Reference to the Project object
|
|
131
|
+
report: Reference to the Report being generated
|
|
132
|
+
query: Query object for attribute access
|
|
133
|
+
tasks: List of tasks in scope for this report
|
|
134
|
+
resources: List of resources in scope for this report
|
|
135
|
+
dynamic_report_id: Unique identifier for nested reports
|
|
136
|
+
child_report_counter: Counter for generating child report IDs
|
|
137
|
+
attribute_backup: Backup of modified attributes for restoration
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, project: 'Project', report: Any):
|
|
141
|
+
"""
|
|
142
|
+
Initialize a new ReportContext.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
project: The Project object
|
|
146
|
+
report: The Report object being generated
|
|
147
|
+
"""
|
|
148
|
+
self.project = project
|
|
149
|
+
self.report = report
|
|
150
|
+
self.child_report_counter = 0
|
|
151
|
+
self.attribute_backup = None
|
|
152
|
+
|
|
153
|
+
# Build query attributes from report settings
|
|
154
|
+
query_attrs = {
|
|
155
|
+
'project': self.project,
|
|
156
|
+
'loadUnit': self._get_report_attr('loadUnit', 'days'),
|
|
157
|
+
'numberFormat': self._get_report_attr('numberFormat'),
|
|
158
|
+
'timeFormat': self._get_report_attr('timeFormat', '%Y-%m-%d'),
|
|
159
|
+
'currencyFormat': self._get_report_attr('currencyFormat'),
|
|
160
|
+
'start': self._get_report_attr('start'),
|
|
161
|
+
'end': self._get_report_attr('end'),
|
|
162
|
+
'hideJournalEntry': self._get_report_attr('hideJournalEntry'),
|
|
163
|
+
'journalMode': self._get_report_attr('journalMode'),
|
|
164
|
+
'journalAttributes': self._get_report_attr('journalAttributes'),
|
|
165
|
+
'sortJournalEntries': self._get_report_attr('sortJournalEntries'),
|
|
166
|
+
'costAccount': self._get_report_attr('costaccount'),
|
|
167
|
+
'revenueAccount': self._get_report_attr('revenueaccount'),
|
|
168
|
+
}
|
|
169
|
+
self.query = Query(query_attrs)
|
|
170
|
+
|
|
171
|
+
# Get parent context if exists
|
|
172
|
+
parent = project.reportContexts[-1] if project.reportContexts else None
|
|
173
|
+
|
|
174
|
+
if parent:
|
|
175
|
+
# For interactive/nested reports, generate a unique ID based on
|
|
176
|
+
# parent's ID and child counter
|
|
177
|
+
self.dynamic_report_id = f"{parent.dynamic_report_id}.{parent.child_report_counter}"
|
|
178
|
+
parent.child_report_counter += 1
|
|
179
|
+
|
|
180
|
+
# Inherit task and resource lists from parent
|
|
181
|
+
self.tasks = list(parent.tasks) if parent.tasks else []
|
|
182
|
+
self.resources = list(parent.resources) if parent.resources else []
|
|
183
|
+
else:
|
|
184
|
+
# Root context - ID is "0", get all tasks/resources from project
|
|
185
|
+
self.dynamic_report_id = "0"
|
|
186
|
+
self.tasks = list(project.tasks) if project.tasks else []
|
|
187
|
+
self.resources = list(project.resources) if project.resources else []
|
|
188
|
+
|
|
189
|
+
def _get_report_attr(self, attr_name: str, default: Any = None) -> Any:
|
|
190
|
+
"""
|
|
191
|
+
Get an attribute from the report.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
attr_name: Name of the attribute
|
|
195
|
+
default: Default value if attribute not found
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The attribute value or default
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
if hasattr(self.report, 'get'):
|
|
202
|
+
val = self.report.get(attr_name)
|
|
203
|
+
return val if val is not None else default
|
|
204
|
+
except (ValueError, KeyError, AttributeError):
|
|
205
|
+
pass
|
|
206
|
+
return default
|
|
207
|
+
|
|
208
|
+
def push(self) -> 'ReportContext':
|
|
209
|
+
"""
|
|
210
|
+
Push this context onto the project's context stack.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
self for chaining
|
|
214
|
+
"""
|
|
215
|
+
self.project.reportContexts.append(self)
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def pop(self) -> 'ReportContext':
|
|
219
|
+
"""
|
|
220
|
+
Pop this context from the project's context stack.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
self for chaining
|
|
224
|
+
"""
|
|
225
|
+
if self.project.reportContexts and self.project.reportContexts[-1] is self:
|
|
226
|
+
self.project.reportContexts.pop()
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def backup_attributes(self, property_node: Any) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Backup attributes from a property node for later restoration.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
property_node: The property node whose attributes to backup
|
|
235
|
+
"""
|
|
236
|
+
if hasattr(property_node, 'backupAttributes'):
|
|
237
|
+
self.attribute_backup = property_node.backupAttributes()
|
|
238
|
+
|
|
239
|
+
def restore_attributes(self, property_node: Any) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Restore previously backed up attributes to a property node.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
property_node: The property node to restore attributes to
|
|
245
|
+
"""
|
|
246
|
+
if self.attribute_backup and hasattr(property_node, 'restoreAttributes'):
|
|
247
|
+
property_node.restoreAttributes(self.attribute_backup)
|
|
248
|
+
self.attribute_backup = None
|