'Eingang' und 'Offene Buchungen' zeigten fast dasselbe. Seit angebundene Konten selbst buchen, ist eine Bank-Buchung sofort gebucht und ohne Kategorie – sie stand damit in BEIDEN Listen. Die Trennung stammte aus der Zeit, als der Eingang nur Vorschlaege enthielt. Jetzt eine Seite 'Zu erledigen' mit allem, was noch eine Hand braucht: offene Vorschlaege zum Buchen oder Uebertragen und Buchungen ohne Kategorie, egal ob von Bank, Mail, Kurzbefehl oder Datei. Aelteste zuerst, mit der Herkunft in der Zeile. Erledigtes verschwindet – die Eingangs-Eintraege bleiben in der Datenbank, weil der spaetere Import gegen sie abgleicht. /offen leitet um. Das Haekchen 'Schlagwort merken' aus der alten Seite bleibt: Es ist der Grund, warum der Stapel mit der Zeit kleiner wird, und der Eingang hatte es nie. Der Uebertrags-Knopf gilt jetzt auch fuer schon gebuchte Buchungen – sonst haette in einer gemeinsamen Liste die eine Haelfte der Zeilen ihn gehabt und die andere nicht. In der Buchungsliste steht die Kategorie jetzt als Auswahl in der Zeile, dort wo bisher nur ihr Name stand. Vorher musste man jede Buchung einzeln zum Bearbeiten oeffnen. Die Zeile aendert sich an Ort und Stelle, die Bildlaufposition bleibt. Ausserdem tragen API-Antworten jetzt 'Cache-Control: no-store'. Ohne das darf ein Browser selbst entscheiden, und der Zurueck-Knopf zeigt Salden von vorhin – bei Geld ist eine veraltete Anzeige schlimmer als eine langsame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
9.5 KiB
Python
270 lines
9.5 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=["*"],
|
||
)
|
||
|
||
@app.middleware("http")
|
||
async def keine_zwischenspeicherung(request, call_next):
|
||
"""API-Antworten nie zwischenspeichern.
|
||
|
||
Ohne Cache-Control darf ein Browser selbst entscheiden, und der Zurueck-
|
||
Knopf zeigt dann Salden von vorhin. Bei Geld ist eine veraltete Anzeige
|
||
schlimmer als eine langsame.
|
||
"""
|
||
antwort = await call_next(request)
|
||
if request.url.path.startswith("/api"):
|
||
antwort.headers["Cache-Control"] = "no-store"
|
||
return antwort
|
||
|
||
|
||
# --- 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"})
|