"""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, 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() 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(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"})