home · contact · privacy
27675da2778e2d7cd94c0354fa7c93848b6eef05
[misc] / ledger.py
1 from http.server import BaseHTTPRequestHandler, HTTPServer
2 import sys
3 import os
4 import html
5 import jinja2 
6 from urllib.parse import parse_qs, urlparse
7 hostName = "localhost"
8 serverPort = 8082
9
10
11 class HandledException(Exception):
12     pass
13
14
15 def handled_error_exit(msg):
16     print(f"ERROR: {msg}")
17     sys.exit(1)
18
19
20 def apply_booking_to_account_balances(account_sums, account, currency, amount):
21     if not account in account_sums:
22         account_sums[account] = {currency: amount}
23     elif not currency in account_sums[account].keys():
24         account_sums[account][currency] = amount
25     else:
26         account_sums[account][currency] += amount
27
28
29 def add_taxes(lines):
30     import decimal
31     bookings, _ = parse_lines(lines)
32     _, account_sums = bookings_to_account_tree(bookings)
33     expenses_so_far = -1 * account_sums['Assets']['€']
34     needed_income_before_krankenkasse = expenses_so_far
35     ESt_this_month = 0
36     left_over = needed_income_before_krankenkasse - ESt_this_month
37     too_low = 0
38     too_high = 2 * needed_income_before_krankenkasse 
39     E0 = 10908
40     E1 = 15999 
41     E2 = 62809 
42     E3 = 277825 
43     while True:
44         zvE = 12 * needed_income_before_krankenkasse
45         if zvE < E0:
46             ESt = decimal.Decimal(0)
47         elif zvE < E1:
48             y = (zvE - E0)/10000
49             ESt = (decimal.Decimal(979.18) * y + 1400) * y
50         elif zvE < E2:
51             y = (zvE - E1)/10000
52             ESt = (decimal.Decimal(192.59) * y + 2397) * y + decimal.Decimal(966.53)
53         elif zvE < E3:
54             ESt = decimal.Decimal(0.42) * (zvE - decimal.Decimal(62809))  + decimal.Decimal(16405.54)
55         else: 
56             ESt = decimal.Decimal(0.45) * (zvE - decimal.Decimal(277825)) + decimal.Decimal(106713.52) 
57         ESt_this_month = ESt / 12
58         left_over = needed_income_before_krankenkasse - ESt_this_month
59         if abs(left_over - expenses_so_far) < 0.1:
60             break
61         elif left_over < expenses_so_far:
62             too_low = needed_income_before_krankenkasse
63         elif left_over > expenses_so_far:
64             too_high = needed_income_before_krankenkasse
65         needed_income_before_krankenkasse = too_low + (too_high - too_low)/2
66     line_income_tax = f'  Reserves:Einkommenssteuer  {ESt_this_month:.2f}€ ; expenses so far: {expenses_so_far:.2f}€; zvE: {zvE:.2f}€; ESt total: {ESt:.2f}€; needed before Krankenkasse: {needed_income_before_krankenkasse:.2f}€'
67     kk_minimum_income = 1096.67 
68     kk_factor = decimal.Decimal(0.189) 
69     kk_minimum_tax = decimal.Decimal(207.27)
70     # kk_minimum_income = 1131.67 
71     # kk_factor = decimal.Decimal(0.191) 
72     # kk_minimum_tax = decimal.Decimal(216.15)
73     # kk_factor = decimal.Decimal(0.197) 
74     # kk_minimum_tax = decimal.Decimal(222.94)
75     kk_add = max(0, kk_factor * needed_income_before_krankenkasse - kk_minimum_tax)
76     line_kk_minimum = f'  Reserves:Month:Krankenkassendefaultbeitrag  {kk_minimum_tax:.2f}€  ; assed minimum income {kk_minimum_income:.2f}€ * {kk_factor:.3f}'
77     line_kk_add = f'  Reserves:Month:Krankenkassenbeitragswachstum {kk_add:.2f}€  ; max(0, {kk_factor:.3f} * {needed_income_before_krankenkasse:.2f}€ - {kk_minimum_tax:.2f}€)'
78     final_minus = expenses_so_far + ESt_this_month + kk_minimum_tax + kk_add 
79     line_finish = f'  Assets  -{final_minus:.2f}€'
80     return [line_income_tax, line_kk_minimum, line_kk_add, line_finish]
81
82
83 def bookings_to_account_tree(bookings):
84     account_sums = {}
85     for booking in bookings:
86         for account, changes in booking.account_changes.items():
87             for currency, amount in changes.items():
88                 apply_booking_to_account_balances(account_sums, account, currency, amount)
89     account_tree = {}
90     def collect_branches(account_name, path):
91         node = account_tree
92         path_copy = path[:]
93         while len(path_copy) > 0:
94             step = path_copy.pop(0)
95             node = node[step]
96         toks = account_name.split(":", maxsplit=1)
97         parent = toks[0]
98         if parent in node.keys():
99             child = node[parent]
100         else:
101             child = {}
102             node[parent] = child
103         if len(toks) == 2:
104             k, v = collect_branches(toks[1], path + [parent])
105             if k not in child.keys():
106                 child[k] = v
107             else:
108                 child[k].update(v)
109         return parent, child
110     for account_name in sorted(account_sums.keys()):
111         k, v = collect_branches(account_name, [])
112         if k not in account_tree.keys():
113             account_tree[k] = v
114         else:
115             account_tree[k].update(v)
116     def collect_totals(parent_path, tree_node):
117         for k, v in tree_node.items():
118             child_path = parent_path + ":" + k
119             for currency, amount in collect_totals(child_path, v).items():
120                 apply_booking_to_account_balances(account_sums, parent_path, currency, amount)
121         return account_sums[parent_path]
122     for account_name in account_tree.keys():
123         account_sums[account_name] = collect_totals(account_name, account_tree[account_name])
124     return account_tree, account_sums
125
126
127 def parse_lines(lines):
128     import datetime
129     import decimal
130     inside_booking = False
131     date_string, description = None, None
132     booking_lines = []
133     start_line = 0
134     bookings = []
135     comments = []
136     lines = lines.copy() + [''] # to ensure a booking-ending last line
137     for i, line in enumerate(lines):
138         prefix = f"line {i}"
139         # we start with the case of an utterly empty line
140         comments += [""]
141         stripped_line = line.rstrip()
142         if stripped_line == '':
143             if inside_booking:
144                 # assume we finished a booking, finalize, and commit to DB
145                 if len(booking_lines) < 2:
146                     raise HandledException(f"{prefix} booking ends to early")
147                 booking = Booking(date_string, description, booking_lines, start_line)
148                 bookings += [booking]
149             # expect new booking to follow so re-zeroall booking data
150             inside_booking = False
151             date_string, description = None, None
152             booking_lines = []
153             continue
154         # if non-empty line, first get comment if any, and commit to DB
155         split_by_comment = stripped_line.split(sep=";", maxsplit=1)
156         if len(split_by_comment) == 2:
157             comments[i] = split_by_comment[1].lstrip()
158         # if pre-comment empty: if inside_booking, this must be a comment-only line so we keep it for later ledger-output to capture those comments; otherwise, no more to process for this line
159         non_comment = split_by_comment[0].rstrip()
160         if non_comment.rstrip() == '':
161              if inside_booking:
162                  booking_lines += ['']
163              continue
164         # if we're starting a booking, parse by first-line pattern
165         if not inside_booking:
166             start_line = i
167             toks = non_comment.split(maxsplit=1)
168             date_string = toks[0]
169             try:
170                 datetime.datetime.strptime(date_string, '%Y-%m-%d')
171             except ValueError:
172                 raise HandledException(f"{prefix} bad date string: {date_string}")
173             try:
174                 description = toks[1]
175             except IndexError:
176                 raise HandledException(f"{prefix} bad description: {description}")
177             inside_booking = True
178             booking_lines += [non_comment]
179             continue
180         # otherwise, read as transfer data
181         toks = non_comment.split()  # ignore specification's allowance of single spaces in names
182         if len(toks) > 3:
183             raise HandledException(f"{prefix} too many booking line tokens: {toks}")
184         amount, currency = None, None
185         account_name = toks[0]
186         if account_name[0] == '[' and account_name[-1] == ']':
187             # ignore specification's differentiation of "virtual" accounts
188             account_name = account_name[1:-1]
189         decimal_chars = ".-0123456789"
190         if len(toks) == 3:
191             i_currency = 1
192             try:
193                 amount = decimal.Decimal(toks[1])
194                 i_currency = 2
195             except decimal.InvalidOperation:
196                 try:
197                     amount = decimal.Decimal(toks[2])
198                 except decimal.InvalidOperation:
199                     raise HandledException(f"{prefix} no decimal number in: {toks[1:]}")
200             currency = toks[i_currency]
201             if currency[0] in decimal_chars:
202                 raise HandledException(f"{prefix} currency starts with int, dot, or minus: {currency}")
203         elif len(toks) == 2:
204             value = toks[1]
205             inside_amount = False
206             inside_currency = False
207             amount_string = ""
208             currency = ""
209             dots_counted = 0
210             for i, c in enumerate(value):
211                 if i == 0:
212                     if c in decimal_chars:
213                         inside_amount = True
214                     else:
215                         inside_currency = True
216                 if inside_currency:
217                     if c in decimal_chars and len(amount_string) == 0:
218                         inside_currency = False
219                         inside_amount = True
220                     else:
221                         currency += c
222                         continue
223                 if inside_amount:
224                     if c not in decimal_chars:
225                         if len(currency) > 0:
226                             raise HandledException(f"{prefix} amount has non-decimal chars: {value}")
227                         inside_currency = True
228                         inside_amount = False
229                         currency += c
230                         continue
231                     if c == '-' and len(amount_string) > 1:
232                         raise HandledException(f"{prefix} amount has non-start '-': {value}")
233                     if c == '.':
234                         if dots_counted > 1:
235                             raise HandledException(f"{prefix} amount has multiple dots: {value}")
236                         dots_counted += 1
237                     amount_string += c
238             if len(amount_string) == 0:
239                 raise HandledException(f"{prefix} amount missing: {value}")
240             if len(currency) == 0:
241                 raise HandledException(f"{prefix} currency missing: {value}")
242             amount = decimal.Decimal(amount_string)
243         booking_lines += [(account_name, amount, currency)]
244     if inside_booking:
245         raise HandledException(f"{prefix} last booking unfinished")
246     return bookings, comments
247
248
249 class Booking:
250
251     def __init__(self, date_string, description, booking_lines, start_line):
252         self.date_string = date_string
253         self.description = description
254         self.lines = booking_lines
255         self.start_line = start_line
256         self.validate_booking_lines()
257         self.account_changes = self.parse_booking_lines_to_account_changes()
258
259     def validate_booking_lines(self):
260         prefix = f"booking at line {self.start_line}"
261         sums = {}
262         empty_values = 0
263         for line in self.lines[1:]:
264             if line == '':
265                 continue
266             _, amount, currency = line
267             if amount is None:
268                 if empty_values > 0:
269                     raise HandledException(f"{prefix} relates more than one empty value of same currency {currency}")
270                 empty_values += 1
271                 continue
272             if currency not in sums:
273                 sums[currency] = 0
274             sums[currency] += amount
275         if empty_values == 0:
276             for k, v in sums.items():
277                 if v != 0:
278                     raise HandledException(f"{prefix} does not sum up to zero")
279         else:
280             sinkable = False
281             for k, v in sums.items():
282                 if v != 0:
283                     sinkable = True
284             if not sinkable:
285                 raise HandledException(f"{prefix} has empty value that cannot be filled")
286
287     def parse_booking_lines_to_account_changes(self):
288         account_changes = {}
289         debt = {}
290         sink_account = None
291         for line in self.lines[1:]:
292             if line == '':
293                 continue
294             account, amount, currency = line
295             if amount is None:
296                 sink_account = account
297                 continue
298             apply_booking_to_account_balances(account_changes, account, currency, amount)
299             if currency not in debt:
300                 debt[currency] = amount
301             else:
302                 debt[currency] += amount
303         if sink_account:
304             for currency, amount in debt.items():
305                 apply_booking_to_account_balances(account_changes, sink_account, currency, -amount)
306         return account_changes
307
308
309
310 class Database:
311
312     def __init__(self):
313         db_name = "_ledger"
314         self.db_file = db_name + ".json"
315         self.lock_file = db_name+ ".lock"
316         self.bookings = []
317         self.comments = []
318         self.real_lines = []
319         if os.path.exists(self.db_file):
320             with open(self.db_file, "r") as f:
321                 self.real_lines += [l.rstrip() for l in f.readlines()]
322         ret = parse_lines(self.real_lines)
323         self.bookings += ret[0]
324         self.comments += ret[1]
325
326     def get_lines(self, start, end):
327         return self.real_lines[start:end]
328
329     def replace(self, start, end, lines):
330         import shutil
331         if os.path.exists(self.lock_file):
332             raise HandledException('Sorry, lock file!')
333         if os.path.exists(self.db_file):
334             shutil.copy(self.db_file, self.db_file + ".bak")
335         f = open(self.lock_file, 'w+')
336         f.close()
337         total_lines = self.real_lines[:start] + lines + self.real_lines[end:]
338         text = '\n'.join(total_lines)
339         with open(self.db_file, 'w') as f:
340             f.write(text);
341         os.remove(self.lock_file)
342
343     def append(self, lines):
344         import shutil
345         if os.path.exists(self.lock_file):
346             raise HandledException('Sorry, lock file!')
347         if os.path.exists(self.db_file):
348             shutil.copy(self.db_file, self.db_file + ".bak")
349         f = open(self.lock_file, 'w+')
350         f.close()
351         with open(self.db_file, 'a') as f:
352             f.write('\n\n' + '\n'.join(lines) + '\n\n');
353         os.remove(self.lock_file)
354
355
356 class MyServer(BaseHTTPRequestHandler):
357     header = """<html>
358 <meta charset="UTF-8">
359 <style>
360 body { color: #000000; }
361 table { margin-bottom: 2em; }
362 th, td { text-align: left }
363 input[type=number] { text-align: right; font-family: monospace; }
364 .money { font-family: monospace; text-align: right; }
365 .comment { font-style: italic; color: #777777; }
366 .full_line_comment { display: block; white-space: nowrap; width: 0; }
367 </style>
368 <body>
369 <a href="/ledger">ledger</a>
370 <a href="/balance">balance</a>
371 <a href="/add_free">add free</a>
372 <a href="/add_structured">add structured</a>
373 <hr />
374 """
375     footer = "</body>\n<html>"
376
377     def do_POST(self):
378         db = Database()
379         length = int(self.headers['content-length'])
380         postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
381         parsed_url = urlparse(self.path)
382         lines = []
383         add_empty_line = None 
384         if '/add_structured' == parsed_url.path and not 'revert' in postvars.keys():
385             date = postvars['date'][0]
386             description = postvars['description'][0]
387             start_comment = postvars['line_0_comment'][0]
388             lines = [f'{date} {description} ; {start_comment}']
389             if 'line_0_add' in postvars.keys():
390                 add_empty_line = 0
391             i = j = 1
392             while f'line_{i}_comment' in postvars.keys():
393                 if f'line_{i}_delete' in postvars.keys():
394                     i += 1
395                     continue
396                 if f'line_{i}_add' in postvars.keys():
397                     add_empty_line = j
398                 account = postvars[f'line_{i}_account'][0]
399                 amount = postvars[f'line_{i}_amount'][0]
400                 currency = postvars[f'line_{i}_currency'][0]
401                 comment = postvars[f'line_{i}_comment'][0]
402                 i += 1
403                 new_main = f'{account} {amount} {currency}'
404                 if '' == new_main.rstrip() == comment.rstrip():  # don't write empty lines
405                     continue
406                 j += 1
407                 new_line = new_main
408                 if comment.rstrip() != '':
409                     new_line += f' ; {comment}'
410                 lines += [new_line]
411             if 'add_taxes' in postvars.keys():
412                 lines += add_taxes(lines)
413         elif '/add_free' == parsed_url.path:
414             lines = postvars['booking'][0].splitlines()
415         start = int(postvars['start'][0])
416         end = int(postvars['end'][0])
417         try:
418             _, _ = parse_lines(lines)
419             if 'save' in postvars.keys():
420                 if start == end == 0:
421                     db.append(lines)
422                 else:
423                     db.replace(start, end, lines)
424                 self.send_response(301)
425                 redir_url = '/'
426                 self.send_header('Location', redir_url)
427                 self.end_headers()
428             else:
429                 page = self.header + self.add_structured(db, start, end, temp_lines=lines, add_empty_line=add_empty_line) + self.footer
430                 self.send_response(200)
431                 self.send_header("Content-type", "text/html")
432                 self.end_headers()
433                 self.wfile.write(bytes(page, "utf-8"))
434         except HandledException as e:
435             self.send_response(400)
436             self.send_header("Content-type", "text/html")
437             self.end_headers()
438             page = f'{self.header}ERROR: {e}{self.footer}'
439             self.wfile.write(bytes(page, "utf-8"))
440
441     def do_GET(self):
442         self.send_response(200)
443         self.send_header("Content-type", "text/html")
444         self.end_headers()
445         db = Database()
446         parsed_url = urlparse(self.path)
447         page = self.header + ''
448         params = parse_qs(parsed_url.query)
449         start = int(params.get('start', ['0'])[0])
450         end = int(params.get('end', ['0'])[0])
451         if parsed_url.path == '/balance':
452             page += self.balance_as_html(db)
453         elif parsed_url.path == '/add_free':
454             page += self.add_free(db, start, end)
455         elif parsed_url.path == '/add_structured':
456             page += self.add_structured(db, start, end)
457         elif parsed_url.path == '/copy_free':
458             page += self.add_free(db, start, end, copy=True)
459         elif parsed_url.path == '/copy_structured':
460             page += self.add_structured(db, start, end, copy=True)
461         else:
462             page += self.ledger_as_html(db)
463         page += self.footer
464         self.wfile.write(bytes(page, "utf-8"))
465
466     def balance_as_html(self, db):
467         lines = []
468         account_tree, account_sums = bookings_to_account_tree(db.bookings)
469         def print_subtree(lines, indent, node, subtree, path):
470             line = f"{indent}{node}"
471             n_tabs = 5 - (len(line) // 8)
472             line += n_tabs * "\t"
473             if "€" in account_sums[path + node].keys():
474                 amount = account_sums[path + node]["€"]
475                 line += f"{amount:9.2f} €\t"
476             else:
477                 line += f"\t\t"
478             for currency, amount in account_sums[path + node].items():
479                 if currency != '€' and amount > 0:
480                     line += f"{amount:5.2f} {currency}\t"
481             lines += [line]
482             indent += "  "
483             for k, v in sorted(subtree.items()):
484                 print_subtree(lines, indent, k, v, path + node + ":")
485         for k, v in sorted(account_tree.items()):
486             print_subtree(lines, "", k, v, "")
487         content = "\n".join(lines)
488         return f"<pre>{content}</pre>"
489
490     def ledger_as_html(self, db):
491         single_c_tmpl = jinja2.Template('<span class="comment">{{c|e}}</span><br />')
492         booking_tmpl = jinja2.Template("""
493 <p>{{date}} {{desc}} <span class="comment">{{head_comment|e}}</span>
494 [edit: <a href="/add_structured?start={{start}}&end={{end}}">structured</a> 
495 / <a href="/add_free?start={{start}}&end={{end}}">free</a> 
496 | copy:<a href="/copy_structured?start={{start}}&end={{end}}">structured</a>
497 / <a href="/copy_free?start={{start}}&end={{end}}">free</a>]
498 <table>
499 {% for l in booking_lines %}
500 {% if l.acc %}
501 <tr><td>{{l.acc|e}}</td><td class="money">{{l.money|e}}</td><td class="comment">{{l.comment|e}}</td></tr>
502 {% else %}
503 <tr><td><div class="comment full_line_comment">{{l.comment|e}}</div></td></tr>
504 {% endif %}
505 {% endfor %}
506 </table></p>
507 """)
508         elements_to_write = []
509         last_i = i = 0
510         for booking in db.bookings:
511             i = booking.start_line
512             elements_to_write += [single_c_tmpl.render(c=c) for c in db.comments[last_i:i] if c != '']
513             booking_end = last_i = booking.start_line + len(booking.lines)
514             booking_lines = []
515             for booking_line in booking.lines[1:]:
516                 i += 1
517                 comment = db.comments[i] 
518                 if booking_line == '':
519                     booking_lines += [{'acc': None, 'money': None, 'comment': comment}]
520                     continue
521                 money = ''
522                 if booking_line[1]:
523                     money = f'{booking_line[1]} {booking_line[2]}'
524                 account = booking_line[0] 
525                 booking_lines += [{'acc': booking_line[0], 'money':money, 'comment':comment}] 
526             elements_to_write += [booking_tmpl.render(
527                 start=booking.start_line,
528                 end=booking_end,
529                 date=booking.date_string,
530                 desc=booking.description,
531                 head_comment=db.comments[booking.start_line],
532                 booking_lines = booking_lines)]
533         return '\n'.join(elements_to_write) 
534
535     def add_free(self, db, start=0, end=0, copy=False):
536         tmpl = jinja2.Template("""
537 <form method="POST" action="{{action}}">
538 <textarea name="booking" rows=10 cols=80>
539 {% for line in lines %}{{ line }}
540 {% endfor %}
541 </textarea>
542 <input type="hidden" name="start" value={{start}} />
543 <input type="hidden" name="end" value={{end}} />
544 <input type="submit" name="save" value="save!">
545 </form>
546 """)
547         lines = db.get_lines(start, end)
548         if copy:
549             start = end = 0
550         return tmpl.render(start=start, end=end, lines=lines) 
551
552     def add_structured(self, db, start=0, end=0, copy=False, temp_lines=[], add_empty_line=None):
553         tmpl = jinja2.Template("""
554 <form method="POST" action="{{action|e}}">
555 <input type="submit" name="check" value="check" />
556 <input type="submit" name="revert" value="revert" />
557 <input type="submit" name="add_taxes" value="add taxes" />
558 <br />
559 <input name="date" value="{{date|e}}" size=9 />
560 <input name="description" value="{{desc|e}}" list="descriptions" />
561 <textarea name="line_0_comment" rows=1 cols=20>{{head_comment|e}}</textarea>
562 <input type="submit" name="line_0_add" value="[+]" />
563 <br />
564 {% for line in booking_lines %}
565 <input name="line_{{line.i}}_account" value="{{line.acc|e}}" size=40 list="accounts" />
566 <input type="number" name="line_{{line.i}}_amount" value="{{line.amt}}" size=10 />
567 <input name="line_{{line.i}}_currency" value="{{line.curr|e}}" size=3 list="currencies" />
568 <textarea name="line_{{line.i}}_comment" rows=1 cols={% if line.comm_cols %}{{line.comm_cols}}{% else %}20{% endif %}>{{line.comment|e}}</textarea>
569 <input type="submit" name="line_{{line.i}}_delete" value="[x]" />
570 <input type="submit" name="line_{{line.i}}_add" value="[+]" />
571 <br />
572 {% endfor %}
573 {% for name, items in datalist_sets.items() %}
574 <datalist id="{{name}}">
575 {% for item in items %}
576   <option value="{{item|e}}">{{item|e}}</option>
577 {% endfor %}
578 </datalist>
579 {% endfor %}
580 <input type="hidden" name="start" value={{start}} />
581 <input type="hidden" name="end" value={{end}} />
582 <input type="submit" name="save" value="save!">
583 </form>
584 """)
585         import datetime
586         lines = temp_lines if len(''.join(temp_lines)) > 0 else db.get_lines(start, end)
587         bookings, comments = parse_lines(lines)
588         if len(bookings) > 1:
589             raise HandledException('can only edit single Booking')
590         if add_empty_line is not None:
591             comments = comments[:add_empty_line+1] + [''] + comments[add_empty_line+1:]
592             booking = bookings[0]
593             booking.lines = booking.lines[:add_empty_line+1] + [''] + booking.lines[add_empty_line+1:] 
594         action = 'add_structured'
595         datalist_sets = {'descriptions': set(), 'accounts': set(), 'currencies': set()}
596         for b in db.bookings:
597             datalist_sets['descriptions'].add(b.description)
598             for account, moneys in b.account_changes.items():
599                 datalist_sets['accounts'].add(account)
600                 for currency in moneys.keys():
601                     datalist_sets['currencies'].add(currency)
602         content = ''
603         today = str(datetime.datetime.now())[:10]
604         booking_lines = []
605         if copy:
606             start = end = 0
607         desc = head_comment = ''
608         if len(bookings) == 0:
609             for i in range(1, 3):
610                 booking_lines += [{'i': i, 'acc': '', 'amt': '', 'curr': '', 'comment': ''}]
611             date=today 
612         else:
613             booking = bookings[0]
614             desc = booking.description
615             date = today if copy else booking.date_string
616             head_comment=comments[0]
617             last_line = len(comments)
618             for i in range(1, len(comments)):
619                 account = amount = currency = ''
620                 if i < len(booking.lines) and booking.lines[i] != '':
621                     account = booking.lines[i][0]
622                     amount = booking.lines[i][1]
623                     currency = booking.lines[i][2]
624                 booking_lines += [{
625                         'i': i, 
626                         'acc': account,
627                         'amt': amount, 
628                         'curr': currency if currency else '',
629                         'comment': comments[i],
630                         'comm_cols': len(comments[i])}]
631         content += tmpl.render(
632                 action=action, 
633                 date=date,
634                 desc=desc,
635                 head_comment=head_comment, 
636                 booking_lines=booking_lines,
637                 datalist_sets=datalist_sets,
638                 start=start,
639                 end=end) 
640         return content 
641
642
643 if __name__ == "__main__":    
644     webServer = HTTPServer((hostName, serverPort), MyServer)
645     print(f"Server started http://{hostName}:{serverPort}")
646     try:
647         webServer.serve_forever()
648     except KeyboardInterrupt:
649         pass
650     webServer.server_close()
651     print("Server stopped.")