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>
This commit is contained in:
BlackMarkandClaude Opus 5.5 committed 2026-11-02 16:52:27 +01:00
commit 45424e6fd6
47 files changed
+6701

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
"""The only module that opens the database (PostgreSQL) and applies schema migrations.
SQL in this code base uses `?` placeholders (DB-API qmark style); `_translate` turns each statement into
psycopg's format once. Rows behave like sqlite3.Row (by name, by index, dict(row)), an INSERT reports
`lastrowid`, and ints are sent untyped so Postgres reads them as the column needs. Two roles: the app's role
reads and writes data only; the owner role applies migrations — `python -m app.db migrate` — and takes
snapshots before risky writes. With a single role, DATABASE_URL serves both.
"""
import re
import sys
from datetime import date, datetime
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
import psycopg
from psycopg.adapt import Dumper, Loader
from psycopg.conninfo import conninfo_to_dict
from psycopg.sql import Identifier
from app import config
SYSTEM_DIR = Path(__file__).resolve().parent.parent
MIGRATIONS_DIR = SYSTEM_DIR / "migrations"
URL = config.get("DATABASE_URL") # the app's role: data only
OWNER_URL = config.get("DATABASE_OWNER_URL") or URL # migrations, snapshots
SCHEMA = "public" # the tests give every test a schema of its own
TIMEZONE = "Europe/Vienna"
APP_ROLE = conninfo_to_dict(URL)["user"] if URL else None
DATA_TABLES = ("unit", "room", "company", "client", "company_key", "dismissed_finding", "guest", "invoice",
"stay", "charge", "payment")
TABLES_WITH_ID = {"unit", "room", "company", "client", "guest", "invoice", "stay", "charge", "payment"}
IntegrityError = psycopg.IntegrityError
INSERT = re.compile(r"\s*INSERT\s+INTO\s+(\w+)", re.I)
class Row:
"""A result row like sqlite3.Row: row["name"] (any case), row[0], dict(row), iteration over the values."""
__slots__ = ("_index", "_values")
def __init__(self, index, values):
self._index, self._values = index, values
def keys(self):
return [k for k in self._index if not k.startswith("\0")]
def __getitem__(self, key):
if isinstance(key, (int, slice)):
return self._values[key]
try:
return self._values[self._index[key]]
except KeyError:
return self._values[self._index["\0" + key.lower()]]
def __iter__(self):
return iter(self._values)
def __len__(self):
return len(self._values)
def __eq__(self, other):
return isinstance(other, Row) and self.keys() == other.keys() and self._values == other._values
def __hash__(self):
return hash(tuple(self._values))
def __repr__(self):
return f"Row({dict(self)!r})"
def _row_factory(cursor):
names = [c.name for c in cursor.description or ()]
index = {n: i for i, n in enumerate(names)}
index.update({"\0" + n.lower(): i for i, n in enumerate(names)}) # case-insensitive, like sqlite3.Row
return lambda values: Row(index, values)
class _Untyped(Dumper):
"""Python ints and bools go to the server as untyped literals (bools as 1/0, the integer flag columns):
Postgres then reads `'22'` as the text room number or the integer id, whichever the context needs."""
oid = 0
def dump(self, obj):
return str(int(obj)).encode()
class _IsoText(Dumper):
"""Dates go as untyped ISO text: the date columns are text, and Postgres has no `text >= date`
operator."""
oid = 0
def dump(self, obj):
return (obj.isoformat(" ") if isinstance(obj, datetime) else obj.isoformat()).encode()
class _Numeric(Loader):
"""numeric -> int when integral, else float (charge.persons: 2, or 1.5)."""
def load(self, data):
d = Decimal(bytes(data).decode())
return int(d) if d == d.to_integral_value() else float(d)
@lru_cache(maxsize=None)
def _translate(sql):
"""`?` -> `%s` and `%` -> `%%`. psycopg reads `%` everywhere — inside string literals and comments too —
so every `%` is doubled; a `?` inside a literal or comment stays a question mark."""
out, i, n = [], 0, len(sql)
while i < n:
ch = sql[i]
if ch in "'\"":
end = sql.find(ch, i + 1)
end = n - 1 if end < 0 else end
out.append(sql[i:end + 1].replace("%", "%%"))
i = end + 1
continue
if sql.startswith("--", i):
end = sql.find("\n", i)
end = n if end < 0 else end
out.append(sql[i:end].replace("%", "%%"))
i = end
continue
out.append("%s" if ch == "?" else "%%" if ch == "%" else ch)
i += 1
return "".join(out)
class Cursor:
"""A cursor as sqlite3 offers one: fetchone/fetchall/iteration, lastrowid after an INSERT, rowcount."""
def __init__(self, cur, returning):
self._cur, self.lastrowid, self.rowcount = cur, None, cur.rowcount
self._done = cur.description is None
if returning:
ids = cur.fetchall()
self.lastrowid = ids[-1][0] if ids else None
self._done = True
def fetchone(self):
return None if self._done else self._cur.fetchone()
def fetchall(self):
return [] if self._done else self._cur.fetchall()
def __iter__(self):
return iter(self.fetchall())
class Connection:
"""A psycopg connection with the sqlite3 interface this code is written against. `with conn:` commits
or rolls back and keeps the connection open, as sqlite3 does; `close()` closes it."""
def __init__(self, raw):
self.raw = raw
def execute(self, sql, params=()):
m = INSERT.match(sql)
returning = bool(m and m.group(1).lower() in TABLES_WITH_ID and not re.search(r"\bRETURNING\b", sql, re.I))
if returning:
sql = sql.rstrip().rstrip(";") + " RETURNING id"
return Cursor(self.raw.execute(_translate(sql), tuple(params)), returning)
def executescript(self, sql):
self.raw.execute(sql) # no parameters: Postgres takes several statements at once
def commit(self):
self.raw.commit()
def rollback(self):
self.raw.rollback()
def close(self):
self.raw.close()
def __enter__(self):
return self
def __exit__(self, kind, exc, tb):
self.raw.rollback() if kind else self.raw.commit()
def connect(owner=False, actor="cli"):
"""A connection as the app's role — or as the owner, for migrations, snapshots and maintenance. `actor`
names whoever writes through it in the audit log (002): `web`, `cli`, ..."""
url = OWNER_URL if owner else URL
if not url:
raise RuntimeError("no database URL: set DATABASE_URL (environment or .env)")
actor = actor.replace("\\", "\\\\").replace(" ", "\\ ") # libpq options split at unescaped spaces
raw = psycopg.connect(url, options=f"-c search_path={SCHEMA} -c TimeZone={TIMEZONE} -c app.actor={actor}",
application_name="vermietungs-tool", row_factory=_row_factory)
raw.adapters.register_dumper(int, _Untyped)
raw.adapters.register_dumper(bool, _Untyped)
raw.adapters.register_dumper(date, _IsoText)
raw.adapters.register_dumper(datetime, _IsoText)
raw.adapters.register_loader("numeric", _Numeric)
return Connection(raw)
def _migrations():
return {int(p.name[:3]): p for p in sorted(MIGRATIONS_DIR.glob("[0-9][0-9][0-9]_*.sql"))}
def pending_migrations(conn):
exists = conn.execute("SELECT to_regclass('schema_version') IS NOT NULL").fetchone()[0]
applied = {r[0] for r in conn.execute("SELECT version FROM schema_version")} if exists else set()
return sorted(v for v in _migrations() if v not in applied)
def migrate(conn):
"""Apply every migration not yet recorded in schema_version, each in its own transaction (DDL is
transactional in Postgres), then grant the app's role its data rights. Needs the owner role."""
conn.execute("CREATE TABLE IF NOT EXISTS schema_version (version integer PRIMARY KEY, applied_at text "
"COLLATE \"C\" DEFAULT to_char(now(), 'YYYY-MM-DD HH24:MI:SS'))")
conn.commit()
for version in pending_migrations(conn):
conn.executescript(_migrations()[version].read_text(encoding="utf-8"))
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
conn.commit()
_grant(conn)
def _grant(conn):
"""The app's role may read and write the data tables — nothing else: no DDL, no TRUNCATE, and it cannot
rewrite the schema history or the audit log (its trigger writes as the owner)."""
me = conn.execute("SELECT current_user").fetchone()[0]
if not APP_ROLE or APP_ROLE == me:
return
schema, role = Identifier(SCHEMA).as_string(conn.raw), Identifier(APP_ROLE).as_string(conn.raw)
conn.executescript(f"GRANT USAGE ON SCHEMA {schema} TO {role};"
f"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA {schema} TO {role};"
f"GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA {schema} TO {role};"
f"REVOKE INSERT, UPDATE, DELETE ON {schema}.schema_version, {schema}.audit_log FROM {role};"
f"REVOKE ALL ON SEQUENCE {schema}.audit_log_id_seq FROM {role};")
conn.commit()
def snapshot(conn, label):
"""Copy every data table into a new schema `snap_<YYYYMMDD_HHMMSS>_<label>` — the backup before a risky
write outside the app. Owner role. Returns the schema name."""
name = f"snap_{datetime.now():%Y%m%d_%H%M%S}_{re.sub(r'[^a-z0-9]+', '_', label.lower()).strip('_')}"[:63]
conn.execute(f'CREATE SCHEMA "{name}"')
for table in DATA_TABLES:
conn.execute(f'CREATE TABLE "{name}"."{table}" AS TABLE "{SCHEMA}"."{table}"')
conn.commit()
return name
def rows(conn, sql, params=()):
return conn.execute(sql, params).fetchall()
def row(conn, sql, params=()):
return conn.execute(sql, params).fetchone()
def execute(conn, sql, params=()):
cur = conn.execute(sql, params)
conn.commit()
return cur.lastrowid
def main():
"""python -m app.db migrate | status | snapshot <label>"""
command = sys.argv[1] if len(sys.argv) > 1 else "status"
with connect(owner=True) as conn:
if command == "migrate":
migrate(conn)
elif command == "snapshot":
print(snapshot(conn, sys.argv[2] if len(sys.argv) > 2 else "manual"))
return
print("pending migrations:", pending_migrations(conn) or "none")
print("snapshots:", [r[0] for r in conn.execute(
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'snap\\_%' ORDER BY 1")])
if __name__ == "__main__":
main()