scriptplan 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. scriptplan/__init__.py +22 -0
  2. scriptplan/cli/__init__.py +7 -0
  3. scriptplan/cli/main.py +546 -0
  4. scriptplan/core/__init__.py +0 -0
  5. scriptplan/core/account.py +125 -0
  6. scriptplan/core/allocation.py +69 -0
  7. scriptplan/core/booking.py +39 -0
  8. scriptplan/core/journal.py +377 -0
  9. scriptplan/core/leave.py +14 -0
  10. scriptplan/core/limits.py +354 -0
  11. scriptplan/core/project.py +924 -0
  12. scriptplan/core/property.py +1290 -0
  13. scriptplan/core/resource.py +198 -0
  14. scriptplan/core/resource_scenario.py +711 -0
  15. scriptplan/core/scenario.py +5 -0
  16. scriptplan/core/scenario_data.py +39 -0
  17. scriptplan/core/shift.py +71 -0
  18. scriptplan/core/task.py +77 -0
  19. scriptplan/core/task_scenario.py +1515 -0
  20. scriptplan/core/timesheet.py +457 -0
  21. scriptplan/core/working_hours.py +231 -0
  22. scriptplan/parser/__init__.py +0 -0
  23. scriptplan/parser/macro_processor.py +264 -0
  24. scriptplan/parser/tjp.lark +412 -0
  25. scriptplan/parser/tjp_parser.py +1904 -0
  26. scriptplan/py.typed +0 -0
  27. scriptplan/report/__init__.py +75 -0
  28. scriptplan/report/html_generator.py +477 -0
  29. scriptplan/report/report.py +466 -0
  30. scriptplan/report/report_base.py +397 -0
  31. scriptplan/report/report_context.py +248 -0
  32. scriptplan/report/resource_report.py +341 -0
  33. scriptplan/report/table_report.py +693 -0
  34. scriptplan/report/task_report.py +362 -0
  35. scriptplan/report/text_report.py +172 -0
  36. scriptplan/scheduler/__init__.py +0 -0
  37. scriptplan/scheduler/batch_processor.py +238 -0
  38. scriptplan/scheduler/scoreboard.py +120 -0
  39. scriptplan/utils/__init__.py +0 -0
  40. scriptplan/utils/data_cache.py +46 -0
  41. scriptplan/utils/logger.py +243 -0
  42. scriptplan/utils/message_handler.py +515 -0
  43. scriptplan/utils/time.py +195 -0
  44. scriptplan-0.9.0.dist-info/METADATA +161 -0
  45. scriptplan-0.9.0.dist-info/RECORD +49 -0
  46. scriptplan-0.9.0.dist-info/WHEEL +5 -0
  47. scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
  48. scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
  49. scriptplan-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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