Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs), OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant), Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich. Runbook: docs/RUNBOOK_PARALLELTEST.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
155 lines
5.2 KiB
Python
155 lines
5.2 KiB
Python
"""Tenant configuration loading.
|
||
|
||
One process serves exactly one tenant, selected via the TENANT env var
|
||
(default "default"). The tenant folder lives in tenants/<id>/ and contains:
|
||
|
||
tenant.json branding, office info, feature flags, STT provider (data, not code)
|
||
incl. org_short/org_profile for the shared default prompt
|
||
logo.svg|png brand logo (web topbar + PDF header)
|
||
prompt.txt summary prompt (optional — falls back to core default)
|
||
protocol.html PDF layout template (optional — falls back to core default)
|
||
protocol.css PDF styling (optional — falls back to core default)
|
||
overrides.py code hooks (optional — see core/hooks.py)
|
||
|
||
Everything that differs per customer is *data* here. The core code never
|
||
changes per customer.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from core import bundle
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
# TENANTS_DIR (Env) nur für Tests/Entwicklung; Standard: eingebackene Tenants im Image
|
||
TENANTS_DIR = Path(os.getenv("TENANTS_DIR") or ROOT / "tenants")
|
||
CORE_DEFAULTS = ROOT / "core" / "defaults"
|
||
CORE_TEMPLATES = ROOT / "core" / "templates"
|
||
|
||
# Default theme — used for any colour a tenant does not override.
|
||
DEFAULT_COLORS = {
|
||
"accent": "#327F24",
|
||
"accent_dark": "#235A19",
|
||
"accent_mid": "#3E9130",
|
||
"accent_light": "#EAF4E7",
|
||
"accent_xlight": "#F3FAF1",
|
||
"bg": "#F1F5F0",
|
||
"surface": "#FFFFFF",
|
||
"surface2": "#F6FAF5",
|
||
"border": "#DCE8D9",
|
||
"border2": "#BDD1B8",
|
||
"text": "#444444",
|
||
"text_2": "#666666",
|
||
"text_3": "#9AA89A",
|
||
"red": "#DC2626",
|
||
"progress_to": "#8bbf43",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class TenantConfig:
|
||
id: str
|
||
dir: Path
|
||
name: str
|
||
title: str
|
||
description: str
|
||
colors: dict
|
||
office: dict
|
||
flags: dict
|
||
ai_detect_org: str
|
||
org_short: str = ""
|
||
org_profile: str = ""
|
||
stt: dict = field(default_factory=dict)
|
||
raw: dict = field(default_factory=dict)
|
||
# Herkunft der Tenant-Daten (siehe core/bundle.py): {source: git|admin|cache|baked, version, stale, error}
|
||
bundle: dict = field(default_factory=dict)
|
||
|
||
# ── resolved file paths (tenant override → core default) ──
|
||
def _pick(self, tenant_name: str, default_path: Path) -> Path:
|
||
candidate = self.dir / tenant_name
|
||
return candidate if candidate.exists() else default_path
|
||
|
||
@property
|
||
def prompt_path(self) -> Path:
|
||
return self._pick("prompt.txt", CORE_DEFAULTS / "prompt.txt")
|
||
|
||
@property
|
||
def pdf_template_path(self) -> Path:
|
||
return self._pick("protocol.html", CORE_TEMPLATES / "protocol.html")
|
||
|
||
@property
|
||
def pdf_css_path(self) -> Path:
|
||
return self._pick("protocol.css", CORE_TEMPLATES / "protocol.css")
|
||
|
||
@property
|
||
def logo_path(self) -> Path | None:
|
||
for name in ("logo.svg", "logo.png"):
|
||
p = self.dir / name
|
||
if p.exists():
|
||
return p
|
||
return None
|
||
|
||
@property
|
||
def docx_logo_path(self) -> Path | None:
|
||
"""Logo for the Word header — Word needs a raster image, no SVG.
|
||
|
||
A tenant may ship both (logo.svg for web/PDF, logo.png next to it);
|
||
this prefers the raster file where logo_path prefers the vector one.
|
||
"""
|
||
for name in ("logo.png", "logo.jpg", "logo.jpeg", "logo.svg"):
|
||
p = self.dir / name
|
||
if p.exists():
|
||
return p
|
||
return None
|
||
|
||
@property
|
||
def favicon_path(self) -> Path | None:
|
||
p = self.dir / "favicon.svg"
|
||
return p if p.exists() else None
|
||
|
||
def flag(self, key: str, default=None):
|
||
return self.flags.get(key, default)
|
||
|
||
|
||
def load_tenant(tenant_id: str | None = None) -> TenantConfig:
|
||
tenant_id = (tenant_id or os.getenv("TENANT", "default")).strip()
|
||
tenant_dir, bundle_info = bundle.resolve(tenant_id, TENANTS_DIR)
|
||
config_file = tenant_dir / "tenant.json"
|
||
if not config_file.exists():
|
||
raise RuntimeError(
|
||
f"Tenant '{tenant_id}' nicht gefunden: {config_file} fehlt. "
|
||
f"Verfügbar: {[p.name for p in TENANTS_DIR.iterdir() if p.is_dir()] if TENANTS_DIR.exists() else []}"
|
||
)
|
||
|
||
raw = json.loads(config_file.read_text(encoding="utf-8"))
|
||
|
||
colors = {**DEFAULT_COLORS, **(raw.get("colors") or {})}
|
||
name = raw.get("name", tenant_id)
|
||
|
||
return TenantConfig(
|
||
id=tenant_id,
|
||
dir=tenant_dir,
|
||
name=name,
|
||
title=raw.get("title", f"{name} – Gesprächsprotokoll"),
|
||
description=raw.get("description", f"Gesprächsprotokoll-Tool für {name}"),
|
||
colors=colors,
|
||
office=raw.get("office") or {},
|
||
flags=raw.get("flags") or {},
|
||
ai_detect_org=raw.get("ai_detect_org", name),
|
||
# How the tenant refers to itself inside the protocol text ("revier",
|
||
# "Onyva") and one optional sentence describing what it does — both are
|
||
# substituted into the shared default prompt.
|
||
org_short=raw.get("org_short", name),
|
||
org_profile=raw.get("org_profile", ""),
|
||
stt=raw.get("stt") or {},
|
||
raw=raw,
|
||
bundle=bundle_info,
|
||
)
|
||
|
||
|
||
# Singleton for the running process (TENANT is fixed per instance).
|
||
CFG: TenantConfig = load_tenant()
|