Persist runtime config in app db and seed from env

This commit is contained in:
2026-04-20 15:56:10 +02:00
parent 3f7c9e43d9
commit 179aae264e
21 changed files with 921 additions and 125 deletions
+15 -8
View File
@@ -18,6 +18,7 @@ from app.services.auth import (
revoke_session,
validate_csrf_token,
)
from app.services.config_page import build_config_sections
logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
@@ -33,7 +34,7 @@ def login_page(
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is not None:
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
@@ -79,7 +80,7 @@ def login_submit(
)
auth_session, raw_token = create_session(session, user=user, settings=settings)
response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
response.set_cookie(
key=settings.auth_session_cookie_name,
@@ -94,7 +95,7 @@ def login_submit(
return response
@router.post("/admin/change-password", response_class=HTMLResponse)
@router.post("/config/change-password", response_class=HTMLResponse)
def change_password_submit(
request: Request,
current_password: str = Form(),
@@ -110,9 +111,10 @@ def change_password_submit(
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
logger.warning("Rejected password change attempt due to CSRF validation failure")
return _render_admin_page(
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="invalid password change request",
@@ -132,16 +134,17 @@ def change_password_submit(
current_auth.user.username,
exc,
)
return _render_admin_page(
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="password change failed",
)
logger.info("Password updated for user '%s'", current_auth.user.username)
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
@@ -200,17 +203,18 @@ def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token:
)
def _render_admin_page(
def _render_config_page(
request: Request,
*,
settings: Settings,
auth_db_session: Session,
current_auth: AuthenticatedSession,
status_code: int,
password_change_error: str | None,
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"admin.html",
"config.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
@@ -218,6 +222,9 @@ def _render_admin_page(
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": None,
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
},
status_code=status_code,
)
+87 -11
View File
@@ -1,30 +1,45 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings
from app.dependencies import get_app_settings, get_current_auth_session
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"])
logger = logging.getLogger(__name__)
@router.get("/", response_class=HTMLResponse)
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse:
context = {
"app_name": settings.app_name,
"app_env": settings.app_env,
"notion_status": "Legacy scope, removed from the Python rewrite target.",
}
return templates.TemplateResponse(request, "home.html", context)
def home(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin", response_class=HTMLResponse)
def admin_page(
def admin_redirect(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/config", response_class=HTMLResponse)
def config_page(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
@@ -38,5 +53,66 @@ def admin_page(
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": None,
"config_saved": request.query_params.get("saved") == "1",
"config_sections": build_config_sections(auth_db_session, settings),
}
return templates.TemplateResponse(request, "admin.html", context)
return templates.TemplateResponse(request, "config.html", context)
@router.post("/config", response_class=HTMLResponse)
async def config_submit(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected config update due to CSRF validation failure")
context = {
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": "invalid config update request",
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
}
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
save_config_updates(auth_db_session, dict(form), settings)
except ConfigSaveError:
logger.warning("Rejected config update due to invalid submitted values")
refreshed_settings = build_runtime_settings(auth_db_session, get_settings())
context = {
"app_name": refreshed_settings.app_name,
"app_env": refreshed_settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": "invalid config submission",
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
}
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+7 -6
View File
@@ -9,16 +9,17 @@ from app.db import get_db_session
from app.integrations.homeassistant import HomeAssistantClient
from app.poo_db import get_poo_db_session
from app.services.auth import AuthenticatedSession, get_authenticated_session
def get_app_settings() -> Settings:
return get_settings()
from app.services.config_page import build_runtime_settings
def get_auth_db() -> Generator[Session, None, None]:
yield from get_auth_db_session()
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
return build_runtime_settings(session, get_settings())
def get_db() -> Generator[Session, None, None]:
yield from get_db_session()
@@ -27,8 +28,8 @@ def get_poo_db() -> Generator[Session, None, None]:
yield from get_poo_db_session()
def get_homeassistant_client() -> HomeAssistantClient:
return HomeAssistantClient(get_settings())
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
return HomeAssistantClient(settings)
def get_current_auth_session(
+2
View File
@@ -14,6 +14,7 @@ from app.api.routes.location import router as location_router
from app.api.routes.poo import router as poo_router
from app.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
@@ -25,6 +26,7 @@ def ensure_auth_db_ready() -> None:
try:
validate_app_runtime_db(get_settings().app_database_url)
initialize_auth_schema(session, get_settings())
seed_missing_config_from_bootstrap(session, get_settings())
except AppDatabaseAdoptionError as exc:
raise RuntimeError(str(exc)) from exc
except AuthBootstrapError as exc:
+2 -1
View File
@@ -1,6 +1,7 @@
"""SQLAlchemy models package."""
from app.models.auth import AuthSession, AuthUser
from app.models.config import AppConfigEntry
from app.models.location import Location
__all__ = ["AuthSession", "AuthUser", "Location"]
__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.auth_db import AuthBase
class AppConfigEntry(AuthBase):
__tablename__ = "app_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
value: Mapped[str] = mapped_column(String, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+245
View File
@@ -0,0 +1,245 @@
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_HOST", "app_host", "App Host"),
ConfigField("System", "APP_PORT", "app_port", "App Port"),
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_REDIRECT_URI",
"ticktick_redirect_uri",
"TickTick Redirect URI",
),
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 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 _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_host": settings.app_host,
"app_port": settings.app_port,
"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_redirect_uri": settings.ticktick_redirect_uri,
"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,
}
+46
View File
@@ -61,6 +61,11 @@ h1 {
margin: 0;
}
.single-column {
grid-template-columns: minmax(180px, 320px);
margin-bottom: 24px;
}
.meta div {
padding: 16px;
border-radius: 16px;
@@ -136,6 +141,47 @@ button:hover {
color: #8b2a2a;
}
.notice {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(45, 106, 79, 0.08);
border: 1px solid rgba(45, 106, 79, 0.14);
color: var(--accent);
}
.config-block + .config-block {
margin-top: 28px;
}
.config-block h2 {
margin: 0 0 16px;
font-size: 1.25rem;
}
.config-form {
display: grid;
gap: 20px;
}
.config-section {
margin: 0;
padding: 18px;
border: 1px solid rgba(31, 41, 51, 0.08);
border-radius: 16px;
display: grid;
gap: 14px;
}
.config-section legend {
padding: 0 8px;
color: var(--accent);
}
.config-form label small {
color: var(--muted);
}
@media (max-width: 640px) {
.shell {
margin: 24px auto;
-64
View File
@@ -1,64 +0,0 @@
{% extends "base.html" %}
{% block title %}Admin · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Protected Area</p>
<h1>Admin</h1>
{% if force_password_change %}
<p class="lead">
首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。
</p>
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
<form class="auth-form" method="post" action="/admin/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
{% else %}
<p class="lead">
你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。
</p>
<dl class="meta">
<div>
<dt>当前用户</dt>
<dd>{{ current_username }}</dd>
</div>
<div>
<dt>运行环境</dt>
<dd>{{ app_env }}</dd>
</div>
<div>
<dt>下一步</dt>
<dd>在这里接入配置页面与更细的受保护操作。</dd>
</div>
</dl>
{% endif %}
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Config · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Configuration</p>
<h1>Config</h1>
{% if force_password_change %}
<div class="alert">
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
</div>
{% endif %}
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
{% if config_error %}
<div class="alert">{{ config_error }}</div>
{% endif %}
{% if config_saved %}
<div class="notice">config saved to .env. Some changes may require an app restart.</div>
{% endif %}
<div class="meta single-column">
<div>
<dt>当前用户</dt>
<dd>admin</dd>
</div>
</div>
<section class="config-block">
<h2>Change Password</h2>
<form class="auth-form" method="post" action="/config/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
</section>
<section class="config-block">
<h2>Config</h2>
<form class="config-form" method="post" action="/config">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for section in config_sections %}
<fieldset class="config-section">
<legend>{{ section.name }}</legend>
{% for field in section.fields %}
<label>
<span>{{ field.label }}</span>
{% if field.secret %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
{% else %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
{% endif %}
</label>
{% endfor %}
</fieldset>
{% endfor %}
<button type="submit">Save Config</button>
</form>
</section>
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
+1 -1
View File
@@ -7,7 +7,7 @@
<p class="eyebrow">Authentication</p>
<h1>登录</h1>
<p class="lead">
这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入
登录成功后会进入受保护的 config 页面
</p>
{% if error_message %}