"""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. """ from contextlib import asynccontextmanager from pathlib import Path from fastapi import APIRouter, FastAPI, HTTPException 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 .migrate import upgrade_to_head from .routers import ( accounts, auth, budgets, categories, 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) # Best-effort: aktuelle EZB-Kurse nachladen (ohne Internet läuft es weiter). ecb_rates.ensure_rates_on_startup(db, settings.ecb_fetch_on_start) finally: db.close() yield 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 ------------------------------------------------------------------- api = APIRouter(prefix="/api") @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(accounts.router) api.include_router(categories.router) api.include_router(transactions.router) api.include_router(budgets.router) api.include_router(rates.router) 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"})