Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 cf0e290066 Add admin quest-definition list route
GET /api/v1/admin/quest-definitions returns every definition, active and
inactive, in rising id order with the same shape as the create/update
response. Backed by a new core list_all_definitions helper over
QuestDefinitionsDao.list_all/list_snapshots_all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:18:21 -04:00
dev_agent a808f89993 Feature 21: Admin Today Snapshot API (#190) (#191)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:01:25 -04:00
5 changed files with 761 additions and 0 deletions
+149
View File
@@ -116,6 +116,22 @@ owns every rule so the two routes cannot drift:
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
The snapshot route (task ef3f4877) is the admin-plane twin of the
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
the PWA is internet-facing and never holds the panel service token
(D-012), so it reads the same snapshot here:
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
child's instances through
:func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True`` — admin payloads KEEP missed instances with
their ``missed`` state (D-009), where the panel omits them. The
body is the panel's documented shape (``today_iso``, ``cycle_day``,
``children`` with the per-child presence, rollup counts and
``instances``); the builder, the state derivation and the shaping
all live in the core.
The settings routes (task 2b3de7e5) follow the same pattern over
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
store. The API now OWNS settings in the database (a validated JSON
@@ -254,6 +270,7 @@ from api.nestquest_core import (
core_recurrence,
core_settings,
core_settings_store,
core_snapshot,
core_sweep,
)
@@ -351,6 +368,12 @@ class AdminQuestDefinitionResponse(BaseModel):
windows: list[AdminDefinitionWindowResponse]
class AdminQuestDefinitionListResponse(BaseModel):
"""The definitions list: every definition, active AND inactive, by id."""
definitions: list[AdminQuestDefinitionResponse]
class AdminChildCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/children``.
@@ -683,6 +706,59 @@ class AdminMissedSweepResponse(BaseModel):
fired: int
class AdminInstancePayload(BaseModel):
"""One instance in the admin snapshot payload.
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
``missed`` (admin payloads keep missed instances, D-009),
``on_time`` passes the completion event's flag through verbatim
(``None`` unless completed), 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 AdminChildSnapshotPayload(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;
#: ``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[AdminInstancePayload]
class AdminSnapshotResponse(BaseModel):
"""Today's whole household snapshot for the admin Today tab.
The panel snapshot's shape (``today_iso``, ``cycle_day``,
``children`` in the household's sort order), with missed instances
kept in each child's ``instances``.
"""
today_iso: str
cycle_day: int
children: list[AdminChildSnapshotPayload]
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -1098,6 +1174,32 @@ async def admin_reorder_children(
return {"status": "ok"}
@router.get(
"/quest-definitions",
summary="List quest definitions",
response_model=AdminQuestDefinitionListResponse,
)
async def admin_list_quest_definitions(
request: Request,
) -> AdminQuestDefinitionListResponse:
"""Return every quest definition in rising id order.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
active AND inactive definitions included (the admin screen manages
both), each serialized exactly as the create/edit responses are.
"""
state: DatabaseState = request.app.state.db
bundles = await core_quest_definitions.list_all_definitions(
state.database
)
return AdminQuestDefinitionListResponse(
definitions=[_quest_definition_response(bundle) for bundle in bundles]
)
@router.post(
"/quest-definitions",
summary="Create a quest definition",
@@ -1729,4 +1831,51 @@ async def admin_run_missed_sweep(
return AdminMissedSweepResponse(fired=len(events))
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=AdminSnapshotResponse,
)
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
"""Return today's full household snapshot for the admin Today tab.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE clock read
(:func:`_local_now`) goes into
:func:`nestquest_core.snapshot.build_snapshot` against the live
database, and each child's instances are shaped with
``instance_payload(include_missed=True)`` — missed instances are
KEPT in the admin payload (D-009) — into
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
AdminChildSnapshotPayload(
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=[
AdminInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=True
)
],
)
for child in snapshot.children
],
)
__all__ = ["router"]
@@ -907,6 +907,13 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def list_all(self) -> list[QuestDefinitionRecord]:
"""Return every definition, active AND inactive, oldest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
@@ -951,6 +958,18 @@ class QuestDefinitionsDao:
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
"""Return every definition (active AND inactive) with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
(oldest first), with the same coherence guarantee.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_all()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
@@ -558,6 +558,21 @@ async def list_active_definitions(
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_all_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return EVERY definition, active AND inactive (rich view).
Mirrors :func:`list_active_definitions` without the activity
filter: ordered by rising definition id, each entry the same
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
locked transaction.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_all()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
+250
View File
@@ -0,0 +1,250 @@
"""Admin-plane quest-definition LIST route tests (task d4667573).
``GET /api/v1/admin/quest-definitions`` against the ASGI app with httpx,
reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely authenticates
with NO network access.
- An admin JWT lists EVERY definition — active AND inactive — in rising
id order, each in the same documented shape as the create/edit
responses (id, title, description, icon, is_active, rule, assignees,
windows).
- An empty household lists nothing: 200 with an empty list.
- Authentication runs through the router's ONE ``require_admin``
dependency: an absent credential is 401, the panel service token is
refused outright (403, the credential-type detail), and a JWT without
the nestquest-admins group is 403.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
#: The admin list route under test.
LIST_PATH = "/api/v1/admin/quest-definitions"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture
async def admin_client(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client over a fresh (empty) household."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield client
async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]:
"""Seed one child, an ACTIVE definition and an INACTIVE one.
Returns ``(child_id, active_id, inactive_id)``. Seeded through the
admin routes so the rows are exactly what the admin plane writes.
"""
child = await client.post(
"/api/v1/admin/children",
json={"display_name": "Ada"},
headers=_admin_headers(),
)
assert child.status_code == 201
child_id = child.json()["id"]
active = await client.post(
LIST_PATH,
json={
"title": "Tidy the den",
"rule": {
"rule_type": "weekly",
"interval": 1,
"weekday_set": [0, 2],
"start_date": "2026-03-02",
},
"assignee_child_ids": [child_id],
"windows": ["morning", ["evening", "19:30"]],
"description": "Put everything back where it belongs.",
"icon": "toy-box",
},
headers=_admin_headers(),
)
assert active.status_code == 201
inactive = await client.post(
LIST_PATH,
json={
"title": "Water the plants",
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child_id],
"windows": ["afternoon"],
},
headers=_admin_headers(),
)
assert inactive.status_code == 201
inactive_id = inactive.json()["id"]
deactivated = await client.patch(
f"{LIST_PATH}/{inactive_id}/active",
json={"is_active": False},
headers=_admin_headers(),
)
assert deactivated.status_code == 200
return child_id, active.json()["id"], inactive_id
async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
admin_client: object,
) -> None:
"""Both definitions come back, by rising id, in the documented shape."""
child_id, active_id, inactive_id = await _seed_active_and_inactive(
admin_client
)
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {
"definitions": [
{
"id": active_id,
"title": "Tidy the den",
"description": "Put everything back where it belongs.",
"icon": "toy-box",
"is_active": True,
"rule": {
"rule_type": "weekly",
"interval": 1,
"weekday_set": [0, 2],
"day_of_month": None,
"nth_weekday": None,
"nth_weekday_weekday": None,
"month": None,
"start_date": "2026-03-02",
"end_date": None,
},
"assignees": [{"id": child_id, "display_name": "Ada"}],
"windows": [
{"window": "morning", "due_time": None},
{"window": "evening", "due_time": "19:30"},
],
},
{
"id": inactive_id,
"title": "Water the plants",
"description": None,
"icon": None,
"is_active": False,
"rule": {
"rule_type": "daily",
"interval": 1,
"weekday_set": None,
"day_of_month": None,
"nth_weekday": None,
"nth_weekday_weekday": None,
"month": None,
"start_date": "2026-03-01",
"end_date": None,
},
"assignees": [{"id": child_id, "display_name": "Ada"}],
"windows": [{"window": "afternoon", "due_time": None}],
},
]
}
async def test_list_items_match_the_create_response_shape(
admin_client: object,
) -> None:
"""Each listed item is exactly what the create route returned."""
child = await admin_client.post(
"/api/v1/admin/children",
json={"display_name": "Ben"},
headers=_admin_headers(),
)
created = await admin_client.post(
LIST_PATH,
json={
"title": "Feed the cat",
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child.json()["id"]],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {"definitions": [created.json()]}
async def test_empty_household_lists_no_definitions(
admin_client: object,
) -> None:
"""With no definitions stored the list is 200 and empty."""
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {"definitions": []}
async def test_list_without_credentials_is_401(admin_client: object) -> None:
"""An absent credential is the uniform 401."""
response = await admin_client.get(LIST_PATH)
assert response.status_code == 401
async def test_list_with_panel_service_token_is_403(
admin_client: object,
) -> None:
"""The panel service token is refused outright, never a JWT."""
response = await admin_client.get(
LIST_PATH,
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await admin_client.get(
LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"}
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
+328
View File
@@ -0,0 +1,328 @@
"""Admin-plane snapshot route tests (task ef3f4877).
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
authenticates with NO network access. The household is seeded with
:func:`tests.test_api_panel._seed_household` through the SAME core
copy the app uses (``nestquest_core.*``), and both routes' single
clock read (``_local_now``) is pinned to noon so nothing straddles
midnight.
Proven per the done-condition:
- An admin JWT returns 200 with the documented shape: ``today_iso``,
``cycle_day`` and, per child, the presence fields, the rollup counts
and ``instances`` in the core ``instance_payload`` shape.
- A MISSED instance is KEPT in the admin payload (D-009) and still
OMITTED from the panel payload. The core builder lists only
instances due on ``now.date()``, and ``derive_state`` calls an
instance missed only when its due date is BEFORE that day — so a
real snapshot never carries a missed view. The test therefore
wraps the real builder to add the seeded past-due instance as a
view whose state the core's own ``derive_state`` computes
(``missed``), and reads it through both routes.
- Auth runs through the router's ONE ``require_admin`` dependency: no
credential is 401, the panel service token is refused outright
(403, the credential-type detail), and a non-admin JWT is 403.
- The handler performs exactly ONE clock read per request.
"""
from __future__ import annotations
import dataclasses
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
# The completion layer through the SAME core copy the app uses — the
# missed test derives its view's state with the core's own rule.
_completion = importlib.import_module("nestquest_core.completion")
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
"""The Authorization header carrying the harness panel token."""
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def snapshot_household(
temp_db_path: str, monkeypatch
) -> SimpleNamespace:
"""A seeded household served by the admin-plane app, clock pinned.
``today`` comes from one host clock read; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
the admin and the panel route's ``_local_now`` return it, and the
admin one counts its calls.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(pinned_now)
return pinned_now
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
today=today,
pinned_now=pinned_now,
pinned_utc_iso=pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
clock_reads=clock_reads,
)
# --- authentication: the ONE require_admin dependency ----------------------
async def test_snapshot_without_credential_is_401(
snapshot_household: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
assert response.status_code == 401
async def test_snapshot_refuses_panel_service_token_outright(
snapshot_household: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403).
403 with the credential-type detail — not the uniform 401 and not
the non-admin group detail — proves the token was refused outright,
never tried as a JWT.
"""
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot", headers=_panel_headers()
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_snapshot_refuses_non_admin_jwt(
snapshot_household: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
# --- the payload -----------------------------------------------------------
async def test_admin_jwt_returns_documented_snapshot(
snapshot_household: SimpleNamespace,
) -> None:
"""An admin JWT returns 200 with the documented snapshot shape.
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
"""
client = snapshot_household.client
seed = snapshot_household.seed
response = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert response.status_code == 200
assert len(snapshot_household.clock_reads) == 1
body = response.json()
assert body == {
"today_iso": snapshot_household.today.isoformat(),
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
"cycle_day": 2,
"children": [
{
"child_id": seed.ada.id,
"child_name": "Ada",
"present": False,
"next_present": seed.returns_date.isoformat(),
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{
"id": seed.pack_bag_instance.id,
"definition_id": seed.pack_bag_instance.definition_id,
"child_id": seed.ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
],
},
{
"child_id": seed.bo.id,
"child_name": "Bo",
"present": True,
"next_present": None,
"due_today": 1,
"completed_today": 1,
"remaining_today": 0,
"completion_pct": 100,
"instances": [
{
"id": seed.brush_teeth_instance.id,
"definition_id": (
seed.brush_teeth_instance.definition_id
),
"child_id": seed.bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": snapshot_household.pinned_utc_iso,
"on_time": True,
}
],
},
],
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
async def test_admin_keeps_missed_instance_panel_omits_it(
snapshot_household: SimpleNamespace, monkeypatch
) -> None:
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
The real builder is wrapped to add the seeded past-due Stale chore
instance to Ada's views, its state derived by the core's own
``derive_state`` (no events, due yesterday => ``missed``). Both
routes read the SAME wrapped snapshot, so the only difference is
each route's ``include_missed`` flag.
"""
client = snapshot_household.client
seed = snapshot_household.seed
stale = seed.stale_instance
missed_state = _completion.derive_state(
stale, None, snapshot_household.today
)
assert missed_state == "missed"
missed_view = core_snapshot.QuestInstanceView(
instance_id=stale.id,
definition_id=stale.definition_id,
child_id=stale.child_id,
title="Stale chore",
icon=None,
window=stale.window,
due_date=stale.due_date,
due_time=stale.due_time,
state=missed_state,
overdue=False,
completed_at=None,
was_on_time=None,
)
real_build_snapshot = core_snapshot.build_snapshot
async def _with_missed_view(database, settings, now):
snapshot = await real_build_snapshot(database, settings, now)
return dataclasses.replace(
snapshot,
children=tuple(
dataclasses.replace(
child, instances=(*child.instances, missed_view)
)
if child.child_id == seed.ada.id
else child
for child in snapshot.children
),
)
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
admin = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert admin.status_code == 200
admin_ada = admin.json()["children"][0]
assert admin_ada["child_id"] == seed.ada.id
assert [instance["id"] for instance in admin_ada["instances"]] == [
seed.pack_bag_instance.id,
stale.id,
]
assert admin_ada["instances"][1] == {
"id": stale.id,
"definition_id": stale.definition_id,
"child_id": seed.ada.id,
"title": "Stale chore",
"icon": None,
"window": "morning",
"due_time": "09:00",
"state": "missed",
"overdue": False,
"completed_at": None,
"on_time": None,
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
panel_ada = panel.json()["children"][0]
assert [instance["id"] for instance in panel_ada["instances"]] == [
seed.pack_bag_instance.id
], "the panel payload must still omit missed instances"