"""P42 Dashboard – FastAPI-Backend. Liefert die API unter /api, die hochgeladenen Bilder unter /uploads und (falls gebaut) das React-Frontend aus frontend/dist. """ import asyncio import os import re from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from . import auth, haushalt, kanban, links, monitoring, projects, reisen, rezept from .auth import current_user from .common import ValidationError from .config import FRONTEND_DIST from .tracking import track @asynccontextmanager async def lifespan(_: FastAPI): # Verlauf der Server-Auslastung (P42-S.M.) im Hintergrund sammeln sampler = asyncio.create_task(monitoring.sample_loop()) # Erledigte Kanban-Aufgaben nach 14 Tagen löschen cleaner = asyncio.create_task(kanban.cleanup_loop()) # Hauptstadt-Kennzeichen älterer Reise-Städte nachtragen (für den Karten-Export) capitals = asyncio.create_task(reisen.capitals_loop()) yield sampler.cancel() cleaner.cancel() capitals.cancel() # API-Beschreibung nur bei der Entwicklung (RELOAD=1 oder P42_API_DOCS=1) – öffentlich verriete sie alle Schnittstellen API_DOCS = os.environ.get("RELOAD") == "1" or os.environ.get("P42_API_DOCS") == "1" app = FastAPI( title="P42 Dashboard", docs_url="/api/docs" if API_DOCS else None, openapi_url="/api/openapi.json" if API_DOCS else None, lifespan=lifespan, ) # Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB) app.add_middleware(GZipMiddleware, minimum_size=1000) # ---------- Fehler einheitlich als {"error": "..."} ---------- @app.exception_handler(HTTPException) async def http_error(_: Request, exc: HTTPException): return JSONResponse({"error": exc.detail}, status_code=exc.status_code) @app.exception_handler(ValidationError) async def validation_error(_: Request, exc: ValidationError): return JSONResponse({"error": str(exc)}, status_code=400) # ---------- API ---------- app.include_router(auth.router) app.include_router(links.router) app.include_router(projects.router) app.include_router(monitoring.router) app.include_router(haushalt.router) app.include_router(rezept.router) app.include_router(reisen.router) app.include_router(kanban.router) @app.get("/api/track") async def get_tracking(number: str = "", _: dict = Depends(current_user)): number = re.sub(r"\s+", "", number).upper() if not re.fullmatch(r"[A-Z0-9-]{6,40}", number): raise HTTPException(400, "Ungültige Sendungsnummer.") return await track(number) @app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False) def api_not_found(path: str): raise HTTPException(404, "Nicht gefunden.") # ---------- React-Frontend (nach `npm run build`) ---------- if (FRONTEND_DIST / "index.html").exists(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") @app.get("/{path:path}", include_in_schema=False) def spa(path: str): file = (FRONTEND_DIST / path).resolve() if path and file.is_file() and file.is_relative_to(FRONTEND_DIST.resolve()): return FileResponse(file) # Alle anderen Pfade übernimmt der React-Router return FileResponse(FRONTEND_DIST / "index.html", headers={"Cache-Control": "no-cache"})