"""Flask app: the web UI. Refuses to start while a migration is pending. All data access goes through repo.py, so scripts get the same operations as the pages. """ import base64 import io import mimetypes import secrets import struct from functools import lru_cache from pathlib import Path from flask import (Flask, render_template, request, redirect, url_for, g, flash, abort, send_file) from app import compute, db, i18n, money, pdf, plan, repo, quality, tvb, vat APP_DIR = Path(__file__).resolve().parent INVOICE_ASSETS = APP_DIR / "static" / "invoice" def get_db(): if "conn" not in g: g.conn = db.connect(actor="web") return g.conn _page_cache = {} def assets_fingerprint(): """Newest mtime of the grid's own code — static files AND templates. It belongs in the fingerprint that keys the FROZEN column layouts in the browser's localStorage: without it a change is invisible after a reload, because the browser re-applies the widths it measured against the old code. The templates count too: a template change can widen a column without touching a single static file.""" files = [APP_DIR / "static" / f for f in ("grids.js", "style.css")] files += sorted((APP_DIR / "templates").glob("*.html")) return str(max(int(f.stat().st_mtime) for f in files)) def _ledger_year(row): """Which year does a Zimmerbelegung row belong to? That of the first date it actually has — `repo.ledger_rows` says which.""" return (row["first_date"] or "")[:4] or None def page_fingerprint(): """What a rendered list page depends on: the data AND the grid code. Keys both the server-side page cache and the frozen column layouts in the browser — a cached page carrying the old fingerprint would keep the stale layout alive just as effectively as the layout itself.""" return db_fingerprint() + "-" + assets_fingerprint() def db_fingerprint(): """The schema and its write counter (`data_version`, counted up by every statement that writes a data table — this app or any script; migration 001). Keys the server-side page cache and the browser's stored grid layouts. The schema belongs in it: the tests run each in a schema of their own.""" return f"{db.SCHEMA}.{db.row(get_db(), 'SELECT version FROM data_version')[0]}" def cached_page(view): """Serve the big read-only list pages from memory while the database is unchanged. Pages carrying flash messages are rendered fresh and never stored.""" from functools import wraps @wraps(view) def wrapped(**kwargs): from flask import session if "_flashes" in session: return view(**kwargs) key = (request.full_path, page_fingerprint()) if _page_cache.get(view.__name__, (None,))[0] != key: _page_cache[view.__name__] = (key, view(**kwargs)) return _page_cache[view.__name__][1] return wrapped def asset_uri(filename): """Inline an invoice image as a data URI so it renders in both browser-print and the headless PDF.""" if not filename: return "" path = INVOICE_ASSETS / filename if not path.exists(): return "" mime = mimetypes.guess_type(str(path))[0] or "image/png" return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode() ART_BOX_W, ART_BOX_H, ART_GAP = 64, 80, 4 # masthead art box (mm) — keep in sync with .masthead .art CSS @lru_cache(maxsize=None) def _image_size(path_str): """Pixel (w, h) of a PNG or JPEG read from the file header — avoids an imaging dependency.""" data = Path(path_str).read_bytes() if data[:8] == b"\x89PNG\r\n\x1a\n": w, h = struct.unpack(">II", data[16:24]) return w, h if data[:2] == b"\xff\xd8": i = 2 while i + 9 < len(data): if data[i] != 0xFF: i += 1 elif data[i + 1] == 0x01 or 0xD0 <= data[i + 1] <= 0xD9: # marker without a length field i += 2 elif 0xC0 <= data[i + 1] <= 0xCF and data[i + 1] not in (0xC4, 0xC8, 0xCC): # SOFn h, w = struct.unpack(">HH", data[i + 5:i + 9]) return w, h else: i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0] raise ValueError(f"cannot read image size: {path_str}") def masthead_art(unit): """Explicit print sizes for the branding images. All images of a unit share ONE width, solved so the stack exactly fills the art box height (capped at the box width) — a wide logo can never render bigger than the house photo, and a lone image grows to fill the whole box.""" imgs = [{"src": name, "cls": cls} for name, cls in ((unit.get("logo"), "logo"), (unit.get("photo"), "photo")) if name and (INVOICE_ASSETS / name).exists()] for a in imgs: w_px, h_px = _image_size(str(INVOICE_ASSETS / a["src"])) a["ratio"] = h_px / w_px if not imgs: return [] usable_h = ART_BOX_H - ART_GAP * (len(imgs) - 1) width = min(ART_BOX_W, usable_h / sum(a["ratio"] for a in imgs)) for a in imgs: a["w"] = round(width, 2) a["h"] = round(width * a.pop("ratio"), 2) return imgs def _tourism_rate(on_date=None): """The Aufenthaltsabgabe per person and night in force on that date — handed to the ledger form so the browser can propose the amount without a second copy of the rate table.""" import datetime return compute.tourism_rate(on_date or datetime.date.today().isoformat()) def create_app(): app = Flask(__name__) app.secret_key = secrets.token_hex(32) # the session only carries flash messages across a redirect # Static assets: version the URL by file mtime and cache hard. Without this the browser's heuristic # cache kept serving OLD grids.js/style.css after updates (no revalidation for hours/days). app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 60 * 60 * 24 * 365 @app.url_defaults def bust_static_cache(endpoint, values): if endpoint == "static" and "filename" in values: f = APP_DIR / "static" / values["filename"] if f.exists(): values["v"] = int(f.stat().st_mtime) conn = db.connect() pending = db.pending_migrations(conn) conn.close() if pending: # the app's role may not change the schema — the owner does, before the app starts raise RuntimeError(f"database schema is behind the code (migrations {pending}) — " f"run first: python -m app.db migrate") # the grid pages render thousands of rows — don't ship template indentation as page weight app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True app.context_processor(lambda: {"db_fp": page_fingerprint()}) app.jinja_env.globals["t"] = i18n.t app.jinja_env.globals["asset_uri"] = asset_uri app.jinja_env.globals["masthead_art"] = masthead_art app.jinja_env.filters["euro"] = money.euro app.jinja_env.filters["num"] = money.de_number app.jinja_env.filters["date"] = money.de_date app.jinja_env.filters["datewd"] = money.de_date_wd # with the weekday, for the two lists @app.after_request def never_cache_pages(resp): # dynamic pages must always re-render — without this, the browser's back button (bfcache / # heuristic caching) shows a STALE copy after an edit. Versioned static assets keep their year. if request.endpoint != "static": resp.headers["Cache-Control"] = "no-store" return resp @app.teardown_appcontext def close_db(exc): conn = g.pop("conn", None) if conn is not None: conn.close() @app.route("/") def home(): return render_template("home.html", stats=repo.stats(get_db())) # --- guest register --- @app.route("/register") @cached_page def register(): search = request.args.get("q", "").strip() return render_template("register.html", rows=repo.register_rows(get_db(), search), q=search) @app.route("/register/new", methods=["GET", "POST"]) @app.route("/register//edit", methods=["GET", "POST"]) def stay_form(stay_id=None): conn = get_db() stay = repo.get_stay(conn, stay_id) if stay_id else None # a room change: the new sheet continues the old one — same person, same number from_id = None if stay_id else request.args.get("from", type=int) moved_from = repo.get_stay(conn, from_id) if from_id else None if request.method == "POST": f = request.form if not f.get("last_name", "").strip(): flash(i18n.t("err_required", field=i18n.t("col_name"))) elif f.get("arrival") and f.get("departure") and f["departure"] < f["arrival"]: flash(i18n.t("err_departure_before_arrival")) # reject at the door, not on Datenprüfung else: last = f["last_name"].strip() first = f.get("first_name", "").strip() or None gfields = dict(birth_date=f.get("birth_date") or None, nationality=f.get("nationality") or None, doc_type=f.get("doc_type") or None, doc_number=f.get("doc_number") or None, profession=f.get("profession") or None, home_street=f.get("home_street") or None, home_postcode=f.get("home_postcode") or None, home_city=f.get("home_city") or None, home_country=f.get("home_country") or None, phone=f.get("phone") or None) person = stay or moved_from # edit, or the same person in another room if person and person.get("guest_id"): # update this exact person repo.update_guest(conn, person["guest_id"], last_name=last, first_name=first, **gfields) guest_id = person["guest_id"] else: # new: reuse only on exact person match guest_id = repo.find_or_create_guest(conn, last, first, **gfields) room_id = int(f["room_id"]) if f.get("room_id") else None saved = repo.save_stay(conn, stay_id, guest_id, room_id, int(f["unit_id"]) if f.get("unit_id") else None, repo.find_or_create_client(conn, f.get("employer")), f.get("arrival") or None, f.get("departure") or None, f.get("sheet_number") or None, int(f["hauptwohnsitz"]) if f.get("hauptwohnsitz") in ("0", "1") else None, int(f["tourism_liable"]) if f.get("tourism_liable") in ("0", "1") else None, f.get("notes") or None) if f.get("action") == "room_change": if not f.get("departure"): # the departure IS the day of the change flash(i18n.t("err_room_change_needs_date")) return redirect(url_for("stay_form", stay_id=saved)) return redirect(url_for("stay_form", **{"from": saved})) if moved_from and repo.carry_ledger_to_room(conn, f.get("sheet_number") or None, moved_from["room_id"], room_id, f.get("arrival"), {"last_name": last, "first_name": first}): flash(i18n.t("msg_room_changed", date=money.de_date(f["arrival"]), room=_room_number(conn, room_id))) return redirect(url_for("register")) if moved_from: # the old sheet, as the new one starts stay = dict(moved_from, id=None, room_id=None, arrival=moved_from["departure"], departure=None, notes=i18n.t("note_room_change", room=_room_number(conn, moved_from["room_id"]))) return render_template("stay_form.html", stay=stay, room_change=bool(moved_from), rooms=repo.rooms(conn), units=repo.units(conn), suggest=repo.field_suggestions(conn)) @app.route("/register//delete", methods=["POST"]) def stay_delete(stay_id): try: repo.delete_stay(get_db(), stay_id) except ValueError: flash(i18n.t("err_delete_blocked")) return redirect(url_for("register")) # --- room ledger --- @app.route("/ledger") @cached_page def ledger(): conn = get_db() room = request.args.get("room", "").strip() search = request.args.get("q", "").strip() rows = repo.ledger_rows(conn, room or None, search) years = sorted({y for y in (_ledger_year(r) for r in rows) if y}, reverse=True) return render_template("ledger.html", rows=rows, years=years, rooms=repo.rooms(conn), room=room, q=search) @app.route("/ledger/new", methods=["GET", "POST"]) def ledger_new(): conn = get_db() if request.method == "POST": f = request.form room = f.get("room_id", "").strip() repo.create_ledger_row(conn, int(room) if room.isdigit() else None, f.get("occupant", "").strip() or None, f.get("period_from") or None, f.get("period_to") or None, parse_number(f.get("persons")), parse_number(f.get("quantity")), f.get("unit_type") or None, parse_number(f.get("gross_per_unit")), parse_number(f.get("betrag")), f.get("paid_status") or None, f.get("paid_date") or None, f.get("art") or None, f.get("payer", "").strip() or None, f.get("rnr", "").strip() or None, f.get("gb_ref", "").strip() or None, f.get("notes", "").strip() or None, tourism_tax=parse_number(f.get("tourism_tax")), payments=_payments(f) or []) return redirect(url_for("ledger")) return render_template("ledger_form.html", ch={}, rooms=repo.rooms(conn), is_new=True, tourism_rate=_tourism_rate(None)) @app.route("/ledger//edit", methods=["GET", "POST"]) def ledger_edit(charge_id): conn = get_db() ch = repo.get_charge(conn, charge_id) if not ch: abort(404) if request.method == "POST": f = request.form repo.update_charge(conn, charge_id, f.get("occupant", "").strip() or None, f.get("period_from") or None, f.get("period_to") or None, parse_number(f.get("persons")), parse_number(f.get("quantity")), f.get("unit_type") or None, parse_number(f.get("gross_per_unit")), parse_number(f.get("betrag")), f.get("paid_status") or None, f.get("paid_date") or None, f.get("art") or None, f.get("payer", "").strip() or None, f.get("rnr", "").strip() or None, f.get("gb_ref", "").strip() or None, f.get("notes", "").strip() or None, tourism_tax=parse_number(f.get("tourism_tax")), payments=_payments(f)) if f.get("action") == "continue": # month-end billing: bill up to this date, then carry the occupancy on from it if not f.get("period_to"): flash(i18n.t("err_continue_needs_date")) return redirect(url_for("ledger_edit", charge_id=charge_id)) repo.continue_ledger_row(conn, charge_id) flash(i18n.t("msg_continued", date=money.de_date(f["period_to"]))) return redirect(url_for("ledger")) return render_template("ledger_form.html", ch=ch, tourism_rate=_tourism_rate(ch.get("period_from"))) @app.route("/ledger//delete", methods=["POST"]) def ledger_delete(charge_id): repo.delete_charge(get_db(), charge_id) return redirect(url_for("ledger")) # --- Belegungsplan (occupancy timeline) --- @app.route("/plan") def plan_page(): # not @cached_page: the view depends on today's date (today marker, occupancy pills), # and the cache key only sees URL + db fingerprint — it would survive midnight. import datetime weeks = 8 if request.args.get("weeks") == "8" else 4 try: start = datetime.date.fromisoformat(request.args.get("start", "")) except ValueError: start = plan.default_start() return render_template("plan.html", p=plan.build(get_db(), start, weeks * 7), weeks=weeks) # --- invoices --- @app.route("/invoices") @cached_page def invoices(): q = request.args.get("q", "").strip() return render_template("invoices.html", rows=repo.invoices_list(get_db(), q), q=q) def _rows_follow(conn, invoice_id): """The Zimmerbelegung takes over what the saved invoice bills — and the user is told it did.""" fixed, new = repo.sync_rows_to_invoice(conn, invoice_id) if fixed or new: flash(i18n.t("msg_rows_follow_invoice", fixed=len(fixed), new=len(new))) @app.route("/invoices/new", methods=["GET", "POST"]) def invoice_new(): conn = get_db() units = repo.units(conn) f = request.form lines = [] order_warning = False client_choices = None if request.method == "POST": lines, incomplete = _parse_lines(f) unit_id = int(f["unit_id"]) if f.get("unit_id") else None number = f.get("number", "").strip() if incomplete: flash(i18n.t("err_number", field=", ".join(incomplete))) elif not unit_id or not f.get("client", "").strip() or not lines: flash(i18n.t("err_required", field="Einheit, Kunde, mind. 1 Zeile")) elif number and repo.invoice_number_taken(conn, unit_id, number): flash(i18n.t("err_number_taken", number=number)) elif _order_warning(conn, f, unit_id, number): order_warning = True elif (client_choices := _recipient_warning(conn, f)) is None: links = [int(x) for x in f.get("link_charges", "").split(",") if x.strip().isdecimal()] inv_id = repo.create_invoice( conn, unit_id, repo.find_or_create_client(conn, f["client"]), number, f.get("date") or None, lines, note=f.get("note"), paid_date=f.get("paid_date") or None, payment_method=f.get("payment_method") or None, link_charge_ids=links) _rows_follow(conn, inv_id) return redirect(url_for("invoice_view", invoice_id=inv_id)) lines = _raw_lines(f) # re-render exactly what was typed, incl. the rejected rows elif request.args.get("stays") or request.args.get("charges"): # generate a pre-filled invoice from selected Gästeblätter rows or Zimmerbelegung rows if request.args.get("charges"): ids = [int(x) for x in request.args["charges"].split(",") if x.strip().isdecimal()] pre = repo.lines_from_charges(conn, ids) else: ids = [int(x) for x in request.args["stays"].split(",") if x.strip().isdecimal()] pre = repo.lines_from_stays(conn, ids) lines = pre["lines"] f = {"unit_id": pre["unit_id"], "client": pre["client"] or "", # remember the ledger rows so saving writes the invoice number back into them "link_charges": ",".join(map(str, ids)) if request.args.get("charges") else ""} if pre["client"]: # a payer without an address asks at once, not only on saving client_choices = repo.recipient_choices(conn, pre["client"]) import datetime suggestions = {u["id"]: repo.next_invoice_number(conn, u["id"]) for u in units} return render_template("invoice_form.html", units=units, suggestions=suggestions, form=f, lines=lines, suggest=repo.field_suggestions(conn), order_warning=order_warning, client_choices=client_choices, today=datetime.date.today().isoformat()) @app.route("/invoices//edit", methods=["GET", "POST"]) def invoice_edit(invoice_id): conn = get_db() inv = repo.get_invoice(conn, invoice_id) if not inv: abort(404) units = repo.units(conn) order_warning = False client_choices = None if request.method == "POST": f = request.form lines, incomplete = _parse_lines(f) unit_id = int(f["unit_id"]) if f.get("unit_id") else None number = f.get("number", "").strip() if incomplete: flash(i18n.t("err_number", field=", ".join(incomplete))) elif not unit_id or not f.get("client", "").strip() or not lines: flash(i18n.t("err_required", field="Einheit, Kunde, mind. 1 Zeile")) elif number and repo.invoice_number_taken(conn, unit_id, number, exclude_id=invoice_id): flash(i18n.t("err_number_taken", number=number)) elif _order_warning(conn, f, unit_id, number, invoice_id): order_warning = True elif (client_choices := _recipient_warning(conn, f)) is None: repo.update_invoice(conn, invoice_id, unit_id, repo.find_or_create_client(conn, f["client"]), number, f.get("date") or None, lines, note=f.get("note"), paid_date=f.get("paid_date") or None, payment_method=f.get("payment_method") or None) _rows_follow(conn, invoice_id) return redirect(url_for("invoice_view", invoice_id=invoice_id)) return render_template("invoice_form.html", units=units, suggestions={}, form=f, lines=_raw_lines(f), editing=inv, suggest=repo.field_suggestions(conn), order_warning=order_warning, client_choices=client_choices) form = {"unit_id": inv["unit_id"], "client": (inv["client"] or {}).get("name", ""), "number": inv["number"], "date": inv["date"], "note": inv.get("notes"), "paid_date": inv.get("paid_date"), "payment_method": inv.get("payment_method")} return render_template("invoice_form.html", units=units, suggestions={}, form=form, lines=inv["lines"], editing=inv, suggest=repo.field_suggestions(conn), client_choices=repo.recipient_choices(conn, form["client"])) @app.route("/invoices//delete", methods=["POST"]) def invoice_delete(invoice_id): repo.delete_invoice(get_db(), invoice_id) return redirect(url_for("invoices")) @app.route("/invoices/") def invoice_view(invoice_id): conn = get_db() inv = repo.get_invoice(conn, invoice_id) if not inv: abort(404) # offer "Neu generieren" only when a linked ledger row changed since the invoice was (re)generated can_regen = bool(inv.get("lines_stale")) and bool(repo.linked_ledger_charges(conn, invoice_id)) return render_template("invoice_print.html", inv=inv, can_regen=can_regen) @app.route("/invoices//regenerate", methods=["POST"]) def invoice_regenerate(invoice_id): if not repo.regenerate_invoice(get_db(), invoice_id): abort(404) return redirect(url_for("invoice_view", invoice_id=invoice_id)) @app.route("/invoices//payment", methods=["GET", "POST"]) def invoice_payment(invoice_id): """Record the settlement of an already issued invoice — the ONLY writing path onto one that is not a full edit. Deliberate and manual by design: an issued invoice never changes as a side effect.""" conn = get_db() inv = repo.get_invoice(conn, invoice_id) if not inv: abort(404) if request.method == "POST": repo.set_invoice_payment(conn, invoice_id, request.form.get("paid_date"), request.form.get("payment_method")) return redirect(url_for("invoice_view", invoice_id=invoice_id)) return render_template("invoice_payment.html", inv=inv, hint=repo.invoice_payment_hint(conn, invoice_id)) @app.route("/invoices//pdf") def invoice_pdf(invoice_id): inv = repo.get_invoice(get_db(), invoice_id) if not inv: abort(404) html = render_template("invoice_print.html", inv=inv, standalone=True) return send_file(io.BytesIO(pdf.render(html)), mimetype="application/pdf", as_attachment=True, download_name=repo.invoice_filename(inv)) # --- companies (canonical clients + their address versions) --- @app.route("/clients") @cached_page def clients(): q = request.args.get("q", "").strip() return render_template("clients.html", rows=repo.companies_list(get_db(), q or None), q=q) @app.route("/clients/", methods=["GET", "POST"]) def client_edit(company_id): conn = get_db() comp = repo.get_company(conn, company_id) if not comp: abort(404) if request.method == "POST": f = request.form if f.get("action") == "add_address": repo.add_company_address(conn, company_id, f.get("address", "").strip(), f.get("postcode", "").strip(), f.get("city", "").strip(), make_current=bool(f.get("make_current"))) elif f.get("action") == "edit_address": repo.update_client_address(conn, int(f["client_id"]), f.get("name", ""), f.get("address", ""), f.get("postcode", ""), f.get("city", "")) elif f.get("action") == "make_current": repo.set_current_address(conn, company_id, int(f["client_id"])) elif f.get("action") == "delete_address": try: repo.delete_client_address(conn, int(f["client_id"])) except ValueError as e: flash(i18n.t("err_address_last" if str(e) == "last_version" else "err_address_has_refs")) elif f.get("action") == "merge": target = f.get("merge_into", "").strip() if target.isdigit() and int(target) != company_id: repo.merge_companies(conn, int(target), company_id) return redirect(url_for("client_edit", company_id=int(target))) elif not f.get("name", "").strip(): flash(i18n.t("err_required", field=i18n.t("col_name"))) else: repo.save_company(conn, company_id, f["name"].strip(), f.get("kind") or "company", f.get("uid", "").strip() or None, f.get("email", "").strip() or None, f.get("phone", "").strip() or None, f.get("notes", "").strip() or None, int(f["current_client_id"]) if f.get("current_client_id", "").isdigit() else None) return redirect(url_for("clients")) return redirect(url_for("client_edit", company_id=company_id)) others = [c for c in repo.companies_list(conn) if c["id"] != company_id] return render_template("client_form.html", comp=comp, others=others) @app.route("/clients//delete", methods=["POST"]) def client_delete(company_id): try: repo.delete_company(get_db(), company_id) except ValueError: flash(i18n.t("err_company_has_refs")) return redirect(url_for("client_edit", company_id=company_id)) return redirect(url_for("clients")) # --- data-quality audit --- @app.route("/quality") @cached_page def quality_page(): return render_template("quality.html", q=quality.overview(get_db())) # --- monthly TVB Sammelmeldung (Aufenthaltsabgabe) --- @app.route("/tvb") def tvb_page(): import datetime m = request.args.get("month", "") try: year, month = int(m[:4]), int(m[5:7]) datetime.date(year, month, 1) except ValueError: # default: the month that is due now prev = datetime.date.today().replace(day=1) - datetime.timedelta(days=1) year, month = prev.year, prev.month return render_template("tvb.html", r=tvb.monthly_report(get_db(), year, month), month_value=f"{year:04d}-{month:02d}") # --- quarterly Umsatzsteuer-Voranmeldung --- @app.route("/vat") def vat_page(): import datetime conn = get_db() today = datetime.date.today() year, quarter = vat.previous_quarter(today) # default: the quarter that is due now try: year = int(request.args.get("year", year)) quarter = min(max(int(request.args.get("q", quarter)), 1), 4) except ValueError: pass units = vat.units(conn) names = {u["code"]: u["name"] for u in units} unit = request.args.get("unit") if request.args.get("unit") in names else next(iter(names), None) return render_template("vat.html", r=vat.quarterly_report(conn, unit, year, quarter), units=units, unit=unit, unit_name=names.get(unit, unit), year=year, quarter=quarter, years=range(today.year, today.year - 8, -1)) @app.route("/quality/companies/merge", methods=["POST"]) def quality_merge_companies(): f = request.form if f.get("keep", "").isdigit() and f.get("drop", "").isdigit(): repo.merge_companies(get_db(), int(f["keep"]), int(f["drop"])) return redirect(url_for("quality_page")) @app.route("/quality/guests/merge", methods=["POST"]) def quality_merge_guests(): f = request.form if f.get("keep", "").isdigit() and f.get("drop", "").isdigit(): repo.merge_guests(get_db(), int(f["keep"]), int(f["drop"])) return redirect(url_for("quality_page")) @app.route("/quality/dismiss", methods=["POST"]) def quality_dismiss(): f = request.form if f.get("kind") and f.get("key"): quality.dismiss(get_db(), f["kind"], f["key"], f.get("note", "").strip() or None) return redirect(url_for("quality_page")) @app.errorhandler(404) def not_found(e): return render_template("error.html", message=i18n.t("err_not_found")), 404 @app.errorhandler(500) def server_error(e): return render_template("error.html", message=i18n.t("err_generic")), 500 return app def _payments(f): """The ledger form's payment lines as [(date, amount)] — None when the form carries no payment lines at all, which leaves the row's payments untouched. A line without an amount is the spare empty one; the form makes the date required as soon as an amount is typed.""" if "pay_amount" not in f: return None lines = zip(f.getlist("pay_date"), (parse_number(a) for a in f.getlist("pay_amount"))) return [(d, a) for d, a in lines if d and a] def _room_number(conn, room_id): return next((r["number"] for r in repo.rooms(conn) if r["id"] == room_id), "") def parse_number(value): """Accept German decimals ('161,50', '1.234,56') and plain numbers; None if not parseable.""" if value is None: return None s = str(value).strip().replace(" ", "") if not s: return None if "," in s: # German: comma = decimal, dot = thousands separator s = s.replace(".", "").replace(",", ".") try: return float(s) except ValueError: return None def _raw_lines(form): """The submitted line rows verbatim, for re-rendering a form that failed validation — a rejected row must come back filled in, not vanish.""" keys = ("worker", "period_from", "period_to", "quantity", "unit_type", "gross_per_unit", "tourism", "kind") lists = {k: form.getlist(k) for k in keys} n = max((len(v) for v in lists.values()), default=0) rows = [] for i in range(n): row = {k: (lists[k][i] if i < len(lists[k]) else "") for k in keys} row["tourism_tax"] = row.pop("tourism") rows.append(row) return rows def _order_warning(conn, f, unit_id, number, exclude_id=None): """Stop the save and say which invoice is in the way when the date breaks the number order (`repo.invoice_out_of_order`) — unless the user has ticked that the date is right on purpose.""" clash = repo.invoice_out_of_order(conn, unit_id, number, f.get("date") or None, exclude_id) if not clash or f.get("confirm_order"): return False flash(i18n.t("warn_order_smaller" if clash["smaller"] else "warn_order_larger", number=number, date=money.de_date(f.get("date")), other=clash["number"], other_date=money.de_date(clash["date"]))) return True def _recipient_warning(conn, f): """None when the recipient prints an address; otherwise stop the save and return the companies the name may mean (`repo.recipient_choices`) — unless the user has ticked that it is right without one.""" if f.get("confirm_client"): return None name = f.get("client", "").strip() choices = repo.recipient_choices(conn, name) if choices is not None: flash(i18n.t("warn_client_no_address", name=name)) return choices def _parse_lines(form): """Return (valid_lines, incomplete). A row with a name but no valid rate/quantity (or vice versa) is reported as incomplete rather than silently dropped, so an invoice can't be undercharged unnoticed.""" workers = form.getlist("worker") gross, qtys = form.getlist("gross_per_unit"), form.getlist("quantity") pfrom, pto, utype = form.getlist("period_from"), form.getlist("period_to"), form.getlist("unit_type") tourism, kinds = form.getlist("tourism"), form.getlist("kind") def at(lst, i): return lst[i] if i < len(lst) else None lines, incomplete = [], [] for i, worker in enumerate(workers): worker = worker.strip() rate = parse_number(at(gross, i)) qty = parse_number(at(qtys, i)) if not worker and rate is None: continue # empty row if not worker or rate is None or not qty or qty <= 0: # a blank/zero Anz is a mistake, not a free line incomplete.append(worker or "Zeile {}".format(i + 1)) continue lines.append({ "worker": worker, "period_from": at(pfrom, i) or None, "period_to": at(pto, i) or None, "quantity": qty, "unit_type": at(utype, i) or "week", "gross_per_unit": rate, "tourism_tax": parse_number(at(tourism, i)) or 0, "kind": at(kinds, i) or "stay", }) return lines, incomplete HOST, PORT = "127.0.0.1", 5000 def server_answers(host=HOST, port=PORT, timeout=0.4): """Is a server already listening? Windows lets a SECOND server bind the same port instead of refusing it (SO_REUSEADDR), so every extra start left another instance running and Windows decided which one answered — after an update often the OLD one, still serving the previous code.""" import socket with socket.socket() as s: s.settimeout(timeout) return s.connect_ex((host, port)) == 0 def main(): import threading import webbrowser url = f"http://{HOST}:{PORT}" if server_answers(): # already running: just open the window, never start twice webbrowser.open(url) return app = create_app() threading.Timer(1.0, lambda: webbrowser.open(url)).start() app.run(host=HOST, port=PORT, debug=False) if __name__ == "__main__": main()