Source code for exlab_wizard.api.setup

"""Setup-state gate + ``/setup/*`` endpoints. Backend Spec §4.6, §4.9.

Two responsibilities live here:

1. The **setup-state gate** -- a per-request dependency that consults
   :func:`paths.evaluate_setup_state` and returns 503 with
   ``code: "setup_incomplete"`` for routes that need a complete
   ``config.yaml`` (creation, browse, problems). Routes that must
   remain available during onboarding (``/setup/*``, ``/config``,
   ``/health``) skip the dependency.
2. The **setup endpoints** -- ``GET /setup/status``,
   ``POST /setup/test-lims``, ``POST /setup/test-equipment``,
   ``POST /setup/autostart``. These are the wizard's "diagnostics"
   surface and must work in any setup state.

Per Backend §4.9.4, ``INCOMPLETE_LIMS_UNREACHABLE`` is a soft block:
the gate treats it as ``READY`` for endpoint-gating purposes; the
``/setup/status`` endpoint surfaces the soft state separately so the
banner can render.
"""

from __future__ import annotations

import inspect
from collections.abc import Callable
from typing import Any

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

from exlab_wizard.api._dependencies import (
    lims_password_present,
    nas_remote_available,
    require_deps,
)
from exlab_wizard.config.models import (
    EquipmentConfig,
    LIMSConfig,
)
from exlab_wizard.constants import SetupState
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__ = [
    "AutostartRequest",
    "EquipmentTestRequest",
    "LIMSTestRequest",
    "ProbeResult",
    "SetupStatusResponse",
    "TestEquipmentRequest",
    "TestLIMSRequest",
    "TestResult",
    "build_setup_router",
    "compute_setup_state",
    "is_creation_blocked",
    "setup_state_gate",
]

_log = get_logger(__name__)


# ---------------------------------------------------------------------------
# Pydantic request / response models
# ---------------------------------------------------------------------------


