Files
home-automation/app/services/config_page.py
T
2026-04-29 12:11:10 +02:00

288 lines
10 KiB
Python

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_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_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,
}