home · contact · privacy
Improve accounting scripts.
[misc] / ledger.py
index aa295d28203d7279137d9b143b5c236e98c92e31..a26ca0f345f5dfdaf02eb8cff45330991eb14d7d 100755 (executable)
--- a/ledger.py
+++ b/ledger.py
@@ -15,6 +15,21 @@ class EditableException(PlomException):
         super().__init__(*args, **kwargs)
     
 
+class LedgerTextLine:
+
+    def __init__(self, text_line):
+        self.text_line = text_line
+        self.comment = '' 
+        split_by_comment = text_line.rstrip().split(sep=';', maxsplit=1)
+        self.non_comment = split_by_comment[0].rstrip()
+        self.empty = len(split_by_comment) == 1 and len(self.non_comment) == 0
+        if self.empty:
+            return
+        if len(split_by_comment) == 2:
+            self.comment = split_by_comment[1].lstrip()
+
+
+
 # html_head = """
 # <style>
 # body { color: #000000; }
@@ -108,17 +123,22 @@ class Wealth:
     def __init__(self):
         self.money_dict = {}
 
-    def add_money_dict(self, moneys):
+    def __iadd__(self, moneys):
+        money_dict = moneys
+        if type(moneys) == Wealth:
+            moneys = moneys.money_dict
         for currency, amount in moneys.items():
             if not currency in self.money_dict.keys():
                 self.money_dict[currency] = 0
             self.money_dict[currency] += amount
+        return self
 
-    def add_wealth(self, moneys):
-        self.add_money_dict(moneys.money_dict)
+    @property
+    def sink_empty(self):
+        return len(self.as_sink) == 0
 
     @property
-    def as_sink_dict(self):
+    def as_sink(self):
         sink = {} 
         for currency, amount in self.money_dict.items():
             if 0 == amount:
@@ -138,7 +158,7 @@ class Account:
         self.children = []
 
     def add_wealth(self, moneys):
-        self.own_moneys.add_wealth(moneys)
+        self.own_moneys += moneys
 
     @property
     def full_name(self):
@@ -150,9 +170,9 @@ class Account:
     @property
     def full_moneys(self):
         full_moneys = Wealth() 
-        full_moneys.add_wealth(self.own_moneys)
+        full_moneys += self.own_moneys
         for child in self.children:
-            full_moneys.add_wealth(child.full_moneys)
+            full_moneys += child.full_moneys
         return full_moneys
 
 
@@ -209,8 +229,9 @@ class Account:
 #     return account_tree, account_sums
 
 
-def parse_lines2(lines, ignore_editable_exceptions=False):
-    lines = lines.copy() + ['']  # to simulate ending of last booking
+def parse_lines_to_bookings(lines, ignore_editable_exceptions=False):
+    lines = [LedgerTextLine(line) for line in lines]
+    lines += [LedgerTextLine('')]  # to simulate ending of last booking
     bookings = []
     inside_booking = False
     booking_lines = []
@@ -218,8 +239,7 @@ def parse_lines2(lines, ignore_editable_exceptions=False):
     last_date = '' 
     for i, line in enumerate(lines):
         intro = f'file line {i}'
-        stripped_line = line.rstrip()
-        if stripped_line == '':
+        if line.empty: 
             if inside_booking:
                 booking = Booking(lines=booking_lines, starts_at=booking_start_i)
                 if last_date > booking.date and not ignore_editable_exceptions:
@@ -369,13 +389,10 @@ class TransferLine:
         self.currency = currency 
         self.comment = comment 
         if line:
-            split_by_comment = line.rstrip().split(sep=';', maxsplit=1)
-            if len(split_by_comment) == 0:
+            if line.empty:
                 raise PlomException('line empty')
-            elif len(split_by_comment) == 2:
-                self.comment = split_by_comment[1].lstrip()
-            non_comment = split_by_comment[0].rstrip()
-            toks = non_comment.split()
+            self.comment = line.comment
+            toks = line.non_comment.split()
             if (len(toks) not in {1, 3}):
                 if validate:
                     raise PlomException(f'number of non-comment tokens not 1 or 3')
