272 lines
9.2 KiB
Python
272 lines
9.2 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(
|
|
"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,
|
|
"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,
|
|
}
|