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:
@@ -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)
|
||||
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user