remove unnecessary __ne__ implementation
[utils.git] / src / budget_sync / budget_graph.py
index cb77a228837529ff879dfa735db382f290ab885a..e713935e4b04f48bdca907111a487ced89fc26cc 100644 (file)
@@ -1,11 +1,14 @@
 from bugzilla.bug import Bug
 from bugzilla import Bugzilla
-from typing import Set, Dict, Iterable, Optional, List
+from typing import Set, Dict, Iterable, Optional, List, Union, Any
 from budget_sync.util import all_bugs
 from budget_sync.money import Money
 from functools import cached_property
 import toml
 import sys
+import enum
+from collections import deque
+from datetime import date, time, datetime
 
 
 class BudgetGraphBaseError(Exception):
@@ -48,6 +51,140 @@ class _NodeSimpleReprWrapper:
         return self.node.bug.id < other.node.bug.id
 
 
+class PayeeState(enum.Enum):
+    NotYetSubmitted = "not yet submitted"
+    Submitted = "submitted"
+    Paid = "paid"
+
+
+_Date = Union[date, datetime]
+
+
+def _parse_money_from_toml(value: Any) -> Money:
+    if not isinstance(value, (int, str)):
+        msg = f"monetary amount is not a string or integer " \
+            f"(to use fractional amounts such as 123.45, write " \
+            f"\"123.45\"): {value!r}"
+        raise ValueError(msg)
+    return Money(value)
+
+
+def _parse_date_time_or_none_from_toml(value: Any) -> Optional[_Date]:
+    if value is None or isinstance(value, (date, datetime)):
+        return value
+    elif isinstance(value, time):
+        msg = f"just a time of day by itself is not enough," \
+            f" a date must be included: {str(value)}"
+        raise ValueError(msg)
+    elif isinstance(value, bool):
+        msg = f"invalid date: {str(value).lower()}"
+        raise ValueError(msg)
+    elif isinstance(value, (str, int, float)):
+        msg = f"invalid date: {value!r}"
+        raise ValueError(msg)
+    else:
+        msg = f"invalid date"
+        raise ValueError(msg)
+
+
+class Payment:
+    def __init__(self,
+                 node: "Node",
+                 payee_key: str,
+                 amount: Money,
+                 paid: Optional[_Date],
+                 submitted: Optional[_Date]):
+        self.node = node
+        self.payee_key = payee_key
+        self.amount = amount
+        self.paid = paid
+        self.submitted = submitted
+
+    @property
+    def state(self):
+        if self.paid is not None:
+            return PayeeState.Paid
+        if self.submitted is not None:
+            return PayeeState.Submitted
+        return PayeeState.NotYetSubmitted
+
+    @staticmethod
+    def from_toml(node: "Node", payee_key: str, toml_value: Any):
+        paid = None
+        submitted = None
+        known_keys = ("paid", "submitted", "amount")
+        if isinstance(toml_value, dict):
+            try:
+                amount = toml_value['amount']
+            except KeyError:
+                msg = f"value for key {payee_key!r} is missing the " \
+                    f"`amount` field which is required"
+                raise BudgetGraphPayeesParseError(node.bug.id, msg) \
+                    .with_traceback(sys.exc_info()[2])
+            for k, v in toml_value.items():
+                if k in ("paid", "submitted"):
+                    try:
+                        parsed_value = _parse_date_time_or_none_from_toml(v)
+                    except ValueError as e:
+                        msg = f"failed to parse `{k}` field for" \
+                            f" key {payee_key!r}: {e}"
+                        raise BudgetGraphPayeesParseError(
+                            node.bug.id, msg) \
+                            .with_traceback(sys.exc_info()[2])
+                    if k == "paid":
+                        paid = parsed_value
+                    else:
+                        assert k == "submitted"
+                        submitted = parsed_value
+                if k not in known_keys:
+                    msg = f"value for key {payee_key!r} has an unknown" \
+                        f" field: `{k}`"
+                    raise BudgetGraphPayeesParseError(node.bug.id, msg) \
+                        .with_traceback(sys.exc_info()[2])
+            try:
+                paid = _parse_date_time_or_none_from_toml(
+                    toml_value.get('paid'))
+            except ValueError as e:
+                msg = f"failed to parse `paid` field for" \
+                    f" key {payee_key!r}: {e}"
+                raise BudgetGraphPayeesParseError(
+                    node.bug.id, msg) \
+                    .with_traceback(sys.exc_info()[2])
+            try:
+                submitted = _parse_date_time_or_none_from_toml(
+                    toml_value.get('submitted'))
+            except ValueError as e:
+                msg = f"failed to parse `submitted` field for" \
+                    f" key {payee_key!r}: {e}"
+                raise BudgetGraphPayeesParseError(
+                    node.bug.id, msg) \
+                    .with_traceback(sys.exc_info()[2])
+        elif isinstance(toml_value, (int, str, float)):
+            # float included for better error messages
+            amount = toml_value
+        else:
+            msg = f"value for key {payee_key!r} is invalid -- it should " \
+                f"either be a monetary value or a table"
+            raise BudgetGraphPayeesParseError(node.bug.id, msg)
+        try:
+            amount = _parse_money_from_toml(amount)
+        except ValueError as e:
+            msg = f"failed to parse monetary amount for key {payee_key!r}: {e}"
+            raise BudgetGraphPayeesParseError(
+                node.bug.id, msg) \
+                .with_traceback(sys.exc_info()[2])
+        return Payment(node=node, payee_key=payee_key, amount=amount,
+                       paid=paid, submitted=submitted)
+
+    def __repr__(self):
+        return (f"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
+                f"payee_key={self.payee_key!r}, "
+                f"amount={self.amount}, "
+                f"state={self.state.name}, "
+                f"paid={str(self.paid)}, "
+                f"submitted={str(self.submitted)})")
+
+
 class Node:
     graph: "BudgetGraph"
     bug: Bug
