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).
Dieser Commit ist enthalten in:
Claude Code
2026-05-13 20:46:04 +00:00
Ursprung 5ec4480598
Commit d27d586003
2 geänderte Dateien mit 139 neuen und 0 gelöschten Zeilen

Datei anzeigen

@@ -345,6 +345,15 @@ CREATE TABLE IF NOT EXISTS network_generation_log (
error_message TEXT, error_message TEXT,
tenant_id INTEGER REFERENCES organizations(id) 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() await db.commit()
logger.info("Migration: token_usage_monthly Tabelle erstellt") 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) # Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
await db.execute( await db.execute(
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart', """UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',

104
src/services/org_settings.py Normale Datei
Datei anzeigen

@@ -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)