Files
BlackMarkandClaude Opus 5.5 45424e6fd6 Vermietungs-Tool
A local web app for a small room-rental business in Austria: guest register,
room ledger, occupancy plan, invoices, the monthly tourist-tax report and the
quarterly VAT list. Application code only; it ships no data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:52:27 +01:00

738 lines
36 KiB
Python

"""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/<int:stay_id>/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/<int:stay_id>/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/<int:charge_id>/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/<int:charge_id>/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/<int:invoice_id>/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/<int:invoice_id>/delete", methods=["POST"])
def invoice_delete(invoice_id):
repo.delete_invoice(get_db(), invoice_id)
return redirect(url_for("invoices"))
@app.route("/invoices/<int:invoice_id>")
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/<int:invoice_id>/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/<int:invoice_id>/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/<int:invoice_id>/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/<int:company_id>", 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/<int:company_id>/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()