@@ -55,6 +192,8 @@ class Node:
     immediate_children: Set["Node"]
     budget_excluding_subtasks: Money
     budget_including_subtasks: Money
+    fixed_budget_excluding_subtasks: Money
+    fixed_budget_including_subtasks: Money
     nlnet_milestone: Optional[str]
 
     def __init__(self, graph: "BudgetGraph", bug: Bug):
@@ -63,13 +202,15 @@ class Node:
         self.parent_id = getattr(bug, "cf_budget_parent", None)
         self.immediate_children = set()
         self.budget_excluding_subtasks = Money.from_str(bug.cf_budget)
+        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
 
     @cached_property
-    def payees(self) -> Dict[str, Money]:
+    def payments(self) -> Dict[str, Payment]:
         try:
             parsed = toml.loads(self.bug.cf_payees_list)
         except toml.TomlDecodeError as e:
@@ -81,19 +222,7 @@ class Node:
             if not isinstance(key, str):
                 raise BudgetGraphPayeesParseError(
                     self.bug.id, f"key is not a string: {key!r}")
-            if not isinstance(value, (int, str)):
-                msg = f"value for key {key!r} is not a string or integer " \
-                    f"(to use fractional values such as 123.45, write " \
-                    f"\"123.45\"): {value!r}"
-                raise BudgetGraphPayeesParseError(self.bug.id, msg)
-            try:
-                money_value = Money(value)
-            except ValueError as e:
-                msg = f"failed to parse Money value for key {key!r}: {e}"
-                raise BudgetGraphPayeesParseError(
-                    self.bug.id, msg) \
-                    .with_traceback(sys.exc_info()[2])
-            retval[key] = money_value
+            retval[key] = Payment.from_toml(self, key, value)
         return retval
 
     @property
@@ -133,12 +262,19 @@ class Node:
                 yield from visitor(i)
         return visitor(self)
 
+    def children_breadth_first(self) -> Iterable["Node"]:
+        q = deque(self.immediate_children)
+        while True:
+            try:
+                node = q.popleft()
+            except IndexError:
+                return
+            q.extend(node.immediate_children)
+            yield node
+
     def __eq__(self, other):
         return self.bug.id == other.bug.id
 
-    def __ne__(self, other):
-        return self.bug.id != other.bug.id
-
     def __hash__(self):
         return self.bug.id
 
@@ -152,15 +288,18 @@ class Node:
             immediate_children.append(_NodeSimpleReprWrapper(i))
         immediate_children.sort()
         parent = f"#{self.parent_id}" if self.parent_id is not None else None
