ppt-tool/backend/services/settings_service.py
Vadym Samoilenko 69a8829750 Phase 3: Bug fixes, feature enhancements, and polish
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>
2026-02-27 12:58:52 +00:00

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