feat: Backup/Export + Import-UX klarer + Raiffeisen-Preset-Fix

Backup & Export:
- routers/backup: /backup/db (Admin, konsistentes SQLite-Online-Backup),
  /export/transactions.csv|json und /export/all.json (owner-gescoped).
- Einstellungen: Bereich „Backup & Export" mit Download-Buttons. docs/BACKUP.md
  (Wiederherstellung + LXC-Sicherung). pytest für Export + Admin-Schutz.

Import-Verbesserungen (Nutzer-Feedback zum Ablauf):
- Klarer Hinweis, dass die Zuordnungstabelle nur Beispielzeilen zeigt und
  „Vorschau erstellen" die vollständige Prüfliste + Buchen-Button erzeugt.
- Zusammenfassung (erkannt/gebucht) und Warnung, wenn keine gültige Zeile erkannt
  wurde (Hinweis auf Format/Bank-Vorlage).
- Fix: Raiffeisen-Preset hat has_header=true (der Export hat eine Kopfzeile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 20:35:02 +02:00
co-authored by Claude Opus 4.8
parent 7461fddb7a
commit f375fc5eaa
11 changed files with 385 additions and 5 deletions
+2
View File
@@ -21,6 +21,7 @@ from .migrate import upgrade_to_head
from .routers import (
accounts,
auth,
backup,
budgets,
categories,
dashboard,
@@ -107,6 +108,7 @@ api.include_router(budgets.router)
api.include_router(dashboard.router)
api.include_router(imports.router)
api.include_router(documents.router)
api.include_router(backup.router)
api.include_router(rates.router)
app.include_router(api)
+200
View File
@@ -0,0 +1,200 @@
"""Backup & Export: kompletter DB-Download (Admin) und Datenexport je Nutzer."""
import csv
import io
import os
import sqlite3
import tempfile
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from starlette.background import BackgroundTask
from ..config import get_settings
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import (
Account,
AccountIdentifier,
BudgetTemplateLine,
Category,
CategoryGroup,
DashboardLayout,
PlannedIncomeLine,
Transaction,
User,
)
router = APIRouter(tags=["backup"])
def _ts() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
def _money(cents: int | None) -> str:
return f"{(cents or 0) / 100:.2f}"
# --- Komplettes DB-Backup (nur Admin) --------------------------------------
@router.get("/backup/db")
def backup_db(_: User = Depends(require_admin)):
url = get_settings().effective_database_url
if not url.startswith("sqlite:///"):
raise HTTPException(status_code=400, detail="DB-Backup ist nur für SQLite verfügbar.")
db_path = url.replace("sqlite:///", "", 1)
tmp = tempfile.NamedTemporaryFile(prefix="cf-backup-", suffix=".db", delete=False)
tmp.close()
# Online-Backup-API: konsistente Kopie auch bei laufendem Betrieb.
src = sqlite3.connect(db_path)
dst = sqlite3.connect(tmp.name)
try:
with dst:
src.backup(dst)
finally:
dst.close()
src.close()
return FileResponse(
tmp.name,
media_type="application/octet-stream",
filename=f"certain-finance-{_ts()}.db",
background=BackgroundTask(os.remove, tmp.name),
)
# --- Transaktionsexport ----------------------------------------------------
def _maps(db: Session, user: User) -> tuple[dict[int, Account], dict[int, Category]]:
accs = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
cats = {c.id: c for c in db.query(Category).filter(Category.owner_id == user.id)}
return accs, cats
def _txns(db: Session, user: User) -> list[Transaction]:
return (
db.query(Transaction)
.filter(Transaction.owner_id == user.id)
.order_by(Transaction.date, Transaction.id)
.all()
)
@router.get("/export/transactions.csv")
def export_transactions_csv(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
accs, cats = _maps(db, user)
buf = io.StringIO()
w = csv.writer(buf, delimiter=";")
w.writerow(["Datum", "Typ", "Konto", "Betrag", "Waehrung", "EUR-Wert",
"Zielkonto", "Kategorie", "Empfaenger", "Verwendungszweck", "Notiz"])
for t in _txns(db, user):
acc = accs.get(t.account_id)
w.writerow([
t.date.isoformat(), t.type.value, acc.name if acc else "",
_money(t.amount_cents), acc.currency if acc else "", _money(t.amount_eur_cents),
accs[t.to_account_id].name if t.to_account_id and t.to_account_id in accs else "",
cats[t.category_id].name if t.category_id and t.category_id in cats else "",
t.payee or "", t.description or "", t.notes or "",
])
return Response(
content=buf.getvalue().encode("utf-8-sig"),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.csv"'},
)
def _txn_dict(t: Transaction, accs: dict[int, Account], cats: dict[int, Category]) -> dict:
acc = accs.get(t.account_id)
return {
"date": t.date.isoformat(),
"type": t.type.value,
"account": acc.name if acc else None,
"currency": acc.currency if acc else None,
"amount_cents": t.amount_cents,
"amount_eur_cents": t.amount_eur_cents,
"to_account": accs[t.to_account_id].name if t.to_account_id in accs else None,
"to_amount_cents": t.to_amount_cents,
"category": cats[t.category_id].name if t.category_id in cats else None,
"payee": t.payee,
"description": t.description,
"notes": t.notes,
}
@router.get("/export/transactions.json")
def export_transactions_json(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
accs, cats = _maps(db, user)
data = [_txn_dict(t, accs, cats) for t in _txns(db, user)]
return Response(
content=_json_bytes(data),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.json"'},
)
# --- Gesamt-Export (alle eigenen Daten als JSON) ---------------------------
def _json_bytes(obj) -> bytes:
import json
return json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
@router.get("/export/all.json")
def export_all_json(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
accs, cats = _maps(db, user)
idents: dict[int, list] = {}
for i in db.query(AccountIdentifier).join(Account).filter(Account.owner_id == user.id):
idents.setdefault(i.account_id, []).append({"kind": i.kind.value, "value": i.value})
accounts = [{
"name": a.name, "type": a.type.value, "currency": a.currency,
"opening_balance_cents": a.opening_balance_cents,
"opening_date": a.opening_date.isoformat() if a.opening_date else None,
"icon": a.icon, "color": a.color, "archived": a.is_archived,
"identifiers": idents.get(a.id, []),
} for a in accs.values()]
groups = []
for g in db.query(CategoryGroup).filter(CategoryGroup.owner_id == user.id).order_by(CategoryGroup.position):
groups.append({
"name": g.name, "kind": g.kind.value, "icon": g.icon, "color": g.color,
"categories": [
{"name": c.name, "icon": c.icon, "color": c.color}
for c in sorted(g.categories, key=lambda x: x.position)
],
})
budget_template = [
{"category": cats[l.category_id].name if l.category_id in cats else None, "planned_cents": l.planned_cents}
for l in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id)
]
planned_income = [
{"label": l.label, "planned_cents": l.planned_cents,
"category": cats[l.category_id].name if l.category_id in cats else None,
"target_account": accs[l.target_account_id].name if l.target_account_id in accs else None}
for l in db.query(PlannedIncomeLine).filter(PlannedIncomeLine.owner_id == user.id)
]
dash = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
data = {
"exported_at": datetime.now(timezone.utc).isoformat(),
"app": "Certain Finance",
"user": user.username,
"accounts": accounts,
"category_groups": groups,
"transactions": [_txn_dict(t, accs, cats) for t in _txns(db, user)],
"budget_template": budget_template,
"planned_income": planned_income,
"dashboard_layout": dash.layout if dash else None,
}
return Response(
content=_json_bytes(data),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="certain-finance-export-{_ts()}.json"'},
)
+1 -1
View File
@@ -39,7 +39,7 @@ PRESETS: list[dict] = [
"decimal_sep": ".",
"date_format": "%Y-%m-%d %H:%M:%S.%f",
"encoding": "utf-8",
"has_header": False,
"has_header": True,
"skip_rows": 0,
"amount_mode": "signed",
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
+59
View File
@@ -0,0 +1,59 @@
import json
import secrets
from datetime import date
def _setup(admin):
acc = admin.post("/api/accounts", json={"name": "Exp", "currency": "EUR"}).json()
admin.post("/api/transactions", json={
"type": "expense", "date": str(date.today()), "account_id": acc["id"],
"amount_cents": 4200, "payee": "Testladen", "description": "Etwas",
})
return acc
def test_export_transactions_csv(admin):
_setup(admin)
r = admin.get("/api/export/transactions.csv")
assert r.status_code == 200
assert "text/csv" in r.headers["content-type"]
body = r.content.decode("utf-8-sig")
assert "Datum;Typ;Konto" in body.splitlines()[0]
assert "Testladen" in body
def test_export_transactions_json(admin):
_setup(admin)
r = admin.get("/api/export/transactions.json")
assert r.status_code == 200
data = json.loads(r.content)
assert any(t["payee"] == "Testladen" for t in data)
def test_export_all_json(admin):
_setup(admin)
r = admin.get("/api/export/all.json")
assert r.status_code == 200
data = json.loads(r.content)
assert data["app"] == "Certain Finance"
assert len(data["accounts"]) >= 1
assert len(data["transactions"]) >= 1
def test_db_backup_admin_only(admin):
from fastapi.testclient import TestClient
from app.main import app
# Admin darf.
r = admin.get("/api/backup/db")
assert r.status_code == 200
assert r.content[:16].startswith(b"SQLite format 3")
# Normaler Nutzer darf nicht.
u, p = "u_" + secrets.token_hex(4), "pw"
admin.post("/api/users", json={"username": u, "password": p, "role": "user"})
other = TestClient(app)
other.post("/api/auth/login", data={"username": u, "password": p})
assert other.get("/api/backup/db").status_code == 403
# ...aber seinen eigenen Export schon (leer).
assert other.get("/api/export/all.json").status_code == 200
+45
View File
@@ -0,0 +1,45 @@
# Backup & Wiederherstellung
## In der App
**Einstellungen → Backup & Export:**
- **Buchungen (CSV/JSON)** und **Alle Daten (JSON)** — exportiert die *eigenen*
Daten des angemeldeten Nutzers (owner-gescoped) zum Archivieren/Weiterverarbeiten.
- **Komplettes DB-Backup** (nur Admin) — lädt eine konsistente Kopie der gesamten
SQLite-Datenbank herunter (alle Nutzer). Erzeugt über die SQLite-Online-Backup-API,
also auch bei laufendem Betrieb gefahrlos.
## Wiederherstellen (DB-Backup)
Die heruntergeladene `.db`-Datei ist die komplette Datenbank. Zum Wiederherstellen
im LXC:
```bash
systemctl stop certain-finance
cp certain-finance-YYYYMMDD-HHMMSS.db /var/lib/certain-finance/app.db
# WAL-/SHM-Reste entfernen, damit die Kopie sauber übernommen wird:
rm -f /var/lib/certain-finance/app.db-wal /var/lib/certain-finance/app.db-shm
chown certain-finance:certain-finance /var/lib/certain-finance/app.db 2>/dev/null || true
systemctl start certain-finance
```
## Datei-Backup (LXC)
Die Datenbank liegt unter `/var/lib/certain-finance/app.db`.
- **Proxmox-Snapshot:** `vzdump` des Containers sichert alles (empfohlen).
- **Cron-Kopie:** täglich eine konsistente Kopie ablegen und rotieren:
```bash
# /etc/cron.daily/certain-finance-backup (ausführbar machen)
#!/bin/sh
D=/var/lib/certain-finance
mkdir -p "$D/backups"
sqlite3 "$D/app.db" ".backup '$D/backups/app-$(date +\%Y\%m\%d).db'"
# älter als 30 Tage löschen
find "$D/backups" -name 'app-*.db' -mtime +30 -delete
```
`update.sh` legt außerdem vor jeder Migration automatisch eine Kopie unter
`/var/lib/certain-finance/backups/pre-update-*.db` an.
+3 -2
View File
@@ -32,5 +32,6 @@ via IBAN, Batch-Undo. Vorgefertigte Bank-Templates folgen aus Nutzer-Beispielen.
Lokal: PyMuPDF (PDF-Text) + Tesseract-OCR (Scans/Bilder), heuristischer
Zeilen-Parser (Datum + Betrag), speist dieselbe Prüfansicht. Best-Effort.
## Querschnitt – Backup/Export
DB-Backup-Download + Export je Entität (CSV/JSON); dokumentierte LXC-Sicherung.
## Querschnitt – Backup/Export ✅
DB-Backup-Download (Admin) + Export je Entität (CSV/JSON); dokumentierte
LXC-Sicherung (docs/BACKUP.md).
+6
View File
@@ -201,4 +201,10 @@ export const api = {
// PDF/Bild-Beleg
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
// Backup & Export
downloadDbBackup: () => downloadFile("/backup/db", `certain-finance-${zeitstempel()}.db`),
exportTransactionsCsv: () => downloadFile("/export/transactions.csv", `transaktionen_${zeitstempel()}.csv`),
exportTransactionsJson: () => downloadFile("/export/transactions.json", `transaktionen_${zeitstempel()}.json`),
exportAllJson: () => downloadFile("/export/all.json", `certain-finance-export_${zeitstempel()}.json`),
};
+12 -1
View File
@@ -72,7 +72,14 @@
"dateFormat": "Datumsformat",
"saved": "Gespeichert.",
"languageDe": "Deutsch",
"languageEn": "Englisch"
"languageEn": "Englisch",
"backup": "Backup & Export",
"exportHint": "Exportiere deine eigenen Daten zum Archivieren oder Weiterverarbeiten.",
"exportTxnCsv": "Buchungen (CSV)",
"exportTxnJson": "Buchungen (JSON)",
"exportAll": "Alle Daten (JSON)",
"dbBackup": "Komplettes DB-Backup herunterladen",
"dbBackupHint": "Vollständige Datenbank aller Nutzer (nur Admin) – zum Wiederherstellen sichern."
},
"dashboard": {
"title": "Übersicht",
@@ -234,6 +241,10 @@
"colPurpose": "Verwendungszweck",
"colIban": "Gegen-IBAN",
"makePreviewHint": "Ordne mindestens Datum und Betrag zu.",
"rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.",
"makePreviewNote": "„Vorschau erstellen“ zeigt danach ALLE Buchungen zum Prüfen und Buchen.",
"summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).",
"noneValid": "Keine gültige Buchung erkannt. Prüfe Datumsformat, Dezimaltrenner und Spaltenzuordnung – am einfachsten oben die passende Bank-Vorlage wählen.",
"commit": "{{n}} Buchungen übernehmen",
"imported": "{{n}} Buchungen importiert.",
"importedCount": "{{n}} gebucht",
+12 -1
View File
@@ -72,7 +72,14 @@
"dateFormat": "Date format",
"saved": "Saved.",
"languageDe": "German",
"languageEn": "English"
"languageEn": "English",
"backup": "Backup & export",
"exportHint": "Export your own data for archiving or further processing.",
"exportTxnCsv": "Transactions (CSV)",
"exportTxnJson": "Transactions (JSON)",
"exportAll": "All data (JSON)",
"dbBackup": "Download full DB backup",
"dbBackupHint": "Complete database of all users (admin only) — keep it for restore."
},
"dashboard": {
"title": "Overview",
@@ -234,6 +241,10 @@
"colPurpose": "Purpose",
"colIban": "Counterparty IBAN",
"makePreviewHint": "Map at least date and amount.",
"rawHint": "Only the first rows – just for mapping the columns.",
"makePreviewNote": "“Build preview” then shows ALL transactions to review and import.",
"summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).",
"noneValid": "No valid transaction detected. Check date format, decimal separator and column mapping – easiest is to pick the matching bank preset above.",
"commit": "Import {{n}} transactions",
"imported": "Imported {{n}} transactions.",
"importedCount": "{{n}} imported",
+9
View File
@@ -315,6 +315,7 @@ export default function Import() {
{raw && (
<div className="card">
<h2>{t("import.step3")}</h2>
<p className="muted small">{t("import.rawHint")}</p>
<div className="table-scroll">
<table className="table raw-table">
<thead>
@@ -357,6 +358,7 @@ export default function Import() {
<button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
</div>
</div>
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.makePreviewNote")}</p>
</div>
)}
@@ -369,6 +371,13 @@ export default function Import() {
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
</button>
</div>
<p className="muted small">{t("import.summary", { total: rows.length, n: includeCount })}</p>
{rows.length > 0 && includeCount === 0 && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{t("import.noneValid")}</span>
</div>
)}
<div className="table-scroll">
<table className="table">
<thead>
+36
View File
@@ -1,10 +1,14 @@
import { useTranslation } from "react-i18next";
import { useSettings } from "../settings";
import { useAuth } from "../auth";
import { useToast } from "../toast";
import { api } from "../api";
import Icon from "../components/Icon";
export default function Settings() {
const { t } = useTranslation();
const s = useSettings();
const { isAdmin } = useAuth();
const toast = useToast();
async function set(key, value) {
@@ -16,6 +20,14 @@ export default function Settings() {
}
}
async function dl(fn) {
try {
await fn();
} catch (err) {
toast(err.message, "error");
}
}
return (
<div>
<h1>{t("settings.title")}</h1>
@@ -62,6 +74,30 @@ export default function Settings() {
</label>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
</div>
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<h2>{t("settings.backup")}</h2>
<p className="muted small">{t("settings.exportHint")}</p>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<button className="btn" onClick={() => dl(api.exportTransactionsCsv)}>
<Icon name="download" size={15} /> {t("settings.exportTxnCsv")}
</button>
<button className="btn" onClick={() => dl(api.exportTransactionsJson)}>
<Icon name="download" size={15} /> {t("settings.exportTxnJson")}
</button>
<button className="btn" onClick={() => dl(api.exportAllJson)}>
<Icon name="download" size={15} /> {t("settings.exportAll")}
</button>
</div>
{isAdmin && (
<>
<p className="muted small" style={{ marginTop: "var(--sp-3)" }}>{t("settings.dbBackupHint")}</p>
<button className="btn primary" onClick={() => dl(api.downloadDbBackup)}>
<Icon name="download" size={15} /> {t("settings.dbBackup")}
</button>
</>
)}
</div>
</div>
);
}