@@ -391,7 +408,7 @@ class TransferLine:
                     if validate:
                         raise PlomException(f'invalid token for Decimal: {toks[1]}')
                     else:
-                        self.amount = toks[1]
+                        self.comment = f'unparsed: {toks[1]}; {self.comment}'
                 self.currency = toks[2]
 
     @property
@@ -403,6 +420,7 @@ class TransferLine:
         else:
             return f'{self.amount:.2f}'
 
+    @property
     def for_writing(self):
         line = f'  {self.account}'
         if self.amount is not None:
@@ -424,7 +442,7 @@ class Booking:
         self.intro = f'booking starting at line {self.starts_at}'
         self.clean()
         if lines:
-            self.real_lines = lines 
+            self.lines = lines 
             self.parse_lines()
         else:
             self.date = date
@@ -435,7 +453,7 @@ class Booking:
             if len(self.transfer_lines) < 2 and self.validate:
                 raise PlomException(f'{self.intro}: too few transfer lines')
             self.calculate_account_changes()
-            self.real_lines = self.for_writing()
+            self.lines = [LedgerTextLine(l) for l in self.for_writing]
 
     @classmethod
     def from_postvars(cls, postvars, starts_at='?', validate=True):
@@ -457,16 +475,13 @@ class Booking:
         self.account_changes = {}
 
     def parse_lines(self):
-        self.top_comment = '' 
-        if len(self.real_lines) < 3 and self.validate:
-            raise PlomException(f'{self.intro}: ends with less than 3 lines:' + str(self.real_lines))
-        split_by_comment = self.real_lines[0].rstrip().split(sep=";", maxsplit=1)
-        if len(split_by_comment) == 0 and self.validate:
+        if len(self.lines) < 3 and self.validate:
+            raise PlomException(f'{self.intro}: ends with less than 3 lines:' + str(self.lines))
+        top_line = self.lines[0]
+        if top_line.empty and self.validate:
             raise PlomException('{self.intro}: headline empty')
-        elif len(split_by_comment) == 2 and self.validate:
-            self.top_comment = split_by_comment[1].lstrip()
-        non_comment = split_by_comment[0].rstrip()
-        toks = non_comment.split()
+        self.top_comment = top_line.comment
+        toks = top_line.non_comment.split(maxsplit=1)
         if len(toks) < 2:
             if self.validate:
                 raise PlomException(f'{self.intro}: headline missing elements: {non_comment}')
@@ -477,7 +492,7 @@ class Booking:
         self.date = toks[0]
         self.description = toks[1]
         self.validate_head()
-        for i, line in enumerate(self.real_lines[1:]):
+        for i, line in enumerate(self.lines[1:]):
             try:
                 self.transfer_lines += [TransferLine(line, validate=self.validate)]
             except PlomException as e:
@@ -497,14 +512,27 @@ class Booking:
                 if not transfer_line.account in self.account_changes.keys():
                     self.account_changes[transfer_line.account] = Wealth()
                 money = {transfer_line.currency: transfer_line.amount}
-                self.account_changes[transfer_line.account].add_money_dict(money)
-                money_changes.add_money_dict(money)
-        if sink_account is None and len(money_changes.as_sink_dict) > 0 and self.validate:
-            raise PlomException(f'{intro}: does not balance (unused non-empty sink)')
+                self.account_changes[transfer_line.account] += money
+                money_changes += money
+        if sink_account is None and (not money_changes.sink_empty) and self.validate:
+            raise PlomException(f'{intro}: does not balance (undeclared non-empty sink)')
         if sink_account is not None:
             if not sink_account in self.account_changes.keys():
                 self.account_changes[sink_account] = Wealth()
