Backend: - Modelle + Migration 0002: accounts, account_identifiers, category_groups, categories, transactions, exchange_rates (alle owner-gescoped). - EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot (amount_eur_cents) eingefroren. - Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung). - Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien), transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates. - Gruppierte Standard-Kategorien je Nutzer beim Anlegen. - pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation. Frontend: - Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung, Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons). - Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert. - Live-Umrechnungs-Vorschau im Transfer-Formular. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
3.5 KiB
Python
113 lines
3.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.
|
||
"""
|
||
|
||
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,
|
||
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(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"})
|