chore: initial import
This commit is contained in:
+233
@@ -0,0 +1,233 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user