a6de7a734feb17e5e9cdf27687986d5db5774097
[utils.git] / src / budget_sync / budget_graph.py
1 from bugzilla.bug import Bug
2 from typing import Set, Dict, Iterable, Optional, List, Union, Any
3 from budget_sync.util import BugStatus
4 from budget_sync.money import Money
5 from budget_sync.config import Config, Person, Milestone
6 from functools import cached_property
7 import toml
8 import sys
9 import enum
10 from collections import deque
11 from datetime import date, time, datetime
12
13
14 class BudgetGraphBaseError(Exception):
15 pass
16
17
18 class BudgetGraphParseError(BudgetGraphBaseError):
19 def __init__(self, bug_id: int):
20 self.bug_id = bug_id
21
22
23 class BudgetGraphPayeesParseError(BudgetGraphParseError):
24 def __init__(self, bug_id: int, msg: str):
25 super().__init__(bug_id)
26 self.msg = msg
27
28 def __str__(self):
29 return f"Failed to parse cf_payees_list field of " \
30 f"bug #{self.bug_id}: {self.msg}"
31
32
33 class BudgetGraphUnknownAssignee(BudgetGraphParseError):
34 def __init__(self, bug_id: int, assignee: str):
35 super().__init__(bug_id)
36 self.assignee = assignee
37
38 def __str__(self):
39 return f"Bug #{self.bug_id} is assigned to an unknown person: " \
40 f"{self.assignee!r}"
41
42
43 class BudgetGraphLoopError(BudgetGraphBaseError):
44 def __init__(self, bug_ids: List[int]):
45 self.bug_ids = bug_ids
46
47 def __str__(self):
48 retval = f"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
49 retval += " -> ".join((f"#{i}" for i in self.bug_ids))
50 return retval
51
52
53 class _NodeSimpleReprWrapper:
54 def __init__(self, node: "Node"):
55 self.node = node
56
57 def __repr__(self):
58 return f"#{self.node.bug.id}"
59
60 def __lt__(self, other):
61 # for list.sort()
62 return self.node.bug.id < other.node.bug.id
63
64
65 class PayeeState(enum.Enum):
66 NotYetSubmitted = "not yet submitted"
67 Submitted = "submitted"
68 Paid = "paid"
69
70
71 _Date = Union[date, datetime]
72
73
74 def _parse_money_from_toml(value: Any) -> Money:
75 if not isinstance(value, (int, str)):
76 msg = f"monetary amount is not a string or integer " \
77 f"(to use fractional amounts such as 123.45, write " \
78 f"\"123.45\"): {value!r}"
79 raise ValueError(msg)
80 return Money(value)
81
82
83 def _parse_date_time_or_none_from_toml(value: Any) -> Optional[_Date]:
84 if value is None or isinstance(value, (date, datetime)):
85 return value
86 elif isinstance(value, time):
87 msg = f"just a time of day by itself is not enough," \
88 f" a date must be included: {str(value)}"
89 raise ValueError(msg)
90 elif isinstance(value, bool):
91 msg = f"invalid date: {str(value).lower()}"
92 raise ValueError(msg)
93 elif isinstance(value, (str, int, float)):
94 msg = f"invalid date: {value!r}"
95 raise ValueError(msg)
96 else:
97 msg = f"invalid date"
98 raise ValueError(msg)
99
100
101 class Payment:
102 def __init__(self,
103 node: "Node",
104 payee_key: str,
105 amount: Money,
106 paid: Optional[_Date],
107 submitted: Optional[_Date]):
108 self.node = node
109 self.payee_key = payee_key
110 self.amount = amount
111 self.paid = paid
112 self.submitted = submitted
113
114 @cached_property
115 def payee(self) -> Person:
116 try:
117 return self.node.graph.config.all_names[self.payee_key]
118 except KeyError:
119 msg = f"unknown payee name: {self.payee_key!r} is not the name " \
120 f"or an alias of any known person"
121 raise BudgetGraphPayeesParseError(self.node.bug.id, msg) \
122 .with_traceback(sys.exc_info()[2])
123
124 @property
125 def state(self):
126 if self.paid is not None:
127 return PayeeState.Paid
128 if self.submitted is not None:
129 return PayeeState.Submitted
130 return PayeeState.NotYetSubmitted
131
132 @staticmethod
133 def _from_toml(node: "Node", payee_key: str, toml_value: Any) -> "Payment":
134 paid = None
135 submitted = None
136 known_keys = ("paid", "submitted", "amount")
137 if isinstance(toml_value, dict):
138 try:
139 amount = toml_value['amount']
140 except KeyError:
141 msg = f"value for key {payee_key!r} is missing the " \
142 f"`amount` field which is required"
143 raise BudgetGraphPayeesParseError(node.bug.id, msg) \
144 .with_traceback(sys.exc_info()[2])
145 for k, v in toml_value.items():
146 if k in ("paid", "submitted"):
147 try:
148 parsed_value = _parse_date_time_or_none_from_toml(v)
149 except ValueError as e:
150 msg = f"failed to parse `{k}` field for" \
151 f" key {payee_key!r}: {e}"
152 raise BudgetGraphPayeesParseError(
153 node.bug.id, msg) \
154 .with_traceback(sys.exc_info()[2])
155 if k == "paid":
156 paid = parsed_value
157 else:
158 assert k == "submitted"
159 submitted = parsed_value
160 if k not in known_keys:
161 msg = f"value for key {payee_key!r} has an unknown" \
162 f" field: `{k}`"
163 raise BudgetGraphPayeesParseError(node.bug.id, msg) \
164 .with_traceback(sys.exc_info()[2])
165 try:
166 paid = _parse_date_time_or_none_from_toml(
167 toml_value.get('paid'))
168 except ValueError as e:
169 msg = f"failed to parse `paid` field for" \
170 f" key {payee_key!r}: {e}"
171 raise BudgetGraphPayeesParseError(
172 node.bug.id, msg) \
173 .with_traceback(sys.exc_info()[2])
174 try:
175 submitted = _parse_date_time_or_none_from_toml(
176 toml_value.get('submitted'))
177 except ValueError as e:
178 msg = f"failed to parse `submitted` field for" \
179 f" key {payee_key!r}: {e}"
180 raise BudgetGraphPayeesParseError(
181 node.bug.id, msg) \
182 .with_traceback(sys.exc_info()[2])
183 elif isinstance(toml_value, (int, str, float)):
184 # float included for better error messages
185 amount = toml_value
186 else:
187 msg = f"value for key {payee_key!r} is invalid -- it should " \
188 f"either be a monetary value or a table"
189 raise BudgetGraphPayeesParseError(node.bug.id, msg)
190 try:
191 amount = _parse_money_from_toml(amount)
192 except ValueError as e:
193 msg = f"failed to parse monetary amount for key {payee_key!r}: {e}"
194 raise BudgetGraphPayeesParseError(
195 node.bug.id, msg) \
196 .with_traceback(sys.exc_info()[2])
197 return Payment(node=node, payee_key=payee_key, amount=amount,
198 paid=paid, submitted=submitted)
199
200 def __repr__(self):
201 try:
202 payee = f"Person<{self.payee.identifier!r}>"
203 except BudgetGraphBaseError:
204 payee = "<unknown person>"
205 return (f"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
206 f"payee={payee}, "
207 f"payee_key={self.payee_key!r}, "
208 f"amount={self.amount}, "
209 f"state={self.state.name}, "
210 f"paid={str(self.paid)}, "
211 f"submitted={str(self.submitted)})")
212
213
214 class BudgetGraphUnknownMilestone(BudgetGraphParseError):
215 def __init__(self, bug_id: int, milestone_str: str):
216 super().__init__(bug_id)
217 self.milestone_str = milestone_str
218
219 def __str__(self):
220 return f"failed to parse cf_nlnet_milestone field of bug " \
221 f"#{self.bug_id}: unknown milestone: {self.milestone_str!r}"
222
223
224 class BudgetGraphUnknownStatus(BudgetGraphParseError):
225 def __init__(self, bug_id: int, status_str: str):
226 super().__init__(bug_id)
227 self.status_str = status_str
228
229 def __str__(self):
230 return f"failed to parse status field of bug " \
231 f"#{self.bug_id}: unknown status: {self.status_str!r}"
232
233
234 class Node:
235 graph: "BudgetGraph"
236 bug: Bug
237 parent_id: Optional[int]
238 immediate_children: Set["Node"]
239 budget_excluding_subtasks: Money
240 budget_including_subtasks: Money
241 fixed_budget_excluding_subtasks: Money
242 fixed_budget_including_subtasks: Money
243 milestone_str: Optional[str]
244
245 def __init__(self, graph: "BudgetGraph", bug: Bug):
246 self.graph = graph
247 self.bug = bug
248 self.parent_id = getattr(bug, "cf_budget_parent", None)
249 self.immediate_children = set()
250 self.budget_excluding_subtasks = Money.from_str(bug.cf_budget)
251 self.fixed_budget_excluding_subtasks = self.budget_excluding_subtasks
252 self.budget_including_subtasks = Money.from_str(bug.cf_total_budget)
253 self.fixed_budget_including_subtasks = self.budget_including_subtasks
254 self.milestone_str = bug.cf_nlnet_milestone
255 if self.milestone_str == "---":
256 self.milestone_str = None
257
258 @property
259 def status(self) -> BugStatus:
260 try:
261 return BugStatus.cast(self.bug.status)
262 except ValueError:
263 new_err = BudgetGraphUnknownStatus(self.bug.id, self.bug.status)
264 raise new_err.with_traceback(sys.exc_info()[2])
265
266 @cached_property
267 def assignee(self) -> Person:
268 try:
269 return self.graph.config.all_names[self.bug.assigned_to]
270 except KeyError:
271 raise BudgetGraphUnknownAssignee(self.bug.id,
272 self.bug.assigned_to) \
273 .with_traceback(sys.exc_info()[2])
274
275 @cached_property
276 def bug_url(self) -> str:
277 return f"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \
278 f"id={self.bug.id}"
279
280 @cached_property
281 def milestone(self) -> Optional[Milestone]:
282 try:
283 if self.milestone_str is not None:
284 return self.graph.config.milestones[self.milestone_str]
285 return None
286 except KeyError:
287 new_err = BudgetGraphUnknownMilestone(
288 self.bug.id, self.milestone_str)
289 raise new_err.with_traceback(sys.exc_info()[2])
290
291 @cached_property
292 def payments(self) -> Dict[str, Payment]:
293 try:
294 parsed = toml.loads(self.bug.cf_payees_list)
295 except toml.TomlDecodeError as e:
296 new_err = BudgetGraphPayeesParseError(
297 self.bug.id, f"TOML parse error: {e}")
298 raise new_err.with_traceback(sys.exc_info()[2])
299 retval = {}
300 for key, value in parsed.items():
301 if not isinstance(key, str):
302 raise BudgetGraphPayeesParseError(
303 self.bug.id, f"key is not a string: {key!r}")
304 retval[key] = Payment._from_toml(self, key, value)
305 return retval
306
307 @property
308 def parent(self) -> Optional["Node"]:
309 if self.parent_id is not None:
310 return self.graph.nodes[self.parent_id]
311 return None
312
313 def parents(self) -> Iterable["Node"]:
314 parent = self.parent
315 while parent is not None:
316 yield parent
317 parent = parent.parent
318
319 def _raise_loop_error(self):
320 bug_ids = []
321 for parent in self.parents():
322 bug_ids.append(parent.bug.id)
323 if parent == self:
324 break
325 raise BudgetGraphLoopError(bug_ids)
326
327 @cached_property
328 def root(self) -> "Node":
329 # also checks for loop errors
330 retval = self
331 for parent in self.parents():
332 retval = parent
333 if parent == self:
334 self._raise_loop_error()
335 return retval
336
337 def children(self) -> Iterable["Node"]:
338 def visitor(node: Node) -> Iterable[Node]:
339 for i in node.immediate_children:
340 yield i
341 yield from visitor(i)
342 return visitor(self)
343
344 def children_breadth_first(self) -> Iterable["Node"]:
345 q = deque(self.immediate_children)
346 while True:
347 try:
348 node = q.popleft()
349 except IndexError:
350 return
351 q.extend(node.immediate_children)
352 yield node
353
354 def __eq__(self, other):
355 return self.bug.id == other.bug.id
356
357 def __hash__(self):
358 return self.bug.id
359
360 def __repr__(self):
361 try:
362 root = _NodeSimpleReprWrapper(self.root)
363 except BudgetGraphLoopError:
364 root = "<loop error>"
365 try:
366 milestone = repr(self.milestone)
367 except BudgetGraphBaseError:
368 milestone = "<unknown milestone>"
369 try:
370 status = repr(self.status)
371 except BudgetGraphBaseError:
372 status = f"<unknown status: {self.bug.status!r}>"
373 try:
374 assignee = f"Person<{self.assignee.identifier!r}>"
375 except BudgetGraphBaseError:
376 assignee = f"<unknown assignee: {self.bug.assigned_to!r}>"
377 immediate_children = []
378 for i in self.immediate_children:
379 immediate_children.append(_NodeSimpleReprWrapper(i))
380 immediate_children.sort()
381 parent = f"#{self.parent_id}" if self.parent_id is not None else None
382 payments = list(self.payments.values())
383 return (f"Node(graph=..., "
384 f"id={_NodeSimpleReprWrapper(self)}, "
385 f"root={root}, "
386 f"parent={parent}, "
387 f"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
388 f"budget_including_subtasks={self.budget_including_subtasks}, "
389 f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
390 f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
391 f"milestone_str={self.milestone_str!r}, "
392 f"milestone={milestone}, "
393 f"immediate_children={immediate_children!r}, "
394 f"payments={payments!r}, "
395 f"status={status}, "
396 f"assignee={assignee})")
397
398
399 class BudgetGraphError(BudgetGraphBaseError):
400 def __init__(self, bug_id: int, root_bug_id: int):
401 self.bug_id = bug_id
402 self.root_bug_id = root_bug_id
403
404
405 class BudgetGraphMoneyWithNoMilestone(BudgetGraphError):
406 def __str__(self):
407 return (f"Bug assigned money but without"
408 f" any assigned milestone: #{self.bug_id}")
409
410
411 class BudgetGraphMilestoneMismatch(BudgetGraphError):
412 def __str__(self):
413 return (f"Bug's assigned milestone doesn't match the milestone "
414 f"assigned to the root bug: descendant bug"
415 f" #{self.bug_id}, root bug"
416 f" #{self.root_bug_id}")
417
418
419 class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError):
420 def __init__(self, bug_id: int, root_bug_id: int,
421 expected_budget_excluding_subtasks: Money):
422 super().__init__(bug_id, root_bug_id)
423 self.expected_budget_excluding_subtasks = \
424 expected_budget_excluding_subtasks
425
426 def __str__(self):
427 return (f"Budget assigned to task excluding subtasks "
428 f"(cf_budget field) doesn't match calculated value: "
429 f"bug #{self.bug_id}, calculated value"
430 f" {self.expected_budget_excluding_subtasks}")
431
432
433 class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError):
434 def __init__(self, bug_id: int, root_bug_id: int,
435 expected_budget_including_subtasks: Money):
436 super().__init__(bug_id, root_bug_id)
437 self.expected_budget_including_subtasks = \
438 expected_budget_including_subtasks
439
440 def __str__(self):
441 return (f"Budget assigned to task including subtasks "
442 f"(cf_total_budget field) doesn't match calculated value: "
443 f"bug #{self.bug_id}, calculated value"
444 f" {self.expected_budget_including_subtasks}")
445
446
447 class BudgetGraphNegativeMoney(BudgetGraphError):
448 def __str__(self):
449 return (f"Budget assigned to task is less than zero: "
450 f"bug #{self.bug_id}")
451
452
453 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError):
454 def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money,
455 expected_payees_total: Money):
456 super().__init__(bug_id, root_bug_id)
457 self.payees_total = payees_total
458 self.expected_payees_total = expected_payees_total
459
460 def __str__(self):
461 return (f"Total budget assigned to payees (cf_payees_list) doesn't "
462 f"match expected value: bug #{self.bug_id}, calculated total "
463 f"{self.payees_total}, expected value "
464 f"{self.expected_payees_total}")
465
466
467 class BudgetGraphNegativePayeeMoney(BudgetGraphError):
468 def __init__(self, bug_id: int, root_bug_id: int, payee_key: str):
469 super().__init__(bug_id, root_bug_id)
470 self.payee_key = payee_key
471
472 def __str__(self):
473 return (f"Budget assigned to payee for task is less than zero: "
474 f"bug #{self.bug_id}, payee {self.payee_key!r}")
475
476
477 class BudgetGraphDuplicatePayeesForTask(BudgetGraphError):
478 def __init__(self, bug_id: int, root_bug_id: int, payee1_key: str, payee2_key: str):
479 super().__init__(bug_id, root_bug_id)
480 self.payee1_key = payee1_key
481 self.payee2_key = payee2_key
482
483 def __str__(self):
484 return (f"Budget assigned to multiple aliases of the same person in "
485 f"a single task: bug #{self.bug_id}, budget assigned to both "
486 f"{self.payee1_key!r} and {self.payee2_key!r}")
487
488
489 class BudgetGraphIncorrectRootForMilestone(BudgetGraphError):
490 def __init__(self, bug_id: int, milestone: str, milestone_canonical_bug_id: int):
491 super().__init__(bug_id, bug_id)
492 self.milestone = milestone
493 self.milestone_canonical_bug_id = milestone_canonical_bug_id
494
495 def __str__(self):
496 return (f"Bug #{self.bug_id} is not the canonical root bug for "
497 f"assigned milestone {self.milestone!r} but has no parent "
498 f"bug set: the milestone's canonical root bug is "
499 f"#{self.milestone_canonical_bug_id}")
500
501
502 class BudgetGraph:
503 nodes: Dict[int, Node]
504
505 def __init__(self, bugs: Iterable[Bug], config: Config):
506 self.nodes = {}
507 self.config = config
508 for bug in bugs:
509 self.nodes[bug.id] = Node(self, bug)
510 for node in self.nodes.values():
511 if node.parent is None:
512 continue
513 node.parent.immediate_children.add(node)
514 # useful debug prints
515 #for bug in bugs:
516 # node = self.nodes[bug.id]
517 # print ("bug added", bug.id, node, node.parent.immediate_children)
518
519 @cached_property
520 def roots(self) -> Set[Node]:
521 roots = set()
522 for node in self.nodes.values():
523 # calling .root also checks for loop errors
524 root = node.root
525 roots.add(root)
526 return roots
527
528 def _get_node_errors(self, root: Node, node: Node,
529 errors: List[BudgetGraphBaseError]):
530 if node.milestone_str is None:
531 if node.budget_including_subtasks != 0 \
532 or node.budget_excluding_subtasks != 0:
533 errors.append(BudgetGraphMoneyWithNoMilestone(
534 node.bug.id, root.bug.id))
535
536 try:
537 # check for milestone errors
538 node.milestone
539 if root == node and node.milestone is not None \
540 and node.milestone.canonical_bug_id != node.bug.id:
541 if node.budget_including_subtasks != 0 \
542 or node.budget_excluding_subtasks != 0:
543 errors.append(BudgetGraphIncorrectRootForMilestone(
544 node.bug.id, node.milestone.identifier,
545 node.milestone.canonical_bug_id
546 ))
547 except BudgetGraphBaseError as e:
548 errors.append(e)
549
550 try:
551 # check for status errors
552 node.status
553 except BudgetGraphBaseError as e:
554 errors.append(e)
555
556 try:
557 # check for assignee errors
558 node.assignee
559 except BudgetGraphBaseError as e:
560 errors.append(e)
561
562 if node.milestone_str != root.milestone_str:
563 errors.append(BudgetGraphMilestoneMismatch(
564 node.bug.id, root.bug.id))
565
566 if node.budget_excluding_subtasks < 0 \
567 or node.budget_including_subtasks < 0:
568 errors.append(BudgetGraphNegativeMoney(
569 node.bug.id, root.bug.id))
570
571 childlist = []
572 subtasks_total = Money(0)
573 for child in node.immediate_children:
574 subtasks_total += child.fixed_budget_including_subtasks
575 childlist.append(child.bug.id)
576 # useful debug prints
577 #print ("subtask total", node.bug.id, root.bug.id, subtasks_total,
578 # childlist)
579
580 payees_total = Money(0)
581 payee_payments = {}
582 for payment in node.payments.values():
583 if payment.amount < 0:
584 errors.append(BudgetGraphNegativePayeeMoney(
585 node.bug.id, root.bug.id, payment.payee_key))
586 payees_total += payment.amount
587 try:
588 # check for payee errors
589 payment.payee
590 previous_payment = payee_payments.get(payment.payee)
591 if previous_payment is not None:
592 # NOT AN ERROR
593 print ("NOT AN ERROR", BudgetGraphDuplicatePayeesForTask(
594 node.bug.id, root.bug.id,
595 previous_payment[-1].payee_key, payment.payee_key))
596 payee_payments[payment.payee].append(payment)
597 else:
598 payee_payments[payment.payee] = [payment]
599 except BudgetGraphBaseError as e:
600 errors.append(e)
601
602 def set_including_from_excluding_and_error():
603 node.fixed_budget_including_subtasks = \
604 node.budget_excluding_subtasks + subtasks_total
605 errors.append(
606 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
607 node.bug.id, root.bug.id,
608 node.fixed_budget_including_subtasks))
609
610 def set_including_from_payees_and_error():
611 node.fixed_budget_including_subtasks = \
612 payees_total + subtasks_total
613 errors.append(
614 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
615 node.bug.id, root.bug.id,
616 node.fixed_budget_including_subtasks))
617
618 def set_excluding_from_including_and_error():
619 node.fixed_budget_excluding_subtasks = \
620 node.budget_including_subtasks - subtasks_total
621 errors.append(
622 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
623 node.bug.id, root.bug.id,
624 node.fixed_budget_excluding_subtasks))
625
626 def set_excluding_from_payees_and_error():
627 node.fixed_budget_excluding_subtasks = \
628 payees_total
629 errors.append(
630 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
631 node.bug.id, root.bug.id,
632 node.fixed_budget_excluding_subtasks))
633
634 def set_payees_from_including_and_error():
635 fixed_payees_total = \
636 node.budget_including_subtasks - subtasks_total
637 errors.append(BudgetGraphPayeesMoneyMismatch(
638 node.bug.id, root.bug.id, payees_total, fixed_payees_total))
639
640 def set_payees_from_excluding_and_error():
641 fixed_payees_total = \
642 node.budget_excluding_subtasks
643 errors.append(BudgetGraphPayeesMoneyMismatch(
644 node.bug.id, root.bug.id, payees_total, fixed_payees_total))
645
646 payees_matches_including = \
647 node.budget_including_subtasks - subtasks_total == payees_total
648 payees_matches_excluding = \
649 node.budget_excluding_subtasks == payees_total
650 including_matches_excluding = \
651 node.budget_including_subtasks - subtasks_total \
652 == node.budget_excluding_subtasks
653
654 if payees_matches_including \
655 and payees_matches_excluding \
656 and including_matches_excluding:
657 pass # no error
658 elif payees_matches_including:
659 # can't have 2 match without all 3 matching
660 assert not payees_matches_excluding
661 assert not including_matches_excluding
662 if node.budget_including_subtasks == 0 and len(node.payments) == 0:
663 set_including_from_excluding_and_error()
664 else:
665 set_excluding_from_including_and_error()
666 elif payees_matches_excluding:
667 # can't have 2 match without all 3 matching
668 assert not payees_matches_including
669 assert not including_matches_excluding
670 if node.budget_excluding_subtasks == 0 and len(node.payments) == 0:
671 if node.budget_including_subtasks == 0:
672 set_including_from_excluding_and_error()
673 else:
674 set_excluding_from_including_and_error()
675 else:
676 set_including_from_excluding_and_error()
677 elif including_matches_excluding:
678 # can't have 2 match without all 3 matching
679 assert not payees_matches_including
680 assert not payees_matches_excluding
681 if len(node.payments) == 0:
682 pass # no error -- payees is just not set
683 elif node.budget_excluding_subtasks == 0 \
684 and node.budget_including_subtasks == 0:
685 set_excluding_from_payees_and_error()
686 set_including_from_payees_and_error()
687 else:
688 set_payees_from_excluding_and_error()
689 else:
690 # nothing matches
691 if len(node.payments) == 0:
692 # payees unset -- don't need to set payees
693 if node.budget_including_subtasks == 0:
694 set_including_from_excluding_and_error()
695 else:
696 set_excluding_from_including_and_error()
697 elif node.budget_excluding_subtasks == 0 \
698 and node.budget_including_subtasks == 0:
699 set_excluding_from_payees_and_error()
700 set_including_from_payees_and_error()
701 elif node.budget_excluding_subtasks == 0:
702 set_excluding_from_including_and_error()
703 set_payees_from_including_and_error()
704 elif node.budget_including_subtasks == 0:
705 set_including_from_excluding_and_error()
706 set_payees_from_excluding_and_error()
707 else:
708 set_including_from_excluding_and_error()
709 set_payees_from_excluding_and_error()
710
711 def get_errors(self) -> List[BudgetGraphBaseError]:
712 errors = []
713 try:
714 roots = self.roots
715 except BudgetGraphBaseError as e:
716 errors.append(e)
717 return errors
718
719 for root in roots:
720 try:
721 for child in reversed(list(root.children_breadth_first())):
722 try:
723 self._get_node_errors(root, child, errors)
724 except BudgetGraphBaseError as e:
725 errors.append(e)
726 self._get_node_errors(root, root, errors)
727 except BudgetGraphBaseError as e:
728 errors.append(e)
729 return errors
730
731 @cached_property
732 def assigned_nodes(self) -> Dict[Person, List[Node]]:
733 retval = {person: [] for person in self.config.people.values()}
734 for node in self.nodes.values():
735 retval[node.assignee].append(node)
736 return retval
737
738 @cached_property
739 def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]:
740 retval = {}
741 for person in self.config.people.values():
742 milestone_payments = {}
743 for milestone in self.config.milestones.values():
744 milestone_payments[milestone] = []
745 retval[person] = milestone_payments
746 for node in self.nodes.values():
747 if node.milestone is not None:
748 for payment in node.payments.values():
749 retval[payment.payee][node.milestone].append(payment)
750 return retval
751
752 def __repr__(self):
753 nodes = [*self.nodes.values()]
754 try:
755 roots = [_NodeSimpleReprWrapper(i) for i in self.roots]
756 roots.sort()
757 roots_str = repr(roots)
758 except BudgetGraphBaseError:
759 roots_str = "<failed>"
760 return f"BudgetGraph{{nodes={nodes!r}, roots={roots}}}"