""" 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]