-            self.account_changes[sink_account].add_money_dict(money_changes.as_sink_dict)
+            self.account_changes[sink_account] += money_changes.as_sink
+
+    @property
+    def for_writing(self):
+        lines = [f'{self.date} {self.description}']
+        if self.top_comment is not None and self.top_comment.rstrip() != '':
+            lines[0] += f' ; {self.top_comment}'
+        for line in self.transfer_lines:
+            lines += [line.for_writing]
+        return lines
+
+    @property
+    def comment_cols(self):
+        return max(20, len(self.top_comment))
 
     def validate_head(self):
         if not self.validate:
@@ -523,19 +551,30 @@ class Booking:
         for i, line in enumerate(self.transfer_lines):
             if line.amount is None:
                 for currency, amount in self.account_changes[line.account].money_dict.items():
-                    replacement_lines += [TransferLine(None, f'{line.account}', amount, currency).for_writing()]
+                    replacement_lines += [TransferLine(None, f'{line.account}', amount, currency).for_writing]
                 break
-        lines = self.real_lines[:i+1] + replacement_lines + self.real_lines[i+2:]
+        lines = self.lines[:i+1] + [LedgerTextLine(l) for l in replacement_lines] + self.lines[i+2:]
         self.clean()
-        self.real_lines = lines
+        self.lines = lines
         self.parse_lines()
 
-    def add_mirror(self):
+    def mirror(self):
         new_transfer_lines = []
-        for line in self.transfer_lines:
-            new_transfer_lines += [TransferLine(None, f'({line.account})', line.amount, line.currency, line.comment)]
+        for transfer_line in self.transfer_lines:
+            uncommented_source = LedgerTextLine(transfer_line.for_writing).non_comment
+            comment = f're: {uncommented_source.lstrip()}'
+            new_account = '?'
+            new_transfer_lines += [TransferLine(None, new_account, -transfer_line.amount, transfer_line.currency, comment)]
         for transfer_line in new_transfer_lines:
-            self.real_lines += [transfer_line.for_writing()]
+            self.lines += [LedgerTextLine(transfer_line.for_writing)]
+        self.clean()
+        self.parse_lines()
+
+    def replace(self, replace_from, replace_to):
+        lines = [] 
+        for l in self.for_writing:
+            lines += [l.replace(replace_from, replace_to)]
+        self.lines = [LedgerTextLine(l) for l in lines]
         self.clean()
         self.parse_lines()
 
@@ -614,18 +653,6 @@ class Booking:
         self.transfer_lines += [TransferLine(None, acc_assets, final_minus, '€')]
         self.transfer_lines += [TransferLine(None, acc_buffer, -final_minus, '€')]
 
-    def for_writing(self):
-        lines = [f'{self.date} {self.description}']
-        if self.top_comment is not None and self.top_comment.rstrip() != '':
-            lines[0] += f' ; {self.top_comment}'
-        for line in self.transfer_lines:
-            lines += [line.for_writing()]
-        return lines
-
-    @property
-    def comment_cols(self):
-        return max(20, len(self.top_comment))
-
 
 # class Booking:
 # 
@@ -697,15 +724,16 @@ class LedgerDB(PlomDB):
         self.prefix = prefix 
         self.bookings = []
         self.comments = []
-        self.real_lines = []
+        self.text_lines = []
         super().__init__(db_path)
-        self.bookings = parse_lines2(self.real_lines, ignore_editable_exceptions)
+        self.bookings = parse_lines_to_bookings(self.text_lines, ignore_editable_exceptions)
 
     def read_db_file(self, f):
-        self.real_lines += [l.rstrip() for l in f.readlines()]  # TODO is this necessary? (parser already removes lines?)
+        self.text_lines += f.readlines()
+        # self.text_lines += [l.rstrip() for l in f.readlines()]  # TODO is this necessary? (parser already removes lines?)
 
     # def get_lines(self, start, end):
-    #     return self.real_lines[start:end]
+    #     return self.text_lines[start:end]
 
     # def write_db(self, text, mode='w'):
     #     if text[-1] != '\n':
