Fix setup persistence and load persisted runtime configuration

This commit is contained in:
2026-04-07 21:41:55 +02:00
parent be46547114
commit 6eab47f7ba
6 changed files with 188 additions and 15 deletions

View File

@@ -10,10 +10,10 @@ from __future__ import annotations
import structlog
from fastapi import APIRouter, HTTPException, status
from app.dependencies import AppDep, DbDep
from app.dependencies import AppDep, DbDep, SettingsDep
from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse, SetupTimezoneResponse
from app.services import setup_service
from app.utils.setup_state import set_setup_complete_cache
from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_cache
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -25,14 +25,14 @@ router = APIRouter(prefix="/api/setup", tags=["setup"])
response_model=SetupStatusResponse,
summary="Check whether setup has been completed",
)
async def get_setup_status(db: DbDep) -> SetupStatusResponse:
async def get_setup_status(app: AppDep) -> SetupStatusResponse:
"""Return whether the initial setup wizard has been completed.
Returns:
:class:`~app.models.setup.SetupStatusResponse` with ``completed``
set to ``True`` if setup is done, ``False`` otherwise.
"""
done = await setup_service.is_setup_complete(db)
done = is_setup_complete_cached(app)
return SetupStatusResponse(completed=done)
@@ -60,7 +60,7 @@ async def post_setup(
Raises:
HTTPException: 409 if setup has already been completed.
"""
if await setup_service.is_setup_complete(db):
if is_setup_complete_cached(app) or await setup_service.is_setup_complete(db):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Setup has already been completed.",
@@ -75,6 +75,14 @@ async def post_setup(
session_duration_minutes=body.session_duration_minutes,
)
set_setup_complete_cache(app, True)
app.state.settings = app.state.settings.model_copy(
update={
"database_path": body.database_path,
"fail2ban_socket": body.fail2ban_socket,
"timezone": body.timezone,
"session_duration_minutes": body.session_duration_minutes,
}
)
return SetupResponse()
@@ -83,7 +91,7 @@ async def post_setup(
response_model=SetupTimezoneResponse,
summary="Return the configured IANA timezone",
)
async def get_timezone(db: DbDep) -> SetupTimezoneResponse:
async def get_timezone(settings: SettingsDep) -> SetupTimezoneResponse:
"""Return the IANA timezone configured during the initial setup wizard.
The frontend uses this to convert UTC timestamps to the local time zone
@@ -94,5 +102,4 @@ async def get_timezone(db: DbDep) -> SetupTimezoneResponse:
set to the stored IANA identifier (e.g. ``"UTC"`` or
``"Europe/Berlin"``), defaulting to ``"UTC"`` if unset.
"""
tz = await setup_service.get_timezone(db)
return SetupTimezoneResponse(timezone=tz)
return SetupTimezoneResponse(timezone=settings.timezone)

View File

@@ -8,6 +8,7 @@ enforcing the rule that setup can only run once.
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING
import bcrypt
@@ -16,6 +17,7 @@ import structlog
if TYPE_CHECKING:
import aiosqlite
from app.db import init_db, open_db
from app.repositories import settings_repo
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -95,6 +97,9 @@ async def run_setup(
await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_HIGH, "100")
await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, "50")
await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_LOW, "20")
await _ensure_database_initialized(database_path)
# Mark setup as complete — must be last so a partial failure leaves
# setup_completed unset and does not lock out the user.
await settings_repo.set_setting(db, _KEY_SETUP_DONE, "1")
@@ -114,6 +119,58 @@ async def get_password_hash(db: aiosqlite.Connection) -> str | None:
return await util_get_password_hash(db)
async def get_persisted_runtime_settings(db: aiosqlite.Connection) -> dict[str, str | int]:
"""Return runtime configuration values persisted during initial setup."""
runtime_settings: dict[str, str | int] = {}
database_path = await settings_repo.get_setting(db, _KEY_DATABASE_PATH)
if database_path:
runtime_settings["database_path"] = database_path
fail2ban_socket = await settings_repo.get_setting(db, _KEY_FAIL2BAN_SOCKET)
if fail2ban_socket:
runtime_settings["fail2ban_socket"] = fail2ban_socket
timezone = await settings_repo.get_setting(db, _KEY_TIMEZONE)
if timezone:
runtime_settings["timezone"] = timezone
session_duration = await settings_repo.get_setting(db, _KEY_SESSION_DURATION)
if session_duration is not None:
try:
runtime_settings["session_duration_minutes"] = int(session_duration)
except ValueError:
log.warning(
"invalid_setup_setting",
key=_KEY_SESSION_DURATION,
value=session_duration,
)
return runtime_settings
async def _ensure_database_initialized(database_path: str) -> None:
"""Create and initialise the configured runtime database if it does not exist."""
database_path_obj = Path(database_path)
parent_dir = database_path_obj.parent
try:
parent_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
log.warning(
"cannot_create_runtime_database_parent",
database_path=database_path,
parent=str(parent_dir),
)
return
db = await open_db(str(database_path_obj))
try:
await init_db(db)
finally:
await db.close()
async def get_timezone(db: aiosqlite.Connection) -> str:
"""Return the configured IANA timezone string."""
tz = await settings_repo.get_setting(db, _KEY_TIMEZONE)

View File

@@ -24,6 +24,15 @@ from app.utils.setup_state import set_setup_complete_cache
log: structlog.stdlib.BoundLogger = structlog.get_logger()
async def _ensure_database_schema(database_path: str) -> None:
"""Create the configured runtime database if it does not already exist."""
db = await open_db(database_path)
try:
await init_db(db)
finally:
await db.close()
async def startup_shared_resources(
app: FastAPI,
settings: Settings,
@@ -44,6 +53,7 @@ async def startup_shared_resources(
log.debug("database_directory_ensured", directory=str(db_path.parent))
original_db_path = db_path.resolve()
startup_db = await open_db(settings.database_path)
try:
await init_db(startup_db)
@@ -52,6 +62,20 @@ async def startup_shared_resources(
setup_complete = await setup_service.is_setup_complete(startup_db)
set_setup_complete_cache(app, setup_complete)
log.debug("setup_completion_cached", completed=setup_complete)
if setup_complete:
persisted_runtime_settings = (
await setup_service.get_persisted_runtime_settings(startup_db)
)
if persisted_runtime_settings:
updated_settings = settings.model_copy(update=persisted_runtime_settings)
if Path(updated_settings.database_path).resolve() != original_db_path:
await _ensure_database_schema(updated_settings.database_path)
app.state.settings = updated_settings
log.info(
"runtime_settings_overridden_from_setup",
overrides=persisted_runtime_settings,
)
finally:
await startup_db.close()