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