@@ -716,7 +744,7 @@ class LedgerDB(PlomDB):
     #     start_at = 0 
     #     if len(self.bookings) > 0:
     #         if date >= self.bookings[-1].date_string:
-    #             start_at = len(self.real_lines)
+    #             start_at = len(self.text_lines)
     #             lines = [''] + lines
     #         else:
     #             for b in self.bookings:
@@ -726,10 +754,10 @@ class LedgerDB(PlomDB):
     #                     start_at = b.start_line 
     #                     break
     #             lines += ['']  # DEBUG is new
-    #     return self.write_lines_in_total_lines_at(self.real_lines, start_at, lines)
+    #     return self.write_lines_in_total_lines_at(self.text_lines, start_at, lines)
 
     # def update(self, start, end, lines, date):
-    #     remaining_lines = self.real_lines[:start] + self.real_lines[end:]
+    #     remaining_lines = self.text_lines[:start] + self.text_lines[end:]
     #     n_original_lines = end - start
     #     start_at = len(remaining_lines)
     #     for b in self.bookings:
@@ -740,8 +768,8 @@ class LedgerDB(PlomDB):
     #                     start_at -= n_original_lines
     #         elif b.date_string > date:
     #             break
-    #     # print("DEBUG update start_at", start_at, "len(remaining_lines)", len(remaining_lines), "len(self.real_lines)", len(self.real_lines), "end", end)
-    #     if start_at != 0 and end != len(self.real_lines) and start_at == len(remaining_lines):
+    #     # print("DEBUG update start_at", start_at, "len(remaining_lines)", len(remaining_lines), "len(self.text_lines)", len(self.text_lines), "end", end)
+    #     if start_at != 0 and end != len(self.text_lines) and start_at == len(remaining_lines):
     #         # Add empty predecessor line if appending.
     #         lines = [''] + lines
     #     return self.write_lines_in_total_lines_at(remaining_lines, start_at, lines)
@@ -773,7 +801,7 @@ class LedgerDB(PlomDB):
                     break
                 else:
                     place_at = i + 1
-        self.bookings = self.bookings[:place_at] + [booking] + self.bookings[place_at:]
+        self.bookings.insert(place_at, booking)
 
     def add_taxes(self, lines, finish=False):
         ret = []
@@ -902,9 +930,9 @@ class LedgerDB(PlomDB):
     #     return ret
 
     def ledger_as_html(self):
-        for nth, booking in enumerate(self.bookings):
-            booking.can_up = nth > 0 and self.bookings[nth - 1].date == booking.date
-            booking.can_down = nth < len(self.bookings) - 1 and self.bookings[nth + 1].date == booking.date
+        for index, booking in enumerate(self.bookings):
+            booking.can_up = index > 0 and self.bookings[index - 1].date == booking.date
+            booking.can_down = index < len(self.bookings) - 1 and self.bookings[index + 1].date == booking.date
         return j2env.get_template('ledger.html').render(bookings=self.bookings)
 
     # def ledger_as_html(self):
@@ -944,8 +972,8 @@ class LedgerDB(PlomDB):
     #     elements_to_write += [single_c_tmpl.render(c=c) for c in self.comments[last_i:] if c != '']  #
     #     return '\n'.join(elements_to_write)
 
-    def balance_as_html(self, until=None):
-        bookings = self.bookings[:(until if until is None else int(until)+1)]
+    def balance_as_html(self, until_after=None):
+        bookings = self.bookings[:(until_after if until_after is None else int(until_after)+1)]
         account_trunk = Account('', None)
         accounts = {account_trunk.full_name: account_trunk}
         for booking in bookings:
@@ -998,19 +1026,21 @@ class LedgerDB(PlomDB):
     #     content = "\n".join(lines)
     #     return f"<pre>{content}</pre>"
 
-    def edit(self, index, sent=None, error_msg=None, edit_mode='table'):
+    def edit(self, index, sent=None, error_msg=None, edit_mode='table', copy=False):
         accounts = set() 
         if sent or -1 == index:
             content = sent if sent else ([] if 'textarea'==edit_mode else None)
         else:
             content = self.bookings[index]
