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:
commit
45424e6fd6
47 files changed
+6701
No files matched your search
+116
@@ -0,0 +1,116 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user