from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session from app.auth_db import reset_auth_db_caches from app.config import Settings, get_settings from app.models.config import AppConfigEntry @dataclass(frozen=True, slots=True) class ConfigField: section: str env_name: str setting_attr: str label: str secret: bool = False input_type: str = "text" CONFIG_FIELDS: tuple[ConfigField, ...] = ( ConfigField("System", "APP_NAME", "app_name", "App Name"), ConfigField("System", "APP_ENV", "app_env", "App Env"), ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"), ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"), ConfigField("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled"), ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"), ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"), ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"), ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True), ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"), ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"), ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"), ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS"), ConfigField( "Authentication", "AUTH_SESSION_COOKIE_NAME", "auth_session_cookie_name", "Session Cookie Name", ), ConfigField("Authentication", "AUTH_SESSION_TTL_HOURS", "auth_session_ttl_hours", "Session TTL Hours"), ConfigField( "Authentication", "AUTH_COOKIE_SECURE_OVERRIDE", "auth_cookie_secure_override", "Cookie Secure Override", ), ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True), ConfigField( "Poo", "POO_SENSOR_ENTITY_NAME", "poo_sensor_entity_name", "Poo Sensor Entity Name", ), ConfigField( "Poo", "POO_SENSOR_FRIENDLY_NAME", "poo_sensor_friendly_name", "Poo Sensor Friendly Name", ), ConfigField("TickTick", "TICKTICK_CLIENT_ID", "ticktick_client_id", "TickTick Client ID"), ConfigField( "TickTick", "TICKTICK_CLIENT_SECRET", "ticktick_client_secret", "TickTick Client Secret", secret=True, ), ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True), ConfigField( "Home Assistant", "HOME_ASSISTANT_BASE_URL", "home_assistant_base_url", "Home Assistant Base URL", ), ConfigField( "Home Assistant", "HOME_ASSISTANT_AUTH_TOKEN", "home_assistant_auth_token", "Home Assistant Auth Token", secret=True, ), ConfigField( "Home Assistant", "HOME_ASSISTANT_TIMEOUT_SECONDS", "home_assistant_timeout_seconds", "Home Assistant Timeout Seconds", ), ConfigField( "Home Assistant", "HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "home_assistant_action_task_project_id", "Home Assistant Action Task Project ID", ), ) class ConfigSaveError(ValueError): """Raised when the submitted config payload is invalid.""" def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None: current_values = _read_config_values(session) missing_values: dict[str, str] = {} for field in CONFIG_FIELDS: if field.env_name in current_values: continue missing_values[field.env_name] = _stringify(getattr(bootstrap_settings, field.setting_attr)) if not missing_values: return _persist_config_values(session, {**current_values, **missing_values}) def sync_app_hostname_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None: current_values = _read_config_values(session) bootstrap_hostname = _stringify(bootstrap_settings.app_hostname) if current_values.get("APP_HOSTNAME") == bootstrap_hostname: return current_values["APP_HOSTNAME"] = bootstrap_hostname _persist_config_values(session, current_values) get_settings.cache_clear() reset_auth_db_caches() def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings: overrides = _read_config_values(session) if not overrides: return bootstrap_settings payload = _settings_payload(bootstrap_settings) for field in CONFIG_FIELDS: if field.env_name in overrides: payload[field.setting_attr] = overrides[field.env_name] return Settings(_env_file=None, **payload) def build_config_sections(session: Session, bootstrap_settings: Settings) -> list[dict[str, Any]]: runtime_settings = build_runtime_settings(session, bootstrap_settings) persisted_values = _read_config_values(session) sections: list[dict[str, Any]] = [] current_section: dict[str, Any] | None = None for field in CONFIG_FIELDS: if current_section is None or current_section["name"] != field.section: current_section = {"name": field.section, "fields": []} sections.append(current_section) current_section["fields"].append( { "env_name": field.env_name, "label": field.label, "value": "" if field.secret else _stringify(getattr(runtime_settings, field.setting_attr)), "secret": field.secret, "input_type": "password" if field.secret else field.input_type, "configured": field.env_name in persisted_values or bool(_stringify(getattr(bootstrap_settings, field.setting_attr))), } ) return sections def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_settings: Settings) -> None: current_values = _read_config_values(session) merged_values = dict(current_values) for field in CONFIG_FIELDS: submitted_value = form_data.get(field.env_name, "") if field.secret: if submitted_value: merged_values[field.env_name] = submitted_value else: merged_values[field.env_name] = submitted_value _validate_config_values(merged_values, bootstrap_settings) _persist_config_values(session, merged_values) get_settings.cache_clear() reset_auth_db_caches() def save_config_value( session: Session, *, env_name: str, value: str, bootstrap_settings: Settings, ) -> None: current_values = _read_config_values(session) current_values[env_name] = value _validate_config_values(current_values, bootstrap_settings) _persist_config_values(session, current_values) get_settings.cache_clear() reset_auth_db_caches() def is_ticktick_oauth_ready(settings: Settings) -> bool: return bool( settings.app_hostname and settings.ticktick_client_id and settings.ticktick_client_secret ) def _read_config_values(session: Session) -> dict[str, str]: rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all() return {row.key: row.value for row in rows} def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None: payload = _settings_payload(bootstrap_settings) for field in CONFIG_FIELDS: if field.env_name in config_values: payload[field.setting_attr] = config_values[field.env_name] try: Settings(_env_file=None, **payload) except Exception as exc: raise ConfigSaveError("invalid config submission") from exc def _persist_config_values(session: Session, config_values: dict[str, str]) -> None: existing_entries = { row.key: row for row in session.execute(select(AppConfigEntry)).scalars().all() } now = datetime.now(UTC) for env_name, value in config_values.items(): entry = existing_entries.get(env_name) if entry is None: session.add(AppConfigEntry(key=env_name, value=value, updated_at=now)) else: entry.value = value entry.updated_at = now session.commit() def _stringify(value: Any) -> str: if value is None: return "" if isinstance(value, bool): return str(value).lower() return str(value) def _settings_payload(settings: Settings) -> dict[str, Any]: return { "app_name": settings.app_name, "app_env": settings.app_env, "app_debug": settings.app_debug, "app_hostname": settings.app_hostname, "app_database_url": settings.app_database_url, "location_database_url": settings.location_database_url, "poo_database_url": settings.poo_database_url, "ticktick_client_id": settings.ticktick_client_id, "ticktick_client_secret": settings.ticktick_client_secret, "ticktick_token": settings.ticktick_token, "home_assistant_base_url": settings.home_assistant_base_url, "home_assistant_auth_token": settings.home_assistant_auth_token, "home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds, "home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id, "smtp_enabled": settings.smtp_enabled, "smtp_host": settings.smtp_host, "smtp_port": settings.smtp_port, "smtp_username": settings.smtp_username, "smtp_password": settings.smtp_password, "smtp_from_name": settings.smtp_from_name, "smtp_from_address": settings.smtp_from_address, "smtp_to_address": settings.smtp_to_address, "smtp_use_starttls": settings.smtp_use_starttls, "poo_webhook_id": settings.poo_webhook_id, "poo_sensor_entity_name": settings.poo_sensor_entity_name, "poo_sensor_friendly_name": settings.poo_sensor_friendly_name, "auth_bootstrap_username": settings.auth_bootstrap_username, "auth_bootstrap_password": settings.auth_bootstrap_password, "auth_session_cookie_name": settings.auth_session_cookie_name, "auth_session_ttl_hours": settings.auth_session_ttl_hours, "auth_cookie_secure_override": settings.auth_cookie_secure_override, }