Backend: - services/ocr: PDF-Text via PyMuPDF, OCR (Tesseract) für Scans/Bilder, heuristischer Parser (Datum + Betrag je Zeile, CH/DE/US-Beträge). - routers/documents: /documents/preview (Upload -> Text -> Kandidaten -> gemeinsame Prüf-/Buchungslogik des CSV-Imports). csv_import refaktoriert: detect_rows() von CSV und Beleg gemeinsam genutzt. - requirements: PyMuPDF, pytesseract, pillow; install.sh installiert tesseract-ocr (+ deu). pytest: Heuristik-Parser und PDF-Vorschau. Frontend: - Import-Seite: CSV/PDF-Umschalter; PDF-Modus mit Beleg-Auslesen, extrahiertem Text zur Kontrolle und Best-Effort-Hinweis; gleiche Prüfansicht + Buchen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
4.1 KiB
Python
133 lines
4.1 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, 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,
|
||
dashboard,
|
||
documents,
|
||
imports,
|
||
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 -------------------------------------------------------------------
|
||
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(dashboard.router)
|
||
api.include_router(imports.router)
|
||
api.include_router(documents.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"})
|