"""Database-backed settings, integrations, and user management.""" from __future__ import annotations import json import logging from datetime import datetime, timezone from typing import Any, Dict, List, Optional from server.db import get_pool logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Admin User # --------------------------------------------------------------------------- async def get_admin_user() -> Optional[Dict[str, Any]]: """Return the admin user row, or None if not yet created.""" pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow("SELECT * FROM admin_user LIMIT 1") return dict(row) if row else None async def create_admin_user(username: str, password_hash: str) -> None: """Insert the initial admin user.""" pool = await get_pool() async with pool.acquire() as conn: await conn.execute( "INSERT INTO admin_user (username, password_hash) VALUES ($1, $2)", username, password_hash, ) logger.info("Admin user '%s' created", username) async def update_admin_password(user_id: int, password_hash: str) -> None: """Update the admin user's password.""" pool = await get_pool() async with pool.acquire() as conn: await conn.execute( "UPDATE admin_user SET password_hash = $1, updated_at = NOW() WHERE id = $2", password_hash, user_id, ) logger.info("Admin password updated (user_id=%d)", user_id) # --------------------------------------------------------------------------- # App Settings (key/value) # --------------------------------------------------------------------------- async def get_all_settings() -> Dict[str, Any]: """Return all settings as a dict, casting values to their declared type.""" pool = await get_pool() async with pool.acquire() as conn: rows = await conn.fetch("SELECT * FROM app_settings ORDER BY category, key") result: Dict[str, Any] = {} for row in rows: result[row["key"]] = { "value": _cast_value(row["value"], row["value_type"]), "value_type": row["value_type"], "category": row["category"], "label": row["label"], "description": row["description"], } return result async def get_setting(key: str) -> Optional[Any]: """Return a single setting's typed value, or None.""" pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow("SELECT value, value_type FROM app_settings WHERE key = $1", key) if row is None: return None return _cast_value(row["value"], row["value_type"]) async def set_setting( key: str, value: Any, value_type: str = "string", category: str = "general", label: str = "", description: str = "", ) -> None: """Upsert a single setting.""" pool = await get_pool() str_value = json.dumps(value) if value_type == "json" else str(value) async with pool.acquire() as conn: await conn.execute( """ INSERT INTO app_settings (key, value, value_type, category, label, description, updated_at) VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, value_type = EXCLUDED.value_type, category = EXCLUDED.category, label = EXCLUDED.label, description = EXCLUDED.description, updated_at = NOW() """, key, str_value, value_type, category, label, description, ) async def bulk_set_settings(settings_dict: Dict[str, Any]) -> None: """Bulk upsert settings from a flat key→value dict.""" pool = await get_pool() async with pool.acquire() as conn: async with conn.transaction(): for key, val in settings_dict.items(): str_val = str(val) await conn.execute( """ UPDATE app_settings SET value = $1, updated_at = NOW() WHERE key = $2 """, str_val, key, ) def _cast_value(raw: str, value_type: str) -> Any: """Cast a stored string value to its declared type.""" if value_type == "int": try: return int(raw) except (ValueError, TypeError): return 0 elif value_type == "bool": return raw.lower() in ("1", "true", "yes") elif value_type == "json": try: return json.loads(raw) except (json.JSONDecodeError, TypeError): return raw return raw # --------------------------------------------------------------------------- # Integrations # --------------------------------------------------------------------------- async def get_integrations() -> List[Dict[str, Any]]: """Return all integration configs.""" pool = await get_pool() async with pool.acquire() as conn: rows = await conn.fetch( "SELECT * FROM integrations ORDER BY display_order, type" ) return [_integration_to_dict(row) for row in rows] async def get_integration(type_name: str) -> Optional[Dict[str, Any]]: """Return a single integration by type name.""" pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT * FROM integrations WHERE type = $1", type_name ) return _integration_to_dict(row) if row else None async def upsert_integration( type_name: str, name: str, config: Dict[str, Any], enabled: bool = True, display_order: int = 0, ) -> Dict[str, Any]: """Insert or update an integration config.""" pool = await get_pool() config_json = json.dumps(config) async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO integrations (type, name, config, enabled, display_order, updated_at) VALUES ($1, $2, $3::jsonb, $4, $5, NOW()) ON CONFLICT (type) DO UPDATE SET name = EXCLUDED.name, config = EXCLUDED.config, enabled = EXCLUDED.enabled, display_order = EXCLUDED.display_order, updated_at = NOW() RETURNING * """, type_name, name, config_json, enabled, display_order, ) return _integration_to_dict(row) async def toggle_integration(type_name: str, enabled: bool) -> None: """Enable or disable an integration.""" pool = await get_pool() async with pool.acquire() as conn: await conn.execute( "UPDATE integrations SET enabled = $1, updated_at = NOW() WHERE type = $2", enabled, type_name, ) def _integration_to_dict(row: Any) -> Dict[str, Any]: """Convert an integration row to a dict.""" d = dict(row) # Ensure config is a dict (asyncpg returns JSONB as dict already) if isinstance(d.get("config"), str): d["config"] = json.loads(d["config"]) # Convert datetimes for k in ("created_at", "updated_at"): if k in d and d[k] is not None: d[k] = d[k].isoformat() return d # --------------------------------------------------------------------------- # MQTT Subscriptions # --------------------------------------------------------------------------- async def get_mqtt_subscriptions() -> List[Dict[str, Any]]: """Return all MQTT subscriptions.""" pool = await get_pool() async with pool.acquire() as conn: rows = await conn.fetch( "SELECT * FROM mqtt_subscriptions ORDER BY display_order, id" ) return [_sub_to_dict(row) for row in rows] async def create_mqtt_subscription( topic_pattern: str, display_name: str = "", category: str = "other", unit: str = "", widget_type: str = "value", enabled: bool = True, display_order: int = 0, ) -> Dict[str, Any]: """Create a new MQTT subscription.""" pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO mqtt_subscriptions (topic_pattern, display_name, category, unit, widget_type, enabled, display_order) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * """, topic_pattern, display_name, category, unit, widget_type, enabled, display_order, ) return _sub_to_dict(row) async def update_mqtt_subscription(sub_id: int, **fields: Any) -> Optional[Dict[str, Any]]: """Update specific fields of an MQTT subscription.""" pool = await get_pool() allowed = {"topic_pattern", "display_name", "category", "unit", "widget_type", "enabled", "display_order"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: return None set_parts = [] params = [] for i, (k, v) in enumerate(updates.items(), start=1): set_parts.append(f"{k} = ${i}") params.append(v) params.append(sub_id) set_clause = ", ".join(set_parts) async with pool.acquire() as conn: row = await conn.fetchrow( f"UPDATE mqtt_subscriptions SET {set_clause}, updated_at = NOW() " f"WHERE id = ${len(params)} RETURNING *", *params, ) return _sub_to_dict(row) if row else None async def delete_mqtt_subscription(sub_id: int) -> bool: """Delete an MQTT subscription. Returns True if deleted.""" pool = await get_pool() async with pool.acquire() as conn: result = await conn.execute( "DELETE FROM mqtt_subscriptions WHERE id = $1", sub_id ) return result == "DELETE 1" def _sub_to_dict(row: Any) -> Dict[str, Any]: d = dict(row) for k in ("created_at", "updated_at"): if k in d and d[k] is not None: d[k] = d[k].isoformat() return d