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