+        payments = list(self.payments.values())
         return (f"Node(graph=..., "
                 f"id={_NodeSimpleReprWrapper(self)}, "
                 f"root={root}, "
                 f"parent={parent}, "
                 f"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
                 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"immediate_children={immediate_children!r}, "
-                f"payees={self.payees!r}")
+                f"payments={payments!r}")
 
 
 class BudgetGraphError(BudgetGraphBaseError):
@@ -183,7 +322,7 @@ class BudgetGraphMilestoneMismatch(BudgetGraphError):
                 f" #{self.root_bug_id}")
 
 
-class BudgetGraphMoneyMismatch(BudgetGraphError):
+class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError):
     def __init__(self, bug_id: int, root_bug_id: int,
                  expected_budget_excluding_subtasks: Money):
         super().__init__(bug_id, root_bug_id)
@@ -197,6 +336,20 @@ class BudgetGraphMoneyMismatch(BudgetGraphError):
                 f" {self.expected_budget_excluding_subtasks}")
 
 
+class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError):
+    def __init__(self, bug_id: int, root_bug_id: int,
+                 expected_budget_including_subtasks: Money):
+        super().__init__(bug_id, root_bug_id)
+        self.expected_budget_including_subtasks = \
+            expected_budget_including_subtasks
+
+    def __str__(self):
+        return (f"Budget assigned to task including subtasks "
+                f"(cf_total_budget field) doesn't match calculated value: "
+                f"bug #{self.bug_id}, calculated value"
+                f" {self.expected_budget_including_subtasks}")
+
+
 class BudgetGraphNegativeMoney(BudgetGraphError):
     def __str__(self):
         return (f"Budget assigned to task is less than zero: "
@@ -204,16 +357,17 @@ class BudgetGraphNegativeMoney(BudgetGraphError):
 
 
 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError):
-    def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money):
+    def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money,
+                 expected_payees_total: Money):
         super().__init__(bug_id, root_bug_id)
         self.payees_total = payees_total
+        self.expected_payees_total = expected_payees_total
 
     def __str__(self):
-        return (f"Budget assigned to task excluding subtasks "
-                f"(cf_budget field) doesn't match total value "
-                f"assigned to payees (cf_payees_list): "
-                f"bug #{self.bug_id}, calculated total"
-                f" {self.payees_total}")
+        return (f"Total budget assigned to payees (cf_payees_list) doesn't "
+                f"match expected value: bug #{self.bug_id}, calculated total "
+                f"{self.payees_total}, expected value "
+                f"{self.expected_payees_total}")
 
 
 class BudgetGraphNegativePayeeMoney(BudgetGraphError):
@@ -254,29 +408,135 @@ class BudgetGraph:
                     or node.budget_excluding_subtasks != 0:
                 errors.append(BudgetGraphMoneyWithNoMilestone(
                     node.bug.id, root.bug.id))
+
         if node.nlnet_milestone != root.nlnet_milestone:
             errors.append(BudgetGraphMilestoneMismatch(
                 node.bug.id, root.bug.id))
+
         if node.budget_excluding_subtasks < 0 \
                 or node.budget_including_subtasks < 0:
             errors.append(BudgetGraphNegativeMoney(
                 node.bug.id, root.bug.id))
-        budget = node.budget_including_subtasks
+
+        subtasks_total = Money(0)
         for child in node.immediate_children:
-            budget -= child.budget_including_subtasks
-        if node.budget_excluding_subtasks != budget:
-            errors.append(BudgetGraphMoneyMismatch(
-                node.bug.id, root.bug.id, budget))
+            subtasks_total += child.fixed_budget_including_subtasks
+
         payees_total = Money(0)
