# -*- 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//``) 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()