# roundtable/core/settings.py """DB-backed application settings. Keys use dotted notation (e.g. "smtp.host"). Values are JSON-encoded in the DB. Usage in create_app() (before event loop): from roundtable.core.settings import load_settings_sync settings = load_settings_sync(db_url) Usage at runtime (inside async handlers): from roundtable.core.settings import get_setting, set_setting async with app.db_sessionmaker() as session: value = await get_setting(session, "smtp.host") await set_setting(session, "smtp.host", "mail.example.com") """ from __future__ import annotations import asyncio import json import logging from datetime import datetime, timezone from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from roundtable.models.settings import AppSetting logger = logging.getLogger(__name__) _DEFAULT_WEBHOOK_TEMPLATE = ( '{"content": "**{{ alert.state }}** — {{ alert.resource }} — ' '{{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}' ) # All recognised settings and their defaults. # Plugin settings are stored as "plugin." and handled separately. DEFAULTS: dict[str, Any] = { "session.lifetime_hours": 8, "data.retention_days": 90, "monitors.poll_interval_seconds": 60, "smtp.host": "", "smtp.port": 587, "smtp.tls": True, "smtp.username": "", "smtp.password": "", "smtp.recipients": [], "webhook.url": "", "webhook.template": _DEFAULT_WEBHOOK_TEMPLATE, "ansible.sources": [], "ping.threshold.good_ms": 50, "ping.threshold.warn_ms": 200, "plugins.index_url": "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/raw/branch/main/index.yaml", # OIDC single-sign-on "oidc.enabled": False, "oidc.discovery_url": "", "oidc.client_id": "", "oidc.client_secret": "", "oidc.scopes": "openid profile email", "oidc.username_claim": "preferred_username", "oidc.email_claim": "email", "oidc.groups_claim": "groups", "oidc.admin_group": "", "oidc.operator_group": "", # LDAP authentication "ldap.enabled": False, "ldap.host": "", "ldap.port": 389, "ldap.tls": False, "ldap.bind_dn": "", "ldap.bind_password": "", "ldap.base_dn": "", "ldap.user_filter": "(uid={username})", "ldap.admin_group_dn": "", "ldap.operator_group_dn": "", "ldap.attr_username": "uid", "ldap.attr_email": "mail", # Scheduled reports "reports.enabled": False, "reports.schedule_day": 6, # 0=Monday … 6=Sunday "reports.schedule_hour": 8, # UTC hour "reports.last_sent_at": "", } # ───────────────────────────────────────────────────────────────────────────── # Async helpers (use inside request handlers / scheduled tasks) # ───────────────────────────────────────────────────────────────────────────── async def get_setting(session: AsyncSession, key: str) -> Any: """Return the value for key, or the default if not set.""" result = await session.execute( select(AppSetting).where(AppSetting.key == key) ) row = result.scalar_one_or_none() if row is None: return DEFAULTS.get(key) return json.loads(row.value_json) async def set_setting(session: AsyncSession, key: str, value: Any) -> None: """Upsert a setting. Call inside an active transaction.""" result = await session.execute( select(AppSetting).where(AppSetting.key == key) ) row = result.scalar_one_or_none() now = datetime.now(timezone.utc) if row is None: session.add(AppSetting(key=key, value_json=json.dumps(value), updated_at=now)) else: row.value_json = json.dumps(value) row.updated_at = now async def get_all_settings(session: AsyncSession) -> dict[str, Any]: """Return flat key→value dict with defaults filled in for missing keys.""" result = await session.execute(select(AppSetting)) stored = {row.key: json.loads(row.value_json) for row in result.scalars()} out: dict[str, Any] = {} for key, default in DEFAULTS.items(): out[key] = stored.get(key, default) # Include any plugin.* keys stored in DB for key, value in stored.items(): if key.startswith("plugin.") and key not in out: out[key] = value return out # ───────────────────────────────────────────────────────────────────────────── # Structured config extractors (dict shapes expected by existing consumers) # ───────────────────────────────────────────────────────────────────────────── def to_smtp_cfg(settings: dict[str, Any]) -> dict: return { "host": settings.get("smtp.host", ""), "port": settings.get("smtp.port", 587), "tls": settings.get("smtp.tls", True), "username": settings.get("smtp.username", ""), "password": settings.get("smtp.password", ""), "recipients": settings.get("smtp.recipients", []), } def to_webhook_cfg(settings: dict[str, Any]) -> dict: return { "url": settings.get("webhook.url", ""), "template": settings.get("webhook.template", _DEFAULT_WEBHOOK_TEMPLATE), } def to_ansible_cfg(settings: dict[str, Any]) -> dict: return {"sources": settings.get("ansible.sources", [])} def to_oidc_cfg(settings: dict[str, Any]) -> dict: return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")} def to_ldap_cfg(settings: dict[str, Any]) -> dict: return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")} def to_plugins_cfg(settings: dict[str, Any]) -> dict: """Assemble {plugin_name: {...config}} from all plugin.* keys.""" result = {} for key, value in settings.items(): if key.startswith("plugin."): name = key[len("plugin."):] result[name] = value return result # ───────────────────────────────────────────────────────────────────────────── # Synchronous loader — safe to call before the event loop starts # ───────────────────────────────────────────────────────────────────────────── def load_settings_sync(db_url: str) -> dict[str, Any]: """Load all settings from DB synchronously via asyncio.run(). Safe to call in create_app() before the Quart event loop starts. Returns flat key→value dict with defaults filled in. """ async def _load() -> dict[str, Any]: from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker engine = create_async_engine(db_url, echo=False) factory = async_sessionmaker(engine, expire_on_commit=False) try: async with factory() as session: result = await session.execute(select(AppSetting)) return {row.key: json.loads(row.value_json) for row in result.scalars()} finally: await engine.dispose() stored = asyncio.run(_load()) out: dict[str, Any] = {} for key, default in DEFAULTS.items(): out[key] = stored.get(key, default) for key, value in stored.items(): if key.startswith("plugin.") and key not in out: out[key] = value return out