home · contact · privacy
Extend Booking edit views with per-booking balance. master
authorChristian Heller <c.heller@plomlompom.de>
Wed, 5 Feb 2025 00:53:26 +0000 (01:53 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Wed, 5 Feb 2025 00:53:26 +0000 (01:53 +0100)
ledger.py
templates/_macros.tmpl
templates/balance.tmpl
templates/edit_raw.tmpl
templates/edit_structured.tmpl

index 614fef5f98cbc1492c0ec7586ddce64bba7c9209..b9df71d07aac3bac3628bda5c285a36fb6069dcf 100755 (executable)
--- a/ledger.py
+++ b/ledger.py
@@ -43,33 +43,44 @@ class Dictable:
         return d
 
 
         return d
 
 
-class Wealth:
+class Wealth():
     """Collects amounts mapped to currencies."""
 
     def __init__(self, moneys: Optional[dict[str, Decimal]] = None) -> None:
         self.moneys = moneys if moneys else {}
     """Collects amounts mapped to currencies."""
 
     def __init__(self, moneys: Optional[dict[str, Decimal]] = None) -> None:
         self.moneys = moneys if moneys else {}
-        self._move_euro_up()
+        self._sort_with_euro_up()
 
 
-    def _move_euro_up(self) -> None:
+    def _sort_with_euro_up(self) -> None:
         if '€' in self.moneys:
             temp = {'€': self.moneys['€']}
         if '€' in self.moneys:
             temp = {'€': self.moneys['€']}
-            for curr in [c for c in self.moneys if c != '€']:
+            for curr in sorted([c for c in self.moneys if c != '€']):
                 temp[curr] = self.moneys[curr]
             self.moneys = temp
 
                 temp[curr] = self.moneys[curr]
             self.moneys = temp
 
-    def _inc_by(self, other: Self, add=True) -> Self:
-        for currency, amount in other.moneys.items():
+    def ensure_currencies(self, currencies: set[str]) -> None:
+        """Ensure all of currencies have at least a Decimal(0) entry."""
+        for currency in currencies:
             if currency not in self.moneys:
                 self.moneys[currency] = Decimal(0)
             if currency not in self.moneys:
                 self.moneys[currency] = Decimal(0)
-            self.moneys[currency] += amount if add else -amount
-        self._move_euro_up()
-        return self
+        self._sort_with_euro_up()
+
+    def purge_currencies_except(self, currencies: set[str]) -> None:
+        """From .moneys remove currencies except those listed."""
+        self.moneys = {curr: amt for curr, amt in self.moneys.items()
+                       if curr in currencies}
 
 
-    def __iadd__(self, other: Self) -> Self:
-        return self._inc_by(other, True)
+    def _add(self, other: Self, add=True) -> Self:
+        result = self.__class__(self.moneys.copy())
+        result.ensure_currencies(set(other.moneys.keys()))
+        for currency, amount in other.moneys.items():
+            result.moneys[currency] += amount if add else -amount
+        return result
 
 
-    def __isub__(self, other: Self) -> Self:
-        return self._inc_by(other, False)
+    def __add__(self, other: Self) -> Self:
+        return self._add(other)
+
+    def __sub__(self, other: Self) -> Self:
+        return self._add(other, add=False)
 
     @property
     def sink_empty(self) -> bool:
 
     @property
     def sink_empty(self) -> bool:
@@ -91,7 +102,7 @@ class Account:
     def __init__(self, parent: Optional['Account'], basename: str) -> None:
         self.local_wealth = Wealth()
         self.basename = basename
     def __init__(self, parent: Optional['Account'], basename: str) -> None:
         self.local_wealth = Wealth()
         self.basename = basename
-        self.children: list[Account] = []
+        self.children: list[Self] = []
         self.parent = parent
         if self.parent:
             self.parent.children += [self]
         self.parent = parent
         if self.parent:
             self.parent.children += [self]
@@ -105,6 +116,21 @@ class Account:
             total += child.wealth
         return total
 
             total += child.wealth
         return total
 
+    @staticmethod
+    def by_paths(acc_names: list[str]) -> dict[str, 'Account']:
+        """From bookings generate dict of all refered Accounts by paths."""
+        paths_to_accs: dict[str, Account] = {}
+        for full_name in acc_names:
+            path = ''
+            for step_name in full_name.split(':'):
+                parent_name = path[:]
+                path = ':'.join([path, step_name]) if path else step_name
+                if path not in paths_to_accs:
+                    paths_to_accs[path] = Account(
+                        paths_to_accs[parent_name] if parent_name else None,
+                        step_name)
+        return paths_to_accs
+
     @staticmethod
     def names_over_bookings(bookings: list['Booking']) -> list[str]:
         """Sorted list of all account names refered to in bookings."""
     @staticmethod
     def names_over_bookings(bookings: list['Booking']) -> list[str]:
         """Sorted list of all account names refered to in bookings."""
@@ -322,6 +348,11 @@ class Booking:
             return True
         return False
 
             return True
         return False
 
+    def apply_to_account_dict(self, acc_dict: dict[str, Account]) -> None:
+        """To account dictionary of expected keys, apply .account_changes."""
+        for acc_name, wealth in self.account_changes.items():
+            acc_dict[acc_name].local_wealth += wealth
+
 
 class Handler(PlomHttpHandler):
     """"Handles HTTP requests."""
 
 class Handler(PlomHttpHandler):
     """"Handles HTTP requests."""
@@ -422,24 +453,10 @@ class Handler(PlomHttpHandler):
         to_balance = (self.server.bookings[:id_ + 1] if id_ >= 0
                       else self.server.bookings)
         valid = 0 == len([b for b in to_balance if b.is_questionable])
         to_balance = (self.server.bookings[:id_ + 1] if id_ >= 0
                       else self.server.bookings)
         valid = 0 == len([b for b in to_balance if b.is_questionable])
-        full_names_to_accounts: dict[str, Account] = {}
-        for full_name in Account.names_over_bookings(to_balance):
-            step_names = full_name.split(':')
-            path = ''
-            for step_name in step_names:
-                parent_name = path[:]
-                path = ':'.join([path, step_name]) if path else step_name
-                if path not in full_names_to_accounts:
-                    full_names_to_accounts[path] = Account(
-                        full_names_to_accounts[parent_name] if parent_name
-                        else None,
-                        step_name)
+        acc_dict = Account.by_paths(Account.names_over_bookings(to_balance))
         for booking in to_balance:
         for booking in to_balance:
-            for account_name in booking.account_changes:
-                full_names_to_accounts[account_name].local_wealth +=\
-                        booking.account_changes[account_name]
-        ctx['roots'] = [ac for ac in full_names_to_accounts.values()
-                        if not ac.parent]
+            booking.apply_to_account_dict(acc_dict)
+        ctx['roots'] = [ac for ac in acc_dict.values() if not ac.parent]
         ctx['valid'] = valid
         ctx['booking'] = self.server.bookings[id_]
         self._send_rendered('balance', ctx)
         ctx['valid'] = valid
         ctx['booking'] = self.server.bookings[id_]
         self._send_rendered('balance', ctx)
@@ -447,11 +464,49 @@ class Handler(PlomHttpHandler):
     def get_edit(self, ctx, raw: bool) -> None:
         """Display edit form for individual Booking."""
         id_ = int(self.path_toks[2])
     def get_edit(self, ctx, raw: bool) -> None:
         """Display edit form for individual Booking."""
         id_ = int(self.path_toks[2])
+        booking = self.server.bookings[id_]
+        acc_names = Account.names_over_bookings(self.server.bookings)
+        to_balance = self.server.bookings[:id_ + 1]
+        accounts_after = Account.by_paths(acc_names)
+        accounts_before = Account.by_paths(acc_names)
+        for b in to_balance:
+            if b != booking:
+                b.apply_to_account_dict(accounts_before)
+            b.apply_to_account_dict(accounts_after)
+        observed_tree: list[dict[str, Any]] = []
+        for full_name in sorted(booking.account_changes.keys()):
+            parent_children: list[dict[str, Any]] = observed_tree
+            path = ''
+            for step_name in full_name.split(':'):
+                path = ':'.join([path, step_name]) if path else step_name
+                for child in [n for n in parent_children if path == n['name']]:
+                    parent_children = child['children']
+                    continue
+                wealth_before = accounts_before[path].wealth
+                wealth_after = accounts_after[path].wealth
+                diff = {c: a for c, a in (wealth_after - wealth_before
+                                          ).moneys.items()
+                        if a != 0}
+                if diff:
+                    displayed_currencies = set(diff.keys())
+                    for wealth in wealth_before, wealth_after:
+                        wealth.ensure_currencies(displayed_currencies)
+                        wealth.purge_currencies_except(displayed_currencies)
+                    node: dict[str, Any] = {
+                            'name': path,
+                            'wealth_before': wealth_before.moneys,
+                            'wealth_diff': diff,
+                            'wealth_after': wealth_after.moneys,
+                            'children': []}
+                    parent_children += [node]
+                    parent_children = node['children']
         ctx['id'] = id_
         ctx['id'] = id_
-        ctx['dat_lines'] = [dl if raw else dl.as_dict for dl
-                            in self.server.bookings[id_].booked_lines]
+        ctx['dat_lines'] = [dl if raw else dl.as_dict
+                            for dl in booking.booked_lines]
+        ctx['valid'] = 0 == len([b for b in to_balance if b.is_questionable])
+        ctx['roots'] = observed_tree
         if not raw:
         if not raw:
-            ctx['accounts'] = Account.names_over_bookings(self.server.bookings)
+            ctx['all_accounts'] = acc_names
         self._send_rendered(EDIT_RAW if raw else EDIT_STRUCT, ctx)
 
     def get_ledger(self, ctx: dict[str, Any], raw: bool) -> None:
         self._send_rendered(EDIT_RAW if raw else EDIT_STRUCT, ctx)
 
     def get_ledger(self, ctx: dict[str, Any], raw: bool) -> None:
index a7ed6196b1c1cb1e5869b7ea376ce0e1f4996509..5da40966912bc52f88b6b147fc99b0f14b1a612e 100644 (file)
@@ -3,14 +3,31 @@ td.amt { text-align: right }
 td.amt, td.curr { font-family: monospace; font-size: 1.3em; }
 {% endmacro %}
 
 td.amt, td.curr { font-family: monospace; font-size: 1.3em; }
 {% endmacro %}
 
+
+{% macro css_td_money_balance() %}
+td.balance.amt { width: 10em; }
+td.balance.curr { width: 3em; }
+{% endmacro %}
+
+
 {% macro css_errors() %}
 td.invalid, tr.warning td.invalid { background-color: #ff0000; }
 {% endmacro %}
 
 {% macro css_errors() %}
 td.invalid, tr.warning td.invalid { background-color: #ff0000; }
 {% endmacro %}
 
+
 {% macro css_ledger_index_col() %}
 table.ledger tr > td:first-child { background-color: white; }
 {% endmacro %}
 
 {% macro css_ledger_index_col() %}
 table.ledger tr > td:first-child { background-color: white; }
 {% endmacro %}
 
+
+{% macro tr_money_balance(amt, curr) %}
+<tr>
+<td class="balance amt">{{amt}}</td>
+<td class="balance curr">{{curr|truncate(4,true,"…")}}</td>
+</tr>
+{% endmacro %}
+
+
 {% macro table_dat_lines(dat_lines, raw) %}
 <form action="/ledger_{% if raw %}raw{% else %}structured{% endif %}" method="POST">
 <table class="ledger">
 {% macro table_dat_lines(dat_lines, raw) %}
 <form action="/ledger_{% if raw %}raw{% else %}structured{% endif %}" method="POST">
 <table class="ledger">
@@ -62,6 +79,7 @@ table.ledger tr > td:first-child { background-color: white; }
 </form>
 {% endmacro %}
 
 </form>
 {% endmacro %}
 
+
 {% macro taint_js() %}
 function taint() {
   // activate buttons "apply", "revert"
 {% macro taint_js() %}
 function taint() {
   // activate buttons "apply", "revert"
@@ -88,6 +106,7 @@ function taint() {
 }
 {% endmacro %}
 
 }
 {% endmacro %}
 
+
 {% macro edit_bar(target, id) %}
 <span class="disable_on_change">
 <a href="/bookings/{{id-1}}">prev</a> · <a href="/bookings/{{id+1}}">next</a>
 {% macro edit_bar(target, id) %}
 <span class="disable_on_change">
 <a href="/bookings/{{id-1}}">prev</a> · <a href="/bookings/{{id+1}}">next</a>
@@ -99,3 +118,45 @@ function taint() {
 </span>
 <hr />
 {% endmacro %}
 </span>
 <hr />
 {% endmacro %}
+
+
+{% macro booking_balance_account_with_children(account) %}
+<tr class="alternating">
+<td>{{account.name}}{% if account.children %}:{% endif %}</td>
+<td class="money">
+<table>
+{% for curr, amt in account.wealth_before.items() %}
+  {{ tr_money_balance(amt, curr) }}
+{% endfor %}
+</table>
+</td>
+<td class="money">
+<table>
+{% for curr, amt in account.wealth_diff.items() %}
+  {{ tr_money_balance(amt, curr) }}
+{% endfor %}
+</table>
+</td>
+<td class="money">
+<table>
+{% for curr, amt in account.wealth_after.items() %}
+  {{ tr_money_balance(amt, curr) }}
+{% endfor %}
+</table>
+</td>
+</tr>
+{% for child in account.children %}
+  {{ booking_balance_account_with_children(child) }}
+{% endfor %}
+{% endmacro %}
+
+
+{% macro booking_balance(valid, roots) %}
+<hr />
+<table{% if not valid %} class="warning"{% endif %}>
+<tr class="alternating"><th>account</th><th>before</th><th>diff</th><th>after</th></tr>
+{% for root in roots %}
+{{ booking_balance_account_with_children(root) }}
+{% endfor %}
+</table>
+{% endmacro %}
index 4cde2749d76b8f168105b49702eaa0aaaa4fa8ef..6bbb6484d58015a0121429e5b88fdac5257646f3 100644 (file)
@@ -1,21 +1,13 @@
 {% extends '_base.tmpl' %}
 
 
 {% extends '_base.tmpl' %}
 
 
-{% macro tr_money(amt, curr) %}
-<tr>
-<td class="amt">{{amt}}</td>
-<td class="curr">{{curr|truncate(4,true,"…")}}</td>
-</tr>
-{% endmacro %}
-
-
 {% macro account_with_children(account, indent) %}
   <tr class="alternating">
   <td class="money">
   {% if account.wealth.moneys|length == 1 %}
     <table>
     {% for curr, amt in account.wealth.moneys.items() %}
 {% macro account_with_children(account, indent) %}
   <tr class="alternating">
   <td class="money">
   {% if account.wealth.moneys|length == 1 %}
     <table>
     {% for curr, amt in account.wealth.moneys.items() %}
-      {{ tr_money(amt, curr) }}
+      {{ macros.tr_money_balance(amt, curr) }}
     {% endfor %}
     </table>
   {% else %}
     {% endfor %}
     </table>
   {% else %}
@@ -24,7 +16,7 @@
     <table>
     {% for curr, amt in account.wealth.moneys.items() %}
       {% if 1 == loop.index %}
     <table>
     {% for curr, amt in account.wealth.moneys.items() %}
       {% if 1 == loop.index %}
-        {{ tr_money(amt, curr) }}
+        {{ macros.tr_money_balance(amt, curr) }}
       {% endif %}
     {% endfor %}
     </table>
       {% endif %}
     {% endfor %}
     </table>
@@ -32,7 +24,7 @@
     <table>
     {% for curr, amt in account.wealth.moneys.items() %}
       {% if 1 < loop.index %}
     <table>
     {% for curr, amt in account.wealth.moneys.items() %}
       {% if 1 < loop.index %}
-        {{ tr_money(amt, curr) }}
+        {{ macros.tr_money_balance(amt, curr) }}
       {% endif %}
     {% endfor %}
     </table>
       {% endif %}
     {% endfor %}
     </table>
 
 {% block css %}
 {{ macros.css_td_money() }}
 
 {% block css %}
 {{ macros.css_td_money() }}
-
+{{ macros.css_td_money_balance() }}
 td.money table { float: left; }
 td.money table { float: left; }
-td.amt { width: 10em; }
-td.curr { width: 3em; }
 summary::marker { font-family: monospace; font-size: 1.2em; }
 summary { list-style-type: "[…]"; }
 details[open] > summary { list-style-type: "[^]"; }
 summary::marker { font-family: monospace; font-size: 1.2em; }
 summary { list-style-type: "[…]"; }
 details[open] > summary { list-style-type: "[^]"; }
-
 span.indent { letter-spacing: 3em; }
 span.indent { letter-spacing: 3em; }
-
 {% endblock css %}
 
 {% block content %}
 {% endblock css %}
 
 {% block content %}
index b58e7b394ffb9aa17149d2a2ccb882f25cdf0b6d..adfccbca07980e5e19738e9d979658db08e2e391 100644 (file)
@@ -3,6 +3,7 @@
 
 {% block css %}
 {{ macros.css_td_money() }}
 
 {% block css %}
 {{ macros.css_td_money() }}
+{{ macros.css_td_money_balance() }}
 {{ macros.css_errors() }}
 {% endblock %}
 
 {{ macros.css_errors() }}
 {% endblock %}
 
@@ -19,4 +20,5 @@
 {% for dat_line in dat_lines %}{{ dat_line.raw }}
 {% endfor %}</textarea>
 </form>
 {% for dat_line in dat_lines %}{{ dat_line.raw }}
 {% endfor %}</textarea>
 </form>
+{{ macros.booking_balance(valid, roots) }}
 {% endblock %}
 {% endblock %}
index 8f2bcfa89d6596740d4865cb62af27e41a384b3d..7ffa642a7b465a70568bd511c315aa3a9a35d12b 100644 (file)
@@ -3,6 +3,7 @@
 
 {% block css %}
 {{ macros.css_td_money() }}
 
 {% block css %}
 {{ macros.css_td_money() }}
+{{ macros.css_td_money_balance() }}
 {{ macros.css_errors() }}
 input.date_input, input.number_input { font-family: monospace; }
 input.number_input { text-align: right; }
 {{ macros.css_errors() }}
 input.date_input, input.number_input { font-family: monospace; }
 input.number_input { text-align: right; }
@@ -60,7 +61,7 @@ function update_form() {
       add_input(td, 'target', dat_line.booking_line.target, 35)
     } else if (!dat_line.error) {  // i.e. valid TransferLine
       const acc_input = add_td_input('account', dat_line.booking_line.account, 30);
       add_input(td, 'target', dat_line.booking_line.target, 35)
     } else if (!dat_line.error) {  // i.e. valid TransferLine
       const acc_input = add_td_input('account', dat_line.booking_line.account, 30);
-      acc_input.setAttribute ('list', 'accounts');
+      acc_input.setAttribute ('list', 'all_accounts');
       acc_input.autocomplete = 'off';
       // not using input[type=number] cuz no minimal step size, therefore regex test instead
       const amt_input = add_td_input('amount', dat_line.booking_line.amount == 'None' ? '' : dat_line.booking_line.amount, 12);
       acc_input.autocomplete = 'off';
       // not using input[type=number] cuz no minimal step size, therefore regex test instead
       const amt_input = add_td_input('amount', dat_line.booking_line.amount == 'None' ? '' : dat_line.booking_line.amount, 12);
@@ -123,9 +124,10 @@ window.onload = update_form;
 <table id="dat_lines">
 </table>
 </form>
 <table id="dat_lines">
 </table>
 </form>
-<datalist id="accounts">
+<datalist id="all_accounts">
 {% for acc in accounts %}
 <option value="{{acc}}">{{acc}}</a>
 {% endfor %}
 </datalist>
 {% for acc in accounts %}
 <option value="{{acc}}">{{acc}}</a>
 {% endfor %}
 </datalist>
+{{ macros.booking_balance(valid, roots) }}
 {% endblock %}
 {% endblock %}