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,69 @@
1
+ import random
2
+
3
+ class Allocation:
4
+ # Selection modes
5
+ ORDER = 0
6
+ MIN_ALLOCATED = 1
7
+ MIN_LOADED = 2
8
+ MAX_LOADED = 3
9
+ RANDOM = 4
10
+
11
+ def __init__(self, candidates, selectionMode=1, persistent=False, mandatory=False, atomic=False):
12
+ self.candidates_list = candidates
13
+ self.selectionMode = selectionMode
14
+ self.atomic = atomic
15
+ self.persistent = persistent
16
+ self.mandatory = mandatory
17
+ self.shifts = None
18
+ self.lockedResource = None
19
+ self.staticCandidates = None
20
+
21
+ def setSelectionMode(self, mode_str):
22
+ modes = ['order', 'minallocated', 'minloaded', 'maxloaded', 'random']
23
+ try:
24
+ self.selectionMode = modes.index(mode_str)
25
+ except ValueError:
26
+ raise ValueError(f"Unknown selection mode {mode_str}")
27
+
28
+ def addCandidate(self, candidate):
29
+ self.candidates_list.append(candidate)
30
+
31
+ def onShift(self, sbIdx):
32
+ if self.shifts:
33
+ return self.shifts.onShift(sbIdx)
34
+ return True
35
+
36
+ def candidates(self, scenarioIdx=None):
37
+ if self.staticCandidates:
38
+ return self.staticCandidates
39
+
40
+ if scenarioIdx is None or self.selectionMode == self.ORDER:
41
+ return self.candidates_list
42
+
43
+ if self.selectionMode == self.RANDOM:
44
+ # Random shuffle
45
+ shuffled = list(self.candidates_list)
46
+ random.shuffle(shuffled)
47
+ return shuffled
48
+
49
+ def sort_key(res):
50
+ if self.selectionMode == self.MIN_ALLOCATED:
51
+ crit = res.get('criticalness', scenarioIdx) or 0.0
52
+ if self.persistent:
53
+ effort = res.bookedEffort(scenarioIdx) or 0
54
+ return (effort, crit)
55
+ else:
56
+ return crit
57
+ elif self.selectionMode == self.MIN_LOADED:
58
+ return res.bookedEffort(scenarioIdx) or 0
59
+ elif self.selectionMode == self.MAX_LOADED:
60
+ return -(res.bookedEffort(scenarioIdx) or 0)
61
+ else:
62
+ raise ValueError(f"Unknown selection mode {self.selectionMode}")
63
+
64
+ sorted_list = sorted(self.candidates_list, key=sort_key)
65
+
66
+ if self.selectionMode == self.MIN_ALLOCATED and not self.persistent:
67
+ self.staticCandidates = sorted_list
68
+
69
+ return sorted_list
@@ -0,0 +1,39 @@
1
+ from scriptplan.utils.time import TimeInterval
2
+
3
+ class Booking:
4
+ def __init__(self, resource, task, intervals=None):
5
+ self.resource = resource
6
+ self.task = task
7
+ self.intervals = intervals if intervals else []
8
+ self.sourceFileInfo = None
9
+ self.overtime = 0
10
+ self.sloppy = 0
11
+
12
+ def to_s(self):
13
+ out = f"{self.resource.fullId} "
14
+ first = True
15
+ for iv in self.intervals:
16
+ if first:
17
+ first = False
18
+ else:
19
+ out += ", "
20
+
21
+ # Assuming iv.start and iv.end are datetime objects
22
+ duration_hours = (iv.end - iv.start).total_seconds() / 3600
23
+ out += f"{iv.start} + {duration_hours}h"
24
+ return out
25
+
26
+ def to_tjp(self, taskMode):
27
+ out = f"{self.task.fullId} " if taskMode else f"{self.resource.fullId} "
28
+ first = True
29
+ for iv in self.intervals:
30
+ if first:
31
+ first = False
32
+ else:
33
+ out += ",\n"
34
+
35
+ duration_hours = (iv.end - iv.start).total_seconds() / 3600
36
+ out += f"{iv.start} + {duration_hours}h"
37
+
38
+ out += ' { overtime 2 }'
39
+ return out
@@ -0,0 +1,377 @@
1
+ """
2
+ Journal - Project journal for tracking status and progress.
3
+
4
+ This module implements the Journal and JournalEntry classes for storing
5
+ and managing status reports and progress updates on tasks and resources.
6
+
7
+ A JournalEntry stores RichText strings to describe a status or property
8
+ of the project at a certain point in time. Additionally, the entry can
9
+ contain a reference to a Resource as author and an alert level.
10
+ """
11
+
12
+ from typing import TYPE_CHECKING, Optional, List, Any, Dict, Callable
13
+ from datetime import datetime
14
+ from enum import IntEnum
15
+
16
+ if TYPE_CHECKING:
17
+ from scriptplan.core.project import Project
18
+ from scriptplan.core.resource import Resource
19
+ from scriptplan.core.task import Task
20
+
21
+
22
+ class AlertLevel(IntEnum):
23
+ """Alert levels for journal entries."""
24
+ GREEN = 0 # On track
25
+ YELLOW = 1 # Minor issues
26
+ RED = 2 # Major issues/blocked
27
+
28
+
29
+ class JournalEntry:
30
+ """
31
+ A journal entry stores status or progress information about a task
32
+ or resource at a specific point in time.
33
+
34
+ The text is structured in 3 elements:
35
+ - headline: A very short description (should not exceed ~40 characters)
36
+ - summary: An introductory or summarizing paragraph (optional)
37
+ - details: A longer text segment (optional)
38
+
39
+ Attributes:
40
+ journal: Reference to the parent Journal object
41
+ date: The date of the entry
42
+ headline: Short description (mandatory)
43
+ property: Reference to the Task or Resource this entry is about
44
+ source_file_info: Source file location of this entry
45
+ author: Reference to the Resource who authored this entry
46
+ moderators: List of Resources who moderated this entry
47
+ summary: Introductory/summarizing RichText paragraph
48
+ details: RichText of arbitrary length
49
+ alert_level: The alert level (GREEN, YELLOW, RED)
50
+ flags: List of flag identifiers
51
+ timesheet_record: Reference to associated TimeSheetRecord
52
+ """
53
+
54
+ def __init__(self, journal: 'Journal', date: datetime, headline: str,
55
+ property_node: Any, source_file_info: Any = None):
56
+ """
57
+ Create a new JournalEntry object.
58
+
59
+ Args:
60
+ journal: The parent Journal object
61
+ date: The date of the entry
62
+ headline: Short description text
63
+ property_node: The Task or Resource this entry is about
64
+ source_file_info: Optional source file location
65
+ """
66
+ self.journal = journal
67
+ self.date = date
68
+ self.headline = headline
69
+ self.property = property_node
70
+ self.source_file_info = source_file_info
71
+
72
+ self.author: Optional['Resource'] = None
73
+ self.moderators: List['Resource'] = []
74
+ self.summary: Optional[str] = None
75
+ self.details: Optional[str] = None
76
+ self.alert_level: AlertLevel = AlertLevel.GREEN
77
+ self.flags: List[str] = []
78
+ self.timesheet_record: Any = None
79
+
80
+ def to_dict(self) -> Dict[str, Any]:
81
+ """Convert entry to dictionary for serialization."""
82
+ return {
83
+ 'date': self.date.isoformat() if self.date else None,
84
+ 'headline': self.headline,
85
+ 'property_id': self.property.fullId if self.property else None,
86
+ 'author_id': self.author.fullId if self.author else None,
87
+ 'alert_level': self.alert_level.name,
88
+ 'summary': str(self.summary) if self.summary else None,
89
+ 'details': str(self.details) if self.details else None,
90
+ 'flags': self.flags,
91
+ }
92
+
93
+ def __repr__(self) -> str:
94
+ return (f"JournalEntry(date={self.date}, headline='{self.headline}', "
95
+ f"property={self.property.fullId if self.property else None})")
96
+
97
+
98
+ class JournalEntryList(list):
99
+ """
100
+ A list of JournalEntry objects with sorting capabilities.
101
+
102
+ This class provides methods to sort journal entries and apply
103
+ various filtering operations.
104
+ """
105
+
106
+ def __init__(self, entries: Optional[List[JournalEntry]] = None):
107
+ """Initialize with optional list of entries."""
108
+ super().__init__(entries or [])
109
+
110
+ def sort_by(self, criteria: List[tuple]) -> 'JournalEntryList':
111
+ """
112
+ Sort entries by multiple criteria.
113
+
114
+ Args:
115
+ criteria: List of (attribute, ascending) tuples
116
+
117
+ Returns:
118
+ Self for chaining
119
+ """
120
+ def sort_key(entry: JournalEntry):
121
+ key_parts = []
122
+ for attr, ascending in criteria:
123
+ val = getattr(entry, attr, None)
124
+ if val is None:
125
+ val = ''
126
+ if not ascending:
127
+ if isinstance(val, (int, float)):
128
+ val = -val
129
+ elif isinstance(val, datetime):
130
+ # Invert datetime for descending
131
+ val = datetime.max - val
132
+ key_parts.append(val)
133
+ return tuple(key_parts)
134
+
135
+ self.sort(key=sort_key)
136
+ return self
137
+
138
+ def filter(self, predicate: Callable[[JournalEntry], bool]) -> 'JournalEntryList':
139
+ """
140
+ Filter entries using a predicate function.
141
+
142
+ Args:
143
+ predicate: Function that returns True for entries to keep
144
+
145
+ Returns:
146
+ New JournalEntryList with filtered entries
147
+ """
148
+ return JournalEntryList([e for e in self if predicate(e)])
149
+
150
+
151
+ class Journal:
152
+ """
153
+ Container for all JournalEntry objects of a project.
154
+
155
+ The Journal provides methods to add entries and query them by
156
+ task, resource, date range, and other criteria.
157
+
158
+ Attributes:
159
+ project: Reference to the Project object
160
+ entries: List of all journal entries
161
+ """
162
+
163
+ def __init__(self, project: Optional['Project'] = None):
164
+ """
165
+ Create a new Journal.
166
+
167
+ Args:
168
+ project: Optional reference to the Project
169
+ """
170
+ self.project = project
171
+ self._entries: List[JournalEntry] = []
172
+ self._entries_by_property: Dict[str, List[JournalEntry]] = {}
173
+
174
+ def add_entry(self, entry: JournalEntry) -> JournalEntry:
175
+ """
176
+ Add a journal entry to the journal.
177
+
178
+ Args:
179
+ entry: The JournalEntry to add
180
+
181
+ Returns:
182
+ The added entry
183
+ """
184
+ self._entries.append(entry)
185
+
186
+ # Index by property ID for fast lookup
187
+ if entry.property:
188
+ prop_id = entry.property.fullId
189
+ if prop_id not in self._entries_by_property:
190
+ self._entries_by_property[prop_id] = []
191
+ self._entries_by_property[prop_id].append(entry)
192
+
193
+ return entry
194
+
195
+ def create_entry(self, date: datetime, headline: str, property_node: Any,
196
+ source_file_info: Any = None) -> JournalEntry:
197
+ """
198
+ Create and add a new journal entry.
199
+
200
+ Args:
201
+ date: The date of the entry
202
+ headline: Short description text
203
+ property_node: The Task or Resource this entry is about
204
+ source_file_info: Optional source file location
205
+
206
+ Returns:
207
+ The created JournalEntry
208
+ """
209
+ entry = JournalEntry(self, date, headline, property_node, source_file_info)
210
+ return self.add_entry(entry)
211
+
212
+ @property
213
+ def entries(self) -> JournalEntryList:
214
+ """Get all entries as a JournalEntryList."""
215
+ return JournalEntryList(self._entries)
216
+
217
+ def __len__(self) -> int:
218
+ """Return the number of entries."""
219
+ return len(self._entries)
220
+
221
+ def __iter__(self):
222
+ """Iterate over entries."""
223
+ return iter(self._entries)
224
+
225
+ def entries_by_task(self, task: 'Task', start: Optional[datetime] = None,
226
+ end: Optional[datetime] = None,
227
+ alert_level: Optional[AlertLevel] = None) -> JournalEntryList:
228
+ """
229
+ Get journal entries for a specific task.
230
+
231
+ Args:
232
+ task: The task to get entries for
233
+ start: Optional start date filter
234
+ end: Optional end date filter
235
+ alert_level: Optional minimum alert level filter
236
+
237
+ Returns:
238
+ JournalEntryList of matching entries
239
+ """
240
+ entries = self._entries_by_property.get(task.fullId, [])
241
+ return self._filter_entries(entries, start, end, alert_level)
242
+
243
+ def entries_by_task_recursive(self, task: 'Task', start: Optional[datetime] = None,
244
+ end: Optional[datetime] = None,
245
+ alert_level: Optional[AlertLevel] = None) -> JournalEntryList:
246
+ """
247
+ Get journal entries for a task and all its children.
248
+
249
+ Args:
250
+ task: The root task
251
+ start: Optional start date filter
252
+ end: Optional end date filter
253
+ alert_level: Optional minimum alert level filter
254
+
255
+ Returns:
256
+ JournalEntryList of matching entries from task and children
257
+ """
258
+ result = []
259
+
260
+ # Get entries for this task
261
+ result.extend(self._entries_by_property.get(task.fullId, []))
262
+
263
+ # Get entries for all children recursively
264
+ if hasattr(task, 'children'):
265
+ for child in task.children:
266
+ result.extend(self.entries_by_task_recursive(child, start, end, alert_level))
267
+
268
+ return self._filter_entries(result, start, end, alert_level)
269
+
270
+ def entries_by_resource(self, resource: 'Resource',
271
+ start: Optional[datetime] = None,
272
+ end: Optional[datetime] = None) -> JournalEntryList:
273
+ """
274
+ Get journal entries authored by a specific resource.
275
+
276
+ Args:
277
+ resource: The author resource
278
+ start: Optional start date filter
279
+ end: Optional end date filter
280
+
281
+ Returns:
282
+ JournalEntryList of entries by this resource
283
+ """
284
+ entries = [e for e in self._entries if e.author == resource]
285
+ return self._filter_entries(entries, start, end)
286
+
287
+ def entries_by_date(self, date: datetime) -> JournalEntryList:
288
+ """
289
+ Get all journal entries for a specific date.
290
+
291
+ Args:
292
+ date: The date to query
293
+
294
+ Returns:
295
+ JournalEntryList of entries on that date
296
+ """
297
+ # Compare dates only (ignore time)
298
+ target_date = date.date() if isinstance(date, datetime) else date
299
+ entries = [e for e in self._entries
300
+ if e.date and e.date.date() == target_date]
301
+ return JournalEntryList(entries)
302
+
303
+ def entries_in_range(self, start: datetime, end: datetime) -> JournalEntryList:
304
+ """
305
+ Get all journal entries within a date range.
306
+
307
+ Args:
308
+ start: Start date (inclusive)
309
+ end: End date (exclusive)
310
+
311
+ Returns:
312
+ JournalEntryList of entries in the range
313
+ """
314
+ return self._filter_entries(self._entries, start, end)
315
+
316
+ def current_entries(self, scenario_idx: int, property_node: Any,
317
+ start: datetime, end: datetime,
318
+ alert_level: Optional[AlertLevel] = None) -> JournalEntryList:
319
+ """
320
+ Get current (most recent) entries for a property within a time range.
321
+
322
+ This is used to get the latest status for tasks/resources.
323
+
324
+ Args:
325
+ scenario_idx: Scenario index
326
+ property_node: The task or resource
327
+ start: Start of time range
328
+ end: End of time range
329
+ alert_level: Optional minimum alert level
330
+
331
+ Returns:
332
+ JournalEntryList of current entries
333
+ """
334
+ entries = self._entries_by_property.get(property_node.fullId, [])
335
+ filtered = self._filter_entries(entries, start, end, alert_level)
336
+
337
+ # Sort by date descending and return most recent
338
+ filtered.sort_by([('date', False)])
339
+ return filtered
340
+
341
+ def _filter_entries(self, entries: List[JournalEntry],
342
+ start: Optional[datetime] = None,
343
+ end: Optional[datetime] = None,
344
+ alert_level: Optional[AlertLevel] = None) -> JournalEntryList:
345
+ """
346
+ Apply date and alert level filters to entries.
347
+
348
+ Args:
349
+ entries: List of entries to filter
350
+ start: Optional start date (inclusive)
351
+ end: Optional end date (exclusive)
352
+ alert_level: Optional minimum alert level
353
+
354
+ Returns:
355
+ Filtered JournalEntryList
356
+ """
357
+ result = list(entries)
358
+
359
+ if start:
360
+ result = [e for e in result if e.date and e.date >= start]
361
+
362
+ if end:
363
+ result = [e for e in result if e.date and e.date < end]
364
+
365
+ if alert_level is not None:
366
+ result = [e for e in result if e.alert_level >= alert_level]
367
+
368
+ return JournalEntryList(result)
369
+
370
+ def clear(self) -> None:
371
+ """Remove all entries from the journal."""
372
+ self._entries.clear()
373
+ self._entries_by_property.clear()
374
+
375
+ def to_list(self) -> List[Dict[str, Any]]:
376
+ """Convert all entries to a list of dictionaries."""
377
+ return [entry.to_dict() for entry in self._entries]
@@ -0,0 +1,14 @@
1
+ class Leave:
2
+ Types = {
3
+ 'project': 0,
4
+ 'holiday': 1,
5
+ 'sick': 2,
6
+ 'special': 3,
7
+ 'unpaid': 4,
8
+ 'annual': 5,
9
+ 'unemployed': 6
10
+ }
11
+
12
+ def __init__(self, interval, type_idx):
13
+ self.interval = interval
14
+ self.type_idx = type_idx