- Add central settings database client (system_settings on postgres-shared) - User-scoped settings with global fallback - API config storage with enabled/disabled toggle - Per-source category filtering for news - Add modular external API providers in src/apis/: - OpenMeteoProvider: weather with geocoding (free, no key) - NOSProvider: Dutch news RSS feeds - BBCProvider: English news RSS feeds - AggregatedNewsProvider: merges sources with category filtering - AlphaVantageProvider: financial quotes (API key from settings DB) - Add provider dependencies and lifecycle management - Add requirements-dev.txt with pip-audit for security auditing - Add MEMORY_REMEMBER_PLAN.md documenting volatile/document memory architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
218 lines
6.9 KiB
Python
218 lines
6.9 KiB
Python
"""
|
|
Client for central Tatlock settings database.
|
|
|
|
Reads settings from the shared system_settings PostgreSQL database.
|
|
Writes are done via psql CLI or future CRUD manager.
|
|
"""
|
|
|
|
import asyncpg
|
|
import logging
|
|
from typing import Optional, Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SettingsClient:
|
|
"""Client for system_settings database."""
|
|
|
|
def __init__(self, dsn: str):
|
|
"""
|
|
Initialize settings client.
|
|
|
|
Args:
|
|
dsn: PostgreSQL connection string
|
|
e.g., "postgresql://settings:password@postgres-shared:5432/system_settings"
|
|
"""
|
|
self.dsn = dsn
|
|
self._pool: Optional[asyncpg.Pool] = None
|
|
|
|
async def connect(self):
|
|
"""Initialize connection pool."""
|
|
if not self._pool:
|
|
try:
|
|
self._pool = await asyncpg.create_pool(
|
|
self.dsn,
|
|
min_size=1,
|
|
max_size=5,
|
|
command_timeout=10,
|
|
)
|
|
logger.info("Connected to system_settings database")
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to system_settings: {e}")
|
|
raise
|
|
|
|
async def close(self):
|
|
"""Close connection pool."""
|
|
if self._pool:
|
|
await self._pool.close()
|
|
self._pool = None
|
|
logger.info("Disconnected from system_settings database")
|
|
|
|
async def health_check(self) -> bool:
|
|
"""Check database connectivity."""
|
|
try:
|
|
await self.connect()
|
|
async with self._pool.acquire() as conn:
|
|
await conn.fetchval("SELECT 1")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Settings database health check failed: {e}")
|
|
return False
|
|
|
|
async def get(self, key: str, user_scope: str = "global") -> Optional[Any]:
|
|
"""
|
|
Get a setting by key with user fallback to global.
|
|
|
|
Args:
|
|
key: Setting key (e.g., "api.openmeteo", "weather.units")
|
|
user_scope: User identifier or "global"
|
|
|
|
Returns:
|
|
Setting value (parsed from JSONB) or None if not found.
|
|
User-specific value takes precedence over global.
|
|
"""
|
|
await self.connect()
|
|
async with self._pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT value FROM settings
|
|
WHERE key = $1 AND user_scope IN ($2, 'global')
|
|
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
|
LIMIT 1
|
|
""",
|
|
key, user_scope
|
|
)
|
|
if row:
|
|
return row["value"]
|
|
return None
|
|
|
|
async def get_with_schema(self, key: str, user_scope: str = "global") -> Optional[dict]:
|
|
"""
|
|
Get a setting with its JSON Schema.
|
|
|
|
Returns:
|
|
Dict with "value" and "schema" keys, or None if not found.
|
|
"""
|
|
await self.connect()
|
|
async with self._pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT value, schema FROM settings
|
|
WHERE key = $1 AND user_scope IN ($2, 'global')
|
|
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
|
LIMIT 1
|
|
""",
|
|
key, user_scope
|
|
)
|
|
if row:
|
|
return {"value": row["value"], "schema": row["schema"]}
|
|
return None
|
|
|
|
async def get_by_prefix(self, prefix: str, user_scope: str = "global") -> dict[str, Any]:
|
|
"""
|
|
Get all settings matching a key prefix.
|
|
|
|
Args:
|
|
prefix: Key prefix (e.g., "api." for all API configs)
|
|
user_scope: User identifier or "global"
|
|
|
|
Returns:
|
|
Dict mapping keys to values. User-specific values override global.
|
|
"""
|
|
await self.connect()
|
|
async with self._pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT DISTINCT ON (key) key, value FROM settings
|
|
WHERE key LIKE $1 AND user_scope IN ($2, 'global')
|
|
ORDER BY key, CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
|
""",
|
|
f"{prefix}%", user_scope
|
|
)
|
|
return {row["key"]: row["value"] for row in rows}
|
|
|
|
async def get_api_config(self, service: str) -> Optional[dict]:
|
|
"""
|
|
Get API configuration for a service.
|
|
|
|
Args:
|
|
service: Service name (e.g., "openmeteo", "nos", "alphavantage")
|
|
|
|
Returns:
|
|
API config dict or None if not found.
|
|
"""
|
|
value = await self.get(f"api.{service}")
|
|
if isinstance(value, dict):
|
|
return value
|
|
return None
|
|
|
|
async def get_api_key(self, service: str) -> Optional[str]:
|
|
"""
|
|
Get API key for a service if enabled.
|
|
|
|
Args:
|
|
service: Service name (e.g., "alphavantage")
|
|
|
|
Returns:
|
|
API key string or None if not found or disabled.
|
|
"""
|
|
config = await self.get_api_config(service)
|
|
if config:
|
|
# Check if explicitly disabled
|
|
if config.get("enabled") is False:
|
|
return None
|
|
return config.get("api_key")
|
|
return None
|
|
|
|
async def is_api_enabled(self, service: str) -> bool:
|
|
"""
|
|
Check if an API service is enabled.
|
|
|
|
Args:
|
|
service: Service name (e.g., "alphavantage", "openmeteo")
|
|
|
|
Returns:
|
|
True if enabled (or no explicit setting), False if disabled.
|
|
"""
|
|
config = await self.get_api_config(service)
|
|
if config:
|
|
# Default to enabled if not specified
|
|
return config.get("enabled", True)
|
|
return False # No config means not available
|
|
|
|
async def get_user_preference(self, key: str, user: str) -> Optional[Any]:
|
|
"""
|
|
Get a user-specific preference.
|
|
|
|
Args:
|
|
key: Preference key (e.g., "weather.units", "news.sources")
|
|
user: User identifier
|
|
|
|
Returns:
|
|
Preference value or None if not set.
|
|
"""
|
|
return await self.get(key, user_scope=user)
|
|
|
|
async def list_keys(self, user_scope: Optional[str] = None) -> list[str]:
|
|
"""
|
|
List all setting keys, optionally filtered by user_scope.
|
|
|
|
Args:
|
|
user_scope: Filter by scope (None for all)
|
|
|
|
Returns:
|
|
List of setting keys.
|
|
"""
|
|
await self.connect()
|
|
async with self._pool.acquire() as conn:
|
|
if user_scope:
|
|
rows = await conn.fetch(
|
|
"SELECT key FROM settings WHERE user_scope = $1 ORDER BY key",
|
|
user_scope
|
|
)
|
|
else:
|
|
rows = await conn.fetch(
|
|
"SELECT DISTINCT key FROM settings ORDER BY key"
|
|
)
|
|
return [row["key"] for row in rows]
|