-        for payee_key, payee_value in node.payees.items():
-            if payee_value < 0:
+        for payment in node.payments.values():
+            if payment.amount < 0:
                 errors.append(BudgetGraphNegativePayeeMoney(
-                    node.bug.id, root.bug.id, payee_key))
-            payees_total += payee_value
-        if node.budget_excluding_subtasks != payees_total \
-                and len(node.payees) != 0:
+                    node.bug.id, root.bug.id, payment.payee_key))
+            payees_total += payment.amount
+
+        def set_including_from_excluding_and_error():
+            node.fixed_budget_including_subtasks = \
+                node.budget_excluding_subtasks + subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_including_subtasks))
+
+        def set_including_from_payees_and_error():
+            node.fixed_budget_including_subtasks = \
+                payees_total + subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_including_subtasks))
+
+        def set_excluding_from_including_and_error():
+            node.fixed_budget_excluding_subtasks = \
+                node.budget_including_subtasks - subtasks_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_excluding_subtasks))
+
+        def set_excluding_from_payees_and_error():
+            node.fixed_budget_excluding_subtasks = \
+                payees_total
+            errors.append(
+                BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+                    node.bug.id, root.bug.id,
+                    node.fixed_budget_excluding_subtasks))
+
+        def set_payees_from_including_and_error():
+            fixed_payees_total = \
+                node.budget_including_subtasks - subtasks_total
+            errors.append(BudgetGraphPayeesMoneyMismatch(
+                node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+        def set_payees_from_excluding_and_error():
+            fixed_payees_total = \
+                node.budget_excluding_subtasks
             errors.append(BudgetGraphPayeesMoneyMismatch(
-                node.bug.id, root.bug.id, payees_total))
+                node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+        payees_matches_including = \
+            node.budget_including_subtasks - subtasks_total == payees_total
+        payees_matches_excluding = \
+            node.budget_excluding_subtasks == payees_total
+        including_matches_excluding = \
+            node.budget_including_subtasks - subtasks_total \
+            == node.budget_excluding_subtasks
+
+        if payees_matches_including \
+                and payees_matches_excluding \
+                and including_matches_excluding:
+            pass  # no error
+        elif payees_matches_including:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_excluding
+            assert not including_matches_excluding
+            if node.budget_including_subtasks == 0 and len(node.payments) == 0:
+                set_including_from_excluding_and_error()
+            else:
+                set_excluding_from_including_and_error()
+        elif payees_matches_excluding:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_including
+            assert not including_matches_excluding
+            if node.budget_excluding_subtasks == 0 and len(node.payments) == 0:
+                if node.budget_including_subtasks == 0:
+                    set_including_from_excluding_and_error()
+                else:
+                    set_excluding_from_including_and_error()
+            else:
+                set_including_from_excluding_and_error()
+        elif including_matches_excluding:
+            # can't have 2 match without all 3 matching
+            assert not payees_matches_including
+            assert not payees_matches_excluding
+            if len(node.payments) == 0:
+                pass  # no error -- payees is just not set
+            elif node.budget_excluding_subtasks == 0 \
+                    and node.budget_including_subtasks == 0:
+                set_excluding_from_payees_and_error()
+                set_including_from_payees_and_error()
+            else:
+                set_payees_from_excluding_and_error()
+        else:
+            # nothing matches
+            if len(node.payments) == 0:
+                # payees unset -- don't need to set payees
+                if node.budget_including_subtasks == 0:
+                    set_including_from_excluding_and_error()
+                else:
+                    set_excluding_from_including_and_error()
+            elif node.budget_excluding_subtasks == 0 \
+                    and node.budget_including_subtasks == 0:
+                set_excluding_from_payees_and_error()
+                set_including_from_payees_and_error()
+            elif node.budget_excluding_subtasks == 0:
+                set_excluding_from_including_and_error()
+                set_payees_from_including_and_error()
+            elif node.budget_including_subtasks == 0:
+                set_including_from_excluding_and_error()
+                set_payees_from_excluding_and_error()
+            else:
+                set_including_from_excluding_and_error()
+                set_payees_from_excluding_and_error()
 
     def get_errors(self) -> List[BudgetGraphBaseError]:
         errors = []
@@ -288,12 +548,12 @@ class BudgetGraph:
 
         for root in roots:
             try:
-                self._get_node_errors(root, root, errors)
-                for child in root.children():
+                for child in reversed(list(root.children_breadth_first())):
                     try:
                         self._get_node_errors(root, child, errors)
                     except BudgetGraphBaseError as e:
                         errors.append(e)
+                self._get_node_errors(root, root, errors)
             except BudgetGraphBaseError as e:
                 errors.append(e)
         return errors
@@ -302,6 +562,6 @@ class BudgetGraph:
     def payee_keys(self) -> Set[str]:
         retval = set()
         for node in self.nodes.values():
-            for payee_key in node.payees.keys():
+            for payee_key in node.payments.keys():
                 retval.add(payee_key)
         return retval