Files
FabledScribe/src/fabledassistant/routes/settings.py
T
bvandeusen 8ec91ceea7 feat(settings): propagate user_timezone to briefing scheduler on save
When user_timezone is saved via PUT /api/settings, immediately call
update_user_schedule if briefing is enabled so the scheduler picks up
the new timezone without requiring a restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:35:00 -04:00

171 lines
6.1 KiB
Python

import asyncio
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
from fabledassistant.services.llm import get_installed_models, _is_private_url
from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
logger = logging.getLogger(__name__)
async def _prime_kv_cache_bg(user_id: int, model: str) -> None:
"""Fire-and-forget: prime Ollama's KV cache with the user's system prompt."""
import httpx
from fabledassistant.services.llm import build_context, pick_num_ctx
try:
messages, _ = await build_context(
user_id=user_id,
history=[],
current_note_id=None,
user_message=" ",
)
num_ctx = pick_num_ctx(messages)
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": "2h",
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
except Exception:
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@settings_bp.route("", methods=["GET"])
@login_required
async def get_settings_route():
uid = get_current_user_id()
settings = await get_all_settings(uid)
return jsonify(settings)
@settings_bp.route("", methods=["PUT"])
@login_required
async def update_settings_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "default_model" in data:
installed = await get_installed_models()
if data["default_model"]:
model = str(data["default_model"])
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
# Empty string for model keys means "reset to system default".
# Delete the DB row so get_setting() falls back to Config defaults
# rather than returning "" and breaking model resolution everywhere.
_MODEL_KEYS = frozenset({"default_model", "background_model"})
to_save = {}
for k, v in data.items():
str_v = str(v)
if k in _MODEL_KEYS and not str_v:
await delete_setting(uid, k)
else:
to_save[k] = str_v
if to_save:
await set_settings_batch(uid, to_save)
# When timezone changes, live-patch the briefing scheduler immediately
if "user_timezone" in to_save:
import json as _json
from fabledassistant.services.briefing_scheduler import update_user_schedule
config_raw = await get_setting(uid, "briefing_config", "{}")
try:
config = _json.loads(config_raw) if isinstance(config_raw, str) else {}
except Exception:
config = {}
if config.get("enabled"):
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
if "default_model" in to_save and to_save["default_model"]:
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
settings = await get_all_settings(uid)
return jsonify(settings)
@settings_bp.route("/models", methods=["GET"])
@login_required
async def get_models_route():
"""Return installed Ollama models and the configured defaults."""
models = sorted(await get_installed_models())
return jsonify({
"models": models,
"default_chat_model": Config.OLLAMA_MODEL,
})
@settings_bp.route("/caldav", methods=["GET"])
@login_required
async def get_caldav():
uid = get_current_user_id()
config = await get_caldav_config(uid)
if config.get("caldav_password"):
config["caldav_password"] = "********"
return jsonify(config)
@settings_bp.route("/caldav", methods=["PUT"])
@login_required
async def update_caldav():
uid = get_current_user_id()
data = await request.get_json()
# Validate CalDAV URL before saving — block internal/private addresses
if "caldav_url" in data:
url = str(data.get("caldav_url") or "").strip()
if url:
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
if parsed_scheme not in ("http", "https"):
return jsonify({"error": "CalDAV URL must use http or https"}), 400
if _is_private_url(url):
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
settings_to_save = {}
for key in CALDAV_SETTING_KEYS:
if key in data:
if key == "caldav_password" and data[key] == "********":
continue
settings_to_save[key] = str(data[key])
if settings_to_save:
await set_settings_batch(uid, settings_to_save)
return jsonify({"status": "ok"})
@settings_bp.route("/caldav/test", methods=["POST"])
@login_required
async def test_caldav():
uid = get_current_user_id()
result = await test_connection(uid)
return jsonify(result)
@settings_bp.route("/search", methods=["GET"])
@login_required
async def test_search():
"""Test SearXNG connectivity and preview results for a query."""
if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""})
q = request.args.get("q", "").strip()
if not q:
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
from fabledassistant.services.research import _search_searxng
results = await _search_searxng(q)
return jsonify({"configured": True, "results": results, "query": q, "searxng_url": Config.SEARXNG_URL})