P0 Critical: presentation isolation (client scoping), storage super_admin fix, template selection in worker, IMAGE_PROVIDERS list fix. P1 High: template layout management UI (delete/filter/bulk), slide-based parsing mode, LLM model listing & connection test, settings persistence to DB (Fernet encryption), logout button. P2 Polish: storage improvements (master deck files, per-client breakdown, bulk delete, hard purge, client selector), image generation error visibility (__image_error__ badge), hamster wheel loading animation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
"""Service for persisting and loading system settings from the database.
|
|
|
|
Settings are stored in KeyValueSqlModel with key="system_settings".
|
|
API keys are stored separately with key="api_keys" and encrypted at rest
|
|
using Fernet if SETTINGS_ENCRYPTION_KEY is set, otherwise stored in plain text.
|
|
|
|
On startup, persisted settings are loaded into os.environ so the rest of the
|
|
application can read them transparently.
|
|
"""
|
|
import os
|
|
from typing import Optional
|
|
|
|
try:
|
|
from cryptography.fernet import Fernet
|
|
HAS_FERNET = True
|
|
except ImportError:
|
|
HAS_FERNET = False
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models.sql.key_value import KeyValueSqlModel
|
|
|
|
SETTINGS_KEY = "system_settings"
|
|
API_KEYS_KEY = "api_keys"
|
|
|
|
# Env vars that map to settings
|
|
SETTING_ENV_MAP = {
|
|
"llm_provider": "LLM",
|
|
"llm_model": None, # Handled specially per-provider
|
|
"image_provider": "IMAGE_PROVIDER",
|
|
}
|
|
|
|
MODEL_ENV_MAP = {
|
|
"anthropic": "ANTHROPIC_MODEL",
|
|
"openai": "OPENAI_MODEL",
|
|
"google": "GOOGLE_MODEL",
|
|
"ollama": "OLLAMA_MODEL",
|
|
"custom": "CUSTOM_MODEL",
|
|
}
|
|
|
|
API_KEY_ENV_MAP = {
|
|
"anthropic_api_key": "ANTHROPIC_API_KEY",
|
|
"openai_api_key": "OPENAI_API_KEY",
|
|
"google_api_key": "GOOGLE_API_KEY",
|
|
}
|
|
|
|
|
|
def _get_fernet():
|
|
"""Return Fernet cipher if encryption key is configured and cryptography is available."""
|
|
if not HAS_FERNET:
|
|
return None
|
|
key = os.getenv("SETTINGS_ENCRYPTION_KEY")
|
|
if key:
|
|
try:
|
|
return Fernet(key.encode() if isinstance(key, str) else key)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _encrypt(value: str) -> str:
|
|
f = _get_fernet()
|
|
if f:
|
|
return f.encrypt(value.encode()).decode()
|
|
return value
|
|
|
|
|
|
def _decrypt(value: str) -> str:
|
|
f = _get_fernet()
|
|
if f:
|
|
try:
|
|
return f.decrypt(value.encode()).decode()
|
|
except Exception:
|
|
return value # Fallback: might be plain text from before encryption was enabled
|
|
return value
|
|
|
|
|
|
async def _get_kv(session: AsyncSession, key: str) -> Optional[dict]:
|
|
"""Get a key-value record."""
|
|
stmt = select(KeyValueSqlModel).where(KeyValueSqlModel.key == key)
|
|
result = await session.execute(stmt)
|
|
row = result.scalar_one_or_none()
|
|
return row.value if row else None
|
|
|
|
|
|
async def _set_kv(session: AsyncSession, key: str, value: dict) -> None:
|
|
"""Upsert a key-value record."""
|
|
stmt = select(KeyValueSqlModel).where(KeyValueSqlModel.key == key)
|
|
result = await session.execute(stmt)
|
|
row = result.scalar_one_or_none()
|
|
if row:
|
|
row.value = value
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
flag_modified(row, "value")
|
|
else:
|
|
row = KeyValueSqlModel(key=key, value=value)
|
|
session.add(row)
|
|
await session.commit()
|
|
|
|
|
|
async def load_settings(session: AsyncSession) -> dict:
|
|
"""Load persisted settings from DB and apply to os.environ.
|
|
|
|
Returns the settings dict. Falls back to env vars if nothing persisted.
|
|
"""
|
|
settings = await _get_kv(session, SETTINGS_KEY) or {}
|
|
api_keys = await _get_kv(session, API_KEYS_KEY) or {}
|
|
|
|
# Apply settings to env
|
|
if "llm_provider" in settings:
|
|
os.environ["LLM"] = settings["llm_provider"]
|
|
|
|
if "llm_model" in settings:
|
|
provider = settings.get("llm_provider", os.getenv("LLM", "anthropic"))
|
|
env_key = MODEL_ENV_MAP.get(provider)
|
|
if env_key:
|
|
os.environ[env_key] = settings["llm_model"]
|
|
|
|
if "image_provider" in settings:
|
|
os.environ["IMAGE_PROVIDER"] = settings["image_provider"]
|
|
|
|
# Apply API keys (decrypt first)
|
|
for setting_key, env_key in API_KEY_ENV_MAP.items():
|
|
encrypted_val = api_keys.get(setting_key)
|
|
if encrypted_val:
|
|
os.environ[env_key] = _decrypt(encrypted_val)
|
|
|
|
return settings
|
|
|
|
|
|
async def save_settings(session: AsyncSession, data: dict) -> list:
|
|
"""Persist settings to DB and update os.environ.
|
|
|
|
Handles both regular settings and API keys.
|
|
Returns list of changed field names.
|
|
"""
|
|
changed = []
|
|
|
|
# Load existing
|
|
current_settings = await _get_kv(session, SETTINGS_KEY) or {}
|
|
current_api_keys = await _get_kv(session, API_KEYS_KEY) or {}
|
|
|
|
# Process regular settings
|
|
for field in ("llm_provider", "llm_model", "image_provider"):
|
|
if field in data and data[field] is not None:
|
|
current_settings[field] = data[field]
|
|
changed.append(field)
|
|
|
|
# Also update env immediately
|
|
if field == "llm_provider":
|
|
os.environ["LLM"] = data[field]
|
|
elif field == "image_provider":
|
|
os.environ["IMAGE_PROVIDER"] = data[field]
|
|
elif field == "llm_model":
|
|
provider = data.get("llm_provider", current_settings.get("llm_provider", os.getenv("LLM", "anthropic")))
|
|
env_key = MODEL_ENV_MAP.get(provider)
|
|
if env_key:
|
|
os.environ[env_key] = data[field]
|
|
|
|
# Process API keys (encrypt before storing)
|
|
for setting_key, env_key in API_KEY_ENV_MAP.items():
|
|
if setting_key in data and data[setting_key] is not None:
|
|
current_api_keys[setting_key] = _encrypt(data[setting_key])
|
|
os.environ[env_key] = data[setting_key]
|
|
changed.append(setting_key)
|
|
|
|
# Persist
|
|
if any(f in changed for f in ("llm_provider", "llm_model", "image_provider")):
|
|
await _set_kv(session, SETTINGS_KEY, current_settings)
|
|
|
|
if any(f in changed for f in API_KEY_ENV_MAP):
|
|
await _set_kv(session, API_KEYS_KEY, current_api_keys)
|
|
|
|
return changed
|