add support for reporting the closest task that is in a signed MoU
[utils.git] / src / budget_sync / budget_graph.py
1 from budget_sync.ordered_set import OrderedSet
2 from bugzilla.bug import Bug
3 from typing import Callable, Set, Dict, Iterable, Optional, List, Tuple, Union, Any
4 from budget_sync.util import BugStatus, PrettyPrinter
5 from budget_sync.money import Money
6 from budget_sync.config import Config, Person, Milestone
7 import toml
8 import sys
9 import enum
10 from collections import deque
11 from datetime import date, time, datetime
12 try:
13 from functools import cached_property
14 except ImportError: # :nocov:
15 # compatibility with python < 3.8
16 from cached_property import cached_property # :nocov:
17
18
19 class BudgetGraphBaseError(Exception):
20 pass
21
22
23 class BudgetGraphParseError(BudgetGraphBaseError):
24 def __init__(self, bug_id: int):
25 self.bug_id = bug_id
26
27
28 class BudgetGraphPayeesParseError(BudgetGraphParseError):
29 def __init__(self, bug_id: int, msg: str):
30 super().__init__(bug_id)
31 self.msg = msg
32
33 def __str__(self):
34 return f"Failed to parse cf_payees_list field of " \
35 f"bug #{self.bug_id}: {self.msg}"
36
37
38 class BudgetGraphUnknownAssignee(BudgetGraphParseError):
39 def __init__(self, bug_id: int, assignee: str):
40 super().__init__(bug_id)
41 self.assignee = assignee
42
43 def __str__(self):
44 return f"Bug #{self.bug_id} is assigned to an unknown person: " \
45 f"{self.assignee!r}"
46
47
48 class BudgetGraphLoopError(BudgetGraphBaseError):
49 def __init__(self, bug_ids: List[int]):
50 self.bug_ids = bug_ids
51
52 def __str__(self):
53 retval = f"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
54 retval += " -> ".join((f"#{i}" for i in self.bug_ids))
55 return retval
56
57
58 class _NodeSimpleReprWrapper:
59 def __init__(self, node: "Node"):
60 self.node = node
61
62 def __repr__(self):
63 return f"#{self.node.bug.id}"
64
65 def __lt__(self, other):
66 # for list.sort()
67 return self.node.bug.id < other.node.bug.id
68
69
70 class PayeeState(enum.Enum):
71 NotYetSubmitted = "not yet submitted"
72 Submitted = "submitted"
73 Paid = "paid"
74
75
76 _Date = Union[date, datetime]
77
78
79 def _parse_money_from_toml(value: Any) -> Money:
80 if not isinstance(value, (int, str)):
81 msg = f"monetary amount is not a string or integer " \
82 f"(to use fractional amounts such as 123.45, write " \
83 f"\"123.45\"): {value!r}"
84 raise ValueError(msg)
85 return Money(value)
86
87
88 def _parse_date_time_or_none_from_toml(value: Any) -> Optional[_Date]:
89 if value is None or isinstance(value, (date, datetime)):
90 return value
91 elif isinstance(value, time):
92 msg = f"just a time of day by itself is not enough," \
93 f" a date must be included: {str(value)}"
94 raise ValueError(msg)
95 elif isinstance(value, bool):
96 msg = f"invalid date: {str(value).lower()}"
97 raise ValueError(msg)
98 elif isinstance(value, (str, int, float)):
99 msg = f"invalid date: {value!r}"
100 raise ValueError(msg)
101 else:
102 msg = f"invalid date"
103 raise ValueError(msg)
104
105
106 class Payment:
107 def __init__(self,
108 node: "Node",
109 payee_key: str,
110 amount: Money,
111 paid: Optional[_Date],
112 submitted: Optional[_Date]):
113 self.node = node
114 self.payee_key = payee_key
115 self.amount = amount
116 self.paid = paid
117 self.submitted = submitted
118
119 @cached_property
120 def payee(self) -> Person:
121 try:
122 return self.node.graph.config.all_names[self.payee_key]
123 except KeyError:
124 msg = f"unknown payee name: {self.payee_key!r} is not the name " \
125 f"or an alias of any known person"
126 raise BudgetGraphPayeesParseError(self.node.bug.id, msg) \
127 .with_traceback(sys.exc_info()[2])
128
129 @property
130 def state(self):
131 if self.paid is not None:
132 return PayeeState.Paid
133 if self.submitted is not None:
134 return PayeeState.Submitted
135 return PayeeState.NotYetSubmitted
136
137 @staticmethod
138 def _from_toml(node: "Node", payee_key: str, toml_value: Any) -> "Payment":
139 paid = None
140 submitted = None
141 known_keys = ("paid", "submitted", "amount")
142 if isinstance(toml_value, dict):
143 try:
144 amount = toml_value['amount']
145 except KeyError:
146 msg = f"value for key {payee_key!r} is missing the " \
147 f"`amount` field which is required"
148 raise BudgetGraphPayeesParseError(node.bug.id, msg) \
149 .with_traceback(sys.exc_info()[2])
150 for k, v in toml_value.items():
151 if k in ("paid", "submitted"):
152 try:
153 parsed_value = _parse_date_time_or_none_from_toml(v)
154 except ValueError as e:
155 msg = f"failed to parse `{k}` field for" \
156 f" key {payee_key!r}: {e}"
157 raise BudgetGraphPayeesParseError(
158 node.bug.id, msg) \
159 .with_traceback(sys.exc_info()[2])
160 if k == "paid":
161 paid = parsed_value
162 else:
163 assert k == "submitted"
164 submitted = parsed_value
165 if k not in known_keys:
166 msg = f"value for key {payee_key!r} has an unknown" \
167 f" field: `{k}`"
168 raise BudgetGraphPayeesParseError(node.bug.id, msg) \
169 .with_traceback(sys.exc_info()[2])
170 try:
171 paid = _parse_date_time_or_none_from_toml(
172 toml_value.get('paid'))
173 except ValueError as e:
174 msg = f"failed to parse `paid` field for" \
175 f" key {payee_key!r}: {e}"
176 raise BudgetGraphPayeesParseError(
177 node.bug.id, msg) \
178 .with_traceback(sys.exc_info()[2])
179 try:
180 submitted = _parse_date_time_or_none_from_toml(
181 toml_value.get('submitted'))
182 except ValueError as e:
183 msg = f"failed to parse `submitted` field for" \
184 f" key {payee_key!r}: {e}"
185 raise BudgetGraphPayeesParseError(
186 node.bug.id, msg) \
187 .with_traceback(sys.exc_info()[2])
188 elif isinstance(toml_value, (int, str, float)):
189 # float included for better error messages
190 amount = toml_value
191 else:
192 msg = f"value for key {payee_key!r} is invalid -- it should " \
193 f"either be a monetary value or a table"
194 raise BudgetGraphPayeesParseError(node.bug.id, msg)
195 try:
196 amount = _parse_money_from_toml(amount)
197 except ValueError as e:
198 msg = f"failed to parse monetary amount for key {payee_key!r}: {e}"
199 raise BudgetGraphPayeesParseError(
200 node.bug.id, msg) \
201 .with_traceback(sys.exc_info()[2])
202 return Payment(node=node, payee_key=payee_key, amount=amount,
203 paid=paid, submitted=submitted)
204
205 def __repr__(self):
206 try:
207 payee = f"Person<{self.payee.identifier!r}>"
208 except BudgetGraphBaseError:
209 payee = "<unknown person>"
210 return (f"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
211 f"payee={payee}, "
212 f"payee_key={self.payee_key!r}, "
213 f"amount={self.amount}, "
214 f"state={self.state.name}, "
215 f"paid={str(self.paid)}, "
216 f"submitted={str(self.submitted)})")
217
218
219 @enum.unique
220 class PaymentSummaryState(enum.Enum):
221 Submitted = PayeeState.Submitted
222 Paid = PayeeState.Paid
223 NotYetSubmitted = PayeeState.NotYetSubmitted
224 Inconsistent = None
225
226
227 class PaymentSummary:
228 total_submitted: Money
229 """includes amount paid"""
230
231 def __init__(self, payments: Iterable[Payment]):
232 self.payments = tuple(payments)
233 self.total = Money(0)
234 self.total_paid = Money(0)
235 self.total_submitted = Money(0)
236 self.submitted_date = None
237 self.paid_date = None
238 self.not_submitted = []
239 summary_state = None
240 for payment in self.payments:
241 if summary_state is None:
242 summary_state = PaymentSummaryState(payment.state)
243 self.submitted_date = payment.submitted
244 self.paid_date = payment.paid
245 elif summary_state != PaymentSummaryState(payment.state) \
246 or self.submitted_date != payment.submitted \
247 or self.paid_date != payment.paid:
248 summary_state = PaymentSummaryState.Inconsistent
249 self.paid_date = None
250 self.submitted_date = None
251 self.total += payment.amount
252 if payment.state is PayeeState.Submitted:
253 self.total_submitted += payment.amount
254 elif payment.state is PayeeState.Paid:
255 self.total_submitted += payment.amount
256 self.total_paid += payment.amount
257 else:
258 assert payment.state is PayeeState.NotYetSubmitted
259 self.not_submitted.append(payment.node.bug.id)
260 if summary_state is None:
261 self.state = PaymentSummaryState.NotYetSubmitted
262 else:
263 self.state = summary_state
264
265 def get_not_submitted(self):
266 return self.not_submitted
267
268 def __repr__(self) -> str:
269 return (f"PaymentSummary(total={self.total}, "
270 f"total_paid={self.total_paid}, "
271 f"total_submitted={self.total_submitted}, "
272 f"submitted_date={self.submitted_date}, "
273 f"paid_date={self.paid_date}, "
274 f"state={self.state}, "
275 f"payments={self.payments})")
276
277 def __pretty_print__(self, pp: PrettyPrinter):
278 with pp.type_pp("PaymentSummary") as tpp:
279 tpp.field("total", self.total)
280 tpp.field("total_submitted", self.total_submitted)
281 tpp.field("submitted_date", self.submitted_date)
282 tpp.field("paid_date", self.paid_date)
283 tpp.field("state", self.state)
284 tpp.field("payments", self.payments)
285
286
287 class BudgetGraphUnknownMilestone(BudgetGraphParseError):
288 def __init__(self, bug_id: int, milestone_str: str):
289 super().__init__(bug_id)
290 self.milestone_str = milestone_str
291
292 def __str__(self):
293 return f"failed to parse cf_nlnet_milestone field of bug " \
294 f"#{self.bug_id}: unknown milestone: {self.milestone_str!r}"
295
296
297 class BudgetGraphUnknownStatus(BudgetGraphParseError):
298 def __init__(self, bug_id: int, status_str: str):
299 super().__init__(bug_id)
300 self.status_str = status_str
301
302 def __str__(self):
303 return f"failed to parse status field of bug " \
304 f"#{self.bug_id}: unknown status: {self.status_str!r}"
305
306
307 class Node:
308 graph: "BudgetGraph"
309 bug: Bug
310 parent_id: Optional[int]
311 immediate_children: OrderedSet["Node"]
312 budget_excluding_subtasks: Money
313 budget_including_subtasks: Money
314 fixed_budget_excluding_subtasks: Money
315 fixed_budget_including_subtasks: Money
316 milestone_str: Optional[str]
317 is_in_nlnet_mou: bool
318
319 def __init__(self, graph: "BudgetGraph", bug: Bug):
320 self.graph = graph
321 self.bug = bug
322 self.parent_id = getattr(bug, "cf_budget_parent", None)
323 self.immediate_children = OrderedSet()
324 self.budget_excluding_subtasks = Money.from_str(bug.cf_budget)
325 self.fixed_budget_excluding_subtasks = self.budget_excluding_subtasks
326 self.budget_including_subtasks = Money.from_str(bug.cf_total_budget)
327 self.fixed_budget_including_subtasks = self.budget_including_subtasks
328 self.milestone_str = bug.cf_nlnet_milestone
329 if self.milestone_str == "---":
330 self.milestone_str = None
331 self.is_in_nlnet_mou = bug.cf_is_in_nlnet_mou2 == "Yes"
332
333 @property
334 def status(self) -> BugStatus:
335 try:
336 return BugStatus.cast(self.bug.status)
337 except ValueError:
338 new_err = BudgetGraphUnknownStatus(self.bug.id, self.bug.status)
339 raise new_err.with_traceback(sys.exc_info()[2])
340
341 @cached_property
342 def assignee(self) -> Person:
343 try:
344 return self.graph.config.all_names[self.bug.assigned_to]
345 except KeyError:
346 raise BudgetGraphUnknownAssignee(self.bug.id,
347 self.bug.assigned_to) \
348 .with_traceback(sys.exc_info()[2])
349
350 @cached_property
351 def bug_url(self) -> str:
352 return f"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \
353 f"id={self.bug.id}"
354
355 @cached_property
356 def milestone(self) -> Optional[Milestone]:
357 if self.milestone_str is None:
358 return None
359 try:
360 return self.graph.config.milestones[self.milestone_str]
361 except KeyError:
362 new_err = BudgetGraphUnknownMilestone(
363 self.bug.id, self.milestone_str)
364 raise new_err.with_traceback(sys.exc_info()[2])
365
366 @cached_property
367 def payments(self) -> Dict[str, Payment]:
368 try:
369 parsed = toml.loads(self.bug.cf_payees_list)
370 except toml.TomlDecodeError as e:
371 new_err = BudgetGraphPayeesParseError(
372 self.bug.id, f"TOML parse error: {e}")
373 raise new_err.with_traceback(sys.exc_info()[2])
374 retval = {}
375 for key, value in parsed.items():
376 if not isinstance(key, str):
377 raise BudgetGraphPayeesParseError(
378 self.bug.id, f"key is not a string: {key!r}")
379 retval[key] = Payment._from_toml(self, key, value)
380 return retval
381
382 @cached_property
383 def resolved_payments(self) -> Dict[Person, List[Payment]]:
384 retval: Dict[Person, List[Payment]] = {}
385 for payment in self.payments.values():
386 if payment.payee not in retval:
387 retval[payment.payee] = []
388 retval[payment.payee].append(payment)
389 return retval
390
391 @cached_property
392 def payment_summaries(self) -> Dict[Person, PaymentSummary]:
393 return {person: PaymentSummary(payments)
394 for person, payments in self.resolved_payments.items()}
395
396 @cached_property
397 def submitted_excluding_subtasks(self) -> Money:
398 retval = Money()
399 for payment in self.payments.values():
400 if payment.submitted is not None or payment.paid is not None:
401 retval += payment.amount
402 return retval
403
404 @cached_property
405 def paid_excluding_subtasks(self) -> Money:
406 retval = Money()
407 for payment in self.payments.values():
408 if payment.paid is not None:
409 retval += payment.amount
410 return retval
411
412 @cached_property
413 def submitted_including_subtasks(self) -> Money:
414 retval = self.submitted_excluding_subtasks
415 for i in self.immediate_children:
416 retval += i.submitted_including_subtasks
417 return retval
418
419 @cached_property
420 def paid_including_subtasks(self) -> Money:
421 retval = self.paid_excluding_subtasks
422 for i in self.immediate_children:
423 retval += i.paid_including_subtasks
424 return retval
425
426 @property
427 def parent(self) -> Optional["Node"]:
428 if self.parent_id is not None:
429 return self.graph.nodes[self.parent_id]
430 return None
431
432 def parents(self) -> Iterable["Node"]:
433 parent = self.parent
434 while parent is not None:
435 yield parent
436 parent = parent.parent
437
438 def _raise_loop_error(self):
439 bug_ids = []
440 for parent in self.parents():
441 bug_ids.append(parent.bug.id)
442 if parent == self:
443 break
444 raise BudgetGraphLoopError(bug_ids)
445
446 @cached_property
447 def root(self) -> "Node":
448 # also checks for loop errors
449 retval = self
450 for parent in self.parents():
451 retval = parent
452 if parent == self:
453 self._raise_loop_error()
454 return retval
455
456 @cached_property
457 def closest_bug_in_mou(self) -> Optional["Node"]:
458 """returns the closest bug that is in a NLNet MoU, searching only in
459 this bug and parents.
460 """
461 if self.is_in_nlnet_mou:
462 return self
463 for parent in self.parents():
464 if parent.is_in_nlnet_mou:
465 return parent
466 return None
467
468 def children(self) -> Iterable["Node"]:
469 def visitor(node: Node) -> Iterable[Node]:
470 for i in node.immediate_children:
471 yield i
472 yield from visitor(i)
473 return visitor(self)
474
475 def children_breadth_first(self) -> Iterable["Node"]:
476 q = deque(self.immediate_children)
477 while True:
478 try:
479 node = q.popleft()
480 except IndexError:
481 return
482 q.extend(node.immediate_children)
483 yield node
484
485 def __eq__(self, other):
486 return self.bug.id == other.bug.id
487
488 def __hash__(self):
489 return self.bug.id
490
491 def __pretty_print__(self, pp: PrettyPrinter):
492 with pp.type_pp("Node") as tpp:
493 tpp.field("graph", ...)
494 tpp.field("id", _NodeSimpleReprWrapper(self))
495 tpp.try_field("root",
496 lambda: _NodeSimpleReprWrapper(self.root),
497 BudgetGraphLoopError)
498 parent = f"#{self.parent_id}" if self.parent_id is not None else None
499 tpp.field("parent", parent)
500 tpp.field("budget_excluding_subtasks",
501 self.budget_excluding_subtasks)
502 tpp.field("budget_including_subtasks",
503 self.budget_including_subtasks)
504 tpp.field("fixed_budget_excluding_subtasks",
505 self.fixed_budget_excluding_subtasks)
506 tpp.field("fixed_budget_including_subtasks",
507 self.fixed_budget_including_subtasks)
508 tpp.field("milestone_str", self.milestone_str)
509 tpp.field("is_in_nlnet_mou", self.is_in_nlnet_mou)
510 tpp.try_field("milestone", lambda: self.milestone,
511 BudgetGraphBaseError)
512 immediate_children = [_NodeSimpleReprWrapper(i)
513 for i in self.immediate_children]
514 tpp.field("immediate_children", immediate_children)
515 tpp.try_field("payments",
516 lambda: list(self.payments.values()),
517 BudgetGraphBaseError)
518 try:
519 status = repr(self.status)
520 except BudgetGraphBaseError:
521 status = f"<unknown status: {self.bug.status!r}>"
522 tpp.field("status", status)
523 try:
524 assignee = f"Person<{self.assignee.identifier!r}>"
525 except BudgetGraphBaseError:
526 assignee = f"<unknown assignee: {self.bug.assigned_to!r}>"
527 tpp.field("assignee", assignee)
528 tpp.try_field("resolved_payments",
529 lambda: self.resolved_payments,
530 BudgetGraphBaseError)
531 tpp.try_field("payment_summaries",
532 lambda: self.payment_summaries,
533 BudgetGraphBaseError)
534
535 def __repr__(self):
536 try:
537 root = _NodeSimpleReprWrapper(self.root)
538 except BudgetGraphLoopError:
539 root = "<loop error>"
540 try:
541 milestone = repr(self.milestone)
542 except BudgetGraphBaseError:
543 milestone = "<unknown milestone>"
544 try:
545 status = repr(self.status)
546 except BudgetGraphBaseError:
547 status = f"<unknown status: {self.bug.status!r}>"
548 try:
549 assignee = f"Person<{self.assignee.identifier!r}>"
550 except BudgetGraphBaseError:
551 assignee = f"<unknown assignee: {self.bug.assigned_to!r}>"
552 immediate_children = []
553 for i in self.immediate_children:
554 immediate_children.append(_NodeSimpleReprWrapper(i))
555 immediate_children.sort()
556 parent = f"#{self.parent_id}" if self.parent_id is not None else None
557 payments = list(self.payments.values())
558 resolved_payments = self.resolved_payments
559 payment_summaries = self.payment_summaries
560 return (f"Node(graph=..., "
561 f"id={_NodeSimpleReprWrapper(self)}, "
562 f"root={root}, "
563 f"parent={parent}, "
564 f"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
565 f"budget_including_subtasks={self.budget_including_subtasks}, "
566 f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
567 f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
568 f"milestone_str={self.milestone_str!r}, "
569 f"is_in_nlnet_mou={self.is_in_nlnet_mou!r}, "
570 f"milestone={milestone}, "
571 f"immediate_children={immediate_children!r}, "
572 f"payments={payments!r}, "
573 f"status={status}, "
574 f"assignee={assignee}, "
575 f"resolved_payments={resolved_payments!r}, "
576 f"payment_summaries={payment_summaries!r})")
577
578
579 class BudgetGraphError(BudgetGraphBaseError):
580 def __init__(self, bug_id: int, root_bug_id: int):
581 self.bug_id = bug_id
582 self.root_bug_id = root_bug_id
583
584
585 class BudgetGraphMoneyWithNoMilestone(BudgetGraphError):
586 def __str__(self):
587 return (f"Bug assigned money but without"
588 f" any assigned milestone: #{self.bug_id}")
589
590
591 class BudgetGraphMilestoneMismatch(BudgetGraphError):
592 def __str__(self):
593 return (f"Bug's assigned milestone doesn't match the milestone "
594 f"assigned to the root bug: descendant bug"
595 f" #{self.bug_id}, root bug"
596 f" #{self.root_bug_id}")
597
598
599 class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError):
600 def __init__(self, bug_id: int, root_bug_id: int,
601 expected_budget_excluding_subtasks: Money):
602 super().__init__(bug_id, root_bug_id)
603 self.expected_budget_excluding_subtasks = \
604 expected_budget_excluding_subtasks
605
606 def __str__(self):
607 return (f"Budget assigned to task excluding subtasks "
608 f"(cf_budget field) doesn't match calculated value: "
609 f"bug #{self.bug_id}, calculated value"
610 f" {self.expected_budget_excluding_subtasks}")
611
612
613 class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError):
614 def __init__(self, bug_id: int, root_bug_id: int,
615 expected_budget_including_subtasks: Money):
616 super().__init__(bug_id, root_bug_id)
617 self.expected_budget_including_subtasks = \
618 expected_budget_including_subtasks
619
620 def __str__(self):
621 return (f"Budget assigned to task including subtasks "
622 f"(cf_total_budget field) doesn't match calculated value: "
623 f"bug #{self.bug_id}, calculated value"
624 f" {self.expected_budget_including_subtasks}")
625
626
627 class BudgetGraphNegativeMoney(BudgetGraphError):
628 def __str__(self):
629 return (f"Budget assigned to task is less than zero: "
630 f"bug #{self.bug_id}")
631
632
633 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError):
634 def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money,
635 expected_payees_total: Money):
636 super().__init__(bug_id, root_bug_id)
637 self.payees_total = payees_total
638 self.expected_payees_total = expected_payees_total
639
640 def __str__(self):
641 return (f"Total budget assigned to payees (cf_payees_list) doesn't "
642 f"match expected value: bug #{self.bug_id}, calculated total "
643 f"{self.payees_total}, expected value "
644 f"{self.expected_payees_total}")
645
646
647 class BudgetGraphNegativePayeeMoney(BudgetGraphError):
648 def __init__(self, bug_id: int, root_bug_id: int, payee_key: str):
649 super().__init__(bug_id, root_bug_id)
650 self.payee_key = payee_key
651
652 def __str__(self):
653 return (f"Budget assigned to payee for task is less than zero: "
654 f"bug #{self.bug_id}, payee {self.payee_key!r}")
655
656
657 class BudgetGraphIncorrectRootForMilestone(BudgetGraphError):
658 def __init__(self, bug_id: int, milestone: str, milestone_canonical_bug_id: int):
659 super().__init__(bug_id, bug_id)
660 self.milestone = milestone
661 self.milestone_canonical_bug_id = milestone_canonical_bug_id
662
663 def __str__(self):
664 return (f"Bug #{self.bug_id} is not the canonical root bug for "
665 f"assigned milestone {self.milestone!r} but has no parent "
666 f"bug set: the milestone's canonical root bug is "
667 f"#{self.milestone_canonical_bug_id}")
668
669
670 class BudgetGraphRootWithMilestoneNotInMoU(BudgetGraphError):
671 def __init__(self, bug_id: int, milestone: str):
672 super().__init__(bug_id, bug_id)
673 self.milestone = milestone
674
675 def __str__(self):
676 return (f"Bug #{self.bug_id} has no parent bug set and has an "
677 f"assigned milestone {self.milestone!r} but isn't set "
678 f"to be part of the signed MoU")
679
680
681 class BudgetGraphInMoUButParentNotInMoU(BudgetGraphError):
682 def __init__(self, bug_id: int, parent_bug_id: int, root_bug_id: int,
683 milestone: str):
684 super().__init__(bug_id, root_bug_id)
685 self.parent_bug_id = parent_bug_id
686 self.milestone = milestone
687
688 def __str__(self):
689 return (f"Bug #{self.bug_id} is set to be part of the signed MoU for "
690 f"milestone {self.milestone!r}, but its parent bug isn't set "
691 f"to be part of the signed MoU")
692
693
694 class BudgetGraphInMoUWithoutMilestone(BudgetGraphError):
695 def __str__(self):
696 return (f"Bug #{self.bug_id} is set to be part of a signed MoU but "
697 f"has no milestone set")
698
699
700 class BudgetGraph:
701 nodes: Dict[int, Node]
702
703 def __init__(self, bugs: Iterable[Bug], config: Config):
704 self.nodes = {}
705 self.config = config
706 for bug in bugs:
707 self.nodes[bug.id] = Node(self, bug)
708 for node in self.nodes.values():
709 if node.parent is None:
710 continue
711 node.parent.immediate_children.add(node)
712 # useful debug prints
713 # for bug in bugs:
714 # node = self.nodes[bug.id]
715 # print ("bug added", bug.id, node, node.parent.immediate_children)
716
717 @cached_property
718 def roots(self) -> OrderedSet[Node]:
719 roots = OrderedSet()
720 for node in self.nodes.values():
721 # calling .root also checks for loop errors
722 root = node.root
723 roots.add(root)
724 return roots
725
726 def _get_node_errors(self, root: Node, node: Node,
727 errors: List[BudgetGraphBaseError]):
728 if node.milestone_str is None:
729 if node.budget_including_subtasks != 0 \
730 or node.budget_excluding_subtasks != 0:
731 errors.append(BudgetGraphMoneyWithNoMilestone(
732 node.bug.id, root.bug.id))
733
734 try:
735 # check for milestone errors
736 node.milestone
737 if root == node and node.milestone is not None:
738 if node.milestone.canonical_bug_id != node.bug.id:
739 if node.budget_including_subtasks != 0 \
740 or node.budget_excluding_subtasks != 0:
741 errors.append(BudgetGraphIncorrectRootForMilestone(
742 node.bug.id, node.milestone.identifier,
743 node.milestone.canonical_bug_id
744 ))
745 elif not node.is_in_nlnet_mou:
746 errors.append(BudgetGraphRootWithMilestoneNotInMoU(
747 node.bug.id, node.milestone_str))
748 except BudgetGraphBaseError as e:
749 errors.append(e)
750
751 try:
752 # check for status errors
753 node.status
754 except BudgetGraphBaseError as e:
755 errors.append(e)
756
757 try:
758 # check for assignee errors
759 node.assignee
760 except BudgetGraphBaseError as e:
761 errors.append(e)
762
763 if node.milestone_str != root.milestone_str:
764 errors.append(BudgetGraphMilestoneMismatch(
765 node.bug.id, root.bug.id))
766
767 if node.is_in_nlnet_mou:
768 if node.milestone_str is None:
769 errors.append(BudgetGraphInMoUWithoutMilestone(node.bug.id,
770 root.bug.id))
771 elif node.parent is not None and \
772 not node.parent.is_in_nlnet_mou:
773 errors.append(BudgetGraphInMoUButParentNotInMoU(
774 node.bug.id, node.parent.bug.id, root.bug.id,
775 node.milestone_str))
776
777 if node.budget_excluding_subtasks < 0 \
778 or node.budget_including_subtasks < 0:
779 errors.append(BudgetGraphNegativeMoney(
780 node.bug.id, root.bug.id))
781
782 childlist = []
783 subtasks_total = Money(0)
784 for child in node.immediate_children:
785 subtasks_total += child.fixed_budget_including_subtasks
786 childlist.append(child.bug.id)
787 # useful debug prints
788 # print ("subtask total", node.bug.id, root.bug.id, subtasks_total,
789 # childlist)
790
791 payees_total = Money(0)
792 payee_payments: Dict[Person, List[Payment]] = {}
793 for payment in node.payments.values():
794 if payment.amount < 0:
795 errors.append(BudgetGraphNegativePayeeMoney(
796 node.bug.id, root.bug.id, payment.payee_key))
797 payees_total += payment.amount
798 try:
799 # check for payee errors
800 payment.payee
801 previous_payment = payee_payments.get(payment.payee)
802 if previous_payment is not None:
803 payee_payments[payment.payee].append(payment)
804 else:
805 payee_payments[payment.payee] = [payment]
806 except BudgetGraphBaseError as e:
807 errors.append(e)
808
809 def set_including_from_excluding_and_error():
810 node.fixed_budget_including_subtasks = \
811 node.budget_excluding_subtasks + subtasks_total
812 errors.append(
813 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
814 node.bug.id, root.bug.id,
815 node.fixed_budget_including_subtasks))
816
817 def set_including_from_payees_and_error():
818 node.fixed_budget_including_subtasks = \
819 payees_total + subtasks_total
820 errors.append(
821 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
822 node.bug.id, root.bug.id,
823 node.fixed_budget_including_subtasks))
824
825 def set_excluding_from_including_and_error():
826 v = node.budget_including_subtasks - subtasks_total
827 if v < 0:
828 return set_including_from_excluding_and_error()
829 node.fixed_budget_excluding_subtasks = v
830 errors.append(
831 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
832 node.bug.id, root.bug.id,
833 node.fixed_budget_excluding_subtasks))
834
835 def set_excluding_from_payees_and_error():
836 node.fixed_budget_excluding_subtasks = \
837 payees_total
838 errors.append(
839 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
840 node.bug.id, root.bug.id,
841 node.fixed_budget_excluding_subtasks))
842
843 def set_payees_from_including_and_error():
844 fixed_payees_total = \
845 node.budget_including_subtasks - subtasks_total
846 errors.append(BudgetGraphPayeesMoneyMismatch(
847 node.bug.id, root.bug.id, payees_total, fixed_payees_total))
848
849 def set_payees_from_excluding_and_error():
850 fixed_payees_total = \
851 node.budget_excluding_subtasks
852 errors.append(BudgetGraphPayeesMoneyMismatch(
853 node.bug.id, root.bug.id, payees_total, fixed_payees_total))
854
855 payees_matches_including = \
856 node.budget_including_subtasks - subtasks_total == payees_total
857 payees_matches_excluding = \
858 node.budget_excluding_subtasks == payees_total
859 including_matches_excluding = \
860 node.budget_including_subtasks - subtasks_total \
861 == node.budget_excluding_subtasks
862
863 if payees_matches_including \
864 and payees_matches_excluding \
865 and including_matches_excluding:
866 pass # no error
867 elif payees_matches_including:
868 # can't have 2 match without all 3 matching
869 assert not payees_matches_excluding
870 assert not including_matches_excluding
871 if node.budget_including_subtasks == 0 and len(node.payments) == 0:
872 set_including_from_excluding_and_error()
873 else:
874 set_excluding_from_including_and_error()
875 elif payees_matches_excluding:
876 # can't have 2 match without all 3 matching
877 assert not payees_matches_including
878 assert not including_matches_excluding
879 if node.budget_excluding_subtasks == 0 and len(node.payments) == 0:
880 if node.budget_including_subtasks == 0:
881 set_including_from_excluding_and_error()
882 else:
883 set_excluding_from_including_and_error()
884 else:
885 set_including_from_excluding_and_error()
886 elif including_matches_excluding:
887 # can't have 2 match without all 3 matching
888 assert not payees_matches_including
889 assert not payees_matches_excluding
890 if len(node.payments) == 0:
891 pass # no error -- payees is just not set
892 elif node.budget_excluding_subtasks == 0 \
893 and node.budget_including_subtasks == 0:
894 set_excluding_from_payees_and_error()
895 set_including_from_payees_and_error()
896 else:
897 set_payees_from_excluding_and_error()
898 else:
899 # nothing matches
900 if len(node.payments) == 0:
901 # payees unset -- don't need to set payees
902 if node.budget_including_subtasks == 0:
903 set_including_from_excluding_and_error()
904 else:
905 set_excluding_from_including_and_error()
906 elif node.budget_excluding_subtasks == 0 \
907 and node.budget_including_subtasks == 0:
908 set_excluding_from_payees_and_error()
909 set_including_from_payees_and_error()
910 elif node.budget_excluding_subtasks == 0:
911 set_excluding_from_including_and_error()
912 set_payees_from_including_and_error()
913 elif node.budget_including_subtasks == 0:
914 set_including_from_excluding_and_error()
915 set_payees_from_excluding_and_error()
916 else:
917 set_including_from_excluding_and_error()
918 set_payees_from_excluding_and_error()
919
920 def get_errors(self) -> List[BudgetGraphBaseError]:
921 errors = []
922 try:
923 roots = self.roots
924 except BudgetGraphBaseError as e:
925 errors.append(e)
926 return errors
927
928 for root in roots:
929 try:
930 for child in reversed(list(root.children_breadth_first())):
931 try:
932 self._get_node_errors(root, child, errors)
933 except BudgetGraphBaseError as e:
934 errors.append(e)
935 self._get_node_errors(root, root, errors)
936 except BudgetGraphBaseError as e:
937 errors.append(e)
938 return errors
939
940 @cached_property
941 def assigned_nodes(self) -> Dict[Person, List[Node]]:
942 retval: Dict[Person, List[Node]]
943 retval = {person: [] for person in self.config.people.values()}
944 for node in self.nodes.values():
945 retval[node.assignee].append(node)
946 return retval
947
948 @cached_property
949 def assigned_nodes_for_milestones(self) -> Dict[Milestone, List[Node]]:
950 retval: Dict[Milestone, List[Node]]
951 retval = {milestone: []
952 for milestone in self.config.milestones.values()}
953 for node in self.nodes.values():
954 if node.milestone is not None:
955 retval[node.milestone].append(node)
956 return retval
957
958 @cached_property
959 def milestone_payments(self) -> Dict[Milestone, List[Payment]]:
960 retval: Dict[Milestone, List[Payment]] = {
961 milestone: [] for milestone in self.config.milestones.values()
962 }
963 for node in self.nodes.values():
964 if node.milestone is not None:
965 retval[node.milestone].extend(node.payments.values())
966 return retval
967
968 @cached_property
969 def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]:
970 retval: Dict[Person, Dict[Milestone, List[Payment]]] = {
971 person: {
972 milestone: []
973 for milestone in self.config.milestones.values()
974 }
975 for person in self.config.people.values()
976 }
977 for node in self.nodes.values():
978 if node.milestone is not None:
979 for payment in node.payments.values():
980 retval[payment.payee][node.milestone].append(payment)
981 return retval
982
983 @cached_property
984 def milestone_people(self) -> Dict[Milestone, OrderedSet[Person]]:
985 """get a list of people associated with each milestone
986 """
987 payments = list(self.payments) # just activate the payments
988 retval = {}
989 for milestone in self.milestone_payments.keys():
990 retval[milestone] = OrderedSet()
991 for milestone, payments in self.milestone_payments.items():
992 for payment in payments:
993 retval[milestone].add(payment.payee)
994 return retval
995
996 def __pretty_print__(self, pp: PrettyPrinter):
997 with pp.type_pp("BudgetGraph") as tpp:
998 tpp.field("nodes", self.nodes)
999 tpp.try_field("roots",
1000 lambda: [_NodeSimpleReprWrapper(i)
1001 for i in self.roots],
1002 BudgetGraphBaseError)
1003 tpp.try_field("assigned_nodes",
1004 lambda: {
1005 person: [
1006 _NodeSimpleReprWrapper(node)
1007 for node in nodes
1008 ]
1009 for person, nodes in self.assigned_nodes.items()
1010 },
1011 BudgetGraphBaseError)
1012 tpp.try_field("assigned_nodes_for_milestones",
1013 lambda: {
1014 milestone: [
1015 _NodeSimpleReprWrapper(node)
1016 for node in nodes
1017 ]
1018 for milestone, nodes in self.assigned_nodes_for_milestones.items()
1019 },
1020 BudgetGraphBaseError)
1021 tpp.try_field("payments",
1022 lambda: self.payments, BudgetGraphBaseError)
1023 tpp.try_field("milestone_people",
1024 lambda: self.milestone_people,
1025 BudgetGraphBaseError)
1026
1027 def __repr__(self):
1028 nodes = [*self.nodes.values()]
1029
1030 def repr_or_failed(f: Callable[[], Any]) -> str:
1031 try:
1032 return repr(f())
1033 except BudgetGraphBaseError:
1034 return "<failed>"
1035
1036 try:
1037 roots = [_NodeSimpleReprWrapper(i) for i in self.roots]
1038 roots.sort()
1039 roots_str = repr(roots)
1040 except BudgetGraphBaseError:
1041 roots_str = "<failed>"
1042 assigned_nodes = repr_or_failed(lambda: {
1043 person: [
1044 _NodeSimpleReprWrapper(node)
1045 for node in nodes
1046 ]
1047 for person, nodes in self.assigned_nodes.items()
1048 })
1049 assigned_nodes_for_milestones = repr_or_failed(lambda: {
1050 milestone: [
1051 _NodeSimpleReprWrapper(node)
1052 for node in nodes
1053 ]
1054 for milestone, nodes in self.assigned_nodes_for_milestones.items()
1055 })
1056 milestone_payments = repr_or_failed(lambda: self.milestone_payments)
1057 payments = repr_or_failed(lambda: self.payments)
1058 milestone_people = repr_or_failed(lambda: self.milestone_people)
1059 return (f"BudgetGraph{{nodes={nodes!r}, "
1060 f"roots={roots}, "
1061 f"assigned_nodes={assigned_nodes}, "
1062 f"assigned_nodes_for_milestones={assigned_nodes_for_milestones}, "
1063 f"milestone_payments={milestone_payments}, "
1064 f"payments={payments}, "
1065 f"milestone_people={milestone_people}}}")