+        if copy:
+            content.date = str(datetime.now())[:10]
         if 'textarea' == edit_mode and content:
-            content = content.for_writing()
+            content = content.for_writing
         else:
             for booking in self.bookings:
                 for transfer_line in booking.transfer_lines:
                     accounts.add(transfer_line.account)
-        return j2env.get_template('edit.html').render(content=content, index=index, error_msg=error_msg, edit_mode=edit_mode, accounts=accounts)
+        return j2env.get_template('edit.html').render(content=content, index=index, error_msg=error_msg, edit_mode=edit_mode, accounts=accounts, adding=(copy or -1 == index))
 
     # def add_free(self, start=0, end=0, copy=False):
     #     tmpl = jinja2.Template(add_form_header + add_free_html + add_form_footer) 
@@ -1076,26 +1106,26 @@ class LedgerDB(PlomDB):
     #             end=end)
     #     return content
 
-    def move_up(self, nth):
-        return self.move(nth, -1) 
+    def move_up(self, index):
+        return self.move(index, -1) 
 
-    def move_down(self, nth):
-        return self.move(nth, +1) 
+    def move_down(self, index):
+        return self.move(index, +1) 
 
-    def move(self, nth, direction):
-        to_move = self.bookings[nth]
-        swap_nth = nth+1*(direction)
-        to_swap = self.bookings[swap_nth]
-        self.bookings[nth] = to_swap 
-        self.bookings[nth+1*(direction)] = to_move 
-        return swap_nth
+    def move(self, index, direction):
+        to_move = self.bookings[index]
+        swap_index = index + 1*(direction)
+        to_swap = self.bookings[swap_index]
+        self.bookings[index] = to_swap 
+        self.bookings[index + 1*(direction)] = to_move 
+        return swap_index
 
     def write_db(self):
         lines = []
         for i, booking in enumerate(self.bookings):
             if i > 0:
                 lines += ['']
-            lines += booking.for_writing()
+            lines += booking.for_writing
         self.write_text_to_db('\n'.join(lines) + '\n')
 
     # def move_up(self, start, end):
@@ -1124,11 +1154,11 @@ class LedgerDB(PlomDB):
     #     # FIXME currently broken due to changed self.write_lines_in_total_lines_at, easy fix would be lines += [""] maybe?
     #     lines = self.get_lines(start, end)
     #     if start == 0:
-    #         total_lines = self.real_lines[end+1:]
+    #         total_lines = self.text_lines[end+1:]
     #         lines = [''] + lines
     #         start_at += 1
     #     else: 
-    #         total_lines = self.real_lines[:start-1] + self.real_lines[end:]  # -1 because we reduce the original position's two empty limit lines to one in-between line
+    #         total_lines = self.text_lines[:start-1] + self.text_lines[end:]  # -1 because we reduce the original position's two empty limit lines to one in-between line
     #         lines += ['']
     #     self.write_lines_in_total_lines_at(total_lines, start_at, lines)
 
@@ -1208,47 +1238,45 @@ class LedgerHandler(PlomHandler):
         db = LedgerDB(prefix, ignore_editable_exceptions=True)
         index = 0
         parsed_url = urlparse(self.path)
-        for string in {'save', 'copy', 'check', 'mirror', 'fill sink', 'as textarea', 'as table', 'move up', 'move down', 'add taxes'}:
+        for string in {'update', 'add', 'check', 'mirror', 'fill_sink', 'textarea', 'table', 'move_up', 'move_down', 'add_taxes', 'replace'}:
             if string in postvars.keys():
                 submit_button = string
                 break
-        if prefix + '/ledger' == parsed_url.path:
-            if submit_button == 'move up':
-                index = db.move_up(int(postvars['move up'][0]))
-            elif submit_button == 'move down':
-                index = db.move_down(int(postvars['move down'][0]))
+        if f'{prefix}/ledger' == parsed_url.path and submit_button in {'move_up', 'move_down'}:
+            mover = getattr(db, submit_button)
+            index = mover(int(postvars[submit_button][0]))
         elif prefix + '/edit' == parsed_url.path:
             index = int(postvars['index'][0])
