Panel plane: complete route (idempotent) (#153)

This commit was merged in pull request #153.
This commit is contained in:
2026-09-22 22:38:48 -04:00
parent 4da762173c
commit 550a8b3e1e
3 changed files with 299 additions and 7 deletions
+5
View File
@@ -112,12 +112,17 @@ core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
#: assembly the panel routes call. #: assembly the panel routes call.
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot") core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
#: The completion service (``core.completion.complete_instance`` /
#: ``uncomplete_instance``) the panel's write routes call.
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
#: The explicit settings object (``core.settings.NestQuestSettings``) #: The explicit settings object (``core.settings.NestQuestSettings``)
#: the snapshot builder's ``settings`` parameter takes. #: the snapshot builder's ``settings`` parameter takes.
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings") core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
__all__ = [ __all__ = [
"core", "core",
"core_completion",
"core_db", "core_db",
"core_migrations", "core_migrations",
"core_snapshot", "core_snapshot",
+125 -3
View File
@@ -52,13 +52,22 @@ The same :class:`PanelSnapshotResponse` Pydantic model is the route's
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import importlib
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from api.database import DatabaseState from api.database import DatabaseState
from api.dependencies import require_panel_token from api.dependencies import require_panel_token
from api.nestquest_core import core_snapshot, core_settings from api.nestquest_core import (
core_completion,
core_settings,
core_snapshot,
)
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
#: All panel-plane routes share this router; the service-token check is #: All panel-plane routes share this router; the service-token check is
#: attached HERE so every panel route (current and later) requires it. #: attached HERE so every panel route (current and later) requires it.
@@ -122,6 +131,37 @@ class PanelSnapshotResponse(BaseModel):
children: list[PanelChildPayload] children: list[PanelChildPayload]
class PanelCompleteRequest(BaseModel):
"""The body of ``POST /api/v1/panel/instances/{id}/complete``.
Matches the panel's ``nestquest.complete_quest`` payload (§2 of
design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped
profile. A positive integer is required — the core completion
layer rejects non-int (and bool) ids anyway, but rejecting them
here with 422 keeps the contract visible in the OpenAPI document
and fails before any database work.
"""
actor_child_id: int
model_config = {
"json_schema_extra": {
"description": (
"The tapped child profile the completion is "
"attributed to (decision 9)."
)
}
}
@field_validator("actor_child_id")
@classmethod
def _positive(cls, value: int) -> int:
"""Reject zero and negative ids with a validation error."""
if value < 1:
raise ValueError("actor_child_id must be a positive integer")
return value
def _local_now() -> datetime.datetime: def _local_now() -> datetime.datetime:
"""Return the API host's local time as ONE timezone-aware clock read. """Return the API host's local time as ONE timezone-aware clock read.
@@ -180,4 +220,86 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
) )
@router.post(
"/instances/{instance_id}/complete",
summary="Complete one quest instance",
status_code=200,
)
async def panel_complete_instance(
instance_id: int, body: PanelCompleteRequest, request: Request
) -> dict[str, str]:
"""Complete one quest instance as the tapped child profile.
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Thin adapter only: resolves the live database off ``app.state.db``,
reads ONE timezone-aware clock (:func:`_local_now`), and delegates
to :func:`nestquest_core.completion.complete_instance` with
``actor_source="panel"`` and the tapped ``actor_child_id`` — the
append/idempotence logic, the lock, and the actor-shape validation
all live in the core layer, never here.
Response and idempotence: ``complete_instance`` returns a
:class:`~nestquest_core.completion.CompletionResult` whose
``appended`` flag reports whether THIS call wrote an event; a
repeat call for an already-completed instance is a no-op inside
the core layer (no duplicate event) and this route still answers
``{"status": "done"}`` either way, so a retried panel tap succeeds.
Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped
to 404 is the unknown-``instance_id`` one, identified by the
``instance_id:`` prefix ``complete_instance`` names the failing
field with. Every other ``ValueError`` (an invalid actor shape,
which the body model and the panel actor contract already make
unreachable) re-raises to the framework's 500 — nothing else is
turned into a 404 or a 500 of our own making.
Child mismatch: ``complete_instance`` verifies the tapped
``actor_child_id`` exists but does NOT check it is the child the
instance belongs to. The route performs that check here (one
``QuestInstancesDao.get_by_id`` read) and answers 404 when the
instance's ``child_id`` differs — completing another child's quest
from the wrong profile must not succeed, and 404 (not 403) keeps
the instance's existence from leaking to a wrong-profile tap.
"""
state: DatabaseState = request.app.state.db
database = state.database
# Child-mismatch guard (see docstring): the instance must belong to
# the tapped profile before the completion layer is entered.
instance = await _dao_instances.QuestInstancesDao(database).get_by_id(
instance_id
)
if instance is not None and instance.child_id != body.actor_child_id:
raise HTTPException(
status_code=404,
detail="Quest instance not found for this child",
)
try:
result = await core_completion.complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=body.actor_child_id,
now=_local_now(),
)
except ValueError as error:
message = str(error)
if message.startswith("instance_id:"):
# complete_instance names the field in its ValueError:
# "instance_id: quest instance N does not exist".
raise HTTPException(
status_code=404,
detail="Quest instance not found",
) from error
raise
# The derived state is "done" in both the appended and no-op cases;
# ``result.appended`` distinguishes them for callers that care —
# the panel route returns the same success either way.
return {"status": str(result)}
__all__ = ["router"] __all__ = ["router"]
+169 -4
View File
@@ -67,6 +67,30 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances")
_completion = importlib.import_module("nestquest_core.completion") _completion = importlib.import_module("nestquest_core.completion")
async def _events_for_instance(database: object, instance_id: int) -> list:
"""The instance's completion events, on the app's core copy.
Uses the SAME ``nestquest_core.*`` copy the app uses (the two-copy
caveat: never mix copies) against the app's live connection.
"""
dao = _dao_instances.CompletionEventsDao(database)
return await dao.list_by_instance(instance_id)
async def _derived_state(
database: object, instance: object, today: datetime.date
) -> str:
"""The instance's derived state on the app's core copy.
``derive_state(instance, latest, today)`` from the app's core copy;
the latest event is fetched through the same copy's DAO against the
app's live connection.
"""
dao = _dao_instances.CompletionEventsDao(database)
latest = await dao.get_latest_for_instance(instance.id)
return _completion.derive_state(instance, latest, today)
async def _seed_household( async def _seed_household(
db_path: str, db_path: str,
today: datetime.date, today: datetime.date,
@@ -215,14 +239,15 @@ class _PanelRunner:
ApiConfig(db_path=db_path, panel_token=panel_token) ApiConfig(db_path=db_path, panel_token=panel_token)
) )
async def __aenter__(self) -> httpx.AsyncClient: async def __aenter__(self) -> tuple["_PanelRunner", httpx.AsyncClient]:
self._lifespan = self.app.router.lifespan_context(self.app) self._lifespan = self.app.router.lifespan_context(self.app)
await self._lifespan.__aenter__() await self._lifespan.__aenter__()
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app), transport=httpx.ASGITransport(app=self.app),
base_url="http://testserver", base_url="http://testserver",
) )
return await self._client.__aenter__() await self._client.__aenter__()
return self, self._client
async def __aexit__(self, *exc) -> None: async def __aexit__(self, *exc) -> None:
try: try:
@@ -240,7 +265,8 @@ def temp_db_path(tmp_path: Path) -> str:
@pytest.fixture @pytest.fixture
async def panel_client(temp_db_path: str) -> httpx.AsyncClient: async def panel_client(temp_db_path: str) -> httpx.AsyncClient:
"""An app client with an empty (migrated) household database.""" """An app client with an empty (migrated) household database."""
async with _PanelRunner(temp_db_path) as client: async with _PanelRunner(temp_db_path) as runner_and_client:
_, client = runner_and_client
yield client yield client
@@ -251,6 +277,9 @@ async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
One host clock read anchors ``today``; the pinned instant is One host clock read anchors ``today``; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The 12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The
route's ``_local_now`` is patched to return the pinned instant. route's ``_local_now`` is patched to return the pinned instant.
Yields the runner too so tests reach the app's live database (the
SAME ``nestquest_core`` connection) for assertions.
""" """
today = datetime.datetime.now().astimezone().date() today = datetime.datetime.now().astimezone().date()
time_zone = ZoneInfo("UTC") time_zone = ZoneInfo("UTC")
@@ -259,9 +288,11 @@ async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
) )
seed = await _seed_household(temp_db_path, today, pinned_now) seed = await _seed_household(temp_db_path, today, pinned_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now) monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
async with _PanelRunner(temp_db_path) as client: async with _PanelRunner(temp_db_path) as runner_and_client:
runner, client = runner_and_client
yield SimpleNamespace( yield SimpleNamespace(
client=client, client=client,
database=runner.app.state.db.database,
seed=seed, seed=seed,
today=today, today=today,
pinned_now=pinned_now, pinned_now=pinned_now,
@@ -521,3 +552,137 @@ async def test_snapshot_route_is_documented(
assert "PanelSnapshotResponse" in documented assert "PanelSnapshotResponse" in documented
assert "PanelChildPayload" in documented assert "PanelChildPayload" in documented
assert "PanelInstancePayload" in documented assert "PanelInstancePayload" in documented
# --- complete route -------------------------------------------------------
async def test_complete_writes_panel_completion(
seeded_panel: SimpleNamespace,
) -> None:
"""The first POST completes the instance as the tapped profile.
Returns success; the completion event recorded is
``actor_source='panel'`` with the tapped ``actor_child_id`` and NO
``actor_user_id`` (the panel actor contract), and the instance's
derived state becomes ``done``.
"""
seed = seeded_panel.seed
response = await seeded_panel.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": seed.ada.id},
)
assert response.status_code == 200
assert response.json() == {"status": "done"}
# Assert the recorded event and derived state through the SAME core
# copy the app uses (two-copy caveat: never mix copies), against the
# app's live connection.
events = await _events_for_instance(
seeded_panel.database, seed.pack_bag_instance.id
)
assert len(events) == 1
event = events[0]
assert event.event_type == "completed"
assert event.actor_source == "panel"
assert event.actor_child_id == seed.ada.id
assert event.actor_user_id is None
instance = await _dao_instances.QuestInstancesDao(
seeded_panel.database
).get_by_id(seed.pack_bag_instance.id)
assert await _derived_state(
seeded_panel.database, instance, seeded_panel.today
) == "done"
async def test_complete_is_idempotent(
seeded_panel: SimpleNamespace,
) -> None:
"""A second call is a no-op that still returns success.
The core completion layer's ``appended`` flag decides: the repeat
call appends nothing, so exactly ONE completion event row exists
after two calls.
"""
seed = seeded_panel.seed
headers = {"Authorization": f"Bearer {PANEL_TOKEN}"}
url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete"
body = {"actor_child_id": seed.ada.id}
first = await seeded_panel.client.post(url, headers=headers, json=body)
assert first.status_code == 200
second = await seeded_panel.client.post(url, headers=headers, json=body)
assert second.status_code == 200
assert second.json() == {"status": "done"}
events = await _events_for_instance(
seeded_panel.database, seed.pack_bag_instance.id
)
assert len(events) == 1, (
"the idempotent call must not append a second event"
)
async def test_complete_unknown_instance_is_not_found(
panel_client: httpx.AsyncClient,
) -> None:
"""Completing a non-existent instance returns 404."""
response = await panel_client.post(
"/api/v1/panel/instances/999999/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": 1},
)
assert response.status_code == 404
async def test_complete_without_token_is_unauthorized(
panel_client: httpx.AsyncClient,
) -> None:
"""A complete request with no Authorization header returns 401."""
response = await panel_client.post(
"/api/v1/panel/instances/1/complete",
json={"actor_child_id": 1},
)
assert response.status_code == 401
async def test_complete_wrong_child_is_not_found(
seeded_panel: SimpleNamespace,
) -> None:
"""Tapping the wrong child's profile answers 404, not success.
``complete_instance`` does not itself check that the tapped
``actor_child_id`` is the child the instance belongs to; the route
guards it so a wrong-profile tap can never complete another
child's quest.
"""
seed = seeded_panel.seed
response = await seeded_panel.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": seed.bo.id},
)
assert response.status_code == 404
# And nothing was completed.
events = await _events_for_instance(
seeded_panel.database, seed.pack_bag_instance.id
)
assert events == []
async def test_complete_route_is_documented(
panel_client: httpx.AsyncClient,
) -> None:
"""The complete route and its request shape are in the OpenAPI doc."""
response = await panel_client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
path = "/api/v1/panel/instances/{instance_id}/complete"
assert path in schema["paths"]
assert "post" in schema["paths"][path]
documented = {
model["title"]
for model in schema["components"]["schemas"].values()
}
assert "PanelCompleteRequest" in documented