Drei Sachen an der Bankanbindung. 'Umsaetze holen' lief ueber alle Konten einer Verbindung. Seit angebundene Konten selbst buchen, ist ein Klick damit folgenreich: Er bucht auf zwei Konten. Der Knopf sitzt jetzt in der Kontozeile; an der Verbindung bleibt 'Alle Konten holen'. Dabei musste der Zeitstempel mitwandern. last_sync_at hing an der Verbindung – ein Einzelabruf haette damit das Startdatum der uebrigen Konten mit vorgeschoben, und deren Umsaetze aus dem uebersprungenen Zeitraum waeren nie angekommen. Der Stempel gehoert an das Konto, das abgerufen wurde. Neu ist ein Takt je Verbindung, nach demselben Muster wie beim Postfach: ein Hintergrund-Thread, der jede Minute nachsieht, ob etwas faellig ist. Bewusst ein eigener Thread – ein haengender Bankzugang darf den Mail-Abruf nicht mitreissen. Die Vorgabe ist 0, also aus: Anders als beim Mail-Abruf entstehen hier Buchungen, das laeuft nicht ungefragt los. Schlaegt ein Abruf fehl, wird der Stempel trotzdem gesetzt, sonst liefe die Verbindung jede Minute in denselben Fehler. Und die Abschnitte heissen jetzt 'Bankkonten Anbindung' und 'Mail Import'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
9.1 KiB
Python
256 lines
9.1 KiB
Python
"""FastAPI-Anwendung.
|
||
|
||
Ein einzelner Prozess liefert sowohl die JSON-API unter ``/api/*`` als auch das
|
||
gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutzers.
|
||
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
|
||
"""
|
||
|
||
import logging
|
||
import threading
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||
from fastapi.encoders import jsonable_encoder
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import JSONResponse
|
||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from .branding import APP_NAME, VERSION, git_sha
|
||
from .config import get_settings
|
||
from .database import SessionLocal
|
||
from .deps import enforce_account_state
|
||
from .migrate import upgrade_to_head
|
||
|
||
log = logging.getLogger(__name__)
|
||
from .routers import (
|
||
accounts,
|
||
bank,
|
||
auth,
|
||
backup,
|
||
budgets,
|
||
categories,
|
||
dashboard,
|
||
documents,
|
||
imports,
|
||
policy,
|
||
inbox,
|
||
mail,
|
||
quick,
|
||
uebertraege,
|
||
rates,
|
||
settings as settings_router,
|
||
transactions,
|
||
users,
|
||
)
|
||
from .seed import ensure_first_admin
|
||
from .services import ecb_rates
|
||
|
||
settings = get_settings()
|
||
|
||
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||
DIST_DIR = REPO_ROOT / "web" / "dist"
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Schema auf den aktuellen Stand bringen (idempotent).
|
||
upgrade_to_head()
|
||
db = SessionLocal()
|
||
try:
|
||
ensure_first_admin(db)
|
||
finally:
|
||
db.close()
|
||
|
||
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
|
||
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
|
||
# blockieren und der Health-Check nie grün werden).
|
||
if settings.ecb_fetch_on_start:
|
||
def _laden() -> None:
|
||
s = SessionLocal()
|
||
try:
|
||
ecb_rates.ensure_rates_on_startup(s, True)
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
|
||
|
||
# Zahlungs-Mails regelmässig abholen. Wie beim Kursabruf im Hintergrund:
|
||
# Ein langsames oder nicht erreichbares Postfach darf den Dienst nicht
|
||
# blockieren. Fehler landen sichtbar an der Quelle, nicht im Log-Nirwana.
|
||
stopp = threading.Event()
|
||
|
||
def _mails() -> None:
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from .models import MailSource
|
||
from .services import mail_import
|
||
|
||
while not stopp.wait(60):
|
||
s = SessionLocal()
|
||
try:
|
||
jetzt = datetime.now(timezone.utc)
|
||
for quelle in s.query(MailSource).filter(MailSource.is_active.is_(True)):
|
||
faellig = quelle.last_poll_at is None or quelle.last_poll_at + timedelta(
|
||
minutes=quelle.poll_minutes
|
||
) <= jetzt
|
||
if faellig:
|
||
mail_import.abruf_mit_fehlerbehandlung(s, quelle)
|
||
except Exception:
|
||
log.exception("Mail-Abruf fehlgeschlagen")
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_mails, name="mail-abruf", daemon=True).start()
|
||
|
||
# Bankumsätze im selben Takt-Muster. Bewusst ein EIGENER Thread: Ein
|
||
# hängender Bankzugang darf den Mail-Abruf nicht mitreissen.
|
||
#
|
||
# Anders als beim Mail-Abruf entstehen hier BUCHUNGEN. Deshalb steht der
|
||
# Takt je Verbindung auf 0, bis ihn jemand einschaltet – nichts läuft
|
||
# ungefragt los. Gegen doppelte Buchungen sichern die Kennung des Umsatzes,
|
||
# das Startdatum hinter der letzten Buchung und das Übergehen vorgemerkter
|
||
# Umsätze; siehe bank_sync.
|
||
def _bank() -> None:
|
||
from datetime import datetime, timezone
|
||
|
||
from .models import BankConnection, User
|
||
from .services import bank_sync
|
||
|
||
while not stopp.wait(60):
|
||
s = SessionLocal()
|
||
try:
|
||
jetzt = datetime.now(timezone.utc)
|
||
for verbindung in s.query(BankConnection).filter(
|
||
BankConnection.poll_minutes > 0
|
||
):
|
||
if not bank_sync.faellig(verbindung, jetzt):
|
||
continue
|
||
nutzer = s.get(User, verbindung.owner_id)
|
||
if nutzer is None:
|
||
continue
|
||
bank_sync.abruf_mit_fehlerbehandlung(s, nutzer, verbindung)
|
||
except Exception:
|
||
log.exception("Bank-Abruf fehlgeschlagen")
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_bank, name="bank-abruf", daemon=True).start()
|
||
yield
|
||
stopp.set()
|
||
|
||
|
||
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
||
|
||
# CORS nur, wenn ausdrücklich konfiguriert (Standard: gleiche Herkunft, aus).
|
||
if settings.cors_origins.strip():
|
||
origins = (
|
||
["*"]
|
||
if settings.cors_origins.strip() == "*"
|
||
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||
)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# --- API -------------------------------------------------------------------
|
||
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
|
||
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
|
||
# keiner Stelle vorbei, auch nicht an neu hinzukommenden.
|
||
api = APIRouter(prefix="/api", dependencies=[Depends(enforce_account_state)])
|
||
|
||
|
||
@api.get("/health", tags=["meta"])
|
||
def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
|
||
@api.get("/version", tags=["meta"])
|
||
def version() -> dict:
|
||
return {"name": APP_NAME, "version": VERSION, "git_sha": git_sha()}
|
||
|
||
|
||
api.include_router(auth.router)
|
||
api.include_router(users.router)
|
||
api.include_router(settings_router.router)
|
||
api.include_router(policy.router)
|
||
api.include_router(accounts.router)
|
||
api.include_router(categories.router)
|
||
api.include_router(transactions.router)
|
||
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)
|
||
api.include_router(quick.router)
|
||
api.include_router(inbox.router)
|
||
api.include_router(mail.router)
|
||
api.include_router(bank.router)
|
||
api.include_router(uebertraege.router)
|
||
|
||
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
|
||
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
|
||
# steht sonst nur „detail" in der Antwort und die Mitteilung bleibt leer –
|
||
# man sieht dann nicht, DASS etwas schiefging, geschweige denn was.
|
||
def _ist_kurzbefehl(request: Request) -> bool:
|
||
return request.url.path.startswith("/api/quick/")
|
||
|
||
|
||
@app.exception_handler(StarletteHTTPException)
|
||
async def http_fehler(request: Request, exc: StarletteHTTPException):
|
||
inhalt = {"detail": exc.detail}
|
||
if _ist_kurzbefehl(request):
|
||
inhalt["message"] = str(exc.detail)
|
||
inhalt["ok"] = False
|
||
return JSONResponse(status_code=exc.status_code, content=inhalt,
|
||
headers=getattr(exc, "headers", None))
|
||
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def eingabe_fehler(request: Request, exc: RequestValidationError):
|
||
inhalt = {"detail": exc.errors()}
|
||
if _ist_kurzbefehl(request):
|
||
fehlend = [
|
||
".".join(str(t) for t in f.get("loc", ()) if t not in ("body", "query"))
|
||
for f in exc.errors()
|
||
]
|
||
inhalt["message"] = ("Die Anfrage ist unvollständig: "
|
||
+ ", ".join(f for f in fehlend if f)
|
||
+ ". Beim Datei-Kurzbefehl muss der Anfragetext "
|
||
+ "'Formular' sein, mit den Feldern file (Datei) "
|
||
+ "und account_id.")
|
||
inhalt["ok"] = False
|
||
return JSONResponse(status_code=422, content=jsonable_encoder(inhalt))
|
||
|
||
|
||
app.include_router(api)
|
||
|
||
|
||
# --- Frontend (SPA) --------------------------------------------------------
|
||
# Gebaute, gehashte Assets unter /assets aggressiv cachebar ausliefern; alle
|
||
# übrigen (nicht-/api) Pfade auf index.html abbilden, damit Client-seitige
|
||
# Routen wie /accounts direkt aufrufbar sind (verlinkbar).
|
||
if DIST_DIR.exists():
|
||
assets_dir = DIST_DIR / "assets"
|
||
if assets_dir.exists():
|
||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||
|
||
@app.get("/{full_path:path}", include_in_schema=False)
|
||
async def spa(full_path: str):
|
||
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
|
||
if full_path.startswith("api"):
|
||
raise HTTPException(status_code=404, detail="Not Found")
|
||
candidate = DIST_DIR / full_path
|
||
if full_path and candidate.is_file():
|
||
return FileResponse(candidate)
|
||
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})
|