X-Git-Url: https://git.libre-soc.org/?a=blobdiff_plain;f=src%2Fbudget_sync%2Fbudget_graph.py;h=5a9542b3edeb1d3b7026fa40075643a7609d9040;hb=df61adb514d7e33819e3672eebf6fde279118d2c;hp=e713935e4b04f48bdca907111a487ced89fc26cc;hpb=2ecdb8f7869a2d298ac53d0b5e56a24a34cce217;p=utils.git diff --git a/src/budget_sync/budget_graph.py b/src/budget_sync/budget_graph.py index e713935..5a9542b 100644 --- a/src/budget_sync/budget_graph.py +++ b/src/budget_sync/budget_graph.py @@ -1,15 +1,15 @@ from bugzilla.bug import Bug -from bugzilla import Bugzilla from typing import Set, Dict, Iterable, Optional, List, Union, Any -from budget_sync.util import all_bugs +from budget_sync.util import BugStatus from budget_sync.money import Money +from budget_sync.config import Config, Person, Milestone from functools import cached_property import toml import sys import enum from collections import deque from datetime import date, time, datetime - +from collections import OrderedDict class BudgetGraphBaseError(Exception): pass @@ -26,7 +26,18 @@ class BudgetGraphPayeesParseError(BudgetGraphParseError): self.msg = msg def __str__(self): - return f"Failed to parse cf_payees_list field of bug #{self.bug_id}: {self.msg}" + return f"Failed to parse cf_payees_list field of " \ + f"bug #{self.bug_id}: {self.msg}" + + +class BudgetGraphUnknownAssignee(BudgetGraphParseError): + def __init__(self, bug_id: int, assignee: str): + super().__init__(bug_id) + self.assignee = assignee + + def __str__(self): + return f"Bug #{self.bug_id} is assigned to an unknown person: " \ + f"{self.assignee!r}" class BudgetGraphLoopError(BudgetGraphBaseError): @@ -100,6 +111,16 @@ class Payment: self.paid = paid self.submitted = submitted + @cached_property + def payee(self) -> Person: + try: + return self.node.graph.config.all_names[self.payee_key] + except KeyError: + msg = f"unknown payee name: {self.payee_key!r} is not the name " \ + f"or an alias of any known person" + raise BudgetGraphPayeesParseError(self.node.bug.id, msg) \ + .with_traceback(sys.exc_info()[2]) + @property def state(self): if self.paid is not None: @@ -109,7 +130,7 @@ class Payment: return PayeeState.NotYetSubmitted @staticmethod - def from_toml(node: "Node", payee_key: str, toml_value: Any): + def _from_toml(node: "Node", payee_key: str, toml_value: Any) -> "Payment": paid = None submitted = None known_keys = ("paid", "submitted", "amount") @@ -177,7 +198,12 @@ class Payment: paid=paid, submitted=submitted) def __repr__(self): + try: + payee = f"Person<{self.payee.identifier!r}>" + except BudgetGraphBaseError: + payee = "" return (f"Payment(node={_NodeSimpleReprWrapper(self.node)}, " + f"payee={payee}, " f"payee_key={self.payee_key!r}, " f"amount={self.amount}, " f"state={self.state.name}, " @@ -185,6 +211,26 @@ class Payment: f"submitted={str(self.submitted)})") +class BudgetGraphUnknownMilestone(BudgetGraphParseError): + def __init__(self, bug_id: int, milestone_str: str): + super().__init__(bug_id) + self.milestone_str = milestone_str + + def __str__(self): + return f"failed to parse cf_nlnet_milestone field of bug " \ + f"#{self.bug_id}: unknown milestone: {self.milestone_str!r}" + + +class BudgetGraphUnknownStatus(BudgetGraphParseError): + def __init__(self, bug_id: int, status_str: str): + super().__init__(bug_id) + self.status_str = status_str + + def __str__(self): + return f"failed to parse status field of bug " \ + f"#{self.bug_id}: unknown status: {self.status_str!r}" + + class Node: graph: "BudgetGraph" bug: Bug @@ -194,7 +240,7 @@ class Node: budget_including_subtasks: Money fixed_budget_excluding_subtasks: Money fixed_budget_including_subtasks: Money - nlnet_milestone: Optional[str] + milestone_str: Optional[str] def __init__(self, graph: "BudgetGraph", bug: Bug): self.graph = graph @@ -205,9 +251,42 @@ class Node: self.fixed_budget_excluding_subtasks = self.budget_excluding_subtasks self.budget_including_subtasks = Money.from_str(bug.cf_total_budget) self.fixed_budget_including_subtasks = self.budget_including_subtasks - self.nlnet_milestone = bug.cf_nlnet_milestone - if self.nlnet_milestone == "---": - self.nlnet_milestone = None + self.milestone_str = bug.cf_nlnet_milestone + if self.milestone_str == "---": + self.milestone_str = None + + @property + def status(self) -> BugStatus: + try: + return BugStatus.cast(self.bug.status) + except ValueError: + new_err = BudgetGraphUnknownStatus(self.bug.id, self.bug.status) + raise new_err.with_traceback(sys.exc_info()[2]) + + @cached_property + def assignee(self) -> Person: + try: + return self.graph.config.all_names[self.bug.assigned_to] + except KeyError: + raise BudgetGraphUnknownAssignee(self.bug.id, + self.bug.assigned_to) \ + .with_traceback(sys.exc_info()[2]) + + @cached_property + def bug_url(self) -> str: + return f"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \ + f"id={self.bug.id}" + + @cached_property + def milestone(self) -> Optional[Milestone]: + try: + if self.milestone_str is not None: + return self.graph.config.milestones[self.milestone_str] + return None + except KeyError: + new_err = BudgetGraphUnknownMilestone( + self.bug.id, self.milestone_str) + raise new_err.with_traceback(sys.exc_info()[2]) @cached_property def payments(self) -> Dict[str, Payment]: @@ -217,12 +296,12 @@ class Node: new_err = BudgetGraphPayeesParseError( self.bug.id, f"TOML parse error: {e}") raise new_err.with_traceback(sys.exc_info()[2]) - retval = {} + retval = OrderedDict() for key, value in parsed.items(): if not isinstance(key, str): raise BudgetGraphPayeesParseError( self.bug.id, f"key is not a string: {key!r}") - retval[key] = Payment.from_toml(self, key, value) + retval[key] = Payment._from_toml(self, key, value) return retval @property @@ -283,6 +362,18 @@ class Node: root = _NodeSimpleReprWrapper(self.root) except BudgetGraphLoopError: root = "" + try: + milestone = repr(self.milestone) + except BudgetGraphBaseError: + milestone = "" + try: + status = repr(self.status) + except BudgetGraphBaseError: + status = f"" + try: + assignee = f"Person<{self.assignee.identifier!r}>" + except BudgetGraphBaseError: + assignee = f"" immediate_children = [] for i in self.immediate_children: immediate_children.append(_NodeSimpleReprWrapper(i)) @@ -297,9 +388,12 @@ class Node: f"budget_including_subtasks={self.budget_including_subtasks}, " f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, " f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, " - f"nlnet_milestone={self.nlnet_milestone!r}, " + f"milestone_str={self.milestone_str!r}, " + f"milestone={milestone}, " f"immediate_children={immediate_children!r}, " - f"payments={payments!r}") + f"payments={payments!r}, " + f"status={status}, " + f"assignee={assignee})") class BudgetGraphError(BudgetGraphBaseError): @@ -380,17 +474,48 @@ class BudgetGraphNegativePayeeMoney(BudgetGraphError): f"bug #{self.bug_id}, payee {self.payee_key!r}") +class BudgetGraphDuplicatePayeesForTask(BudgetGraphError): + def __init__(self, bug_id: int, root_bug_id: int, payee1_key: str, payee2_key: str): + super().__init__(bug_id, root_bug_id) + self.payee1_key = payee1_key + self.payee2_key = payee2_key + + def __str__(self): + return (f"Budget assigned to multiple aliases of the same person in " + f"a single task: bug #{self.bug_id}, budget assigned to both " + f"{self.payee1_key!r} and {self.payee2_key!r}") + + +class BudgetGraphIncorrectRootForMilestone(BudgetGraphError): + def __init__(self, bug_id: int, milestone: str, milestone_canonical_bug_id: int): + super().__init__(bug_id, bug_id) + self.milestone = milestone + self.milestone_canonical_bug_id = milestone_canonical_bug_id + + def __str__(self): + return (f"Bug #{self.bug_id} is not the canonical root bug for " + f"assigned milestone {self.milestone!r} but has no parent " + f"bug set: the milestone's canonical root bug is " + f"#{self.milestone_canonical_bug_id}") + + class BudgetGraph: nodes: Dict[int, Node] - def __init__(self, bugs: Iterable[Bug]): - self.nodes = {} + def __init__(self, bugs: Iterable[Bug], config: Config): + self.nodes = OrderedDict() + self.config = config for bug in bugs: self.nodes[bug.id] = Node(self, bug) for node in self.nodes.values(): if node.parent is None: continue node.parent.immediate_children.add(node) + self.milestone_payments = OrderedDict() + # useful debug prints + #for bug in bugs: + # node = self.nodes[bug.id] + # print ("bug added", bug.id, node, node.parent.immediate_children) @cached_property def roots(self) -> Set[Node]: @@ -403,13 +528,39 @@ class BudgetGraph: def _get_node_errors(self, root: Node, node: Node, errors: List[BudgetGraphBaseError]): - if node.nlnet_milestone is None: + if node.milestone_str is None: if node.budget_including_subtasks != 0 \ or node.budget_excluding_subtasks != 0: errors.append(BudgetGraphMoneyWithNoMilestone( node.bug.id, root.bug.id)) - if node.nlnet_milestone != root.nlnet_milestone: + try: + # check for milestone errors + node.milestone + if root == node and node.milestone is not None \ + and node.milestone.canonical_bug_id != node.bug.id: + if node.budget_including_subtasks != 0 \ + or node.budget_excluding_subtasks != 0: + errors.append(BudgetGraphIncorrectRootForMilestone( + node.bug.id, node.milestone.identifier, + node.milestone.canonical_bug_id + )) + except BudgetGraphBaseError as e: + errors.append(e) + + try: + # check for status errors + node.status + except BudgetGraphBaseError as e: + errors.append(e) + + try: + # check for assignee errors + node.assignee + except BudgetGraphBaseError as e: + errors.append(e) + + if node.milestone_str != root.milestone_str: errors.append(BudgetGraphMilestoneMismatch( node.bug.id, root.bug.id)) @@ -418,16 +569,36 @@ class BudgetGraph: errors.append(BudgetGraphNegativeMoney( node.bug.id, root.bug.id)) + childlist = [] subtasks_total = Money(0) for child in node.immediate_children: subtasks_total += child.fixed_budget_including_subtasks + childlist.append(child.bug.id) + # useful debug prints + #print ("subtask total", node.bug.id, root.bug.id, subtasks_total, + # childlist) payees_total = Money(0) + payee_payments = OrderedDict() for payment in node.payments.values(): if payment.amount < 0: errors.append(BudgetGraphNegativePayeeMoney( node.bug.id, root.bug.id, payment.payee_key)) payees_total += payment.amount + try: + # check for payee errors + payment.payee + previous_payment = payee_payments.get(payment.payee) + if previous_payment is not None: + # NOT AN ERROR + print ("NOT AN ERROR", BudgetGraphDuplicatePayeesForTask( + node.bug.id, root.bug.id, + previous_payment[-1].payee_key, payment.payee_key)) + payee_payments[payment.payee].append(payment) + else: + payee_payments[payment.payee] = [payment] + except BudgetGraphBaseError as e: + errors.append(e) def set_including_from_excluding_and_error(): node.fixed_budget_including_subtasks = \ @@ -559,9 +730,35 @@ class BudgetGraph: return errors @cached_property - def payee_keys(self) -> Set[str]: - retval = set() + def assigned_nodes(self) -> Dict[Person, List[Node]]: + retval = {person: [] for person in self.config.people.values()} + for node in self.nodes.values(): + retval[node.assignee].append(node) + return retval + + @cached_property + def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]: + retval = OrderedDict() + for person in self.config.people.values(): + milestone_payments = OrderedDict() + for milestone in self.config.milestones.values(): + milestone_payments[milestone] = [] # per-person payments + self.milestone_payments[milestone] = [] # global payments + retval[person] = milestone_payments for node in self.nodes.values(): - for payee_key in node.payments.keys(): - retval.add(payee_key) + if node.milestone is not None: + for payment in node.payments.values(): + retval[payment.payee][node.milestone].append(payment) + # add to global payments as well + self.milestone_payments[node.milestone].append(payment) return retval + + def __repr__(self): + nodes = [*self.nodes.values()] + try: + roots = [_NodeSimpleReprWrapper(i) for i in self.roots] + roots.sort() + roots_str = repr(roots) + except BudgetGraphBaseError: + roots_str = "" + return f"BudgetGraph{{nodes={nodes!r}, roots={roots}}}"