From 97e396585924db658958c64ff5c4aa3aeb7e8a33 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 21:40:41 -0400 Subject: [PATCH] Add admin presence schedule and override read routes GET /api/v1/admin/children/{child_id}/presence-schedule returns {"schedule": ...} (null when unset, 404 for an unknown child) and GET /api/v1/admin/presence-overrides returns {"overrides": [...]} with optional child_id/start/end filters. Both are thin adapters over new core reads in presence_management (get_presence_schedule, list_presence_overrides) backed by PresenceOverridesDao.list_filtered. Task 07fde892-ddd0-4d8f-a03f-8775e77660e5 Co-Authored-By: Claude Opus 5.5 --- api/routes_admin.py | 96 +++++ .../nestquest/core/dao_presence.py | 51 +++ .../nestquest/core/presence_management.py | 40 ++ tests/test_api_admin_presence_reads.py | 345 ++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 tests/test_api_admin_presence_reads.py diff --git a/api/routes_admin.py b/api/routes_admin.py index 614b47f..4d694b9 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -58,6 +58,17 @@ layer the HA services share, so the two planes cannot drift: (children are never hard-deleted; overrides are deletable by design) and regenerates that override's child. It answers ``{"status": "ok"}``. +- ``GET /children/{child_id}/presence-schedule`` reads the child's + schedule through ONE ``get_presence_schedule`` call, wrapped as + ``{"schedule": ...}`` in the PUT response's shape — ``null`` when the + child has none (present every day), never a 404; an unknown child is + 404. +- ``GET /presence-overrides`` lists the household's overrides through + ONE ``list_presence_overrides`` call, wrapped as + ``{"overrides": [...]}`` in the POST response's shape, ordered by + start date, child id, id; the optional ``child_id``, ``start`` and + ``end`` query filters (strict dates, ``end >= start``) are the + core's to validate. The uncomplete and regenerate routes (task da0226b3) follow the same pattern over :mod:`nestquest_core.completion` and @@ -639,6 +650,22 @@ class AdminPresenceOverrideResponse(BaseModel): note: str | None +class AdminPresenceScheduleReadResponse(BaseModel): + """The ``GET /children/{child_id}/presence-schedule`` payload. + + ``schedule`` is ``None`` when the child has no schedule — the child + is present every day (Feature 05); absence is state, not an error. + """ + + schedule: AdminPresenceScheduleResponse | None + + +class AdminPresenceOverrideListResponse(BaseModel): + """The ``GET /presence-overrides`` payload: the matching overrides.""" + + overrides: list[AdminPresenceOverrideResponse] + + class AdminUncompleteResponse(BaseModel): """The result of ``POST /api/v1/admin/instances/{id}/uncomplete``. @@ -1581,6 +1608,75 @@ async def admin_delete_presence_override( return {"status": "ok"} +@router.get( + "/children/{child_id}/presence-schedule", + summary="Read a child's repeating presence schedule", + response_model=AdminPresenceScheduleReadResponse, +) +async def admin_get_presence_schedule( + child_id: int, request: Request +) -> AdminPresenceScheduleReadResponse: + """Return the child's presence schedule, or ``schedule: null``. + + 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.presence_management.get_presence_schedule` + call; a stored schedule is serialized exactly as the PUT response + is, and a child without one answers ``{"schedule": null}`` (present + every day). Errors are mapped by :func:`_raise_child_error`: an + unknown child is 404. + """ + state: DatabaseState = request.app.state.db + try: + schedule = await core_presence_management.get_presence_schedule( + state.database, child_id + ) + except ValueError as error: + _raise_child_error(error) + return AdminPresenceScheduleReadResponse( + schedule=( + None if schedule is None else _presence_schedule_response(schedule) + ) + ) + + +@router.get( + "/presence-overrides", + summary="List presence overrides", + response_model=AdminPresenceOverrideListResponse, +) +async def admin_list_presence_overrides( + request: Request, + child_id: int | None = None, + start: str | None = None, + end: str | None = None, +) -> AdminPresenceOverrideListResponse: + """Return the household's presence overrides, optionally filtered. + + 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: the optional + ``child_id``, ``start`` and ``end`` query parameters go straight to + :func:`nestquest_core.presence_management.list_presence_overrides` + — the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the + overlap rule live in the core — and each record is serialized + exactly as the POST response is, ordered by start date, child id, + id. Errors are mapped by :func:`_raise_child_error`: an unknown + ``child_id`` is 404, a rejected date or range is 422. + """ + state: DatabaseState = request.app.state.db + try: + records = await core_presence_management.list_presence_overrides( + state.database, child_id=child_id, start=start, end=end + ) + except ValueError as error: + _raise_child_error(error) + return AdminPresenceOverrideListResponse( + overrides=[_presence_override_response(record) for record in records] + ) + + @router.post( "/instances/{instance_id}/uncomplete", summary="Reverse one completion (append a reversal event)", diff --git a/custom_components/nestquest/core/dao_presence.py b/custom_components/nestquest/core/dao_presence.py index c273287..8aa5020 100644 --- a/custom_components/nestquest/core/dao_presence.py +++ b/custom_components/nestquest/core/dao_presence.py @@ -270,6 +270,57 @@ class PresenceOverridesDao: ) return [_override_from_row(row) for row in rows] + async def list_filtered( + self, + *, + child_id: int | None = None, + start: str | None = None, + end: str | None = None, + ) -> list[PresenceOverrideRecord]: + """Return the household's overrides, optionally narrowed. + + Every filter is optional and they combine: ``child_id`` keeps + that child's overrides only (an unknown child raises ValueError + rather than reading as "no overrides"); ``start`` keeps + overrides ending on or after it and ``end`` keeps overrides + starting on or before it, so both together select the overrides + overlapping the closed range ``[start, end]`` (the + :meth:`list_by_child_and_range` boundary rule). Dates are + strict YYYY-MM-DD and ``end`` must not precede ``start``. + Ordered stably by start date, then child id, then id. + """ + if start is not None: + _validate_date(start, "start") + if end is not None: + _validate_date(end, "end") + if start is not None and end is not None and end < start: + raise ValueError( + f"end must be on or after start, got {end!r} < {start!r}" + ) + clauses: list[str] = [] + params: list[object] = [] + if child_id is not None: + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + clauses.append("child_id = ?") + params.append(child_id) + if start is not None: + clauses.append("end_date >= ?") + params.append(start) + if end is not None: + clauses.append("start_date <= ?") + params.append(end) + where = f" WHERE {' AND '.join(clauses)}" if clauses else "" + rows = await self._database.fetch_all( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} " + "ORDER BY start_date, child_id, id", + tuple(params), + ) + return [_override_from_row(row) for row in rows] + async def get(self, override_id: int) -> PresenceOverrideRecord | None: """Return one override by id, or None when it does not exist.""" row = await self._database.fetch_one( diff --git a/custom_components/nestquest/core/presence_management.py b/custom_components/nestquest/core/presence_management.py index 2b48edb..b1668ab 100644 --- a/custom_components/nestquest/core/presence_management.py +++ b/custom_components/nestquest/core/presence_management.py @@ -22,6 +22,10 @@ the children layer owns that guardrail). Deleting a nonexistent override raises ValueError prefixed ``override_id:`` so a caller can distinguish "unknown id" from a rejected argument the same way the quest-definitions layer prefixes ``definition_id:``. + +The admin plane's presence READS go through here too +(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a +reader never touches the DAO directly either. """ from __future__ import annotations @@ -130,6 +134,42 @@ async def create_presence_override( return record +async def get_presence_schedule( + database: NestQuestDatabase, child_id: int +) -> PresenceSchedule | None: + """Return the child's presence schedule decoded, or None when unset. + + None means the child exists and has no schedule row — present + every day (Feature 05). An unknown child raises ValueError + (``child N does not exist``) rather than reading as present. + """ + record = await PresenceSchedulesDao(database).get_by_child(child_id) + if record is None: + return None + return PresenceSchedule.decode( + record.child_id, record.anchor_date, record.pattern + ) + + +async def list_presence_overrides( + database: NestQuestDatabase, + *, + child_id: int | None = None, + start: str | None = None, + end: str | None = None, +) -> list[PresenceOverrideRecord]: + """Return the household's presence overrides, optionally filtered. + + ``child_id`` narrows to one child (unknown: ``child N does not + exist``); ``start``/``end`` keep the overrides overlapping that + closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by + start date, then child id, then id. + """ + return await PresenceOverridesDao(database).list_filtered( + child_id=child_id, start=start, end=end + ) + + async def delete_presence_override( database: NestQuestDatabase, override_id: int, diff --git a/tests/test_api_admin_presence_reads.py b/tests/test_api_admin_presence_reads.py new file mode 100644 index 0000000..9ab1e88 --- /dev/null +++ b/tests/test_api_admin_presence_reads.py @@ -0,0 +1,345 @@ +"""Admin-plane presence READ route tests (task 07fde892). + +``GET /api/v1/admin/children/{child_id}/presence-schedule`` and +``GET /api/v1/admin/presence-overrides`` against the ASGI app with +httpx, through the shared stubbed-JWKS harness +(:mod:`tests.admin_jwt_harness`) — no network access. + +- The schedule read answers ``{"schedule": {...}}`` in the PUT + response's shape for a seeded schedule, ``{"schedule": null}`` for a + child with none (present every day — NOT a 404), and 404 for an + unknown child. +- The overrides read answers ``{"overrides": [...]}`` in the POST + response's shape, ordered by start date, child id, id; ``child_id`` + and the ``start``/``end`` range each narrow the list; a malformed or + inverted range is 422 and an unknown ``child_id`` is 404. +- Both routes inherit the router's ONE ``require_admin``: no + credential 401, the panel service token 403, a non-admin JWT 403, an + admin JWT 200. + +Seeding goes through the admin write routes, so the reads are proven +against rows the real write path stored. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for, make_token + +OVERRIDES_URL = "/api/v1/admin/presence-overrides" + +#: 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 _schedule_url(child_id: int) -> str: + return f"/api/v1/admin/children/{child_id}/presence-schedule" + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +async def _create_child(client: object, name: str) -> int: + """Create one child through the admin children route; return the id.""" + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def _create_override( + client: object, + child_id: int, + start_date: str, + end_date: str, + *, + is_present: bool = False, + note: str | None = None, +) -> dict[str, object]: + """Create one override through the admin write route; return it.""" + created = await client.post( + OVERRIDES_URL, + json={ + "child_id": child_id, + "start_date": start_date, + "end_date": end_date, + "is_present": is_present, + "note": note, + }, + headers=_admin_headers(), + ) + assert created.status_code == 201, created.text + return created.json() + + +@pytest.fixture +async def reads(temp_db_path: str) -> SimpleNamespace: + """An admin-plane client over a fresh database.""" + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace(client=client) + + +@pytest.fixture +async def seeded(reads: SimpleNamespace) -> SimpleNamespace: + """Two children with interleaved overrides (Ada has a schedule).""" + client = reads.client + ada = await _create_child(client, "Ada") + ben = await _create_child(client, "Ben") + put = await client.put( + _schedule_url(ada), + json={ + "cycle_length_weeks": 2, + "anchor_date": "2026-01-05", + "pattern": {"0": [4, 0, 2], "1": []}, + }, + headers=_admin_headers(), + ) + assert put.status_code == 200 + ada_march = await _create_override( + client, ada, "2026-03-01", "2026-03-07", note="grandparents" + ) + ben_january = await _create_override( + client, ben, "2026-01-10", "2026-01-12", is_present=True + ) + ben_march = await _create_override(client, ben, "2026-03-01", "2026-03-01") + ada_may = await _create_override(client, ada, "2026-05-20", "2026-05-20") + return SimpleNamespace( + client=client, + ada=ada, + ben=ben, + ada_march=ada_march, + ben_january=ben_january, + ben_march=ben_march, + ada_may=ada_may, + ) + + +# --- presence schedule read ------------------------------------------------ + + +async def test_schedule_read_returns_the_stored_schedule( + seeded: SimpleNamespace, +) -> None: + """A seeded schedule reads back in the PUT response's shape.""" + response = await seeded.client.get( + _schedule_url(seeded.ada), headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == { + "schedule": { + "child_id": seeded.ada, + "cycle_length_weeks": 2, + "anchor_date": "2026-01-05", + "pattern": {"0": [0, 2, 4], "1": []}, + } + } + + +async def test_schedule_read_without_schedule_is_null_not_404( + seeded: SimpleNamespace, +) -> None: + """A child with no schedule (present every day) is schedule: null.""" + response = await seeded.client.get( + _schedule_url(seeded.ben), headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == {"schedule": None} + + +async def test_schedule_read_unknown_child_is_404( + reads: SimpleNamespace, +) -> None: + """An unknown child is 404 with the children routes' detail.""" + response = await reads.client.get( + _schedule_url(999), headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Child not found"} + + +# --- presence overrides read ----------------------------------------------- + + +async def test_overrides_read_lists_all_in_stable_order( + seeded: SimpleNamespace, +) -> None: + """Unfiltered: every override, by start date, then child id, then id.""" + response = await seeded.client.get( + OVERRIDES_URL, headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == { + "overrides": [ + seeded.ben_january, + seeded.ada_march, + seeded.ben_march, + seeded.ada_may, + ] + } + assert seeded.ada_march == { + "id": seeded.ada_march["id"], + "child_id": seeded.ada, + "start_date": "2026-03-01", + "end_date": "2026-03-07", + "is_present": False, + "note": "grandparents", + } + + +async def test_overrides_read_empty_household(reads: SimpleNamespace) -> None: + """No overrides at all is an empty list.""" + response = await reads.client.get(OVERRIDES_URL, headers=_admin_headers()) + assert response.status_code == 200 + assert response.json() == {"overrides": []} + + +async def test_overrides_read_filters_by_child( + seeded: SimpleNamespace, +) -> None: + """child_id keeps only that child's overrides.""" + response = await seeded.client.get( + OVERRIDES_URL, + params={"child_id": seeded.ada}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == { + "overrides": [seeded.ada_march, seeded.ada_may] + } + + +async def test_overrides_read_filters_by_overlapping_range( + seeded: SimpleNamespace, +) -> None: + """start/end keep overrides overlapping the closed range (inclusive).""" + # 2026-03-07 is Ada's March override's LAST day: the boundary counts. + response = await seeded.client.get( + OVERRIDES_URL, + params={"start": "2026-03-07", "end": "2026-05-20"}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == { + "overrides": [seeded.ada_march, seeded.ada_may] + } + + +async def test_overrides_read_open_ended_range_filters( + seeded: SimpleNamespace, +) -> None: + """start alone keeps later-ending, end alone keeps earlier-starting.""" + from_april = await seeded.client.get( + OVERRIDES_URL, params={"start": "2026-04-01"}, headers=_admin_headers() + ) + assert from_april.json() == {"overrides": [seeded.ada_may]} + until_january = await seeded.client.get( + OVERRIDES_URL, params={"end": "2026-01-31"}, headers=_admin_headers() + ) + assert until_january.json() == {"overrides": [seeded.ben_january]} + + +async def test_overrides_read_combines_child_and_range( + seeded: SimpleNamespace, +) -> None: + """child_id and the range narrow together.""" + response = await seeded.client.get( + OVERRIDES_URL, + params={"child_id": seeded.ben, "start": "2026-02-01", "end": "2026-12-31"}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == {"overrides": [seeded.ben_march]} + + +@pytest.mark.parametrize( + "params", + [ + {"start": "2026-03-10", "end": "2026-03-01"}, + {"start": "2026-3-1"}, + {"end": "not-a-date"}, + {"start": "2026-02-30"}, + ], +) +async def test_overrides_read_invalid_range_is_422( + seeded: SimpleNamespace, params: dict[str, str] +) -> None: + """A malformed date or an inverted range is the core's 422.""" + response = await seeded.client.get( + OVERRIDES_URL, params=params, headers=_admin_headers() + ) + assert response.status_code == 422 + + +async def test_overrides_read_unknown_child_is_404( + reads: SimpleNamespace, +) -> None: + """An unknown child_id filter is 404, never an empty list.""" + response = await reads.client.get( + OVERRIDES_URL, params={"child_id": 999}, headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Child not found"} + + +# --- authentication: the router's ONE require_admin -------------------------- + + +@pytest.fixture(params=["schedule", "overrides"]) +async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str: + """Each read route's URL (the schedule route for a real child).""" + if request.param == "overrides": + return OVERRIDES_URL + return _schedule_url(await _create_child(reads.client, "Ada")) + + +async def test_read_without_credential_is_401( + reads: SimpleNamespace, read_url: str +) -> None: + response = await reads.client.get(read_url) + assert response.status_code == 401 + + +async def test_read_refuses_panel_service_token( + reads: SimpleNamespace, read_url: str +) -> None: + response = await reads.client.get( + read_url, headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"} + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_read_refuses_non_admin_jwt( + reads: SimpleNamespace, read_url: str +) -> None: + non_admin = make_token(groups=("some-other-group",)) + response = await reads.client.get( + read_url, headers={"Authorization": f"Bearer {non_admin}"} + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_read_admin_jwt_is_200( + reads: SimpleNamespace, read_url: str +) -> None: + response = await reads.client.get(read_url, headers=_admin_headers()) + assert response.status_code == 200