"""Tenant configuration loading. One process serves exactly one tenant, selected via the TENANT env var (default "default"). The tenant folder lives in tenants// 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()