Add admin presence-override consequence count route
POST /api/v1/admin/presence-overrides/consequence answers {removed: N},
the number of a child's upcoming open quest instances saving a presence
override would remove, computed before anything is saved.
The materialize walk's generation is extracted into a pure
_generation_preview helper that materialize() now writes through, and
count_removed_by_override runs that same preview with and without the
proposed override, excluding instances that already have a completion
event (D-005). The route is a thin adapter over the new
presence_management.preview_presence_override_consequence.
Task 7616e3eb-da8b-42cc-8fc7-bbfac2074662
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,13 @@ 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"}``.
|
||||
- ``POST /presence-overrides/consequence`` previews an override
|
||||
BEFORE it is saved: ONE ``preview_presence_override_consequence``
|
||||
call answers ``{removed: N}``, the number of the child's open
|
||||
upcoming instances the materialization walk would stop generating
|
||||
(instances with a completion event are never counted, D-005).
|
||||
Nothing is written; an unknown child is 404, a malformed or inverted
|
||||
range 422.
|
||||
- ``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
|
||||
@@ -666,6 +673,33 @@ class AdminPresenceOverrideListResponse(BaseModel):
|
||||
overrides: list[AdminPresenceOverrideResponse]
|
||||
|
||||
|
||||
class AdminPresenceOverrideConsequenceRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/presence-overrides/consequence``.
|
||||
|
||||
The create body's shape minus the note (which cannot change what is
|
||||
generated): the strict dates, their order and the real
|
||||
``is_present`` bool are enforced by the SAME
|
||||
:class:`~nestquest_core.presence.PresenceOverride` constructor the
|
||||
create route's core path builds; this model only guards the SHAPE.
|
||||
"""
|
||||
|
||||
child_id: int
|
||||
start_date: str
|
||||
end_date: str
|
||||
is_present: bool
|
||||
|
||||
|
||||
class AdminPresenceOverrideConsequenceResponse(BaseModel):
|
||||
"""The consequence preview: ``removed`` upcoming instances.
|
||||
|
||||
The number of open (no completion event) upcoming quest instances
|
||||
of the child that saving the override would remove — the "This
|
||||
removes N upcoming tasks" warning. Always 0 for a present override.
|
||||
"""
|
||||
|
||||
removed: int
|
||||
|
||||
|
||||
class AdminUncompleteResponse(BaseModel):
|
||||
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
||||
|
||||
@@ -1608,6 +1642,45 @@ async def admin_delete_presence_override(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/presence-overrides/consequence",
|
||||
summary="Count the upcoming instances a presence override would remove",
|
||||
response_model=AdminPresenceOverrideConsequenceResponse,
|
||||
)
|
||||
async def admin_presence_override_consequence(
|
||||
body: AdminPresenceOverrideConsequenceRequest, request: Request
|
||||
) -> AdminPresenceOverrideConsequenceResponse:
|
||||
"""Return how many upcoming instances saving the override removes.
|
||||
|
||||
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.preview_presence_override_consequence`
|
||||
call, anchored on ONE :func:`_local_now` clock read, which compares
|
||||
the materialization walk's output for the child with and without
|
||||
the proposed override and excludes instances that already have a
|
||||
completion event (D-005). Nothing is written.
|
||||
|
||||
Errors are mapped by :func:`_raise_child_error`: an unknown child
|
||||
is 404; a malformed or inverted range is 422.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
removed = await (
|
||||
core_presence_management.preview_presence_override_consequence(
|
||||
state.database,
|
||||
body.child_id,
|
||||
body.start_date,
|
||||
body.end_date,
|
||||
body.is_present,
|
||||
today=_local_now().date(),
|
||||
)
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceOverrideConsequenceResponse(removed=removed)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/children/{child_id}/presence-schedule",
|
||||
summary="Read a child's repeating presence schedule",
|
||||
|
||||
@@ -64,9 +64,14 @@ the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import NamedTuple
|
||||
|
||||
from .const import DEFAULT_HORIZON_DAYS
|
||||
from .dao_instances import QuestInstancesDao, _resolve_today
|
||||
from .dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
_resolve_today,
|
||||
)
|
||||
from .dao_rules import (
|
||||
_validate_date,
|
||||
load_materialization_input,
|
||||
@@ -126,8 +131,15 @@ def _decode_rule(snapshot) -> ScheduleRule:
|
||||
def _build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
*,
|
||||
extra_overrides: tuple[PresenceOverride, ...] = (),
|
||||
) -> PresenceEngine:
|
||||
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||
"""Convert raw presence records into an immutable PresenceEngine.
|
||||
|
||||
``extra_overrides`` layers already-validated, NOT-yet-stored
|
||||
overrides on top of the stored ones — the consequence preview's
|
||||
proposed override (:func:`count_removed_by_override`).
|
||||
"""
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
for child_id, record in schedules_records.items():
|
||||
schedules[child_id] = PresenceSchedule.decode(
|
||||
@@ -145,9 +157,75 @@ def _build_engine(
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
for override in extra_overrides:
|
||||
overrides.setdefault(override.child_id, []).append(override)
|
||||
return PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
class GeneratedTuple(NamedTuple):
|
||||
"""One instance the walk WOULD write: its upsert key plus due time."""
|
||||
|
||||
definition_id: int
|
||||
child_id: int
|
||||
due_date: str
|
||||
window: str
|
||||
due_time: str | None
|
||||
|
||||
|
||||
def _generation_preview(
|
||||
snapshots,
|
||||
engine: PresenceEngine,
|
||||
start: datetime.date,
|
||||
end: datetime.date,
|
||||
*,
|
||||
child_filter: set[int] | None = None,
|
||||
) -> list[GeneratedTuple]:
|
||||
"""Return the tuples the walk would write over ``[start, end]``.
|
||||
|
||||
The pure, read-only half of :func:`materialize`: for each date, each
|
||||
definition whose decoded rule :func:`~.recurrence.occurs_on` it,
|
||||
each assignee in ``child_filter`` (every assignee when None) who is
|
||||
``is_present`` on it — or every assignee when the definition's
|
||||
``skip_on_away`` is off — and each declared window, one tuple, in
|
||||
the walk's write order. :func:`materialize` writes exactly these
|
||||
tuples and :func:`count_removed_by_override` compares two of these
|
||||
previews, so the write and the consequence preview can never
|
||||
diverge. No database access.
|
||||
"""
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows,
|
||||
snapshot.definition.skip_on_away)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
generated: list[GeneratedTuple] = []
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for (
|
||||
definition_id, rule, assignees, windows, skip_on_away
|
||||
) in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if skip_on_away and not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
generated.append(
|
||||
GeneratedTuple(
|
||||
definition_id,
|
||||
child.id,
|
||||
iso,
|
||||
window.window,
|
||||
window.due_time,
|
||||
)
|
||||
)
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return generated
|
||||
|
||||
|
||||
async def materialize(
|
||||
database: NestQuestDatabase,
|
||||
start_date: str,
|
||||
@@ -207,13 +285,6 @@ async def materialize(
|
||||
engine = _build_engine(schedules_records, overrides_records)
|
||||
child_filter = None if child_ids is None else set(child_ids)
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows,
|
||||
snapshot.definition.skip_on_away)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
|
||||
generated_at = _now_stamp()
|
||||
instances = QuestInstancesDao(database)
|
||||
|
||||
@@ -228,32 +299,20 @@ async def materialize(
|
||||
start = max(start, today_date)
|
||||
|
||||
count = 0
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for (
|
||||
definition_id, rule, assignees, windows, skip_on_away
|
||||
) in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if skip_on_away and not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
written = await instances.upsert_if_valid(
|
||||
definition_id,
|
||||
child.id,
|
||||
iso,
|
||||
generated_at,
|
||||
window=window.window,
|
||||
due_time=window.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
cursor += datetime.timedelta(days=1)
|
||||
for generated in _generation_preview(
|
||||
snapshots, engine, start, end, child_filter=child_filter
|
||||
):
|
||||
written = await instances.upsert_if_valid(
|
||||
generated.definition_id,
|
||||
generated.child_id,
|
||||
generated.due_date,
|
||||
generated_at,
|
||||
window=generated.window,
|
||||
due_time=generated.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@@ -379,3 +438,93 @@ async def regenerate_for_child(
|
||||
return await materialize(
|
||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||
)
|
||||
|
||||
|
||||
async def count_removed_by_override(
|
||||
database: NestQuestDatabase,
|
||||
override: PresenceOverride,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Count the upcoming instances saving ``override`` would remove.
|
||||
|
||||
Read-only: nothing is written. The window is the part of the
|
||||
override's range inside the rolling horizon ``[today, today +
|
||||
horizon_days]`` that :func:`regenerate_for_child` rebuilds after the
|
||||
override is saved (outside the range the two presence answers agree,
|
||||
so nothing there can change). The materialization input is read
|
||||
ONCE (:func:`~.dao_rules.load_materialization_input`) and
|
||||
:func:`_generation_preview` runs twice over it for the override's
|
||||
child — with the stored presence, and with ``override`` layered on
|
||||
top (:func:`_build_engine`'s ``extra_overrides``). The count is the
|
||||
tuples the first preview generates and the second does not, MINUS
|
||||
every (definition, date, window) whose stored instance already has a
|
||||
completion event (D-005: regeneration never deletes those). A
|
||||
definition with ``skip_on_away`` off generates regardless of
|
||||
presence, so it contributes nothing; a present override only ever
|
||||
ADDS tuples, so it counts 0.
|
||||
|
||||
An override overlapping a stored one (which the create path refuses)
|
||||
resolves through the engine's latest-start-wins rule. ``today`` and
|
||||
``horizon_days`` resolve exactly as :func:`regenerate_for_child`
|
||||
resolves them; a malformed horizon raises ValueError.
|
||||
"""
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
start = max(today_date, override.start_date)
|
||||
end = min(
|
||||
today_date + datetime.timedelta(days=horizon), override.end_date
|
||||
)
|
||||
if end < start:
|
||||
return 0
|
||||
start_date, end_date = start.isoformat(), end.isoformat()
|
||||
child_id = override.child_id
|
||||
|
||||
snapshots, schedules_records, overrides_records = await (
|
||||
load_materialization_input(database, start_date, end_date)
|
||||
)
|
||||
current = _generation_preview(
|
||||
snapshots,
|
||||
_build_engine(schedules_records, overrides_records),
|
||||
start,
|
||||
end,
|
||||
child_filter={child_id},
|
||||
)
|
||||
proposed = set(
|
||||
_generation_preview(
|
||||
snapshots,
|
||||
_build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
extra_overrides=(override,),
|
||||
),
|
||||
start,
|
||||
end,
|
||||
child_filter={child_id},
|
||||
)
|
||||
)
|
||||
removed = [generated for generated in current if generated not in proposed]
|
||||
if not removed:
|
||||
return 0
|
||||
|
||||
stored = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, start_date, end_date
|
||||
)
|
||||
touched = {
|
||||
event.instance_id
|
||||
for event in await CompletionEventsDao(
|
||||
database
|
||||
).list_by_child_and_date_range(child_id, start_date, end_date)
|
||||
}
|
||||
completed = {
|
||||
(instance.definition_id, instance.due_date, instance.window)
|
||||
for instance in stored
|
||||
if instance.id in touched
|
||||
}
|
||||
return sum(
|
||||
1
|
||||
for generated in removed
|
||||
if (generated.definition_id, generated.due_date, generated.window)
|
||||
not in completed
|
||||
)
|
||||
|
||||
@@ -31,13 +31,14 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_child
|
||||
from .materialize import count_removed_by_override, regenerate_for_child
|
||||
from .presence import PresenceOverride, PresenceSchedule
|
||||
|
||||
|
||||
@@ -134,6 +135,42 @@ async def create_presence_override(
|
||||
return record
|
||||
|
||||
|
||||
async def preview_presence_override_consequence(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Return how many upcoming instances saving this override removes.
|
||||
|
||||
The admin override editor's live consequence warning, computed
|
||||
BEFORE anything is saved: the arguments build the SAME
|
||||
:class:`~.presence.PresenceOverride` :func:`create_presence_override`
|
||||
builds (so a malformed or inverted range, or a non-bool
|
||||
``is_present``, raises the same ValueError), then
|
||||
:func:`~.materialize.count_removed_by_override` compares what the
|
||||
materialization walk generates for the child with and without it.
|
||||
Nothing is written. An unknown child reads ``child N does not
|
||||
exist``; an INACTIVE child has no instances to remove (the walk's
|
||||
write path never materializes one), so it counts 0.
|
||||
``today``/``horizon_days`` resolve as the post-save regeneration
|
||||
resolves them.
|
||||
"""
|
||||
override = PresenceOverride(child_id, start_date, end_date, is_present)
|
||||
child = await ChildrenDao(database).get(override.child_id)
|
||||
if child is None:
|
||||
raise ValueError(f"child {override.child_id} does not exist")
|
||||
if not child.is_active:
|
||||
return 0
|
||||
return await count_removed_by_override(
|
||||
database, override, today=today, horizon_days=horizon_days
|
||||
)
|
||||
|
||||
|
||||
async def get_presence_schedule(
|
||||
database: NestQuestDatabase, child_id: int
|
||||
) -> PresenceSchedule | None:
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Admin override consequence-count route tests (task 7616e3eb).
|
||||
|
||||
``POST /api/v1/admin/presence-overrides/consequence`` answers, BEFORE an
|
||||
override is saved, how many of the child's upcoming open quest
|
||||
instances saving it would remove (``{"removed": N}``) — the ADMIN-SPEC
|
||||
§6 "This removes N upcoming tasks" warning. These tests exercise it
|
||||
against the ASGI app with the shared stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`):
|
||||
|
||||
- a two-week custody pattern (present the whole first week of the
|
||||
cycle, absent the whole second) where an absent override over a
|
||||
known span removes an EXACT number of instances;
|
||||
- ranges that remove nothing (already-absent days, a present
|
||||
override) count 0;
|
||||
- an instance with a completion event inside the range is NOT counted
|
||||
(D-005 — regeneration never deletes it);
|
||||
- a ``skip_on_away = false`` definition contributes nothing;
|
||||
- the count MATCHES a direct materialise comparison: the override is
|
||||
then actually created (which regenerates the child) and the removed
|
||||
open future instances are counted from the database;
|
||||
- the preview writes nothing;
|
||||
- the four auth outcomes, an unknown child 404 and a malformed range
|
||||
422.
|
||||
|
||||
The cycle is anchored on the REAL host "today" (the create route's
|
||||
regeneration reads the host clock, so the comparison test cannot pin
|
||||
it): days today..today+6 are cycle week 0 (present every day), days
|
||||
today+7..today+13 week 1 (absent every day), today+14 week 0 again.
|
||||
"""
|
||||
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 DAOs and materializer through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
|
||||
CONSEQUENCE_URL = "/api/v1/admin/presence-overrides/consequence"
|
||||
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"
|
||||
)
|
||||
|
||||
#: The two-week custody pattern: week 0 every weekday, week 1 none.
|
||||
CUSTODY_PATTERN = {"0": [0, 1, 2, 3, 4, 5, 6], "1": []}
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _day(offset: int) -> str:
|
||||
"""The ISO date ``offset`` days after the host's today."""
|
||||
return (datetime.date.today() + datetime.timedelta(days=offset)).isoformat()
|
||||
|
||||
|
||||
def _body(
|
||||
child_id: int, start: int, end: int, *, is_present: bool = False
|
||||
) -> dict[str, object]:
|
||||
"""A consequence body over ``[today+start, today+end]``."""
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"start_date": _day(start),
|
||||
"end_date": _day(end),
|
||||
"is_present": is_present,
|
||||
}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str = "Ada") -> int:
|
||||
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_daily_definition(
|
||||
client: object,
|
||||
child_id: int,
|
||||
title: str,
|
||||
windows: list[str],
|
||||
*,
|
||||
skip_on_away: bool = True,
|
||||
) -> int:
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": title,
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": windows,
|
||||
"skip_on_away": skip_on_away,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def _set_custody_schedule(client: object, child_id: int) -> None:
|
||||
"""Anchor the two-week custody cycle on today (week 0 starts today)."""
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json={
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": _day(0),
|
||||
"pattern": CUSTODY_PATTERN,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _materialize_horizon(database: object) -> None:
|
||||
"""Materialize the household horizon so the stored state is current."""
|
||||
today = datetime.date.today()
|
||||
await _materialize.materialize(
|
||||
database, _day(0), _day(14), today=today
|
||||
)
|
||||
|
||||
|
||||
async def _open_future_keys(database: object, child_id: int) -> set[tuple]:
|
||||
"""The child's stored open (no completion event) future instance keys."""
|
||||
instances = await _dao_instances.QuestInstancesDao(
|
||||
database
|
||||
).list_by_date_range(child_id, _day(0), _day(60))
|
||||
events = _dao_instances.CompletionEventsDao(database)
|
||||
keys = set()
|
||||
for instance in instances:
|
||||
if not await events.list_by_instance(instance.id):
|
||||
keys.add(
|
||||
(instance.definition_id, instance.due_date, instance.window)
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
async def _complete(
|
||||
database: object, child_id: int, definition_id: int, due_date: str,
|
||||
window: str,
|
||||
) -> None:
|
||||
"""Append a completion event to the stored instance."""
|
||||
instance = await _dao_instances.QuestInstancesDao(database).get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
await _dao_instances.CompletionEventsDao(database).append(
|
||||
instance.id,
|
||||
child_id,
|
||||
"completed",
|
||||
"user",
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
),
|
||||
True,
|
||||
actor_user_id="admin-user-1",
|
||||
)
|
||||
|
||||
|
||||
async def _consequence(client: object, body: dict[str, object]) -> int:
|
||||
response = await client.post(
|
||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["removed"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def custody(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A child on the two-week custody cycle with a two-window chore.
|
||||
|
||||
Also assigns a ``skip_on_away = false`` daily chore (generated
|
||||
regardless of presence) and materializes the horizon, so the stored
|
||||
instances are the walk's current output.
|
||||
"""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
database = runner.app.state.db.database
|
||||
child_id = await _create_child(client)
|
||||
chore_id = await _create_daily_definition(
|
||||
client, child_id, "Brush teeth", ["morning", "evening"]
|
||||
)
|
||||
always_id = await _create_daily_definition(
|
||||
client, child_id, "Feed the fish", ["afternoon"],
|
||||
skip_on_away=False,
|
||||
)
|
||||
await _set_custody_schedule(client, child_id)
|
||||
await _materialize_horizon(database)
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=database,
|
||||
child_id=child_id,
|
||||
chore_id=chore_id,
|
||||
always_id=always_id,
|
||||
)
|
||||
|
||||
|
||||
# --- the count ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_custody_override_removes_a_known_number(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Absent over today+2..today+8 removes days +2..+6 x two windows.
|
||||
|
||||
Days +7 and +8 are already absent (cycle week 1), and the
|
||||
skip_on_away=false chore is generated regardless of presence, so
|
||||
exactly 5 present days x 2 windows = 10 instances would go.
|
||||
"""
|
||||
removed = await _consequence(
|
||||
custody.client, _body(custody.child_id, 2, 8)
|
||||
)
|
||||
assert removed == 10
|
||||
|
||||
|
||||
async def test_already_absent_range_removes_nothing(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An absent override over cycle week 1 changes nothing: 0."""
|
||||
assert await _consequence(
|
||||
custody.client, _body(custody.child_id, 7, 13)
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_present_override_removes_nothing(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A present override only ever adds instances: 0."""
|
||||
assert await _consequence(
|
||||
custody.client, _body(custody.child_id, 2, 8, is_present=True)
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_past_range_removes_nothing(custody: SimpleNamespace) -> None:
|
||||
"""A range entirely before today touches no upcoming instance: 0."""
|
||||
assert await _consequence(
|
||||
custody.client, _body(custody.child_id, -20, -1)
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_completed_instance_in_range_is_not_counted(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""D-005: an instance with a completion event is never removed."""
|
||||
await _complete(
|
||||
custody.database, custody.child_id, custody.chore_id, _day(3),
|
||||
"morning",
|
||||
)
|
||||
removed = await _consequence(
|
||||
custody.client, _body(custody.child_id, 2, 8)
|
||||
)
|
||||
assert removed == 9
|
||||
|
||||
|
||||
async def test_skip_on_away_false_definition_contributes_nothing(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A child whose only chore ignores presence: absent removes 0."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
database = runner.app.state.db.database
|
||||
child_id = await _create_child(client)
|
||||
await _create_daily_definition(
|
||||
client, child_id, "Feed the fish", ["morning"],
|
||||
skip_on_away=False,
|
||||
)
|
||||
await _set_custody_schedule(client, child_id)
|
||||
await _materialize_horizon(database)
|
||||
assert await _consequence(client, _body(child_id, 0, 14)) == 0
|
||||
|
||||
|
||||
async def test_count_matches_a_direct_materialise_comparison(
|
||||
custody: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Saving the override removes EXACTLY the previewed instances.
|
||||
|
||||
With a completed instance inside the range, the preview is taken,
|
||||
then the override is really created (its core path regenerates the
|
||||
child) and the open future instances that disappeared are counted
|
||||
from the database. The completed instance survives the regeneration.
|
||||
"""
|
||||
client, database = custody.client, custody.database
|
||||
await _complete(
|
||||
database, custody.child_id, custody.chore_id, _day(4), "evening"
|
||||
)
|
||||
body = _body(custody.child_id, 1, 9)
|
||||
removed = await _consequence(client, body)
|
||||
|
||||
before = await _open_future_keys(database, custody.child_id)
|
||||
created = await client.post(
|
||||
OVERRIDES_URL, json=body, headers=_admin_headers()
|
||||
)
|
||||
assert created.status_code == 201
|
||||
after = await _open_future_keys(database, custody.child_id)
|
||||
|
||||
assert after <= before
|
||||
assert len(before - after) == removed
|
||||
# Days +1..+6 present x two windows, minus the completed one.
|
||||
assert removed == 11
|
||||
completed = await _dao_instances.QuestInstancesDao(database).get(
|
||||
custody.chore_id, custody.child_id, _day(4), "evening"
|
||||
)
|
||||
assert completed is not None
|
||||
|
||||
|
||||
async def test_preview_writes_nothing(custody: SimpleNamespace) -> None:
|
||||
"""No override is stored and no instance is removed by the preview."""
|
||||
client, database = custody.client, custody.database
|
||||
before = await _open_future_keys(database, custody.child_id)
|
||||
assert await _consequence(client, _body(custody.child_id, 0, 14)) > 0
|
||||
assert await _open_future_keys(database, custody.child_id) == before
|
||||
overrides = await _dao_presence.PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
|
||||
assert overrides == []
|
||||
|
||||
|
||||
# --- errors ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_unknown_child_is_404(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL, json=_body(9999, 1, 2), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"start_date": "2026-02-30"},
|
||||
{"end_date": "2026-1-5"},
|
||||
{"start_date": "2026-03-10", "end_date": "2026-03-01"},
|
||||
{"is_present": "sometimes"},
|
||||
{"child_id": "one"},
|
||||
],
|
||||
)
|
||||
async def test_malformed_range_or_status_is_422(
|
||||
custody: SimpleNamespace, overrides: dict[str, object]
|
||||
) -> None:
|
||||
body = {**_body(custody.child_id, 1, 2), **overrides}
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- authentication: the router's ONE require_admin --------------------------
|
||||
|
||||
|
||||
async def test_no_credential_is_401(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL, json=_body(custody.child_id, 1, 2)
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_panel_service_token_is_403(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL,
|
||||
json=_body(custody.child_id, 1, 2),
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_non_admin_jwt_is_403(custody: SimpleNamespace) -> None:
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL,
|
||||
json=_body(custody.child_id, 1, 2),
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_admin_jwt_is_200(custody: SimpleNamespace) -> None:
|
||||
response = await custody.client.post(
|
||||
CONSEQUENCE_URL,
|
||||
json=_body(custody.child_id, 1, 2),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"removed": 4}
|
||||
Reference in New Issue
Block a user