The panel plane (D-012) needs an authenticated read surface the HA integration polls: GET /api/v1/panel/snapshot. The token check lives in ONE reusable dependency (api/dependencies.require_panel_token) — missing, malformed, wrong, and empty-configured credentials all fail closed with the same 401 (constant-time compare, uniform detail) — and the panel router attaches it once so every later panel route inherits the check. ApiConfig gains the required NESTQUEST_PANEL_TOKEN (same fail-fast discipline as NESTQUEST_DB_PATH: unset/empty is a ConfigError, never a silent default), create_app stores the resolved config on app.state so the dependency reads it without module state, and the snapshot handler stays a thin adapter: it calls the pure core build_snapshot with the live database and one timezone-aware clock read, shapes each child's instances through core.instance_payload(include_missed=False) so missed instances are omitted from the panel payload (D-009), and returns the shape documented by the PanelSnapshotResponse models in OpenAPI. Tests (tests/test_api_panel.py) seed the household through the SAME nestquest_core.* copy the app uses, pin the route's single clock read, and assert 401 for missing/wrong/malformed tokens, 200 with the full documented payload (children in sort order, counts, presence, cycle_day), and that a past-due open (missed) instance never reaches the payload.
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""Panel-plane routes for the NestQuest API service.
|
|
|
|
The panel plane (``/api/v1/panel/*``, D-012) serves the wall panel's
|
|
reads. Every route on :data:`router` requires the panel service token
|
|
through the ONE reusable dependency :func:`api.dependencies.require_panel_token`
|
|
(declared once at the router level, so each later panel route added to
|
|
this router inherits the check).
|
|
|
|
The snapshot route is a thin adapter over the bundled core: it performs
|
|
NO business logic. It reads the live database off ``app.state.db``,
|
|
resolves ONE timezone-aware ``now`` (the API host's local time — the
|
|
API is the household's "local" host now that the domain no longer lives
|
|
inside Home Assistant), calls the pure
|
|
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
|
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
|
``include_missed=False`` (D-009: missed instances are OMITTED from the
|
|
panel payload), and returns the documented JSON shape:
|
|
|
|
.. code-block:: json
|
|
|
|
{
|
|
"today_iso": "2026-09-22",
|
|
"cycle_day": 2,
|
|
"children": [
|
|
{
|
|
"child_id": 1,
|
|
"child_name": "Ada",
|
|
"present": false,
|
|
"next_present": "2026-09-24",
|
|
"due_today": 1,
|
|
"completed_today": 0,
|
|
"remaining_today": 1,
|
|
"completion_pct": 0,
|
|
"instances": [
|
|
{"id": 7, "definition_id": 3, "child_id": 1,
|
|
"title": "Pack bag", "icon": null, "window": "morning",
|
|
"due_time": "10:00", "state": "open", "overdue": true,
|
|
"completed_at": null, "on_time": null}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
``children`` follows the household's sort order (the core builder lists
|
|
active children ``ORDER BY sort_order, id``); ``state`` is the
|
|
documented panel spelling ``open`` | ``completed`` (§1 of
|
|
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
|
|
in the first scheduled child's custody cycle (0 when no child has one).
|
|
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
|
``response_model``, so the OpenAPI document carries the shape too.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from pydantic import BaseModel
|
|
|
|
from api.database import DatabaseState
|
|
from api.dependencies import require_panel_token
|
|
from api.nestquest_core import core_snapshot, core_settings
|
|
|
|
#: All panel-plane routes share this router; the service-token check is
|
|
#: attached HERE so every panel route (current and later) requires it.
|
|
router = APIRouter(
|
|
prefix="/api/v1/panel",
|
|
dependencies=[Depends(require_panel_token)],
|
|
)
|
|
|
|
|
|
class PanelInstancePayload(BaseModel):
|
|
"""One instance in the documented panel payload shape (§1).
|
|
|
|
Mirrors :func:`nestquest_core.snapshot.instance_payload`:
|
|
``state`` is ``open`` | ``completed`` (missed instances never reach
|
|
the panel payload), ``on_time`` passes the completion event's flag
|
|
through verbatim (``None`` for an open instance), and
|
|
``completed_at`` is the event's UTC timestamp when completed.
|
|
"""
|
|
|
|
id: int
|
|
definition_id: int
|
|
child_id: int
|
|
title: str
|
|
icon: str | None
|
|
window: str
|
|
due_time: str | None
|
|
state: str
|
|
overdue: bool
|
|
completed_at: str | None
|
|
on_time: bool | None
|
|
|
|
|
|
class PanelChildPayload(BaseModel):
|
|
"""One child's day: presence, rollup counts, and today's instances."""
|
|
|
|
child_id: int
|
|
child_name: str
|
|
present: bool
|
|
#: ISO date of the child's next present day while they are away
|
|
#: (the away plate renders ``Returns <weekday>, <Mon D>`` from it);
|
|
#: ``None`` when the child is present today.
|
|
next_present: str | None
|
|
due_today: int
|
|
completed_today: int
|
|
remaining_today: int
|
|
completion_pct: int
|
|
instances: list[PanelInstancePayload]
|
|
|
|
|
|
class PanelSnapshotResponse(BaseModel):
|
|
"""The whole household snapshot the panel renders from.
|
|
|
|
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
|
|
1-based day in the first scheduled child's custody cycle (0 when no
|
|
child has a schedule); ``children`` is in the household's sort
|
|
order.
|
|
"""
|
|
|
|
today_iso: str
|
|
cycle_day: int
|
|
children: list[PanelChildPayload]
|
|
|
|
|
|
def _local_now() -> datetime.datetime:
|
|
"""Return the API host's local time as ONE timezone-aware clock read.
|
|
|
|
``now().astimezone()`` attaches the host's local timezone to a
|
|
single clock read, so the calendar date and wall-clock time the
|
|
builder derives can never disagree. Kept as a module function (not
|
|
inlined) so tests pin the instant the same way the coordinator's
|
|
``_local_now`` is pinned.
|
|
"""
|
|
return datetime.datetime.now().astimezone()
|
|
|
|
|
|
@router.get(
|
|
"/snapshot",
|
|
summary="Today's household snapshot",
|
|
response_model=PanelSnapshotResponse,
|
|
)
|
|
async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
|
"""Return today's full household snapshot for the panel.
|
|
|
|
Requires the panel service token (checked by the router's shared
|
|
:func:`~api.dependencies.require_panel_token` dependency). Builds
|
|
the snapshot through the pure core builder against the live
|
|
database and shapes each child's instances with
|
|
``instance_payload(include_missed=False)`` — missed instances are
|
|
OMITTED from the panel payload (D-009) — then returns the shape
|
|
documented in this module's docstring and
|
|
:class:`PanelSnapshotResponse`.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
snapshot = await core_snapshot.build_snapshot(
|
|
state.database, core_settings.NestQuestSettings(), _local_now()
|
|
)
|
|
return PanelSnapshotResponse(
|
|
today_iso=snapshot.today_iso,
|
|
cycle_day=snapshot.cycle_day,
|
|
children=[
|
|
PanelChildPayload(
|
|
child_id=child.child_id,
|
|
child_name=child.child_name,
|
|
present=child.present,
|
|
next_present=child.next_present,
|
|
due_today=child.due_today,
|
|
completed_today=child.completed_today,
|
|
remaining_today=child.remaining_today,
|
|
completion_pct=child.completion_pct,
|
|
instances=[
|
|
PanelInstancePayload.model_validate(instance)
|
|
for instance in core_snapshot.instance_payload(
|
|
child.instances, include_missed=False
|
|
)
|
|
],
|
|
)
|
|
for child in snapshot.children
|
|
],
|
|
)
|
|
|
|
|
|
__all__ = ["router"]
|