Files
2026-07-24 21:37:03 +02:00

234 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
boehmitools Host-Anwendung.
Startet das Dashboard, lädt alle Plugins aus plugins/ und hängt sie unter
ihrem Mount-Pfad ein. Danach verhält sich jedes Tool so, als liefe es allein:
eigene Routen, eigenes Backend, eigener Datenordner nur eben unter einem
gemeinsamen Dach und mit einheitlicher Oberfläche.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.routing import Mount
from . import importer, registry, settings as settings_module
from .jobs import JobManager
from .registry import PluginContext
from .security import basic_auth_middleware
from .settings import Settings
BASE_DIR = Path(__file__).resolve().parent.parent
PLUGINS_DIR = Path(
os.environ.get("BOEHMITOOLS_PLUGINS", BASE_DIR / "plugins")
).resolve()
SHARED_DIR = BASE_DIR / "shared"
STATIC_DIR = BASE_DIR / "core" / "static"
DATA_DIR = Path(os.environ.get("BOEHMITOOLS_DATA", BASE_DIR / "data")).resolve()
VERSION = "1.0.0"
def create_app() -> FastAPI:
DATA_DIR.mkdir(parents=True, exist_ok=True)
app_settings = Settings.load(DATA_DIR / "settings.json")
jobs = JobManager()
app = FastAPI(
title="boehmitools",
version=VERSION,
docs_url="/api/docs",
redoc_url=None,
)
app.middleware("http")(basic_auth_middleware)
app.state.settings = app_settings
app.state.jobs = jobs
app.state.plugins = []
# ------------------------------------------------------------- Plugins
loaded: list[registry.LoadedPlugin] = []
def mount_plugins(*, reload: bool = False) -> dict[str, Any]:
"""
Lädt alle Plugins aus plugins/ und hängt sie ein. Bei ``reload=True``
werden vorher die bisherigen Plugin-Mounts entfernt und die zugehörigen
Module aus dem Cache geworfen, damit geänderter oder neuer Code (und neu
hinzugekommene bzw. entfernte Plugins) ohne Neustart wirksam werden.
Statik (HTML/CSS/JS) wird ohnehin bei jedem Aufruf frisch von der Platte
gelesen; dafür genügt ein Neuladen im Browser. Kernänderungen unter
core/ erfordern weiterhin einen echten Neustart.
"""
if reload:
# Bisherige Plugin-Mounts aus dem Router nehmen (in place, damit der
# Router dieselbe Liste weiterbenutzt).
app.router.routes[:] = [
r for r in app.router.routes
if not (isinstance(r, Mount) and (r.name or "").startswith("plugin_"))
]
for old in loaded:
registry.unload(old.meta)
neu: list[registry.LoadedPlugin] = []
for meta in registry.discover(PLUGINS_DIR):
plugin_data = DATA_DIR / meta.id
plugin_data.mkdir(parents=True, exist_ok=True)
ctx = PluginContext(
meta=meta,
settings=app_settings,
jobs=jobs,
data_dir=plugin_data,
shared_dir=SHARED_DIR,
)
result = registry.load(meta, ctx)
neu.append(result)
if result.ok:
app.mount(meta.mount, result.app, name=f"plugin_{meta.id}")
print(f"[plugins] {meta.id:<24}{meta.mount}")
else:
print(f"[plugins] {meta.id:<24} → FEHLER: {result.error}")
loaded[:] = neu
app.state.plugins = loaded
return {
"gesamt": len(loaded),
"geladen": sum(1 for p in loaded if p.ok),
"fehler": [
{"id": p.meta.id, "error": p.error} for p in loaded if not p.ok
],
}
mount_plugins()
def plugin_payload() -> list[dict[str, Any]]:
return [
{**p.meta.public(), "ok": p.ok, "error": p.error}
for p in loaded
]
# -------------------------------------------------------------- Statik
app.mount("/shared", StaticFiles(directory=SHARED_DIR), name="shared")
# -------------------------------------------------------------- Seiten
@app.get("/", include_in_schema=False)
def dashboard():
return FileResponse(STATIC_DIR / "dashboard.html")
@app.get("/settings", include_in_schema=False)
def settings_page():
return FileResponse(STATIC_DIR / "settings.html")
@app.get("/jobs", include_in_schema=False)
def jobs_page():
return FileResponse(STATIC_DIR / "jobs.html")
# ----------------------------------------------------------------- API
@app.get("/api/plugins")
def api_plugins() -> dict[str, Any]:
return {"plugins": plugin_payload(), "version": VERSION}
@app.post("/api/plugins/reload")
def api_plugins_reload() -> dict[str, Any]:
"""Plugins ohne Neustart neu einlesen und einhängen."""
bericht = mount_plugins(reload=True)
return {
"ok": True,
"plugins": plugin_payload(),
"version": VERSION,
**bericht,
}
@app.post("/api/plugins/import")
async def api_plugins_import(
file: UploadFile = File(...),
force: bool = Form(False),
pruefen_only: bool = Form(False),
) -> dict[str, Any]:
"""
Ein Plugin als ZIP hochladen.
Erkannt wird das Plugin an der ``id`` aus seiner ``plugin.json``. Ist
diese id bereits vorhanden, wird genau jener Ordner ergänzt und
überschrieben — niemals gelöscht. Der Datenordner (``data/<id>/``)
liegt außerhalb und bleibt in jedem Fall unberührt.
"""
daten = await file.read()
try:
if pruefen_only:
return {"ok": True, "vorschau": importer.pruefe(daten, PLUGINS_DIR)}
bericht = importer.importiere(
daten, PLUGINS_DIR, DATA_DIR / "_plugin-sicherungen", force=force)
except importer.ImportFehler as exc:
raise HTTPException(400, str(exc))
# Frisch eingelesen, damit das Plugin sofort nutzbar ist.
neu_geladen = mount_plugins(reload=True)
eigen = next((p for p in loaded if p.meta.id == bericht["id"]), None)
# Achtung: der Reload-Bericht führt selbst ein Feld „geladen“ (Anzahl).
# Der Zustand DIESES Plugins heißt deshalb „aktiv“.
bericht["aktiv"] = bool(eigen and eigen.ok)
bericht["ladefehler"] = None if not eigen or eigen.ok else eigen.error
return {**neu_geladen, **bericht, "plugins": plugin_payload()}
@app.get("/api/health")
def api_health() -> dict[str, Any]:
status = app_settings.status()
return {
"status": "ok",
"version": VERSION,
"plugins": {p.meta.id: ("ok" if p.ok else "error") for p in loaded},
"tandoor_configured": status["tandoor"],
"openai_configured": status["openai"],
"authentication_enabled": bool(os.environ.get("APP_PASSWORD")),
"jobs_running": len(jobs.running()),
}
@app.get("/api/settings")
def api_settings_get() -> dict[str, Any]:
return {
"schema": settings_module.schema(),
"values": app_settings.public(),
"status": app_settings.status(),
}
@app.post("/api/settings")
async def api_settings_post(request: Request) -> dict[str, Any]:
payload = await request.json()
if not isinstance(payload, dict):
raise HTTPException(400, "Ungültige Daten.")
app_settings.update(payload)
return {
"ok": True,
"values": app_settings.public(),
"status": app_settings.status(),
}
@app.get("/api/jobs")
def api_jobs_all() -> dict[str, Any]:
names = {p.meta.id: p.meta.name for p in loaded}
return {
"jobs": [
{**job.info(), "plugin_name": names.get(job.plugin, job.plugin)}
for job in jobs.list()
]
}
@app.exception_handler(404)
async def not_found(request: Request, exc):
if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "Nicht gefunden."}, status_code=404)
return FileResponse(STATIC_DIR / "404.html", status_code=404)
return app
app = create_app()