- Freunde (freunde.py, Tabelle friendships): suchen, anfragen, annehmen, ablehnen, entfernen; neue Seite "Freunde" im Menü - Vorratslisten und Kanban-Projekte immer privat: sichtbar für Ersteller und Mitglieder; teilen nur mit Freunden (Admins: mit allen); Kanban-Mitglieder (Tabelle kanban_project_members), Bearbeiter nur aus dem Projekt, ausgetretene Mitglieder verlieren ihre Zuteilung - Artikel: lesen alle; bearbeiten Autor, Mitarbeitende verknüpfter Kanban-Projekte und Admins; verknüpfen nur bearbeitbare Artikel (sonst ließen sich Rechte erschleichen); Board-Liste auf der Artikelseite zeigt nur sichtbare Boards - Administratoren sehen und bearbeiten alles - Reisen: Städte nur von Freunden (Admins: alle); "Reiserouten zeigen" zeichnet die Strecken der Kilometer-Rechnung als Bögen - Migration Schritt 6: bisher gemeinsame Listen und alle Projekte bekommen die vorhandenen Konten als Mitglieder, ohne Ersteller → erster Admin; erste Liste einer neuen Installation gehört dem ersten Konto - Tests: test_freunde.py, test_zugriff.py, bestehende auf Privat-Modell umgestellt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
3.5 KiB
Python
99 lines
3.5 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 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, freunde, 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(freunde.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"})
|