From 80ce703a15d25c80b8d77cc5d578e67ee184e7f8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 18 Mar 2026 20:29:52 -0400 Subject: [PATCH] feat: settings service (get/set/load helpers, load_settings_sync) --- fablednetmon/core/settings.py | 161 ++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 fablednetmon/core/settings.py diff --git a/fablednetmon/core/settings.py b/fablednetmon/core/settings.py new file mode 100644 index 0000000..6a67e3d --- /dev/null +++ b/fablednetmon/core/settings.py @@ -0,0 +1,161 @@ +# fablednetmon/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 fablednetmon.core.settings import load_settings_sync + settings = load_settings_sync(db_url) + +Usage at runtime (inside async handlers): + from fablednetmon.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 fablednetmon.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": [], +} + + +# ───────────────────────────────────────────────────────────────────────────── +# 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_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