# -*- 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 ]