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>
201 lines
7.2 KiB
Python
201 lines
7.2 KiB
Python
"""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"'},
|
|
)
|