[docs] class SetupStatusResponse(BaseModel): """``GET /setup/status`` response. Backend Spec §4.9.3.""" model_config = ConfigDict(extra="forbid") state: str missing: list[dict[str, str]] = Field(default_factory=list) next_action: str | None = None ready: bool
[docs] class LIMSTestRequest(BaseModel): """``POST /setup/test-lims`` request body. Either reference the currently-configured LIMS settings (no body fields) or supply a ``LIMSConfig`` candidate to test before save. Class is named ``LIMSTestRequest`` (rather than ``TestLIMSRequest``) so pytest does not pick it up as a test class on collection. """ model_config = ConfigDict(extra="forbid") lims: LIMSConfig | None = None password: str | None = None
[docs] class EquipmentTestRequest(BaseModel): """``POST /setup/test-equipment`` request body. Rclone-only NAS sync migration (2026-05-26). The pre-save body-equipment path was removed: the probe needs the keyring password, which is looked up by equipment id, and a candidate equipment that hasn't been saved yet cannot have a keyring entry by definition. Callers must therefore reference an already-saved equipment by id. """ model_config = ConfigDict(extra="forbid") equipment_id: str
[docs] class ProbeResult(BaseModel): """Common ``ok``/``reason`` payload for the diagnostics endpoints.""" model_config = ConfigDict(extra="forbid") ok: bool reason: str | None = None latency_ms: int | None = None
# Backwards-compatible aliases for legacy imports. Kept as a separate # binding rather than via assignment so the API documentation reflects # the canonical names above. TestLIMSRequest = LIMSTestRequest TestEquipmentRequest = EquipmentTestRequest TestResult = ProbeResult
[docs] class AutostartRequest(BaseModel): """``POST /setup/autostart`` request body. Backend Spec §4.9.5 step 0.""" model_config = ConfigDict(extra="forbid") enabled: bool
class AutostartResult(BaseModel): """``POST /setup/autostart`` response.""" model_config = ConfigDict(extra="forbid") enabled: bool registered: bool # --------------------------------------------------------------------------- # Setup-state evaluation # ---------------------------------------------------------------------------
[docs] def compute_setup_state(deps: Any) -> SetupState: """Evaluate the §4.9.1 state for the app's current dependencies. The dependency object exposes ``config``, a ``lims_reachable`` boolean (cached at startup; the ``POST /setup/test-lims`` endpoint refreshes it), and (rclone.conf NAS-sync migration) a ``nas_remote_available`` predicate that answers whether a named rclone remote is present in rclone.conf. """ config = deps.config return evaluate_setup_state( config, lims_reachable=getattr(deps, "lims_reachable", True), keyring_password_present=lims_password_present(deps), nas_remote_available=lambda remote: nas_remote_available(deps, remote), paths_writable=app_root_writable(config) if config is not None else True, )
[docs] def is_creation_blocked(state: SetupState) -> bool: """Return True when ``state`` should gate creation flows. Per §4.9.4 the soft block (``INCOMPLETE_LIMS_UNREACHABLE``) does NOT gate creation -- the operator may be on an offline machine using the cached project list. ``READY`` obviously does not gate. """ return state not in (SetupState.READY, SetupState.INCOMPLETE_LIMS_UNREACHABLE)
[docs] def setup_state_gate(request: Request) -> None: """FastAPI dependency that gates a route on setup state. Looks up the app's bound :class:`AppDependencies`, evaluates the setup state, and raises 503 with the §4.9.2 envelope when the state is any non-soft INCOMPLETE_*. The dependency itself is a plain function so it can be overridden in tests via ``app.dependency_overrides``. """ deps = getattr(request.app.state, "dependencies", None) if deps is None: # No app dependencies wired -- treat as READY (e.g. unit tests # constructing a bare FastAPI). The gate is opt-in; routes that # need it consume this dependency explicitly. return state = compute_setup_state(deps) if not is_creation_blocked(state): return missing = setup_state_missing(state, deps.config) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ "code": "setup_incomplete", "message": "setup is incomplete; complete onboarding before using this endpoint", "state": state.value, "missing": missing, }, )
# --------------------------------------------------------------------------- # Router construction # ---------------------------------------------------------------------------
[docs] def build_setup_router() -> APIRouter: """Construct the ``/setup/*`` router. Always-available endpoints.""" router = APIRouter(prefix="/setup", tags=["setup"]) @router.get("/status", response_model=SetupStatusResponse) async def get_setup_status(request: Request) -> SetupStatusResponse: deps = require_deps(request) state = compute_setup_state(deps) return SetupStatusResponse( state=state.value, missing=setup_state_missing(state, deps.config), next_action=setup_state_next_action(state), ready=state is SetupState.READY, ) @router.post("/test-lims", response_model=TestResult) async def test_lims(request: Request, body: TestLIMSRequest | None = None) -> TestResult: deps = require_deps(request) probe = getattr(deps, "lims_probe", None) if probe is None: return TestResult( ok=False, reason="LIMS probe is not wired on this app instance", ) try: result = await _await_or_call(probe, body) except Exception as exc: return TestResult(ok=False, reason=str(exc)) if isinstance(result, TestResult): return result if isinstance(result, dict): return _coerce_probe_dict(result) return TestResult(ok=bool(result)) @router.post("/test-equipment", response_model=TestResult) async def test_equipment(request: Request, body: TestEquipmentRequest) -> TestResult: deps = require_deps(request) probe = getattr(deps, "equipment_probe", None) if probe is None: return TestResult( ok=False, reason="equipment probe is not wired on this app instance", ) equipment = _resolve_equipment(deps, body) if equipment is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={ "code": "equipment_not_found", "message": f"no equipment with id {body.equipment_id!r}", }, ) try: result = await _await_or_call(probe, equipment) except Exception as exc: return TestResult(ok=False, reason=str(exc)) if isinstance(result, TestResult): return result if isinstance(result, dict): return _coerce_probe_dict(result) return TestResult(ok=bool(result)) @router.post("/autostart", response_model=AutostartResult) async def set_autostart(request: Request, body: AutostartRequest) -> AutostartResult: deps = require_deps(request) toggle = getattr(deps, "autostart_toggle", None) if toggle is None: # No tray module wired (e.g. integration tests). Echo the # operator's choice; persistence is handled by the caller. return AutostartResult(enabled=body.enabled, registered=body.enabled) try: registered = await _await_or_call(toggle, body.enabled) except Exception as exc: _log.warning("autostart toggle failed: %s", exc) return AutostartResult(enabled=body.enabled, registered=False) return AutostartResult(enabled=body.enabled, registered=bool(registered)) return router
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _coerce_probe_dict(payload: dict[str, Any]) -> ProbeResult: """Build a ``ProbeResult`` from a probe's plain-dict return value. Wired through a helper so the ``ok`` field gets coerced to ``bool`` explicitly (probes sometimes return truthy non-bool values such as ``1`` or ``"yes"``); this keeps mypy happy and matches the field's declared type. """ reason = payload.get("reason") latency_ms = payload.get("latency_ms") return ProbeResult( ok=bool(payload.get("ok")), reason=str(reason) if reason is not None else None, latency_ms=int(latency_ms) if latency_ms is not None else None, ) def _resolve_equipment(deps: Any, body: TestEquipmentRequest) -> EquipmentConfig | None: """Resolve the equipment to probe by id through ``deps.config``. Rclone-only NAS sync migration (2026-05-26). The endpoint requires ``equipment_id`` -- the probe needs the keyring password, which is keyed by equipment id, so a body-equipment candidate that has not been saved yet cannot satisfy the probe. Returns ``None`` when the config is unloaded, the equipment list is empty, or the id does not match a registered entry. """ config = getattr(deps, "config", None) if config is None or not getattr(config, "equipment", None): return None for entry in config.equipment: if entry.id == body.equipment_id: return entry return None async def _await_or_call(callable_: Callable[..., Any], *args: Any) -> Any: """Invoke a probe that may be sync or async; await the result. The probes are typed loosely on the dependencies object so tests can pass simple lambdas. We accept either a coroutine function or a plain callable. """ result = callable_(*args) if inspect.isawaitable(result): return await result return result