feat: add external API providers and central settings database
- 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>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
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]
|
||||
Reference in New Issue
Block a user