From d27d58600303eb15faa03403532481cf6092383a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 13 May 2026 20:46:04 +0000 Subject: [PATCH] feat(settings): organization_settings KV-Tabelle + org_settings Helper Neue Tabelle organization_settings (organization_id, key, value) als KV-Store fuer Org-spezifische Konfiguration. Erster Use-Case: output_language (de|en). Bestandsorgs werden per Migration auf de gesetzt. Helper services/org_settings.py mit get_org_setting / set_org_setting / get_org_language / language_display. In-Memory-Cache TTL 60s. Phase 1 von 8 (eng_demo / Org-Sprache). --- src/database.py | 35 ++++++++++++ src/services/org_settings.py | 104 +++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/services/org_settings.py diff --git a/src/database.py b/src/database.py index b8d9366..ecbb33d 100644 --- a/src/database.py +++ b/src/database.py @@ -345,6 +345,15 @@ CREATE TABLE IF NOT EXISTS network_generation_log ( error_message TEXT, tenant_id INTEGER REFERENCES organizations(id) ); + +CREATE TABLE IF NOT EXISTS organization_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id, key) +); """ @@ -782,6 +791,32 @@ async def init_db(): await db.commit() logger.info("Migration: token_usage_monthly Tabelle erstellt") + # Migration: organization_settings KV-Tabelle (pro Org Sprache, ggf. spaeter weitere Settings) + cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='organization_settings'") + if not await cursor.fetchone(): + await db.execute(""" + CREATE TABLE organization_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id, key) + ) + """) + await db.commit() + logger.info("Migration: organization_settings Tabelle erstellt") + + # Default-Setting output_language='de' fuer Orgs ohne Eintrag + await db.execute(""" + INSERT OR IGNORE INTO organization_settings (organization_id, key, value) + SELECT id, 'output_language', 'de' FROM organizations + WHERE id NOT IN ( + SELECT organization_id FROM organization_settings WHERE key='output_language' + ) + """) + await db.commit() + # Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min) await db.execute( """UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart', diff --git a/src/services/org_settings.py b/src/services/org_settings.py new file mode 100644 index 0000000..d152b5d --- /dev/null +++ b/src/services/org_settings.py @@ -0,0 +1,104 @@ +"""Organization-Settings-Helper. + +KV-Store pro Organisation. Aktuell genutzt fuer output_language ('de'|'en'). +Spaeter erweiterbar (Default-Modell, Telegram-Toggle, Theme, ...). + +Cache: TTL 60s in-memory pro (tenant_id, key). Wird bei set_org_setting() +invalidiert. +""" +import logging +import time +from typing import Optional + +import aiosqlite + +logger = logging.getLogger("osint.org_settings") + +_CACHE: dict[tuple[int, str], tuple[float, Optional[str]]] = {} +_TTL_SECONDS = 60.0 + + +def _cache_get(tenant_id: int, key: str) -> tuple[bool, Optional[str]]: + """(hit, value). hit=True heisst Cache traf; value kann auch None sein.""" + entry = _CACHE.get((tenant_id, key)) + if entry is None: + return (False, None) + expires_at, value = entry + if time.monotonic() > expires_at: + _CACHE.pop((tenant_id, key), None) + return (False, None) + return (True, value) + + +def _cache_put(tenant_id: int, key: str, value: Optional[str]) -> None: + _CACHE[(tenant_id, key)] = (time.monotonic() + _TTL_SECONDS, value) + + +def _cache_invalidate(tenant_id: int, key: str) -> None: + _CACHE.pop((tenant_id, key), None) + + +async def get_org_setting( + db: aiosqlite.Connection, + tenant_id: int, + key: str, + default: Optional[str] = None, +) -> Optional[str]: + """Liest ein Org-Setting. Fallback auf default.""" + if tenant_id is None: + return default + hit, cached = _cache_get(tenant_id, key) + if hit: + return cached if cached is not None else default + cursor = await db.execute( + "SELECT value FROM organization_settings WHERE organization_id = ? AND key = ?", + (tenant_id, key), + ) + row = await cursor.fetchone() + value = row["value"] if row else None + _cache_put(tenant_id, key, value) + return value if value is not None else default + + +async def set_org_setting( + db: aiosqlite.Connection, + tenant_id: int, + key: str, + value: str, +) -> None: + """Setzt ein Org-Setting (upsert).""" + await db.execute( + """INSERT INTO organization_settings (organization_id, key, value, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(organization_id, key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP""", + (tenant_id, key, value), + ) + await db.commit() + _cache_invalidate(tenant_id, key) + logger.info("Org %s Setting %s='%s' gespeichert", tenant_id, key, value) + + +# Bekannte Sprachen + Anzeigenamen fuer Prompts +LANGUAGE_DISPLAY_NAMES = { + "de": "Deutsch", + "en": "English", +} + + +async def get_org_language( + db: aiosqlite.Connection, + tenant_id: int, +) -> str: + """Liefert ISO-2-Sprachcode der Org (default 'de').""" + value = await get_org_setting(db, tenant_id, "output_language", default="de") + if value not in LANGUAGE_DISPLAY_NAMES: + logger.warning("Unbekannte output_language '%s' fuer Org %s -- fallback 'de'", value, tenant_id) + return "de" + return value + + +def language_display(lang_iso: str) -> str: + """ISO-Code -> Anzeigename fuer Prompts ('de' -> 'Deutsch').""" + return LANGUAGE_DISPLAY_NAMES.get(lang_iso, lang_iso)