feat: settings service (get/set/load helpers, load_settings_sync)
This commit is contained in:
@@ -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.<name>" 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
|
||||
Reference in New Issue
Block a user