Files
certain/backend/app/main.py
T
GuidoandClaude Opus 5 5ff9b63d0a fix: Fehler am Kurzbefehl-Endpunkt sind auf dem Telefon lesbar
Der Kurzbefehl zeigt das Feld "message" an. Fehlerantworten liefern aber
"detail" – die Mitteilung auf dem Telefon blieb also leer. Man sah nicht
einmal, DASS etwas schiefging: Die Marke wurde als benutzt vermerkt, auf dem
Server kam trotzdem nichts an, und die Ursache war von aussen unsichtbar.

Antworten unter /api/quick liefern jetzt zusaetzlich "message" mit einem
lesbaren Text. Bei fehlenden Pflichtfeldern nennt er sie beim Namen und
erinnert daran, dass der Anfragetext beim Datei-Kurzbefehl 'Formular' sein
muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:47:10 +02:00

218 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
auth,
backup,
budgets,
categories,
dashboard,
documents,
imports,
policy,
inbox,
mail,
quick,
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()
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)
# 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"})