Admin routes: presence schedule and override (#160)

This commit was merged in pull request #160.
This commit is contained in:
2026-09-23 02:26:17 -04:00
parent e85180d492
commit a3773fec8c
6 changed files with 1040 additions and 35 deletions
+22
View File
@@ -142,13 +142,35 @@ core_quest_definitions = importlib.import_module(
#: re-implement rule validation.
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
#: The presence model (``core.presence.PresenceEngine``,
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
#: presence routes serialize and the tests resolve presence with.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: The presence-write business layer
#: (``core.presence_management.set_presence_schedule`` et al.) the
#: admin plane's presence routes call — the ONE implementation the HA
#: services share.
core_presence_management = importlib.import_module(
f"{_CORE_MODULE_NAME}.presence_management"
)
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
#: read stored schedules and overrides back through.
core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence"
)
__all__ = [
"core",
"core_children",
"core_completion",
"core_dao_presence",
"core_db",
"core_events",
"core_migrations",
"core_presence",
"core_presence_management",
"core_quest_definitions",
"core_recurrence",
"core_snapshot",
+264 -3
View File
@@ -35,8 +35,32 @@ through the API: the core's edit path takes no assignees. The edit
route forwards only SUPPLIED fields (``exclude_unset``), like the
children edit.
Error mapping (every children and quest-definition route, deliberately
narrow):
The presence routes follow the same pattern over
:mod:`nestquest_core.presence_management` — the ONE presence write
layer the HA services share, so the two planes cannot drift:
- ``PUT /children/{child_id}/presence-schedule`` sets (UPSERTS) the
child's repeating schedule: the body's ``cycle_length_weeks``,
``anchor_date`` and ``pattern`` go straight into ONE
:class:`~nestquest_core.presence.PresenceSchedule` (the model
validates cycle length, the strict anchor date and the pattern's
week coverage at construction), the core upserts by child (setting
again REPLACES the one schedule a child can have) and regenerates
the child's future instances; the response is the STORED schedule
decoded back through the model.
- ``POST /presence-overrides`` creates one date-range override: the
body becomes ONE :class:`~nestquest_core.presence.PresenceOverride`
(the model validates the dates, ``end >= start``, the real bool and
the note), the core creates it — rejecting a range that overlaps the
same child's existing override — and regenerates; the response is
the stored record with its ``id``.
- ``DELETE /presence-overrides/{override_id}`` deletes one override
(children are never hard-deleted; overrides are deletable by
design) and regenerates that override's child. It answers
``{"status": "ok"}``.
Error mapping (every children, quest-definition and presence route,
deliberately narrow):
- Malformed input the body model or the path parser rejects (a missing
or wrong-typed field, a non-integer child or definition id) is
@@ -55,10 +79,21 @@ narrow):
``rule_type``, a shape-missing field such as a weekly rule without
weekdays, a malformed date, a forbidden field), a bad window name or
due time, a rejected window or assignee list (empty, duplicate,
wrong-typed) — is mapped to 422: the body was well-formed JSON but
wrong-typed); a rejected presence schedule or override (bad cycle
length, a pattern not covering the cycle's weeks, a malformed or
inverted date range, an override overlapping the same child's
existing one) — is mapped to 422: the body was well-formed JSON but
semantically invalid, the same failure class Pydantic reports. The
core rejects these BEFORE any write, so a 422 never leaves a
half-applied change behind.
- A non-existent CHILD on a presence route (setting a schedule or
creating an override for an unknown id) is mapped to 404 through the
SAME :func:`_raise_child_error` the children routes use — the
message reads ``child N does not exist``. A non-existent OVERRIDE
on the delete route is mapped to 404 through
:func:`_raise_presence_override_error`, keyed on the
``override_id:`` prefix the core's unknown-id error carries (the
same convention as the quest-definition 404).
PATCH edit semantics: the body model's fields are optional and only
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
@@ -78,6 +113,8 @@ from api.auth import require_admin
from api.database import DatabaseState
from api.nestquest_core import (
core_children,
core_presence,
core_presence_management,
core_quest_definitions,
core_recurrence,
)
@@ -294,6 +331,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel):
is_active: bool
class AdminPresenceScheduleRequest(BaseModel):
"""The body of ``PUT /api/v1/admin/children/{child_id}/presence-schedule``.
The shape of :class:`~nestquest_core.presence.PresenceSchedule` —
nothing more. ``pattern`` maps each cycle week index (JSON object
keys, coerced to ints by the model) to that week's present weekdays
(Monday=0); every week of the cycle must be covered, which the
PRESENCE MODEL enforces at construction, not this shape. A week
with an empty list means absent every day of that week.
"""
cycle_length_weeks: int
anchor_date: str
pattern: dict[int, list[int]]
class AdminPresenceScheduleResponse(BaseModel):
"""A child's repeating presence schedule as the admin plane serializes it.
The stored schedule decoded back through
:meth:`nestquest_core.presence.PresenceSchedule.decode`: each
pattern week is the sorted list of present weekdays, and the child
has exactly ONE schedule (setting again replaces it).
"""
child_id: int
cycle_length_weeks: int
anchor_date: str
pattern: dict[int, list[int]]
class AdminPresenceOverrideCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/presence-overrides``.
The shape of :class:`~nestquest_core.presence.PresenceOverride` —
the strict ISO dates, the ``end_date >= start_date`` order, the
real ``is_present`` bool and the optional note are ALL enforced by
the model's constructor, which the route builds directly from this
body; this model only guards the SHAPE.
"""
child_id: int
start_date: str
end_date: str
is_present: bool
note: str | None = None
class AdminPresenceOverrideResponse(BaseModel):
"""One presence override as the admin plane serializes it.
Mirrors
:class:`~nestquest_core.dao_presence.PresenceOverrideRecord` — the
``id`` is the handle the DELETE route takes.
"""
id: int
child_id: int
start_date: str
end_date: str
is_present: bool
note: str | None
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -355,6 +456,28 @@ def _raise_quest_definition_error(error: ValueError) -> NoReturn:
raise HTTPException(status_code=422, detail=str(error)) from error
#: 404 detail for an override id the core layer reports as non-existent.
_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL = "Presence override not found"
def _raise_presence_override_error(error: ValueError) -> NoReturn:
"""Map a core presence ``ValueError`` from the DELETE route onto 404/422.
The mapping mirrors :func:`_raise_quest_definition_error`: the core
names a missing override by prefixing its error with
``override_id:`` (an unknown id), and that one case is this route's
404. Every other ``ValueError`` — a non-integer id the path
parser's ``int`` already makes unreachable — becomes 422. The
schedule and override-CREATE routes do NOT use this mapper: their
only 404 is an unknown CHILD, mapped by :func:`_raise_child_error`.
"""
if str(error).startswith("override_id:"):
raise HTTPException(
status_code=404, detail=_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL
) from error
raise HTTPException(status_code=422, detail=str(error)) from error
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
"""Build the core ScheduleRule from the request's ``rule`` object.
@@ -398,6 +521,34 @@ def _quest_definition_response(
)
def _presence_schedule_response(
schedule: core_presence.PresenceSchedule,
) -> AdminPresenceScheduleResponse:
"""Serialize one decoded core PresenceSchedule into the payload."""
return AdminPresenceScheduleResponse(
child_id=schedule.child_id,
cycle_length_weeks=schedule.cycle_length_weeks,
anchor_date=schedule.anchor_date.isoformat(),
pattern={
week: sorted(days) for week, days in schedule.pattern.items()
},
)
def _presence_override_response(
record: core_presence_management.PresenceOverrideRecord,
) -> AdminPresenceOverrideResponse:
"""Serialize one core PresenceOverrideRecord into the payload."""
return AdminPresenceOverrideResponse(
id=record.id,
child_id=record.child_id,
start_date=record.start_date,
end_date=record.end_date,
is_present=record.is_present,
note=record.note,
)
@router.get("/ping", summary="Admin plane probe")
async def admin_ping(
claims: Annotated[dict[str, object], Depends(require_admin)],
@@ -681,4 +832,114 @@ async def admin_set_quest_definition_active(
return _quest_definition_response(bundle)
@router.put(
"/children/{child_id}/presence-schedule",
summary="Set a child's repeating presence schedule",
response_model=AdminPresenceScheduleResponse,
)
async def admin_set_presence_schedule(
child_id: int, body: AdminPresenceScheduleRequest, request: Request
) -> AdminPresenceScheduleResponse:
"""Set (upsert) a child's repeating presence schedule.
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 body model
carries the shape, and the cycle-length, anchor-date and
pattern-coverage policies all live in the
:class:`~nestquest_core.presence.PresenceSchedule` constructor the
core builds the schedule with — setting again REPLACES the child's
one schedule, and the core regenerates the child's future instances
so they track the new presence. Errors are mapped by
:func:`_raise_child_error`: an unknown child is 404, a rejected
schedule 422.
"""
state: DatabaseState = request.app.state.db
try:
schedule = await core_presence_management.set_presence_schedule(
state.database,
child_id,
body.cycle_length_weeks,
body.anchor_date,
body.pattern,
)
except ValueError as error:
_raise_child_error(error)
return _presence_schedule_response(schedule)
@router.post(
"/presence-overrides",
summary="Create a date-range presence override",
status_code=201,
response_model=AdminPresenceOverrideResponse,
)
async def admin_create_presence_override(
body: AdminPresenceOverrideCreateRequest, request: Request
) -> AdminPresenceOverrideResponse:
"""Create one date-range presence override and return it with its id.
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 body model
carries the shape, and the date/order/bool/note policies live in
the :class:`~nestquest_core.presence.PresenceOverride` constructor
while the same-child no-overlap policy lives in the core's create
path — which then regenerates the child's future instances so an
absent range removes them and a present range restores them. The
response carries the stored ``id``, the handle
``DELETE /presence-overrides/{id}`` takes.
Errors are mapped by :func:`_raise_child_error`: an unknown child
is 404; a rejected override (malformed or inverted dates, an
overlapping range) is 422.
"""
state: DatabaseState = request.app.state.db
try:
record = await core_presence_management.create_presence_override(
state.database,
body.child_id,
body.start_date,
body.end_date,
body.is_present,
note=body.note,
)
except ValueError as error:
_raise_child_error(error)
return _presence_override_response(record)
@router.delete(
"/presence-overrides/{override_id}",
summary="Delete a presence override",
)
async def admin_delete_presence_override(
override_id: int, request: Request
) -> dict[str, str]:
"""Delete one presence override; children are never hard-deleted.
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 id goes
straight to
:func:`nestquest_core.presence_management.delete_presence_override`,
which looks the override up first (an unknown id is refused BEFORE
any delete), removes it and regenerates ITS child's future
instances — the id, never a caller-supplied child, decides which
child's presence is rebuilt. On success the route answers
``{"status": "ok"}``.
Error mapping (:func:`_raise_presence_override_error`): an unknown
override id is 404; every other core ``ValueError`` is 422.
"""
state: DatabaseState = request.app.state.db
try:
await core_presence_management.delete_presence_override(
state.database, override_id
)
except ValueError as error:
_raise_presence_override_error(error)
return {"status": "ok"}
__all__ = ["router"]
@@ -0,0 +1,161 @@
"""Business layer for presence writes (Feature 09, API admin plane).
Sits between the write callers (the Feature 09 HA service handlers and
the API admin plane's presence routes) and the typed presence DAOs plus
the pure presence model: callers get the model validation, the upsert /
create / delete operations and the regeneration side effect here, and
never touch the DAO or SQL directly. This is the ONE implementation of
these business rules — every caller constructs the same
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride`
models (whose constructors are total, so no re-validation happens in a
handler) and gets the same post-write
:func:`~.materialize.regenerate_for_child`, so a child's future quest
instances track its changed presence no matter which plane issued the
write. Like the model it is Home-Assistant-free: ``today`` is a plain
calendar date threaded through to the regeneration, defaulting to the
host clock there.
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
schedule — setting again REPLACES the existing row rather than
duplicating it. Overrides are deletable by design (children are not —
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:``.
"""
from __future__ import annotations
import datetime
from .dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceSchedulesDao,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_child
from .presence import PresenceOverride, PresenceSchedule
def _validate_override_id(value: object) -> int:
"""Reject non-int override ids (bools would silently address id 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"override_id must be an integer, got {value!r}")
return value
async def set_presence_schedule(
database: NestQuestDatabase,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceSchedule:
"""Set (upsert) a child's repeating presence schedule.
The :class:`~.presence.PresenceSchedule` model validates at
construction — cycle length 1..4, a strict calendar anchor date,
and a pattern covering week indices 0..n-1 with weekday sets — so
no invalid schedule reaches the DAO. The encoded pattern is
UPSERTed by child (a second set REPLACES the child's existing
schedule; the schema's UNIQUE(child_id) makes duplication
unrepresentable), then :func:`~.materialize.regenerate_for_child`
re-materializes the child's future instances against the new
presence. ``today``/``horizon_days`` thread through to that
regeneration; raising ValueError names what to fix (an unknown
child reads ``child N does not exist``).
Returns the STORED schedule decoded back through the model — the
round-trip proves the encoded pattern the database now holds is
exactly the one the caller asked for.
"""
schedule = PresenceSchedule(
child_id, cycle_length_weeks, anchor_date, pattern
)
record = await PresenceSchedulesDao(database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
database, schedule.child_id, today=today, horizon_days=horizon_days
)
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
async def create_presence_override(
database: NestQuestDatabase,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceOverrideRecord:
"""Create one date-range presence override and return it as stored.
The :class:`~.presence.PresenceOverride` model validates at
construction — strict calendar dates, ``end_date`` on or after
``start_date``, a real ``is_present`` bool, a string-or-None note —
then the DAO insert applies the same-child no-overlap policy (a
conflicting range raises ValueError naming the existing override)
and :func:`~.materialize.regenerate_for_child` re-materializes the
child's future instances against the override's presence answer.
An unknown child reads ``child N does not exist``.
Returns the stored record WITH its ``id`` — the handle a later
:func:`delete_presence_override` takes.
"""
override = PresenceOverride(
child_id, start_date, end_date, is_present, note=note
)
record = await PresenceOverridesDao(database).create(
override.child_id,
override.start_date.isoformat(),
override.end_date.isoformat(),
override.is_present,
note=override.note,
)
await regenerate_for_child(
database, override.child_id, today=today, horizon_days=horizon_days
)
return record
async def delete_presence_override(
database: NestQuestDatabase,
override_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceOverrideRecord:
"""Delete one presence override by id and return it as it was stored.
The override is looked up FIRST and an unknown id raises ValueError
prefixed ``override_id:`` (naming the id) BEFORE any delete, so a
mistyped id can never be silently swallowed by a no-op. The
deletion is followed by :func:`~.materialize.regenerate_for_child`
for the override's own child — the id decides the child, never a
caller-supplied one. ``today``/``horizon_days`` thread through to
that regeneration.
"""
_validate_override_id(override_id)
dao = PresenceOverridesDao(database)
override = await dao.get(override_id)
if override is None:
raise ValueError(
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
database, override.child_id, today=today, horizon_days=horizon_days
)
return override
@@ -0,0 +1,10 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
The presence-write business layer lives in
:mod:`custom_components.nestquest.core.presence_management` (added for
task e4a9d31b); this module re-exports it so the integration's service
handlers call the ONE implementation the API admin plane shares.
"""
from __future__ import annotations
from .core.presence_management import * # noqa: F401,F403
+12 -32
View File
@@ -4,9 +4,11 @@ Registration is domain-scoped and happens once (see
:func:`async_register_services`); handlers resolve a live database at
call time through the injected ``find_runtime`` callable. Permission
checks are Feature 09 later tasks — this module only validates payload
shape and forwards to the Feature 03/06/08 business layers (and the
presence DAOs). ``export_history_csv`` is registered with a schema but
raises until Feature 13. ``regenerate`` is supplied by the caller so
shape and forwards to the Feature 03/06/08 business layers (the
presence writes included, through :mod:`.presence_management` — the
ONE implementation the API admin plane shares).
``export_history_csv`` is registered with a schema but raises until
Feature 13. ``regenerate`` is supplied by the caller so
the Feature 07 handler stays the one in ``__init__``.
"""
from __future__ import annotations
@@ -22,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError
from . import children as children_layer
from . import completion as completion_layer
from . import presence_management as presence_management_layer
from . import quest_definitions as quest_definitions_layer
from .const import (
DOMAIN,
@@ -39,11 +42,8 @@ from .const import (
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .events import fire_quest_completed, fire_quest_uncompleted
from .materialize import regenerate_for_child
from .permissions import permission_gate
from .presence import PresenceSchedule
from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
@@ -405,21 +405,12 @@ def _set_presence_pattern(
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
schedule = PresenceSchedule(
await presence_management_layer.set_presence_schedule(
runtime.database,
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
)
await PresenceSchedulesDao(runtime.database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
runtime.database,
schedule.child_id,
**_generation_kwargs(hass, runtime),
)
@@ -432,16 +423,13 @@ def _create_presence_override(
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await PresenceOverridesDao(runtime.database).create(
await presence_management_layer.create_presence_override(
runtime.database,
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
)
await regenerate_for_child(
runtime.database,
data["child_id"],
**_generation_kwargs(hass, runtime),
)
@@ -453,17 +441,9 @@ def _delete_presence_override(
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
dao = PresenceOverridesDao(runtime.database)
override_id = call.data["override_id"]
override = await dao.get(override_id)
if override is None:
raise HomeAssistantError(
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
await presence_management_layer.delete_presence_override(
runtime.database,
override.child_id,
call.data["override_id"],
**_generation_kwargs(hass, runtime),
)
+571
View File
@@ -0,0 +1,571 @@
"""Admin-plane presence route tests (task e4a9d31b).
These tests exercise the admin plane's presence routes 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 — signature, issuer, audience, expiry and the
nestquest-admins group — with NO network access.
Every done-condition case is proven against the DATABASE and the DOMAIN,
read through the SAME core copy the app uses (``nestquest_core.*``; see
the two-copy caveat in api/nestquest_core.py):
- Authentication runs through the router's ONE ``require_admin``
dependency: an admin JWT passes, a JWT without the group is 403, an
absent credential is 401 — and nothing is written.
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
first set persists the schedule, setting again REPLACES it — one
schedule row per child, never a duplicate.
- ``POST /api/v1/admin/presence-overrides`` creates the override (its
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
removes it again.
- The child's RESOLVED presence changes accordingly: the pure
:class:`~nestquest_core.presence.PresenceEngine` built from the
stored rows answers differently before/after each write — a schedule
restricts the child's present weekdays, an override beats the pattern
for its range, and deleting the override restores the pattern's
answer.
- The regeneration side effect lives in the core layer the route
calls: setting an all-absent schedule through the route deletes the
child's future open quest instances and they are NOT rebuilt while
the child is absent every day — the same proof the HA service test
makes, through the API instead.
Error mapping (documented in api/routes_admin.py): a core ValueError
for a non-existent child (schedule or override create) or override
(delete) is 404; every other core ValueError — a rejected schedule
(cycle length, pattern coverage, anchor date) or override (malformed or
inverted dates, an overlapping range) — is 422, the same failure class
FastAPI reports for malformed bodies.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
# The presence model, DAOs, business layer and materializer through the
# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed
# custom_components copy) — used for the tests' database and resolved-
# presence assertions only; the routes reach them through api.nestquest_core.
_children = importlib.import_module("nestquest_core.children")
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize")
_presence = importlib.import_module("nestquest_core.presence")
#: A Monday anchoring every schedule: cycle week 0 starts here.
ANCHOR_MONDAY = "2026-01-05"
#: The Tuesday of that week — absent under the test schedule.
TUESDAY = "2026-01-06"
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _schedule_body(
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
) -> dict[str, object]:
"""A valid schedule body anchored to the test Monday."""
return {
"cycle_length_weeks": cycle_length_weeks,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
}
def _override_body(
child_id: int,
*,
start_date: str = TUESDAY,
end_date: str = TUESDAY,
is_present: bool = True,
) -> dict[str, object]:
"""A valid single-day override body for the test child."""
return {
"child_id": child_id,
"start_date": start_date,
"end_date": end_date,
"is_present": is_present,
"note": "grandmother's week",
}
async def _create_child(client: object, name: str = "Ada") -> 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"]
def _engine(
database: object,
child_id: int,
schedule_record: object | None,
override_records: list[object],
) -> object:
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
Mirrors what the core snapshot builder assembles: the child's
decoded schedule (absent → present every day) plus its overrides,
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
routes produced.
"""
schedules: dict[int, object] = {}
if schedule_record is not None:
schedules[child_id] = _presence.PresenceSchedule.decode(
schedule_record.child_id,
schedule_record.anchor_date,
schedule_record.pattern,
)
overrides = {
child_id: [
_presence.PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
note=record.note,
)
for record in override_records
]
}
return _presence.PresenceEngine(schedules, overrides)
async def _child_state(
database: object, child_id: int
) -> tuple[object | None, list[object], object]:
"""The child's stored schedule record, overrides and PresenceEngine."""
schedule_record = await _dao_presence.PresenceSchedulesDao(
database
).get_by_child(child_id)
override_records = await _dao_presence.PresenceOverridesDao(
database
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
engine = _engine(database, child_id, schedule_record, override_records)
return schedule_record, override_records, engine
@pytest.fixture
async def admin_presence(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client plus its live database.
The same runner the auth, children and quest-definition tests use
(stubbed JWKS, no network); the fixture also exposes the app's ONE
database connection so tests assert the routes' effects on the
stored rows and the resolved presence built from them.
"""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
database=runner.app.state.db.database,
)
# --- authentication: the ONE require_admin dependency guards every route ---
async def test_presence_routes_refuse_non_admin_and_absent_credentials(
admin_presence: SimpleNamespace,
) -> None:
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
client = admin_presence.client
child_id = await _create_child(client)
non_admin = make_token(groups=("some-other-group",))
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
forbidden_schedule = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=non_admin_headers,
)
assert forbidden_schedule.status_code == 403
assert forbidden_schedule.json()["detail"] == (
"Admin access requires membership in the nestquest-admins group"
)
forbidden_override = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id),
headers=non_admin_headers,
)
assert forbidden_override.status_code == 403
forbidden_delete = await client.delete(
"/api/v1/admin/presence-overrides/1", headers=non_admin_headers
)
assert forbidden_delete.status_code == 403
unauthenticated_schedule = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
)
assert unauthenticated_schedule.status_code == 401
unauthenticated_override = await client.post(
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
)
assert unauthenticated_override.status_code == 401
unauthenticated_delete = await client.delete(
"/api/v1/admin/presence-overrides/1"
)
assert unauthenticated_delete.status_code == 401
# No route ran its handler: the child still has no schedule and no
# overrides (and is present every day, the no-schedule default).
_schedule, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert _schedule is None
assert _overrides == []
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
# --- schedule upsert --------------------------------------------------------
async def test_schedule_upserts_and_replaces_one_row_per_child(
admin_presence: SimpleNamespace,
) -> None:
"""PUT /presence-schedule persists, and setting again REPLACES it."""
client = admin_presence.client
child_id = await _create_child(client)
first = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert first.status_code == 200
assert first.json() == {
"child_id": child_id,
"cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0, 2, 4]},
}
# Database proof: exactly ONE schedule row (the schema's
# UNIQUE(child_id) makes a second row unrepresentable), stored as
# encoded CSV.
schedule, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert schedule is not None
row_id = schedule.id
assert schedule.cycle_length_weeks == 1
assert schedule.anchor_date == ANCHOR_MONDAY
assert schedule.pattern == "0,2,4"
# Setting again UPSERTS: the SAME row is updated in place — new
# values, unchanged id — never a duplicate or a stale first set.
second = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
),
headers=_admin_headers(),
)
assert second.status_code == 200
assert second.json() == {
"child_id": child_id,
"cycle_length_weeks": 2,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [], "1": [1, 3]},
}
schedule, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert schedule is not None
assert schedule.id == row_id
assert schedule.cycle_length_weeks == 2
assert schedule.pattern == "|1,3"
async def test_schedule_changes_the_childs_resolved_presence(
admin_presence: SimpleNamespace,
) -> None:
"""The resolved presence (PresenceEngine) follows the stored schedule."""
client = admin_presence.client
child_id = await _create_child(client)
# Before any schedule the child is present every day.
_schedule, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is True
# Mon/Wed/Fri only, week 0 of a one-week cycle.
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert response.status_code == 200
_schedule, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is False
async def test_schedule_upsert_regenerates_future_instances(
admin_presence: SimpleNamespace,
) -> None:
"""Setting an all-absent schedule deletes the child's future instances.
The regeneration side effect lives in the core layer the route
calls: the child's open instances at or after today are deleted
across ALL its definitions and NOT rebuilt while the child is
absent every day — the same proof the HA service test makes,
exercised here through the API route instead.
"""
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child_id],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=14)).isoformat()
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end)
# The route's write must regenerate: absent every day removes every
# open future instance.
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(pattern={"0": []}),
headers=_admin_headers(),
)
assert response.status_code == 200
assert await instances.list_by_date_range(child_id, start, end) == []
# --- override create + delete -----------------------------------------------
async def test_override_is_created_then_deleted_and_presence_resolves(
admin_presence: SimpleNamespace,
) -> None:
"""POST creates the override, DELETE removes it; presence follows."""
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
schedule_response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert schedule_response.status_code == 200
# Create: the override comes back with its id and the posted shape.
created = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id),
headers=_admin_headers(),
)
assert created.status_code == 201
payload = created.json()
assert set(payload) == {
"id",
"child_id",
"start_date",
"end_date",
"is_present",
"note",
}
assert payload["child_id"] == child_id
assert payload["start_date"] == TUESDAY
assert payload["end_date"] == TUESDAY
assert payload["is_present"] is True
assert payload["note"] == "grandmother's week"
# Database proof: the row exists — and the RESOLVED presence for the
# affected date flips: the override beats the Tuesday-absent pattern.
_schedule, overrides, engine = await _child_state(database, child_id)
assert [record.id for record in overrides] == [payload["id"]]
assert engine.is_present(child_id, TUESDAY) is True
# A date the override does not cover keeps the pattern's answer.
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
# Delete: the row is gone and the resolved presence follows the
# pattern again.
deleted = await client.delete(
f"/api/v1/admin/presence-overrides/{payload['id']}",
headers=_admin_headers(),
)
assert deleted.status_code == 200
assert deleted.json() == {"status": "ok"}
assert (
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
is None
)
_schedule, overrides, engine = await _child_state(database, child_id)
assert overrides == []
assert engine.is_present(child_id, TUESDAY) is False
# --- error mapping: 404 vs 422 ----------------------------------------------
async def test_schedule_and_override_for_unknown_child_are_404(
admin_presence: SimpleNamespace,
) -> None:
"""A presence write naming a child that does not exist is 404."""
client = admin_presence.client
schedule = await client.put(
"/api/v1/admin/children/999/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert schedule.status_code == 404
assert schedule.json()["detail"] == "Child not found"
override = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(999),
headers=_admin_headers(),
)
assert override.status_code == 404
assert override.json()["detail"] == "Child not found"
@pytest.mark.parametrize(
"body",
[
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
_schedule_body(cycle_length_weeks=5), # above the documented cap
# week 1 of the cycle missing:
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
{
"cycle_length_weeks": 1,
"anchor_date": "2026-13-40",
"pattern": {"0": [0]},
},
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
_schedule_body(pattern={"nope": [0]}), # week index not an integer
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
],
)
async def test_rejected_schedule_is_422(
admin_presence: SimpleNamespace, body: dict[str, object]
) -> None:
"""A rejected schedule is 422 and stores nothing."""
client = admin_presence.client
child_id = await _create_child(client)
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=body,
headers=_admin_headers(),
)
assert response.status_code == 422
assert (
await _dao_presence.PresenceSchedulesDao(
admin_presence.database
).get_by_child(child_id)
is None
)
@pytest.mark.parametrize(
"body",
[
_override_body(1, start_date="2026-01-07", end_date="2026-01-06"),
_override_body(1, start_date="2026-02-30", end_date="2026-02-30"),
dict(_override_body(1), note=5),
dict(_override_body(1), child_id="one"),
],
)
async def test_rejected_override_is_422(
admin_presence: SimpleNamespace, body: dict[str, object]
) -> None:
"""A rejected override (inverted/malformed dates, bad note/id) is 422.
The shape failures (a non-string note, a non-integer child id) are
the body model's; the date failures are the PresenceOverride
model's. A string ``is_present`` like ``"yes"`` is deliberately
absent: Pydantic's lax bool COERCES it to ``True`` (the same design
as the children active route), so it never reaches the model's
real-bool check.
"""
response = await admin_presence.client.post(
"/api/v1/admin/presence-overrides", json=body, headers=_admin_headers()
)
assert response.status_code == 422
async def test_overlapping_override_is_422_and_first_survives(
admin_presence: SimpleNamespace,
) -> None:
"""An override overlapping the child's existing one is 422.
The conflicting write persists nothing: the child's original
override survives unchanged.
"""
client = admin_presence.client
child_id = await _create_child(client)
created = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id),
headers=_admin_headers(),
)
assert created.status_code == 201
response = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id, is_present=False),
headers=_admin_headers(),
)
assert response.status_code == 422
_schedule, overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in overrides] == [created.json()["id"]]
async def test_delete_unknown_override_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Deleting an override id that does not exist is 404."""
response = await admin_presence.client.delete(
"/api/v1/admin/presence-overrides/999", headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence override not found"
malformed = await admin_presence.client.delete(
"/api/v1/admin/presence-overrides/not-an-id",
headers=_admin_headers(),
)
assert malformed.status_code == 422