174 lines
6.2 KiB
Python
174 lines
6.2 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Zentrale Einstellungen der Suite.
|
||
|
||
Alle Tools brauchen dieselben Zugangsdaten (Tandoor, OpenAI). Statt sie in
|
||
jedem Plugin einzeln zu pflegen, liegen sie einmal in data/settings.json und
|
||
werden beim Laden in os.environ gespiegelt. Damit funktionieren die
|
||
Original-Tools unverändert weiter: sie lesen wie bisher ihre Umgebungsvariablen.
|
||
|
||
Vorrang:
|
||
1. data/settings.json (über die Oberfläche gepflegt)
|
||
2. Umgebung / .env (Fallback, wenn in settings.json leer)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
MASK = "••••••••"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Field:
|
||
key: str
|
||
label: str
|
||
group: str
|
||
kind: str = "text" # text | secret | bool | number | select
|
||
default: str = ""
|
||
help: str = ""
|
||
options: tuple[str, ...] = ()
|
||
placeholder: str = ""
|
||
|
||
@property
|
||
def secret(self) -> bool:
|
||
return self.kind == "secret"
|
||
|
||
|
||
FIELDS: tuple[Field, ...] = (
|
||
Field("TANDOOR_URL", "Tandoor-URL", "Tandoor", "text",
|
||
placeholder="https://kitchen.example.de",
|
||
help="Basis-URL deiner Tandoor-Instanz, ohne /api."),
|
||
Field("TANDOOR_TOKEN", "Tandoor-Token", "Tandoor", "secret",
|
||
help="Token mit Schreibrechten. Verlässt das lokale Backend nicht."),
|
||
Field("TANDOOR_AUTH_SCHEME", "Auth-Schema", "Tandoor", "select",
|
||
default="Bearer", options=("Bearer", "Token"),
|
||
help="Bei älteren DRF-Tokens „Token“ wählen."),
|
||
Field("TANDOOR_VERIFY_TLS", "TLS prüfen", "Tandoor", "bool", default="true",
|
||
help="Nur für interne Instanzen mit selbstsigniertem Zertifikat abschalten."),
|
||
Field("TANDOOR_TIMEOUT", "Timeout (s)", "Tandoor", "number", default="45"),
|
||
|
||
Field("OPENAI_API_KEY", "OpenAI API-Key", "OpenAI", "secret",
|
||
help="Wird nur für den AI-Webimport verwendet."),
|
||
Field("OPENAI_MODEL", "Modell", "OpenAI", "text", default="gpt-5.5",
|
||
placeholder="gpt-5.5"),
|
||
|
||
Field("SOURCE_VERIFY_TLS", "TLS der Quelle prüfen", "Rezeptquellen", "bool", default="true"),
|
||
Field("SOURCE_TIMEOUT", "Timeout (s)", "Rezeptquellen", "number", default="20"),
|
||
Field("FETCH_MAX_BYTES", "Max. Downloadgröße (Byte)", "Rezeptquellen", "number",
|
||
default="3000000"),
|
||
Field("ALLOW_PRIVATE_SOURCE_URLS", "Private Quell-IPs erlauben", "Rezeptquellen", "bool",
|
||
default="false",
|
||
help="Standardmäßig aus: schützt vor SSRF über localhost und private Netze."),
|
||
)
|
||
|
||
BY_KEY = {f.key: f for f in FIELDS}
|
||
GROUPS = tuple(dict.fromkeys(f.group for f in FIELDS))
|
||
|
||
|
||
@dataclass
|
||
class Settings:
|
||
path: Path
|
||
values: dict[str, str] = field(default_factory=dict)
|
||
|
||
# ------------------------------------------------------------- Laden
|
||
@classmethod
|
||
def load(cls, path: Path) -> "Settings":
|
||
values: dict[str, str] = {}
|
||
if path.exists():
|
||
try:
|
||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||
if isinstance(raw, dict):
|
||
values = {k: str(v) for k, v in raw.items() if k in BY_KEY}
|
||
except Exception:
|
||
values = {}
|
||
instance = cls(path=path, values=values)
|
||
instance.apply_env()
|
||
return instance
|
||
|
||
def save(self) -> None:
|
||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = self.path.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(self.values, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8")
|
||
tmp.replace(self.path)
|
||
try:
|
||
self.path.chmod(0o600)
|
||
except Exception:
|
||
pass
|
||
self.apply_env()
|
||
|
||
# -------------------------------------------------------------- Werte
|
||
def get(self, key: str) -> str:
|
||
value = self.values.get(key, "").strip()
|
||
if value:
|
||
return value
|
||
return os.environ.get(key, "").strip() or BY_KEY[key].default
|
||
|
||
def apply_env(self) -> None:
|
||
"""Spiegelt die Einstellungen in die Prozess-Umgebung."""
|
||
for f in FIELDS:
|
||
value = self.values.get(f.key, "").strip()
|
||
if value:
|
||
os.environ[f.key] = value
|
||
elif f.key not in os.environ and f.default:
|
||
os.environ[f.key] = f.default
|
||
|
||
def tool_env(self) -> dict[str, str]:
|
||
"""Umgebung für Subprozess-Tools (CLI-Plugins)."""
|
||
env = dict(os.environ)
|
||
for f in FIELDS:
|
||
value = self.get(f.key)
|
||
if value:
|
||
env[f.key] = value
|
||
return env
|
||
|
||
# ----------------------------------------------------------- Web-API
|
||
def public(self) -> dict[str, Any]:
|
||
"""Für die Oberfläche – Geheimnisse werden maskiert."""
|
||
out: dict[str, Any] = {}
|
||
for f in FIELDS:
|
||
value = self.get(f.key)
|
||
out[f.key] = MASK if (f.secret and value) else ("" if f.secret else value)
|
||
return out
|
||
|
||
def status(self) -> dict[str, bool]:
|
||
return {
|
||
"tandoor": bool(self.get("TANDOOR_URL") and self.get("TANDOOR_TOKEN")),
|
||
"openai": bool(self.get("OPENAI_API_KEY") and self.get("OPENAI_MODEL")),
|
||
}
|
||
|
||
def update(self, payload: dict[str, Any]) -> None:
|
||
for key, value in payload.items():
|
||
f = BY_KEY.get(key)
|
||
if not f:
|
||
continue
|
||
text = "" if value is None else str(value).strip()
|
||
if f.secret and text == MASK:
|
||
continue # unverändert lassen
|
||
if f.kind == "bool":
|
||
text = "true" if str(value).lower() in {"1", "true", "on", "yes"} else "false"
|
||
self.values[key] = text
|
||
self.save()
|
||
|
||
|
||
def schema() -> list[dict[str, Any]]:
|
||
"""Beschreibung der Felder für die Oberfläche."""
|
||
return [
|
||
{
|
||
"group": group,
|
||
"fields": [
|
||
{
|
||
"key": f.key, "label": f.label, "kind": f.kind,
|
||
"help": f.help, "options": list(f.options),
|
||
"placeholder": f.placeholder, "default": f.default,
|
||
}
|
||
for f in FIELDS if f.group == group
|
||
],
|
||
}
|
||
for group in GROUPS
|
||
]
|