Der Kontoauszug kommt erst am Monatsende; wer im Laden bezahlt hat, weiss in dem Moment noch, wofuer. iOS laesst keine App fremde Mitteilungen mitlesen, also fuehrt der Weg ueber einen Kurzbefehl, der die Zahlung an Certain schickt. Gebaut: - Geraetemarken mit eigener Abhaengigkeit statt Sonderfall in get_current_user. Eine Marke gilt NUR an /quick – auf allen anderen Wegen ist sie keine Anmeldung. Geht das Telefon verloren, ist kein Datenabfluss moeglich; Tests halten das fuer Buchungen, Konten, Kategorien, Export und Datenbanksicherung fest. - POST /quick/booking laeuft durch detect_rows, damit Schlagwort- und Betragsregeln die Kategorie schon vorschlagen. external_id verhindert, dass ein doppelt ausgeloester Kurzbefehl zweimal bucht. - inbox_items merkt sich die Zahlung ueber das Buchen hinaus. Beim spaeteren CSV-Import greift der Import-Schluessel nicht: Der Text vom Telefon ist selten wortgleich mit dem des Auszugs und die Bank bucht ein bis zwei Tage spaeter. detect_rows vergleicht deshalb zusaetzlich unscharf – exakter Betrag, Datum +/- 3 Tage, je Eintrag nur einmal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
4.6 KiB
Python
145 lines
4.6 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 threading
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, 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 .deps import enforce_account_state
|
||
from .migrate import upgrade_to_head
|
||
from .routers import (
|
||
accounts,
|
||
auth,
|
||
backup,
|
||
budgets,
|
||
categories,
|
||
dashboard,
|
||
documents,
|
||
imports,
|
||
policy,
|
||
inbox,
|
||
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()
|
||
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 -------------------------------------------------------------------
|
||
# 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)
|
||
|
||
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"})
|