init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,184 @@
|
||||
"""Reading and writing the settings document.
|
||||
|
||||
Three jobs:
|
||||
|
||||
* seed the document from environment variables the first time the app boots, so
|
||||
a filled-in `.env` (the same variables jira-helper uses) is a working setup;
|
||||
* hand the API a copy with secrets masked, and merge an edited copy back without
|
||||
the UI ever having to know a real token;
|
||||
* notify the rest of the process when something changed, so the scheduler can
|
||||
re-plan its jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.models import SettingsRow
|
||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||
|
||||
_SETTINGS_ID = 1
|
||||
_listeners: list[Callable[[AppSettings], None]] = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def on_change(listener: Callable[[AppSettings], None]) -> None:
|
||||
"""Register a callback fired after every successful settings save."""
|
||||
_listeners.append(listener)
|
||||
|
||||
|
||||
def _csv(value: str) -> list[str]:
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
|
||||
def settings_from_env() -> AppSettings:
|
||||
"""Build the initial document out of the environment.
|
||||
|
||||
The variable names are deliberately the ones from `~/code/jira-helper`, so
|
||||
an existing `.env` can be copied across untouched.
|
||||
"""
|
||||
env = env_settings()
|
||||
settings = AppSettings(timezone=env.timezone)
|
||||
|
||||
jira = settings.sources.jira
|
||||
if env.jira_base_url and env.jira_email and env.jira_api_token:
|
||||
jira.enabled = True
|
||||
jira.base_url = env.jira_base_url.rstrip("/")
|
||||
jira.email = env.jira_email
|
||||
jira.api_token = env.jira_api_token
|
||||
jira.extra_jql = env.jira_extra_jql
|
||||
jira.start_date_field = env.jira_start_date_field or jira.start_date_field
|
||||
|
||||
google = settings.sources.google_tasks
|
||||
if env.google_client_id and env.google_client_secret and env.google_refresh_token:
|
||||
google.enabled = True
|
||||
google.client_id = env.google_client_id
|
||||
google.client_secret = env.google_client_secret
|
||||
google.refresh_token = env.google_refresh_token
|
||||
google.lists = _csv(env.google_tasks_lists)
|
||||
|
||||
todoist = settings.sources.todoist
|
||||
if env.todoist_api_token:
|
||||
todoist.enabled = True
|
||||
todoist.api_token = env.todoist_api_token
|
||||
todoist.projects = _csv(env.todoist_projects)
|
||||
|
||||
laundry = settings.sources.laundry
|
||||
if env.laundry_session:
|
||||
laundry.enabled = True
|
||||
laundry.session_cookie = env.laundry_session
|
||||
laundry.base_url = env.laundry_base_url.rstrip("/")
|
||||
|
||||
telegram = settings.notifications.telegram
|
||||
if env.telegram_bot_token and env.telegram_chat_id:
|
||||
telegram.enabled = True
|
||||
telegram.bot_token = env.telegram_bot_token
|
||||
telegram.chat_id = env.telegram_chat_id
|
||||
|
||||
if env.slack_webhook_url:
|
||||
settings.notifications.slack.enabled = True
|
||||
settings.notifications.slack.webhook_url = env.slack_webhook_url
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def load_settings(session: Session) -> AppSettings:
|
||||
"""The current settings, creating the row from the environment if absent."""
|
||||
row = session.get(SettingsRow, _SETTINGS_ID)
|
||||
if row is None:
|
||||
settings = settings_from_env()
|
||||
row = SettingsRow(id=_SETTINGS_ID, data=settings.model_dump(mode="json"))
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return settings
|
||||
try:
|
||||
return AppSettings.model_validate(row.data)
|
||||
except Exception:
|
||||
# A settings document written by an older build can be missing keys or
|
||||
# carry ones that no longer exist; defaults fill the gaps rather than
|
||||
# taking the whole app down.
|
||||
return AppSettings.model_validate(_merge(AppSettings().model_dump(mode="json"), row.data))
|
||||
|
||||
|
||||
def save_settings(session: Session, settings: AppSettings) -> AppSettings:
|
||||
row = session.get(SettingsRow, _SETTINGS_ID)
|
||||
data = settings.model_dump(mode="json")
|
||||
if row is None:
|
||||
row = SettingsRow(id=_SETTINGS_ID, data=data)
|
||||
session.add(row)
|
||||
else:
|
||||
row.data = data
|
||||
session.commit()
|
||||
|
||||
with _lock:
|
||||
listeners = list(_listeners)
|
||||
for listener in listeners:
|
||||
try:
|
||||
listener(settings)
|
||||
except Exception: # pragma: no cover - a bad listener must not block saving
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("settings listener failed")
|
||||
return settings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Secret masking
|
||||
# --------------------------------------------------------------------------
|
||||
def _walk(node: Any, fn: Callable[[str, Any], Any]) -> Any:
|
||||
if isinstance(node, dict):
|
||||
return {key: fn(key, _walk(value, fn)) for key, value in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [_walk(item, fn) for item in node]
|
||||
return node
|
||||
|
||||
|
||||
def mask_secrets(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replace every configured secret that has a value with the mask."""
|
||||
|
||||
def mask(key: str, value: Any) -> Any:
|
||||
if key in SECRET_FIELDS and isinstance(value, str) and value:
|
||||
return SECRET_MASK
|
||||
return value
|
||||
|
||||
return _walk(data, mask)
|
||||
|
||||
|
||||
def _merge(base: Any, incoming: Any) -> Any:
|
||||
"""Deep-merge `incoming` onto `base`, keeping `base` where a key is missing."""
|
||||
if isinstance(base, dict) and isinstance(incoming, dict):
|
||||
merged = dict(base)
|
||||
for key, value in incoming.items():
|
||||
merged[key] = _merge(base.get(key), value) if key in base else value
|
||||
return merged
|
||||
return incoming
|
||||
|
||||
|
||||
def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Put the stored secret back wherever the client sent the mask untouched."""
|
||||
|
||||
def restore(node_stored: Any, node_in: Any) -> Any:
|
||||
if isinstance(node_in, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in node_in.items():
|
||||
old = node_stored.get(key) if isinstance(node_stored, dict) else None
|
||||
if key in SECRET_FIELDS and value == SECRET_MASK:
|
||||
out[key] = old or ""
|
||||
else:
|
||||
out[key] = restore(old, value)
|
||||
return out
|
||||
return node_in
|
||||
|
||||
return restore(stored, incoming)
|
||||
|
||||
|
||||
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||
"""Merge a partial settings document (as the UI sends it) and store it."""
|
||||
current = load_settings(session).model_dump(mode="json")
|
||||
patch = unmask_secrets(current, patch)
|
||||
merged = _merge(current, patch)
|
||||
return save_settings(session, AppSettings.model_validate(merged))
|
||||
Reference in new issue
Block a user