budget-sync is now working after adding code to use a config file
[utils.git] / src / budget_sync / budget_graph.py
index dd59fd4ac2222d9def9556777ac2f55b1267a4b2..c6b527245104aebaae046f37be19a8ffdddd9df6 100644 (file)
@@ -3,6 +3,7 @@ from bugzilla import Bugzilla
 from typing import Set, Dict, Iterable, Optional, List, Union, Any
 from budget_sync.util import all_bugs
 from budget_sync.money import Money
+from budget_sync.config import Config, Person, Milestone
 from functools import cached_property
 import toml
 import sys
@@ -26,7 +27,8 @@ 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 BudgetGraphLoopError(BudgetGraphBaseError):
@@ -100,6 +102,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:
@@ -177,7 +189,12 @@ class Payment:
                        paid=paid, submitted=submitted)
 
     def __repr__(self):
+        try:
+            payee = f"Person<{self.payee.identifier!r}>"
+        except BudgetGraphBaseError:
+            payee = "<unknown person>"
         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 +202,16 @@ 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 Node:
     graph: "BudgetGraph"
     bug: Bug
@@ -194,7 +221,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 +232,20 @@ 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
+
+    @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]:
@@ -283,6 +321,10 @@ class Node:
             root = _NodeSimpleReprWrapper(self.root)
         except BudgetGraphLoopError:
             root = "<loop error>"
+        try:
+            milestone = repr(self.milestone)
+        except BudgetGraphBaseError:
+            milestone = "<unknown milestone>"
         immediate_children = []
         for i in self.immediate_children:
             immediate_children.append(_NodeSimpleReprWrapper(i))
@@ -297,7 +339,8 @@ 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}")
 
@@ -380,11 +423,37 @@ 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]):
+    def __init__(self, bugs: Iterable[Bug], config: Config):
         self.nodes = {}
+        self.config = config
         for bug in bugs:
             self.nodes[bug.id] = Node(self, bug)
         for node in self.nodes.values():
@@ -403,13 +472,27 @@ 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)
+
+        if node.milestone_str != root.milestone_str:
             errors.append(BudgetGraphMilestoneMismatch(
                 node.bug.id, root.bug.id))
 
@@ -423,11 +506,24 @@ class BudgetGraph:
             subtasks_total += child.fixed_budget_including_subtasks
 
         payees_total = Money(0)
+        payee_payments = {}
         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:
+                    errors.append(BudgetGraphDuplicatePayeesForTask(
+                        node.bug.id, root.bug.id,
+                        previous_payment.payee_key, payment.payee_key
+                    ))
+                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 +655,15 @@ class BudgetGraph:
         return errors
 
     @cached_property
-    def payee_keys(self) -> Set[str]:
-        retval = set()
+    def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]:
+        retval = {}
+        for person in self.config.people.values():
+            milestone_payments = {}
+            for milestone in self.config.milestones.values():
+                milestone_payments[milestone] = []
+            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)
         return retval