Source code for exlab_wizard.api.routers.config

"""``/config`` router. Backend Spec §4.6.1, §4.9.

Endpoints:

* ``GET /config`` -- return the current ``config.yaml`` (always
  available; secrets stripped).
* ``PUT /config`` -- validate + persist new config; re-evaluate setup
  state.

Both endpoints are exempt from the setup-state gate by design (Backend
Spec §4.9.2: the operator needs a way to fix an incomplete config).
"""

from __future__ import annotations

import inspect
from typing import Any

from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict

from exlab_wizard.api._dependencies import (
    lims_password_present,
    nas_remote_available,
    require_deps,
)
from exlab_wizard.config.models import Config, EquipmentConfig, config_with_equipment_appended
from exlab_wizard.constants import SetupState
from exlab_wizard.errors import ConfigError
from exlab_wizard.logging import get_logger
from exlab_wizard.paths import (
    app_root_writable,
    evaluate_setup_state,
    setup_state_missing,
    setup_state_next_action,
)

__all__ = [
    "ConfigUpdateResponse",
    "EquipmentAppendResponse",
    "build_config_router",
]

_log = get_logger(__name__)


[docs] class ConfigUpdateResponse(BaseModel): """``PUT /config`` response with the new setup state.""" model_config = ConfigDict(extra="forbid") state: str missing: list[dict[str, str]] next_action: str | None ready: bool
[docs] class EquipmentAppendResponse(BaseModel): """``POST /config/equipment`` response: the new setup state + appended id. Redesign §6: the Add-Equipment wizard posts a single new ``EquipmentConfig``; the router validates it, persists, and re-evaluates the setup-incomplete state. """ model_config = ConfigDict(extra="forbid") appended_id: str state: str missing: list[dict[str, str]] next_action: str | None ready: bool
[docs] def build_config_router() -> APIRouter: """Construct the ``/config`` router. Routes are always available.""" router = APIRouter(tags=["config"]) @router.get("/config", response_model=Config) async def get_config(request: Request) -> Config: deps = require_deps(request) config = getattr(deps, "config", None) # Empty default config is the right shape when no config.yaml # exists on disk. Frontend treats this the same as # INCOMPLETE_NO_CONFIG. The Config model carries no in-band # secrets (passwords live in the keyring) so no redaction pass # is needed here. return config if config is not None else Config() @router.put("/config", response_model=ConfigUpdateResponse) async def put_config(request: Request, body: Config) -> ConfigUpdateResponse: deps = require_deps(request) # Persist via the host-supplied saver (loader.save_config in # production). Tests can substitute a no-op. saver = getattr(deps, "save_config", None) if saver is not None: await _await_or_call(saver, body) # Push the new config into the running components (logging, sync, # equipment, validator, LIMS, plugins) so the change takes effect # in-process -- no tray relaunch. Sets ``deps.config`` itself. _apply_live_config(deps, body) # Re-evaluate setup state with the new config. remote_lookup = lambda remote: nas_remote_available(deps, remote) # noqa: E731 state = evaluate_setup_state( deps.config, lims_reachable=getattr(deps, "lims_reachable", True), keyring_password_present=lims_password_present(deps), nas_remote_available=remote_lookup, paths_writable=app_root_writable(deps.config) if deps.config is not None else True, ) return ConfigUpdateResponse( state=state.value, missing=setup_state_missing(state, deps.config), next_action=setup_state_next_action(state), ready=state is SetupState.READY, ) @router.post( "/config/equipment", response_model=EquipmentAppendResponse, ) async def append_equipment(request: Request, body: EquipmentConfig) -> EquipmentAppendResponse: """Append a validated ``EquipmentConfig`` to the live config. Redesign §6: the Add-Equipment wizard's confirm step posts here. Duplicate IDs are rejected with a structured error per §10. """ deps = require_deps(request) try: new_config = config_with_equipment_appended(getattr(deps, "config", None), body) except ConfigError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": "equipment_id_conflict", "message": str(exc)}, ) from exc saver = getattr(deps, "save_config", None) if saver is not None: await _await_or_call(saver, new_config) # Push into the running components so the new equipment is live # without a tray relaunch. Sets ``deps.config`` itself. _apply_live_config(deps, new_config) remote_lookup = lambda remote: nas_remote_available(deps, remote) # noqa: E731 state = evaluate_setup_state( deps.config, lims_reachable=getattr(deps, "lims_reachable", True), keyring_password_present=lims_password_present(deps), nas_remote_available=remote_lookup, paths_writable=app_root_writable(deps.config) if deps.config is not None else True, ) return EquipmentAppendResponse( appended_id=body.id, state=state.value, missing=setup_state_missing(state, deps.config), next_action=setup_state_next_action(state), ready=state is SetupState.READY, ) return router
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _await_or_call(callable_: Any, *args: Any) -> Any: """Invoke a saver that may be sync or async.""" result = callable_(*args) if inspect.isawaitable(result): return await result return result def _apply_live_config(deps: Any, cfg: Any) -> None: """Push ``cfg`` into the running components, then keep it as the live config. Imported lazily to avoid the ``tray.dependencies -> api.app -> api.routers.config`` import cycle. ``apply_live_config`` is best-effort per component and assigns ``deps.config`` itself; the fallback covers the unexpected case where the import or coordinator raises wholesale. """ try: from exlab_wizard.tray.dependencies import apply_live_config apply_live_config(deps, cfg) except Exception: _log.exception("live config reload failed") deps.config = cfg