Compare commits

...
13 changed files with 1370 additions and 22 deletions
+160 -4
View File
@@ -116,6 +116,22 @@ owns every rule so the two routes cannot drift:
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
The snapshot route (task ef3f4877) is the admin-plane twin of the
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
the PWA is internet-facing and never holds the panel service token
(D-012), so it reads the same snapshot here:
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
child's instances through
:func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True`` — admin payloads KEEP missed instances with
their ``missed`` state (D-009), where the panel omits them. The
body is the panel's documented shape (``today_iso``, ``cycle_day``,
``children`` with the per-child presence, rollup counts and
``instances``); the builder, the state derivation and the shaping
all live in the core.
The settings routes (task 2b3de7e5) follow the same pattern over
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
store. The API now OWNS settings in the database (a validated JSON
@@ -235,7 +251,7 @@ import datetime
from typing import Annotated, Literal, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, field_validator, model_validator
from pydantic import BaseModel, StrictBool, field_validator, model_validator
from api import transitions as api_transitions
from api.auth import require_admin
@@ -254,6 +270,7 @@ from api.nestquest_core import (
core_recurrence,
core_settings,
core_settings_store,
core_snapshot,
core_sweep,
)
@@ -346,11 +363,18 @@ class AdminQuestDefinitionResponse(BaseModel):
description: str | None
icon: str | None
is_active: bool
skip_on_away: bool
rule: AdminRuleResponse
assignees: list[AdminAssigneeResponse]
windows: list[AdminDefinitionWindowResponse]
class AdminQuestDefinitionListResponse(BaseModel):
"""The definitions list: every definition, active AND inactive, by id."""
definitions: list[AdminQuestDefinitionResponse]
class AdminChildCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/children``.
@@ -427,8 +451,9 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are
required (the core rejects an empty title, a non-list or empty
assignee/window list, and unknown window names); ``description``
and ``icon`` are optional and default to NULL through the core.
``windows`` entries are a window name (``morning``) or a two-item
and ``icon`` are optional and default to NULL through the core, and
``skip_on_away`` is optional and defaults to true (no instance on a
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
``[window, due_time]`` pair whose due time is a strict 24-hour
HH:MM (or ``null`` for none) — both forms the core normalizes.
"""
@@ -439,6 +464,7 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
windows: list[str | tuple[str, str | None]]
description: str | None = None
icon: str | None = None
skip_on_away: StrictBool = True
class AdminQuestDefinitionEditRequest(BaseModel):
@@ -453,7 +479,8 @@ class AdminQuestDefinitionEditRequest(BaseModel):
``rule`` or ``windows`` is passed and rejected by the core. A
supplied ``windows`` list REPLACES the whole window set, and
``assignees`` are deliberately absent — assignment is not settable
through this route.
through this route. An omitted (or ``null``) ``skip_on_away``
keeps the stored value.
"""
title: str | None = None
@@ -461,6 +488,7 @@ class AdminQuestDefinitionEditRequest(BaseModel):
icon: str | None = None
rule: AdminRuleRequest | None = None
windows: list[str | tuple[str, str | None]] | None = None
skip_on_away: StrictBool | None = None
class AdminQuestDefinitionActiveRequest(BaseModel):
@@ -683,6 +711,59 @@ class AdminMissedSweepResponse(BaseModel):
fired: int
class AdminInstancePayload(BaseModel):
"""One instance in the admin snapshot payload.
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
``missed`` (admin payloads keep missed instances, D-009),
``on_time`` passes the completion event's flag through verbatim
(``None`` unless completed), and ``completed_at`` is the event's
UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class AdminChildSnapshotPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away;
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[AdminInstancePayload]
class AdminSnapshotResponse(BaseModel):
"""Today's whole household snapshot for the admin Today tab.
The panel snapshot's shape (``today_iso``, ``cycle_day``,
``children`` in the household's sort order), with missed instances
kept in each child's ``instances``.
"""
today_iso: str
cycle_day: int
children: list[AdminChildSnapshotPayload]
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -887,6 +968,7 @@ def _quest_definition_response(
description=bundle.definition.description,
icon=bundle.definition.icon,
is_active=bundle.definition.is_active,
skip_on_away=bundle.definition.skip_on_away,
rule=_rule_response(bundle.rule),
assignees=[
AdminAssigneeResponse(id=child.id, display_name=child.display_name)
@@ -1098,6 +1180,32 @@ async def admin_reorder_children(
return {"status": "ok"}
@router.get(
"/quest-definitions",
summary="List quest definitions",
response_model=AdminQuestDefinitionListResponse,
)
async def admin_list_quest_definitions(
request: Request,
) -> AdminQuestDefinitionListResponse:
"""Return every quest definition in rising id order.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
active AND inactive definitions included (the admin screen manages
both), each serialized exactly as the create/edit responses are.
"""
state: DatabaseState = request.app.state.db
bundles = await core_quest_definitions.list_all_definitions(
state.database
)
return AdminQuestDefinitionListResponse(
definitions=[_quest_definition_response(bundle) for bundle in bundles]
)
@router.post(
"/quest-definitions",
summary="Create a quest definition",
@@ -1132,6 +1240,7 @@ async def admin_create_quest_definition(
body.windows,
description=body.description,
icon=body.icon,
skip_on_away=body.skip_on_away,
)
except ValueError as error:
_raise_quest_definition_error(error)
@@ -1729,4 +1838,51 @@ async def admin_run_missed_sweep(
return AdminMissedSweepResponse(fired=len(events))
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=AdminSnapshotResponse,
)
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
"""Return today's full household snapshot for the admin Today tab.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE clock read
(:func:`_local_now`) goes into
:func:`nestquest_core.snapshot.build_snapshot` against the live
database, and each child's instances are shaped with
``instance_payload(include_missed=True)`` — missed instances are
KEPT in the admin payload (D-009) — into
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
AdminChildSnapshotPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
AdminInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=True
)
],
)
for child in snapshot.children
],
)
__all__ = ["router"]
+71 -8
View File
@@ -105,6 +105,19 @@ def _validate_window(window: str) -> None:
)
def _validate_skip_on_away(value: object) -> int:
"""Return ``value`` as 0/1, raising ValueError for anything else.
The fresh-schema CHECK constraint enforces this in SQLite, but a
migrated database gained the column through ``ADD COLUMN`` without
it, so every DAO write path validates here instead. Accepts a bool
or the ints 0/1; floats, strings and other ints are rejected.
"""
if not isinstance(value, int) or value not in (0, 1):
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
return int(value)
@dataclass(frozen=True)
class ScheduleRuleRecord:
"""One row of ``schedule_rules``."""
@@ -152,6 +165,7 @@ class QuestDefinitionRecord:
due_time: str | None
is_active: bool
created_at: str
skip_on_away: bool
@dataclass(frozen=True)
@@ -187,7 +201,7 @@ _RULE_COLUMNS = (
)
_DEFINITION_COLUMNS = (
"id, title, description, icon, schedule_rule_id, due_time, "
"is_active, created_at"
"is_active, created_at, skip_on_away"
)
_WINDOW_COLUMNS = "definition_id, window, due_time"
@@ -216,6 +230,7 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
due_time=row[5],
is_active=bool(row[6]),
created_at=row[7],
skip_on_away=bool(row[8]),
)
@@ -632,6 +647,7 @@ class QuestDefinitionsDao:
due_time: str | None = None,
is_active: bool = True,
assignee_child_ids: list[int] | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionRecord:
"""Insert one definition and return the record as stored.
@@ -644,6 +660,7 @@ class QuestDefinitionsDao:
empty list or None creates an unassigned definition, which
callers may fill via :meth:`add_assignee`.
"""
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_rule_exists(schedule_rule_id)
@@ -652,7 +669,8 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
@@ -661,6 +679,7 @@ class QuestDefinitionsDao:
due_time,
int(is_active),
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
@@ -684,6 +703,7 @@ class QuestDefinitionsDao:
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
@@ -713,6 +733,7 @@ class QuestDefinitionsDao:
_validate_date(schedule_rule.start_date, "start_date")
if schedule_rule.end_date is not None:
_validate_date(schedule_rule.end_date, "end_date")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
for child_id in assignee_child_ids:
@@ -721,8 +742,18 @@ class QuestDefinitionsDao:
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, description, icon, rule_id, None, 1, created_at),
"created_at, skip_on_away) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
icon,
rule_id,
None,
1,
created_at,
skip_on_away_value,
),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids:
@@ -764,6 +795,7 @@ class QuestDefinitionsDao:
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
@@ -774,9 +806,10 @@ class QuestDefinitionsDao:
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``title``/``description``/``icon``/``skip_on_away`` update the
definition row in place; the definition-level ``due_time``
column is never written (per-window due times supersede it,
D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
@@ -792,6 +825,8 @@ class QuestDefinitionsDao:
does not exist. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
if skip_on_away is not _UNSET:
skip_on_away = _validate_skip_on_away(skip_on_away)
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
@@ -809,6 +844,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not _UNSET:
assignments.append("skip_on_away = ?")
parameters.append(skip_on_away)
if assignments:
parameters.append(definition_id)
await self._database.execute(
@@ -907,6 +945,13 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def list_all(self) -> list[QuestDefinitionRecord]:
"""Return every definition, active AND inactive, oldest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
@@ -951,6 +996,18 @@ class QuestDefinitionsDao:
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
"""Return every definition (active AND inactive) with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
(oldest first), with the same coherence guarantee.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_all()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
@@ -990,13 +1047,16 @@ class QuestDefinitionsDao:
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
due_time: str | None | object = _UNSET,
skip_on_away: bool | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Arguments default to the module sentinel ``_UNSET`` meaning
"leave this column alone"; passing ``None`` explicitly writes
SQL NULL, so callers can remove optional metadata
(description, icon, due_time). Assignment and activation are
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
so its ``None`` default leaves it unchanged; a supplied value
must be 0/1 (a bool). Assignment and activation are
deliberately NOT settable here: they have their own explicit
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
:meth:`set_active`) so they stay separately loggable and
@@ -1013,6 +1073,9 @@ class QuestDefinitionsDao:
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if skip_on_away is not None:
assignments.append("skip_on_away = ?")
parameters.append(_validate_skip_on_away(skip_on_away))
if not assignments:
return 0
parameters.append(definition_id)
@@ -18,7 +18,8 @@ The walk is deterministic and idempotent:
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
``is_present`` on that date (or every assignee, when the definition's
``skip_on_away`` is off) and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
@@ -208,7 +209,8 @@ async def materialize(
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
snapshot.assignees, snapshot.windows,
snapshot.definition.skip_on_away)
for snapshot in snapshots
]
@@ -229,13 +231,15 @@ async def materialize(
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
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 not engine.is_present(child.id, cursor):
if skip_on_away and not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
+29 -2
View File
@@ -30,8 +30,9 @@ widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Each callable migration runs inside its own
transaction
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
callable migration runs inside its own transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
@@ -400,6 +401,28 @@ async def _add_actor_child_id(
await database.execute("PRAGMA foreign_keys = ON")
async def _add_skip_on_away(
database: NestQuestDatabase, target_version: int
) -> None:
"""Add the ``skip_on_away`` column to ``quest_definitions``.
Strictly additive: ``ALTER TABLE ... ADD COLUMN`` with a NOT NULL
DEFAULT 1, so existing rows read as 1 (skip absent dates — the
behaviour before the flag existed). Fresh databases already carry
the column from the canonical DDL, so the ALTER is guarded and the
step only stamps there.
"""
async with database.transaction():
if not await _table_has_column(
database, "quest_definitions", "skip_on_away"
):
await database.execute(
"ALTER TABLE quest_definitions ADD COLUMN "
"skip_on_away INTEGER NOT NULL DEFAULT 1"
)
await stamp_schema_version(database, target_version)
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
@@ -423,6 +446,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
# fresh databases (which already made it in migration 1) and
# pre-v7 databases. Strictly additive; no existing table changes.
list(SCHEMA_V7_META_STATE_DDL),
# Migration 8 (Feature 22): the skip_on_away flag on
# quest_definitions — an additive column defaulting to 1, so every
# pre-existing definition keeps today's skip-when-absent behaviour.
_add_skip_on_away,
]
@@ -191,6 +191,17 @@ def _validate_definition_id(value: object) -> int:
return value
def _validate_skip_on_away(value: object) -> bool:
"""Reject a non-bool ``skip_on_away`` before any write.
The DAO's ``int()`` would otherwise silently coerce strings and
numerics ("0", 1) into a state the caller never asked for.
"""
if not isinstance(value, bool):
raise ValueError(f"skip_on_away must be a real bool, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
@@ -212,6 +223,7 @@ async def create_quest_definition(
*,
description: str | None = None,
icon: str | None = None,
skip_on_away: bool = True,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
@@ -231,6 +243,7 @@ async def create_quest_definition(
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
skip_on_away_value = _validate_skip_on_away(skip_on_away)
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
@@ -243,6 +256,7 @@ async def create_quest_definition(
window_specs,
description=description_value,
icon=icon_value,
skip_on_away=skip_on_away_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
@@ -271,6 +285,7 @@ async def edit_quest_definition(
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
skip_on_away: bool | None = None,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
@@ -285,7 +300,9 @@ async def edit_quest_definition(
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
time), matching create's non-empty contract. A provided
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
leaves it unchanged. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
@@ -331,6 +348,10 @@ async def edit_quest_definition(
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
skip_on_away_value: bool | object = _UNSET
if skip_on_away is not None:
skip_on_away_value = _validate_skip_on_away(skip_on_away)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
@@ -340,6 +361,7 @@ async def edit_quest_definition(
icon=icon_value,
rule=rule_storage,
windows=window_specs,
skip_on_away=skip_on_away_value,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
@@ -558,6 +580,21 @@ async def list_active_definitions(
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_all_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return EVERY definition, active AND inactive (rich view).
Mirrors :func:`list_active_definitions` without the activity
filter: ordered by rising definition id, each entry the same
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
locked transaction.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_all()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
+2 -1
View File
@@ -257,7 +257,8 @@ QUEST_DEFINITIONS_TABLE_SQL = """(
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
+123
View File
@@ -215,6 +215,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
"description",
"icon",
"is_active",
"skip_on_away",
"rule",
"assignees",
"windows",
@@ -223,6 +224,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
assert payload["description"] == "Put everything back where it belongs."
assert payload["icon"] == "toy-box"
assert payload["is_active"] is True
assert payload["skip_on_away"] is True
assert payload["rule"] == _weekly_rule_dict()
assert {a["id"]: a["display_name"] for a in payload["assignees"]} == {
ada: "Ada",
@@ -521,9 +523,130 @@ async def test_edit_rejections_are_422_and_change_nothing(
}
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
async def test_create_rejects_coercible_skip_on_away_and_persists_nothing(
admin_questdefs: SimpleNamespace, coercible: object
) -> None:
"""A numeric or string skip_on_away is 422, not coerced to a bool."""
client = admin_questdefs.client
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
ada = (await _create_children(client, ("Ada",)))[0]
existing = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Feed the cat",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"skip_on_away": False,
},
headers=_admin_headers(),
)
assert existing.status_code == 201
existing_id = existing.json()["id"]
response = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Tidy the den",
"rule": _weekly_rule_body(),
"assignee_child_ids": [ada],
"windows": ["morning"],
"skip_on_away": coercible,
},
headers=_admin_headers(),
)
assert response.status_code == 422
assert set(await _definitions_by_id(admin_questdefs.database)) == {
existing_id
}
assert (await dao.get(existing_id)).skip_on_away is False
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
async def test_edit_rejects_coercible_skip_on_away_and_changes_nothing(
admin_questdefs: SimpleNamespace, coercible: object
) -> None:
"""A numeric or string skip_on_away edit is 422; the flag is kept."""
client = admin_questdefs.client
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
# Start from each stored value so a coerced write would be visible.
for stored in (True, False):
set_flag = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": stored},
headers=_admin_headers(),
)
assert set_flag.status_code == 200
response = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": coercible, "title": "Renamed"},
headers=_admin_headers(),
)
assert response.status_code == 422
stored_definition = await dao.get(definition_id)
assert stored_definition.skip_on_away is stored
assert stored_definition.title == "Tidy the den"
# --- set_active -------------------------------------------------------------
async def test_edit_toggles_skip_on_away_and_omission_keeps_it(
admin_questdefs: SimpleNamespace,
) -> None:
"""A supplied skip_on_away persists; an edit omitting it keeps it."""
client = admin_questdefs.client
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
ada = (await _create_children(client, ("Ada",)))[0]
created = await _create_definition(client, [ada])
assert created.status_code == 201
definition_id = created.json()["id"]
assert created.json()["skip_on_away"] is True
toggled_off = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": False},
headers=_admin_headers(),
)
assert toggled_off.status_code == 200
assert toggled_off.json()["skip_on_away"] is False
assert (await dao.get(definition_id)).skip_on_away is False
# An edit that omits the flag leaves the stored False untouched.
renamed = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"title": "Tidy the den properly"},
headers=_admin_headers(),
)
assert renamed.status_code == 200
assert renamed.json()["skip_on_away"] is False
stored = await dao.get(definition_id)
assert stored.title == "Tidy the den properly"
assert stored.skip_on_away is False
toggled_on = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": True},
headers=_admin_headers(),
)
assert toggled_on.status_code == 200
assert toggled_on.json()["skip_on_away"] is True
assert (await dao.get(definition_id)).skip_on_away is True
# A non-boolean value is a malformed body: 422, nothing changes.
rejected = await client.patch(
f"/api/v1/admin/quest-definitions/{definition_id}",
json={"skip_on_away": "sometimes"},
headers=_admin_headers(),
)
assert rejected.status_code == 422
assert (await dao.get(definition_id)).skip_on_away is True
async def test_set_active_toggles_the_flag_and_the_definition_survives(
admin_questdefs: SimpleNamespace,
) -> None:
+299
View File
@@ -0,0 +1,299 @@
"""Admin-plane quest-definition LIST route tests (task d4667573).
``GET /api/v1/admin/quest-definitions`` against the ASGI app with httpx,
reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely authenticates
with NO network access.
- An admin JWT lists EVERY definition — active AND inactive — in rising
id order, each in the same documented shape as the create/edit
responses (id, title, description, icon, is_active, rule, assignees,
windows).
- An empty household lists nothing: 200 with an empty list.
- Authentication runs through the router's ONE ``require_admin``
dependency: an absent credential is 401, the panel service token is
refused outright (403, the credential-type detail), and a JWT without
the nestquest-admins group is 403.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
#: The admin list route under test.
LIST_PATH = "/api/v1/admin/quest-definitions"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture
async def admin_client(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client over a fresh (empty) household."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield client
async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]:
"""Seed one child, an ACTIVE definition and an INACTIVE one.
Returns ``(child_id, active_id, inactive_id)``. Seeded through the
admin routes so the rows are exactly what the admin plane writes.
"""
child = await client.post(
"/api/v1/admin/children",
json={"display_name": "Ada"},
headers=_admin_headers(),
)
assert child.status_code == 201
child_id = child.json()["id"]
active = await client.post(
LIST_PATH,
json={
"title": "Tidy the den",
"rule": {
"rule_type": "weekly",
"interval": 1,
"weekday_set": [0, 2],
"start_date": "2026-03-02",
},
"assignee_child_ids": [child_id],
"windows": ["morning", ["evening", "19:30"]],
"description": "Put everything back where it belongs.",
"icon": "toy-box",
},
headers=_admin_headers(),
)
assert active.status_code == 201
inactive = await client.post(
LIST_PATH,
json={
"title": "Water the plants",
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child_id],
"windows": ["afternoon"],
},
headers=_admin_headers(),
)
assert inactive.status_code == 201
inactive_id = inactive.json()["id"]
deactivated = await client.patch(
f"{LIST_PATH}/{inactive_id}/active",
json={"is_active": False},
headers=_admin_headers(),
)
assert deactivated.status_code == 200
return child_id, active.json()["id"], inactive_id
async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
admin_client: object,
) -> None:
"""Both definitions come back, by rising id, in the documented shape."""
child_id, active_id, inactive_id = await _seed_active_and_inactive(
admin_client
)
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {
"definitions": [
{
"id": active_id,
"title": "Tidy the den",
"description": "Put everything back where it belongs.",
"icon": "toy-box",
"is_active": True,
"skip_on_away": True,
"rule": {
"rule_type": "weekly",
"interval": 1,
"weekday_set": [0, 2],
"day_of_month": None,
"nth_weekday": None,
"nth_weekday_weekday": None,
"month": None,
"start_date": "2026-03-02",
"end_date": None,
},
"assignees": [{"id": child_id, "display_name": "Ada"}],
"windows": [
{"window": "morning", "due_time": None},
{"window": "evening", "due_time": "19:30"},
],
},
{
"id": inactive_id,
"title": "Water the plants",
"description": None,
"icon": None,
"is_active": False,
"skip_on_away": True,
"rule": {
"rule_type": "daily",
"interval": 1,
"weekday_set": None,
"day_of_month": None,
"nth_weekday": None,
"nth_weekday_weekday": None,
"month": None,
"start_date": "2026-03-01",
"end_date": None,
},
"assignees": [{"id": child_id, "display_name": "Ada"}],
"windows": [{"window": "afternoon", "due_time": None}],
},
]
}
async def test_list_items_match_the_create_response_shape(
admin_client: object,
) -> None:
"""Each listed item is exactly what the create route returned."""
child = await admin_client.post(
"/api/v1/admin/children",
json={"display_name": "Ben"},
headers=_admin_headers(),
)
created = await admin_client.post(
LIST_PATH,
json={
"title": "Feed the cat",
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child.json()["id"]],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {"definitions": [created.json()]}
async def test_skip_on_away_round_trips_true_and_false_through_list(
admin_client: object,
) -> None:
"""skip_on_away posted true, false, or omitted lists back as stored."""
child = await admin_client.post(
"/api/v1/admin/children",
json={"display_name": "Cleo"},
headers=_admin_headers(),
)
child_id = child.json()["id"]
async def create(title: str, extra: dict[str, object]) -> dict:
response = await admin_client.post(
LIST_PATH,
json={
"title": title,
"rule": {
"rule_type": "daily",
"interval": 1,
"start_date": "2026-03-01",
},
"assignee_child_ids": [child_id],
"windows": ["morning"],
**extra,
},
headers=_admin_headers(),
)
assert response.status_code == 201
return response.json()
skipping = await create("Skip when away", {"skip_on_away": True})
keeping = await create("Keep when away", {"skip_on_away": False})
defaulted = await create("Default", {})
assert skipping["skip_on_away"] is True
assert keeping["skip_on_away"] is False
assert defaulted["skip_on_away"] is True
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
listed = {item["id"]: item for item in response.json()["definitions"]}
assert listed[skipping["id"]]["skip_on_away"] is True
assert listed[keeping["id"]]["skip_on_away"] is False
assert listed[defaulted["id"]]["skip_on_away"] is True
assert response.json() == {"definitions": [skipping, keeping, defaulted]}
async def test_empty_household_lists_no_definitions(
admin_client: object,
) -> None:
"""With no definitions stored the list is 200 and empty."""
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
assert response.status_code == 200
assert response.json() == {"definitions": []}
async def test_list_without_credentials_is_401(admin_client: object) -> None:
"""An absent credential is the uniform 401."""
response = await admin_client.get(LIST_PATH)
assert response.status_code == 401
async def test_list_with_panel_service_token_is_403(
admin_client: object,
) -> None:
"""The panel service token is refused outright, never a JWT."""
response = await admin_client.get(
LIST_PATH,
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await admin_client.get(
LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"}
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
+328
View File
@@ -0,0 +1,328 @@
"""Admin-plane snapshot route tests (task ef3f4877).
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
authenticates with NO network access. The household is seeded with
:func:`tests.test_api_panel._seed_household` through the SAME core
copy the app uses (``nestquest_core.*``), and both routes' single
clock read (``_local_now``) is pinned to noon so nothing straddles
midnight.
Proven per the done-condition:
- An admin JWT returns 200 with the documented shape: ``today_iso``,
``cycle_day`` and, per child, the presence fields, the rollup counts
and ``instances`` in the core ``instance_payload`` shape.
- A MISSED instance is KEPT in the admin payload (D-009) and still
OMITTED from the panel payload. The core builder lists only
instances due on ``now.date()``, and ``derive_state`` calls an
instance missed only when its due date is BEFORE that day — so a
real snapshot never carries a missed view. The test therefore
wraps the real builder to add the seeded past-due instance as a
view whose state the core's own ``derive_state`` computes
(``missed``), and reads it through both routes.
- Auth runs through the router's ONE ``require_admin`` dependency: no
credential is 401, the panel service token is refused outright
(403, the credential-type detail), and a non-admin JWT is 403.
- The handler performs exactly ONE clock read per request.
"""
from __future__ import annotations
import dataclasses
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
# The completion layer through the SAME core copy the app uses — the
# missed test derives its view's state with the core's own rule.
_completion = importlib.import_module("nestquest_core.completion")
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
"""The Authorization header carrying the harness panel token."""
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def snapshot_household(
temp_db_path: str, monkeypatch
) -> SimpleNamespace:
"""A seeded household served by the admin-plane app, clock pinned.
``today`` comes from one host clock read; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
the admin and the panel route's ``_local_now`` return it, and the
admin one counts its calls.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(pinned_now)
return pinned_now
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
today=today,
pinned_now=pinned_now,
pinned_utc_iso=pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
clock_reads=clock_reads,
)
# --- authentication: the ONE require_admin dependency ----------------------
async def test_snapshot_without_credential_is_401(
snapshot_household: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
assert response.status_code == 401
async def test_snapshot_refuses_panel_service_token_outright(
snapshot_household: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403).
403 with the credential-type detail — not the uniform 401 and not
the non-admin group detail — proves the token was refused outright,
never tried as a JWT.
"""
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot", headers=_panel_headers()
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_snapshot_refuses_non_admin_jwt(
snapshot_household: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
# --- the payload -----------------------------------------------------------
async def test_admin_jwt_returns_documented_snapshot(
snapshot_household: SimpleNamespace,
) -> None:
"""An admin JWT returns 200 with the documented snapshot shape.
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
"""
client = snapshot_household.client
seed = snapshot_household.seed
response = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert response.status_code == 200
assert len(snapshot_household.clock_reads) == 1
body = response.json()
assert body == {
"today_iso": snapshot_household.today.isoformat(),
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
"cycle_day": 2,
"children": [
{
"child_id": seed.ada.id,
"child_name": "Ada",
"present": False,
"next_present": seed.returns_date.isoformat(),
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{
"id": seed.pack_bag_instance.id,
"definition_id": seed.pack_bag_instance.definition_id,
"child_id": seed.ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
],
},
{
"child_id": seed.bo.id,
"child_name": "Bo",
"present": True,
"next_present": None,
"due_today": 1,
"completed_today": 1,
"remaining_today": 0,
"completion_pct": 100,
"instances": [
{
"id": seed.brush_teeth_instance.id,
"definition_id": (
seed.brush_teeth_instance.definition_id
),
"child_id": seed.bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": snapshot_household.pinned_utc_iso,
"on_time": True,
}
],
},
],
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
async def test_admin_keeps_missed_instance_panel_omits_it(
snapshot_household: SimpleNamespace, monkeypatch
) -> None:
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
The real builder is wrapped to add the seeded past-due Stale chore
instance to Ada's views, its state derived by the core's own
``derive_state`` (no events, due yesterday => ``missed``). Both
routes read the SAME wrapped snapshot, so the only difference is
each route's ``include_missed`` flag.
"""
client = snapshot_household.client
seed = snapshot_household.seed
stale = seed.stale_instance
missed_state = _completion.derive_state(
stale, None, snapshot_household.today
)
assert missed_state == "missed"
missed_view = core_snapshot.QuestInstanceView(
instance_id=stale.id,
definition_id=stale.definition_id,
child_id=stale.child_id,
title="Stale chore",
icon=None,
window=stale.window,
due_date=stale.due_date,
due_time=stale.due_time,
state=missed_state,
overdue=False,
completed_at=None,
was_on_time=None,
)
real_build_snapshot = core_snapshot.build_snapshot
async def _with_missed_view(database, settings, now):
snapshot = await real_build_snapshot(database, settings, now)
return dataclasses.replace(
snapshot,
children=tuple(
dataclasses.replace(
child, instances=(*child.instances, missed_view)
)
if child.child_id == seed.ada.id
else child
for child in snapshot.children
),
)
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
admin = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert admin.status_code == 200
admin_ada = admin.json()["children"][0]
assert admin_ada["child_id"] == seed.ada.id
assert [instance["id"] for instance in admin_ada["instances"]] == [
seed.pack_bag_instance.id,
stale.id,
]
assert admin_ada["instances"][1] == {
"id": stale.id,
"definition_id": stale.definition_id,
"child_id": seed.ada.id,
"title": "Stale chore",
"icon": None,
"window": "morning",
"due_time": "09:00",
"state": "missed",
"overdue": False,
"completed_at": None,
"on_time": None,
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
panel_ada = panel.json()["children"][0]
assert [instance["id"] for instance in panel_ada["instances"]] == [
seed.pack_bag_instance.id
], "the panel payload must still omit missed instances"
+67
View File
@@ -847,6 +847,7 @@ def test_definition_update_cannot_change_assignee_or_active(
"description",
"icon",
"due_time",
"skip_on_away",
}
assert await definitions.update(
definition.id, title="A2"
@@ -862,6 +863,72 @@ def test_definition_update_cannot_change_assignee_or_active(
_with_db(tmp_path, "definition-update-shape.db")(_body)
def test_definition_update_toggles_skip_on_away_and_omission_keeps_it(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
assert definition.skip_on_away is True
assert await definitions.update(
definition.id, skip_on_away=False
) == 1
assert (await definitions.get(definition.id)).skip_on_away is False
# Omitting the flag (None) leaves the stored False untouched.
assert await definitions.update(definition.id, title="A2") == 1
kept = await definitions.get(definition.id)
assert kept.title == "A2"
assert kept.skip_on_away is False
assert await definitions.update(definition.id) == 0
assert await definitions.update(
definition.id, skip_on_away=True
) == 1
assert (await definitions.get(definition.id)).skip_on_away is True
_with_db(tmp_path, "definition-update-skip-on-away.db")(_body)
@pytest.mark.parametrize("bad", [2, -1, 0.0, 1.5, "1", "true", b"\x01"])
def test_skip_on_away_rejected_unless_zero_or_one_at_every_write(
tmp_path, bad
) -> None:
"""Every DAO entry point taking the flag rejects non-0/1 values.
This is the only guard on migrated databases, whose column was
added without the fresh-schema CHECK constraint.
"""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.create("B", rule.id, NOW, skip_on_away=bad)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.create_with_rule_and_windows(
"C",
schedule_rule_to_storage(
ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-14"
)
),
NOW,
[child.id],
[("morning", None)],
skip_on_away=bad,
)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.edit_definition(
definition.id, title="A2", skip_on_away=bad
)
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
await definitions.update(
definition.id, title="A2", skip_on_away=bad
)
stored = await definitions.list_all()
assert [(d.title, d.skip_on_away) for d in stored] == [("A", True)]
_with_db(tmp_path, "definition-skip-on-away-invalid.db")(_body)
def test_definition_update_no_fields_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
+127
View File
@@ -657,6 +657,133 @@ def test_materialize_honours_overrides(tmp_path) -> None:
_with_db(tmp_path, "materialize-override.db")(_body)
async def _absent_midweek_setup(database, skip_on_away: bool):
"""One child absent on days 2..4 of a future week, one daily quest.
Returns ``(child_id, definition_id, start, end_iso, absent_dates)``.
"""
from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao,
)
child = await ChildrenDao(database).create("Ada", NOW)
start = _future_monday()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
absent = {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in (2, 3, 4)
}
await PresenceOverridesDao(database).create(
child.id, min(absent), max(absent), False, note="away"
)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start.isoformat()),
[child.id],
["morning"],
skip_on_away=skip_on_away,
)
assert created.definition.skip_on_away is skip_on_away
return child.id, created.definition.id, start, end_iso, absent
def test_materialize_skip_on_away_true_skips_absent_dates(tmp_path) -> None:
async def _body(database):
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
database, skip_on_away=True
)
count = await materialize(database, start.isoformat(), end_iso)
records = await QuestInstancesDao(database).list_by_date_range(
child_id, start.isoformat(), end_iso
)
due_dates = {record.due_date for record in records}
assert count == 4
assert due_dates.isdisjoint(absent)
assert len(due_dates) == 4
_with_db(tmp_path, "skip-on-away-true.db")(_body)
def test_materialize_skip_on_away_false_generates_absent_dates(
tmp_path,
) -> None:
async def _body(database):
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
database, skip_on_away=False
)
count = await materialize(database, start.isoformat(), end_iso)
records = await QuestInstancesDao(database).list_by_date_range(
child_id, start.isoformat(), end_iso
)
due_dates = {record.due_date for record in records}
assert count == 7
assert absent <= due_dates
assert len(due_dates) == 7
_with_db(tmp_path, "skip-on-away-false.db")(_body)
def test_edit_skip_on_away_regenerates_absent_dates(tmp_path) -> None:
"""Toggling the flag through the edit path rebuilds future instances."""
async def _body(database):
child_id, definition_id, start, end_iso, absent = (
await _absent_midweek_setup(database, skip_on_away=True)
)
await materialize(database, start.isoformat(), end_iso)
dao = QuestInstancesDao(database)
async def due_dates() -> set[str]:
records = await dao.list_by_date_range(
child_id, start.isoformat(), end_iso
)
return {record.due_date for record in records}
assert (await due_dates()).isdisjoint(absent)
edited = await edit_quest_definition(
database, definition_id, skip_on_away=False
)
assert edited.definition.skip_on_away is False
assert absent <= await due_dates()
# None leaves the stored flag unchanged.
unchanged = await edit_quest_definition(
database, definition_id, title="Daily chore (renamed)"
)
assert unchanged.definition.skip_on_away is False
edited = await edit_quest_definition(
database, definition_id, skip_on_away=True
)
assert edited.definition.skip_on_away is True
assert (await due_dates()).isdisjoint(absent)
_with_db(tmp_path, "skip-on-away-edit.db")(_body)
def test_skip_on_away_rejects_non_bool(tmp_path) -> None:
async def _body(database):
child = await ChildrenDao(database).create("Ada", NOW)
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-01-01")
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
await create_quest_definition(
database, "Chore", rule, [child.id], ["morning"],
skip_on_away=1,
)
created = await create_quest_definition(
database, "Chore", rule, [child.id], ["morning"]
)
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
await edit_quest_definition(
database, created.definition.id, skip_on_away="false"
)
stored = await QuestDefinitionsDao(database).get(created.definition.id)
assert stored.skip_on_away is True
_with_db(tmp_path, "skip-on-away-validation.db")(_body)
def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
async def _body(database):
start = (_future_monday()).isoformat()
+93 -2
View File
@@ -153,7 +153,9 @@ def test_migration_list_shape() -> None:
migration 3 brings pre-D-008 definitions to the multi-assignee
model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key, and migration 6 adds actor_child_id to completion_events.
key, migration 6 adds actor_child_id to completion_events,
migration 7 adds the meta_state table and migration 8 adds
skip_on_away to quest_definitions.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1])
@@ -162,7 +164,8 @@ def test_migration_list_shape() -> None:
assert callable(MIGRATIONS[4])
assert callable(MIGRATIONS[5])
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
assert len(MIGRATIONS) == 7
assert callable(MIGRATIONS[7])
assert len(MIGRATIONS) == 8
# ---------------------------------------------------------------------------
@@ -564,6 +567,13 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert "child_id" not in {c[1] for c in columns}
# The rebuilt row reads the pre-flag behaviour: skip on away.
skip = _run(
database.fetch_one(
"SELECT skip_on_away FROM quest_definitions WHERE id = 1"
)
)
assert skip == (1,)
# The single assignee moved into the assignees table.
assignees = _run(
database.fetch_all(
@@ -637,6 +647,87 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
_run(database.close())
# ---------------------------------------------------------------------------
# Migration 8: skip_on_away on quest_definitions (Feature 22)
# ---------------------------------------------------------------------------
def _seed_v7_database(database) -> None:
"""Build a genuine version-7 database holding one definition.
Migrations 1..7 run first; the fresh v1 DDL already carries
``skip_on_away`` (canonical shape), so the column is dropped to
reproduce the exact pre-migration-8 table a shipped v7 file holds,
and a definition row is written through that old column list.
"""
assert _migrate(database, MIGRATIONS[:7]) == 7
_run(
database.execute(
"ALTER TABLE quest_definitions DROP COLUMN skip_on_away"
)
)
columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)"))
assert "skip_on_away" not in {c[1] for c in columns}
_run(
database.execute(
"INSERT INTO schedule_rules (id, rule_type, interval, "
"start_date) VALUES (1, 'daily', 1, '2026-09-01')"
)
)
_run(
database.execute(
"INSERT INTO quest_definitions (id, title, schedule_rule_id, "
"is_active, created_at) VALUES (1, 'Dishes', 1, 1, "
"'2026-09-01T00:00:00+00:00')"
)
)
def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None:
from custom_components.nestquest.core.dao_rules import (
QuestDefinitionsDao,
)
database = _open_db(tmp_path / "v7-upgrade.db")
try:
_seed_v7_database(database)
before = _version(database)
assert before == 7
final = _migrate(database)
assert final == before + 1
assert _version(database) == before + 1
columns = {
c[1]: c
for c in _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
}
assert "skip_on_away" in columns
# (cid, name, type, notnull, dflt_value, pk)
assert columns["skip_on_away"][2:5] == ("INTEGER", 1, "1")
record = _run(QuestDefinitionsDao(database).get(1))
assert record is not None
assert record.title == "Dishes"
assert record.skip_on_away is True
finally:
_run(database.close())
def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None:
"""A fresh file already has the column; step 8 only stamps."""
database = _open_db(tmp_path / "fresh-v8.db")
try:
assert _migrate(database) == len(MIGRATIONS)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert [c[1] for c in columns].count("skip_on_away") == 1
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Idempotence: second start is a no-op
# ---------------------------------------------------------------------------
+25
View File
@@ -322,6 +322,7 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
("due_time", "TEXT", 0, None, 0),
("is_active", "INTEGER", 1, "1", 0),
("created_at", "TEXT", 1, None, 0),
("skip_on_away", "INTEGER", 1, "1", 0),
]
finally:
_run(database.close())
@@ -633,6 +634,30 @@ def test_schedule_rules_rule_type_check_enforced(tmp_path) -> None:
_run(database.close())
@pytest.mark.parametrize("bad_flag", [2, -1])
def test_quest_definitions_skip_on_away_check_enforced(
tmp_path, bad_flag
) -> None:
database = _open_db(tmp_path / f"skip-on-away-{bad_flag}.db")
try:
_apply(database)
_child(database)
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_definition(database, skip_on_away=bad_flag)
definition_id = _insert_definition(database, skip_on_away=0)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_run(
database.execute(
"UPDATE quest_definitions SET skip_on_away = ? "
"WHERE id = ?",
(bad_flag, definition_id),
)
)
finally:
_run(database.close())
def test_schedule_rules_interval_zero_fails(tmp_path) -> None:
database = _open_db(tmp_path / "interval-zero.db")
try: