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

117 lines
5.0 KiB
Python

"""The audit log (migration 002): every row a data table gains, loses or changes, with who wrote it — and
undoing chosen entries.
python -m app.audit [--table T] [--row ID] [--actor A] [--tx TX] [--limit N] newest entries first
python -m app.audit revert (ENTRY... | --tx TX) [--apply] a dry run without --apply
"""
import argparse
import json
from app import db
KEYS = {"company_key": ("key",), "dismissed_finding": ("kind", "key")} # every other table: id
class Conflict(Exception):
"""The row no longer looks as the entry left it: someone changed it since."""
def entries(conn, table=None, row_id=None, actor=None, tx=None, ids=None, limit=50):
"""Log entries, newest first."""
where, params = [], []
for sql, value in (("table_name = ?", table), ("row_id = ?", row_id), ("actor = ?", actor), ("tx = ?", tx)):
if value is not None:
where.append(sql)
params.append(value)
if ids:
where.append("id = ANY(?)")
params.append(list(ids))
sql = "SELECT * FROM audit_log" + (" WHERE " + " AND ".join(where) if where else "")
return [dict(r) for r in db.rows(conn, sql + " ORDER BY id DESC LIMIT ?", params + [limit])]
def _where(table, row):
cols = KEYS.get(table, ("id",))
return " AND ".join(f'"{c}" = ?' for c in cols), [row[c] for c in cols]
def revert(conn, chosen):
"""Undo the chosen entries newest first, in the caller's transaction (commit or roll back after). Each
step first checks that the row still looks exactly as the entry left it — a later change by anyone else
raises Conflict, so the caller rolls everything back. Returns one line per step."""
conn.execute("SET CONSTRAINTS ALL DEFERRED") # parents and children come back in any order
steps = []
for e in sorted(chosen, key=lambda e: e["id"], reverse=True):
table, op = e["table_name"], e["op"]
if table not in db.DATA_TABLES or op == "TRUNCATE":
raise Conflict(f"#{e['id']}: {op} on {table} cannot be undone row by row — restore a snapshot")
where, key = _where(table, e["new"] or e["old"])
now = db.row(conn, f"SELECT to_jsonb(x) FROM {table} x WHERE {where}", key)
if (now[0] if now else None) != e["new"]:
raise Conflict(f"#{e['id']} {table} {key}: changed since — now {now[0] if now else 'gone'}")
if op == "INSERT":
conn.execute(f"DELETE FROM {table} WHERE {where}", key)
elif op == "DELETE":
conn.execute(f"INSERT INTO {table} SELECT * FROM jsonb_populate_record(NULL::{table}, "
f"(SELECT old FROM audit_log WHERE id = ?))", (e["id"],))
else:
cols = ", ".join(f'"{c}"' for c in e["old"] if e["old"][c] != e["new"].get(c))
conn.execute(f"UPDATE {table} SET ({cols}) = (SELECT {cols} FROM jsonb_populate_record("
f"NULL::{table}, (SELECT old FROM audit_log WHERE id = ?))) WHERE {where}",
[e["id"]] + key)
steps.append(f"#{e['id']} {op} {table} {key} undone")
return steps
def describe(e):
if e["op"] == "UPDATE":
what = "; ".join(f"{c}: {e['old'][c]!r} -> {e['new'].get(c)!r}"
for c in e["old"] if e["old"][c] != e["new"].get(c))
else:
what = json.dumps(e["new"] or e["old"], ensure_ascii=False)
return (f"#{e['id']} {e['at']:%Y-%m-%d %H:%M:%S} tx {e['tx']} {e['actor']} ({e['db_user']}) "
f"{e['op']} {e['table_name']} {e['row_id'] or ''} {what[:300]}")
def main():
p = argparse.ArgumentParser(prog="python -m app.audit")
p.add_argument("command", nargs="?", choices=["list", "revert"], default="list")
p.add_argument("ids", nargs="*", type=int, help="entries to revert")
p.add_argument("--table")
p.add_argument("--row", type=int)
p.add_argument("--actor")
p.add_argument("--tx", type=int)
p.add_argument("--limit", type=int, default=50)
p.add_argument("--apply", action="store_true", help="write the revert (default: dry run)")
a = p.parse_args()
if a.command == "list":
for e in entries(db.connect(), a.table, a.row, a.actor, a.tx, limit=a.limit):
print(describe(e))
return
if not (a.ids or a.tx):
p.error("revert needs entry ids or --tx")
if a.apply:
with db.connect(owner=True) as owner:
print("Snapshot saved:", db.snapshot(owner, "before revert"))
label = f"revert {'tx ' + str(a.tx) if a.tx else ' '.join(f'#{i}' for i in a.ids)}"
conn = db.connect(actor=label)
chosen = entries(conn, tx=a.tx, ids=a.ids, limit=100000)
if not chosen:
raise SystemExit("no such entries")
try:
for step in revert(conn, chosen):
print(step)
except Conflict as c:
conn.rollback()
raise SystemExit(f"NOTHING written — {c}")
if a.apply:
conn.commit()
print(f"reverted {len(chosen)} entries (logged as '{label}')")
else:
conn.rollback()
print("dry run — nothing written; add --apply")
if __name__ == "__main__":
main()