-            starts_at = '?' if index == -1 else db.bookings[index].starts_at
             edit_mode = postvars['edit_mode'][0]
             validate = submit_button in {'save', 'copy', 'check'}
+            starts_at = '?' if index == -1 else db.bookings[index].starts_at
             if 'textarea' == edit_mode:
-                lines = postvars['booking'][0].rstrip().split('\n')
+                lines = [LedgerTextLine(line) for line in postvars['booking'][0].rstrip().split('\n')]
                 booking = Booking(lines, starts_at, validate=validate)
             else:
                 booking = Booking.from_postvars(postvars, starts_at, validate)
-            if submit_button in {'save', 'copy'}:
-                if index != -1 and submit_button != 'copy':
-                     if booking.date == db.bookings[index].date:
-                        db.bookings[index] = booking 
-                        booking_is_placed = True
-                     else:
-                        db.bookings = db.bookings[:index] + db.bookings[index+1:]
-                        db.insert_booking_at_date(booking)
+            if submit_button in {'update', 'add'}:
+                if submit_button == 'update':
+                    if 'textarea' == edit_mode and 'delete' == ''.join([l.text_line for l in lines]).strip():
+                       del db.bookings[index]
+                    # if not creating new Booking, and date unchanged, keep it in place 
+                    elif booking.date == db.bookings[index].date:
+                       db.bookings[index] = booking 
+                    else:
+                       del db.bookings[index]
+                       db.insert_booking_at_date(booking)
                 else: 
                     db.insert_booking_at_date(booking)
-            else:
+            else:  # non-DB-writing calls
                 error_msg = None
                 if 'check' == submit_button:
                     error_msg = 'All looks fine!'
-                elif 'mirror' == submit_button:
-                    booking.add_mirror()
-                elif 'fill sink' == submit_button:
-                    booking.fill_sink()
-                elif 'add taxes' == submit_button:
-                    booking.add_taxes()
-                elif submit_button in {'as textarea', 'as table'}:
-                    edit_mode = submit_button[len('as '):]
+                elif submit_button in {'mirror', 'fill_sink', 'add_taxes'}:
+                    getattr(booking, submit_button)()
+                elif 'replace' == submit_button:
+                    booking.replace(postvars['replace_from'][0], postvars['replace_to'][0])
+                elif submit_button in {'textarea', 'table'}:
+                    edit_mode = submit_button
                 page = db.edit(index, booking, error_msg=error_msg, edit_mode=edit_mode)
                 self.send_HTML(page)
                 return
@@ -1315,18 +1343,21 @@ class LedgerHandler(PlomHandler):
         try:
             db = LedgerDB(prefix=prefix)
         except EditableException as e:
+            # We catch the EditableException for further editing, and then
+            # re-run the DB initiation without it blocking DB creation.
             db = LedgerDB(prefix=prefix, ignore_editable_exceptions=True)
             page = db.edit(index=e.booking_index, error_msg=f'ERROR: {e}')
             self.send_HTML(page)
             return
         parsed_url = urlparse(self.path)
         params = parse_qs(parsed_url.query)
-        if parsed_url.path == prefix + '/balance':
-            stop = params.get('stop', [None])[0]
+        if parsed_url.path == f'{prefix}/balance':
+            stop = params.get('until_after', [None])[0]
             page = db.balance_as_html(stop)
-        elif parsed_url.path == prefix + '/edit':
+        elif parsed_url.path == f'{prefix}/edit':
             index = params.get('i', [-1])[0]
-            page = db.edit(int(index))
+            copy = params.get('copy', [0])[0]
+            page = db.edit(int(index), copy=bool(copy))
         else:
             page = db.ledger_as_html()
         self.send_HTML(page)