- Eine ValidationError-Klasse statt zwei (links.py, projects.py), ein Fehler-Handler in main.py - clean_text() ersetzt vier fast gleiche Text-Prüfungen (Kanban, Haushalt, Reisen, Artikel); Fehlermeldungen nennen jetzt überall die Höchstlänge - Zeitstempel (now_iso, iso_ago, iso_after) und Kontenliste (user_list) an einer Stelle; ungenutzte Importe entfernt - Tests für die Helfer und für einheitliche 400-Fehler aus allen Bereichen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
3.1 KiB
Python
90 lines
3.1 KiB
Python
"""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 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()
|
||
|
||
|
||
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", 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"})
|