a quick hack to display total payments per milestone
[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 from collections import OrderedDict
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 = OrderedDict()
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 = OrderedDict()
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 self.milestone_payments = OrderedDict()
515 # useful debug prints
516 #for bug in bugs:
517 # node = self.nodes[bug.id]
518 # print ("bug added", bug.id, node, node.parent.immediate_children)
519
520 @cached_property
521 def roots(self) -> Set[Node]:
522 roots = set()
523 for node in self.nodes.values():
524 # calling .root also checks for loop errors
525 root = node.root
526 roots.add(root)
527 return roots
528
529 def _get_node_errors(self, root: Node, node: Node,
530 errors: List[BudgetGraphBaseError]):
531 if node.milestone_str is None:
532 if node.budget_including_subtasks != 0 \
533 or node.budget_excluding_subtasks != 0:
534 errors.append(BudgetGraphMoneyWithNoMilestone(
535 node.bug.id, root.bug.id))
536
537 try:
538 # check for milestone errors
539 node.milestone
540 if root == node and node.milestone is not None \
541 and node.milestone.canonical_bug_id != node.bug.id:
542 if node.budget_including_subtasks != 0 \
543 or node.budget_excluding_subtasks != 0:
544 errors.append(BudgetGraphIncorrectRootForMilestone(
545 node.bug.id, node.milestone.identifier,
546 node.milestone.canonical_bug_id
547 ))
548 except BudgetGraphBaseError as e:
549 errors.append(e)
550
551 try:
552 # check for status errors
553 node.status
554 except BudgetGraphBaseError as e:
555 errors.append(e)
556
557 try:
558 # check for assignee errors
559 node.assignee
560 except BudgetGraphBaseError as e:
561 errors.append(e)
562
563 if node.milestone_str != root.milestone_str:
564 errors.append(BudgetGraphMilestoneMismatch(
565 node.bug.id, root.bug.id))
566
567 if node.budget_excluding_subtasks < 0 \
568 or node.budget_including_subtasks < 0:
569 errors.append(BudgetGraphNegativeMoney(
570 node.bug.id, root.bug.id))
571
572 childlist = []
573 subtasks_total = Money(0)
574 for child in node.immediate_children:
575 subtasks_total += child.fixed_budget_including_subtasks
576 childlist.append(child.bug.id)
577 # useful debug prints
578 #print ("subtask total", node.bug.id, root.bug.id, subtasks_total,
579 # childlist)
580
581 payees_total = Money(0)
582 payee_payments = OrderedDict()
583 for payment in node.payments.values():
584 if payment.amount < 0:
585 errors.append(BudgetGraphNegativePayeeMoney(
586 node.bug.id, root.bug.id, payment.payee_key))
587 payees_total += payment.amount
588 try:
589 # check for payee errors
590 payment.payee
591 previous_payment = payee_payments.get(payment.payee)
592 if previous_payment is not None:
593 # NOT AN ERROR
594 print ("NOT AN ERROR", BudgetGraphDuplicatePayeesForTask(
595 node.bug.id, root.bug.id,
596 previous_payment[-1].payee_key, payment.payee_key))
597 payee_payments[payment.payee].append(payment)
598 else:
599 payee_payments[payment.payee] = [payment]
600 except BudgetGraphBaseError as e:
601 errors.append(e)
602
603 def set_including_from_excluding_and_error():
604 node.fixed_budget_including_subtasks = \
605 node.budget_excluding_subtasks + subtasks_total
606 errors.append(
607 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
608 node.bug.id, root.bug.id,
609 node.fixed_budget_including_subtasks))
610
611 def set_including_from_payees_and_error():
612 node.fixed_budget_including_subtasks = \
613 payees_total + subtasks_total
614 errors.append(
615 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
616 node.bug.id, root.bug.id,
617 node.fixed_budget_including_subtasks))
618
619 def set_excluding_from_including_and_error():
620 node.fixed_budget_excluding_subtasks = \
621 node.budget_including_subtasks - subtasks_total
622 errors.append(
623 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
624 node.bug.id, root.bug.id,
625 node.fixed_budget_excluding_subtasks))
626
627 def set_excluding_from_payees_and_error():
628 node.fixed_budget_excluding_subtasks = \
629 payees_total
630 errors.append(
631 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
632 node.bug.id, root.bug.id,
633 node.fixed_budget_excluding_subtasks))
634
635 def set_payees_from_including_and_error():
636 fixed_payees_total = \
637 node.budget_including_subtasks - subtasks_total
638 errors.append(BudgetGraphPayeesMoneyMismatch(
639 node.bug.id, root.bug.id, payees_total, fixed_payees_total))
640
641 def set_payees_from_excluding_and_error():
642 fixed_payees_total = \
643 node.budget_excluding_subtasks
644 errors.append(BudgetGraphPayeesMoneyMismatch(
645 node.bug.id, root.bug.id, payees_total, fixed_payees_total))
646
647 payees_matches_including = \
648 node.budget_including_subtasks - subtasks_total == payees_total
649 payees_matches_excluding = \
650 node.budget_excluding_subtasks == payees_total
651 including_matches_excluding = \
652 node.budget_including_subtasks - subtasks_total \
653 == node.budget_excluding_subtasks
654
655 if payees_matches_including \
656 and payees_matches_excluding \
657 and including_matches_excluding:
658 pass # no error
659 elif payees_matches_including:
660 # can't have 2 match without all 3 matching
661 assert not payees_matches_excluding
662 assert not including_matches_excluding
663 if node.budget_including_subtasks == 0 and len(node.payments) == 0:
664 set_including_from_excluding_and_error()
665 else:
666 set_excluding_from_including_and_error()
667 elif payees_matches_excluding:
668 # can't have 2 match without all 3 matching
669 assert not payees_matches_including
670 assert not including_matches_excluding
671 if node.budget_excluding_subtasks == 0 and len(node.payments) == 0:
672 if node.budget_including_subtasks == 0:
673 set_including_from_excluding_and_error()
674 else:
675 set_excluding_from_including_and_error()
676 else:
677 set_including_from_excluding_and_error()
678 elif including_matches_excluding:
679 # can't have 2 match without all 3 matching
680 assert not payees_matches_including
681 assert not payees_matches_excluding
682 if len(node.payments) == 0:
683 pass # no error -- payees is just not set
684 elif node.budget_excluding_subtasks == 0 \
685 and node.budget_including_subtasks == 0:
686 set_excluding_from_payees_and_error()
687 set_including_from_payees_and_error()
688 else:
689 set_payees_from_excluding_and_error()
690 else:
691 # nothing matches
692 if len(node.payments) == 0:
693 # payees unset -- don't need to set payees
694 if node.budget_including_subtasks == 0:
695 set_including_from_excluding_and_error()
696 else:
697 set_excluding_from_including_and_error()
698 elif node.budget_excluding_subtasks == 0 \
699 and node.budget_including_subtasks == 0:
700 set_excluding_from_payees_and_error()
701 set_including_from_payees_and_error()
702 elif node.budget_excluding_subtasks == 0:
703 set_excluding_from_including_and_error()
704 set_payees_from_including_and_error()
705 elif node.budget_including_subtasks == 0:
706 set_including_from_excluding_and_error()
707 set_payees_from_excluding_and_error()
708 else:
709 set_including_from_excluding_and_error()
710 set_payees_from_excluding_and_error()
711
712 def get_errors(self) -> List[BudgetGraphBaseError]:
713 errors = []
714 try:
715 roots = self.roots
716 except BudgetGraphBaseError as e:
717 errors.append(e)
718 return errors
719
720 for root in roots:
721 try:
722 for child in reversed(list(root.children_breadth_first())):
723 try:
724 self._get_node_errors(root, child, errors)
725 except BudgetGraphBaseError as e:
726 errors.append(e)
727 self._get_node_errors(root, root, errors)
728 except BudgetGraphBaseError as e:
729 errors.append(e)
730 return errors
731
732 @cached_property
733 def assigned_nodes(self) -> Dict[Person, List[Node]]:
734 retval = {person: [] for person in self.config.people.values()}
735 for node in self.nodes.values():
736 retval[node.assignee].append(node)
737 return retval
738
739 @cached_property
740 def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]:
741 retval = OrderedDict()
742 for person in self.config.people.values():
743 milestone_payments = OrderedDict()
744 for milestone in self.config.milestones.values():
745 milestone_payments[milestone] = [] # per-person payments
746 self.milestone_payments[milestone] = [] # global payments
747 retval[person] = milestone_payments
748 for node in self.nodes.values():
749 if node.milestone is not None:
750 for payment in node.payments.values():
751 retval[payment.payee][node.milestone].append(payment)
752 # add to global payments as well
753 self.milestone_payments[node.milestone].append(payment)
754 return retval
755
756 def __repr__(self):
757 nodes = [*self.nodes.values()]
758 try:
759 roots = [_NodeSimpleReprWrapper(i) for i in self.roots]
760 roots.sort()
761 roots_str = repr(roots)
762 except BudgetGraphBaseError:
763 roots_str = "<failed>"
764 return f"BudgetGraph{{nodes={nodes!r}, roots={roots}}}"