diff --git a/api/nestquest_core.py b/api/nestquest_core.py index b327200..d67596a 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -116,6 +116,11 @@ core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot") #: ``uncomplete_instance``) the panel's write routes call. core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion") +#: The read-only history layer (``core.history.query_history``, +#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history +#: query and CSV export routes call. +core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history") + #: The transition-event builders (``core.events.build_quest_completed_event`` #: et al.) the panel's write routes build their SSE events with. core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events") @@ -192,6 +197,7 @@ __all__ = [ "core_dao_rules", "core_db", "core_events", + "core_history", "core_materialize", "core_migrations", "core_presence", diff --git a/api/routes_admin.py b/api/routes_admin.py index 8f26e34..4ff4553 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -92,6 +92,30 @@ pattern over :mod:`nestquest_core.completion` and instance. The response carries the scope, the scoped id (if any) and the core's upsert ``count``. +The history routes (task 2d2dda53) follow the same pattern over +:mod:`nestquest_core.history` — the ONE read-only history layer, which +owns every rule so the two routes cannot drift: + +- ``GET /history?filter=all|reversals|missed&start=YYYY-MM-DD&end=YYYY-MM-DD`` + returns the history rows for ONE filter over the CLOSED due-date + range ``[start, end]``: ``all`` (every completion event — completed + AND uncompleted — whose instance is due in the range), ``reversals`` + (the uncompleted events only) and ``missed`` (the DERIVED past-due + open instances, computed by the core's own derive-state rule — + ``missed`` is never a stored event_type and nothing is appended). + The range scopes by the instance's ``due_date`` (the Feature 13 + rule: history asks what happened to the tasks due that day), not the + event's wall-clock stamp. The filter and date-range policies live + in the core (strict ``YYYY-MM-DD``, ``end >= start``, a + ``ValueError`` naming the offending field); the handler is a thin + adapter that threads ONE clock read (:func:`_local_now`, the missed + derivation's ``today``) and serializes the core's rows. +- ``GET /history.csv?...`` exports the SAME filtered range as CSV: + ONE :func:`nestquest_core.history.export_history_csv` call — the + same query path the JSON route uses — answered as ``text/csv`` with + a ``Content-Disposition`` attachment filename and the documented, + stable header row (Admin spec §5 event-row fields). + Error mapping (every children, quest-definition and presence route, deliberately narrow): @@ -141,6 +165,15 @@ deliberately narrow): stance. Every core ``ValueError`` (a rejected horizon, a malformed date — unreachable from this body model, but mapped anyway) is 422; anything else re-raises. +- The HISTORY routes have no path ids, so they have no 404 case: a + missing query parameter (the request's shape) is FastAPI's 422 + before any handler code runs, and every core ``ValueError`` — an + unknown filter, a non-strict or inverted date range, each naming the + offending field (``filter`` / ``start`` / ``end``) — is 422 + (:func:`_raise_history_error`): well-formed but semantically + invalid, the same failure class Pydantic reports. Only + ``ValueError`` is caught, so an unexpected failure re-raises rather + than becoming a 4xx. PATCH edit semantics: the body model's fields are optional and only SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so @@ -154,7 +187,7 @@ from __future__ import annotations import datetime from typing import Annotated, Literal, NoReturn -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, field_validator, model_validator from api import transitions as api_transitions @@ -166,6 +199,7 @@ from api.nestquest_core import ( core_const, core_dao_children, core_dao_rules, + core_history, core_materialize, core_presence, core_presence_management, @@ -526,6 +560,47 @@ class AdminRegenerateResponse(BaseModel): count: int +class AdminHistoryRow(BaseModel): + """One history row as the admin plane serializes it. + + Mirrors :class:`nestquest_core.history.HistoryRow` — the Admin spec + §5 event-row fields, the same set the CSV export's header carries. + A stored event carries its UTC ``occurred_at`` stamp and the + ``completed``/``uncompleted`` ``event_type``; a derived missed row + (``missed`` is NEVER a stored event_type) carries ``event_type`` + ``'missed'``, ``occurred_at`` ``None`` (there is no event) and the + ``nightly sweep`` actor. + """ + + occurred_at: str | None + event_type: str + child_name: str + child_id: int + quest_title: str + instance_id: int + window: str + due_date: str + due_time: str | None + actor: str + was_on_time: bool | None + + +class AdminHistoryResponse(BaseModel): + """The history query's answer. + + ``filter``/``start``/``end`` echo the request, ``count`` is the row + count, and ``rows`` are the core's rows in its deterministic order + (``due_date``, then instance, then event id — the per-instance + append order, so a completion and its reversal keep their order). + """ + + filter: str + start: str + end: str + count: int + rows: list[AdminHistoryRow] + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -646,6 +721,21 @@ def _raise_regenerate_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +def _raise_history_error(error: ValueError) -> NoReturn: + """Map a core history ``ValueError`` onto 422 and raise it. + + The history routes have no path ids, so they have no 404 case: + every ``ValueError`` that survives to here is a rejected argument — + an unknown filter, a non-strict date, or an inverted range, each + raised by the core naming the offending field (``filter`` / + ``start`` / ``end``) BEFORE any read — and is 422 with the core's + message as the detail. Only ``ValueError`` is caught by the + handlers, so an unexpected failure re-raises rather than becoming a + 4xx. + """ + raise HTTPException(status_code=422, detail=str(error)) from error + + def _local_now() -> datetime.datetime: """Return the API host's local time as ONE timezone-aware clock read. @@ -1294,4 +1384,109 @@ async def admin_regenerate( ) +def _history_row_response(row: core_history.HistoryRow) -> AdminHistoryRow: + """Serialize one core HistoryRow into the documented payload.""" + return AdminHistoryRow( + occurred_at=row.occurred_at, + event_type=row.event_type, + child_name=row.child_name, + child_id=row.child_id, + quest_title=row.quest_title, + instance_id=row.instance_id, + window=row.window, + due_date=row.due_date, + due_time=row.due_time, + actor=row.actor, + was_on_time=row.was_on_time, + ) + + +@router.get( + "/history", + summary="Query the completion history for one filter and range", + response_model=AdminHistoryResponse, +) +async def admin_history_query( + request: Request, + start: str, + end: str, + filter: str = "all", +) -> AdminHistoryResponse: + """Return the history rows for one filter over the closed range. + + 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: ``filter`` + (default ``all``), ``start`` and ``end`` go straight to + :func:`nestquest_core.history.query_history` as STRINGS — the + filter policy (``all`` / ``reversals`` / ``missed``) and the + date-range policy (strict ``YYYY-MM-DD``, ``end >= start``) live in + the core, which raises a ``ValueError`` naming the offending field; + the handler only threads ONE clock read (:func:`_local_now`) as the + missed derivation's ``today`` and serializes the returned rows. + ``start``/``end`` are required query parameters (missing is + FastAPI's 422 before any handler code runs); every core + ``ValueError`` is 422 (:func:`_raise_history_error`). + """ + state: DatabaseState = request.app.state.db + today = _local_now().date() + try: + rows = await core_history.query_history( + state.database, filter, start, end, today=today + ) + except ValueError as error: + _raise_history_error(error) + return AdminHistoryResponse( + filter=filter, + start=start, + end=end, + count=len(rows), + rows=[_history_row_response(row) for row in rows], + ) + + +@router.get( + "/history.csv", + summary="Export the completion history as CSV", + response_class=Response, +) +async def admin_history_csv( + request: Request, + start: str, + end: str, + filter: str = "all", +) -> Response: + """Export the SAME filtered history range as a CSV attachment. + + 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 over ONE + :func:`nestquest_core.history.export_history_csv` call — the same + query path the JSON route uses, so the CSV can never drift from the + query's filtered range — with the core's validation policy and the + :mod:`csv`-based serialization (stable documented header row, RFC + 4180 quoting) living in the core. The answer is ``text/csv`` with + a ``Content-Disposition`` attachment filename naming the range; + the range is core-validated strict ``YYYY-MM-DD`` by the time the + filename is built, so it is injection-safe. Error mapping is the + JSON route's (:func:`_raise_history_error`). + """ + state: DatabaseState = request.app.state.db + today = _local_now().date() + try: + csv_text = await core_history.export_history_csv( + state.database, filter, start, end, today=today + ) + except ValueError as error: + _raise_history_error(error) + filename = f"nestquest-history-{start}-to-{end}.csv" + return Response( + content=csv_text, + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) + + __all__ = ["router"] diff --git a/custom_components/nestquest/core/history.py b/custom_components/nestquest/core/history.py new file mode 100644 index 0000000..5208af0 --- /dev/null +++ b/custom_components/nestquest/core/history.py @@ -0,0 +1,405 @@ +"""Read-only history layer for the admin plane (Admin spec §5, task 2d2dda53). + +Assembles the History screen's event rows over the append-only +completion log: a query over a CLOSED date range ``[start_date, +end_date]`` for one of three filters — + +- ``all`` — every completion event (completed AND uncompleted) whose + instance is due in the range; +- ``reversals`` — the uncompleted events only; +- ``missed`` — the DERIVED missed instances in the range. + +Range semantics (Feature 13's rule, kept): the range scopes by the +INSTANCE's ``due_date`` — the day the task was owed — not the event's +wall-clock ``occurred_at``, because history reporting asks "what +happened to the tasks due that day". ``missed`` is derived, never +stored: rows come from :func:`~.completion.list_missed_for_child` +(past-due open instances, the same ``_derived_state`` rule the +coordinator uses), and nothing is ever appended for them. + +Row shape: the Admin spec §5 event-row fields, fixed and documented in +:data:`CSV_COLUMNS` — ``occurred_at``, ``event_type``, +``child_name``, ``child_id``, ``quest_title``, ``instance_id``, +``window``, ``due_date``, ``due_time``, ``actor``, ``was_on_time``. +Derived missed rows carry ``event_type`` ``'missed'`` (never a stored +event_type), no ``occurred_at`` (there is no event), ``was_on_time`` +``None`` and the spec's ``nightly sweep`` actor. A stored event's +``actor`` follows the spec's meta shape: the ``actor_user_id`` string +for a user event (``joshua``), ``panel (Child)`` naming the tapped +profile for a panel event. + +Validation policy (module-level, shared by both the JSON query and the +CSV export): the filter must be one of :data:`FILTERS` and the range +must be two strict ``YYYY-MM-DD`` dates with ``end >= start``; +violations raise ``ValueError`` naming the offending field +(``filter`` / ``start`` / ``end``) BEFORE any read. The whole +household read runs under the task-reentrant connection lock so every +row of one answer is one snapshot — the same discipline +:mod:`.completion` gives its read-decide-write paths. + +:func:`rows_to_csv` serializes rows with the :mod:`csv` module (proper +quoting, one stable header row) and :func:`export_history_csv` runs +query + serialization as ONE call, so the CSV export can never drift +from the JSON query's filtered range. No permission check lives here +(that is the admin router's single ``require_admin`` dependency). +""" +from __future__ import annotations + +import csv +import datetime +import io +from dataclasses import dataclass + +from .completion import _reentrant_connection_lock, list_missed_for_child +from .dao_children import ChildRecord, ChildrenDao +from .dao_instances import ( + EVENT_UNCOMPLETED, + CompletionEventRecord, + CompletionEventsDao, + QuestInstanceRecord, + QuestInstancesDao, +) +from .dao_rules import QuestDefinitionsDao, _validate_date +from .db import NestQuestDatabase + +#: The ``all`` filter: every completion event in the range. +FILTER_ALL = "all" + +#: The ``reversals`` filter: uncompleted events only. +FILTER_REVERSALS = "reversals" + +#: The ``missed`` filter: derived missed instances in the range. +FILTER_MISSED = "missed" + +#: The filters the query accepts, in the Admin spec's chip order. +FILTERS = (FILTER_ALL, FILTER_REVERSALS, FILTER_MISSED) + +#: The ``event_type`` a derived missed row reports. ``missed`` is a +#: DERIVED state, never a stored event_type (Feature 08). +EVENT_MISSED = "missed" + +#: The actor a derived missed row reports (Admin spec §5 meta: +#: ``missed · nightly sweep``). +ACTOR_NIGHTLY_SWEEP = "nightly sweep" + +#: The CSV column set, in header order — the Admin spec §5 event-row +#: fields. The header row is exactly this tuple, always, so a consumer +#: can key on column names. +CSV_COLUMNS = ( + "occurred_at", + "event_type", + "child_name", + "child_id", + "quest_title", + "instance_id", + "window", + "due_date", + "due_time", + "actor", + "was_on_time", +) + + +@dataclass(frozen=True) +class HistoryRow: + """One history row: a stored event or a derived missed instance. + + The fields are :data:`CSV_COLUMNS` in order. For a derived missed + row ``occurred_at`` is ``None`` (no event exists) and ``was_on_time`` + is ``None``; for a stored event ``event_type`` is + ``'completed'``/``'uncompleted'`` and the fields come from the event + joined with its instance, child and definition. + """ + + occurred_at: str | None + event_type: str + child_name: str + child_id: int + quest_title: str + instance_id: int + window: str + due_date: str + due_time: str | None + actor: str + was_on_time: bool | None + + +def _validate_filter(filter_name: str) -> str: + """Return the filter, or raise ValueError naming the field.""" + if filter_name not in FILTERS: + raise ValueError( + f"filter must be one of {', '.join(repr(f) for f in FILTERS)}, " + f"got {filter_name!r}" + ) + return filter_name + + +def _validate_range(start_date: str, end_date: str) -> tuple[str, str]: + """Validate the closed range and return it, else raise naming the field. + + Both bounds must be strict ``YYYY-MM-DD`` dates (the shared + :func:`~.dao_rules._validate_date`, so non-padded or re-ordered + shapes are rejected and a non-string raises naming the field) and + ``end`` must be on or after ``start``. + """ + _validate_date(start_date, "start") + _validate_date(end_date, "end") + if end_date < start_date: + raise ValueError( + f"end must be on or after start, got {end_date!r} < " + f"{start_date!r}" + ) + return start_date, end_date + + +def _resolve_today(today: datetime.date | None) -> datetime.date: + """Return the threaded HA-local date, or the host calendar date.""" + if today is None: + return datetime.date.today() + return today + + +def _event_actor( + event: CompletionEventRecord, actor_child: ChildRecord | None +) -> str: + """Return the spec-meta actor string for one stored event. + + A user event reports the ``actor_user_id`` string (the spec's + ``uncompleted · joshua (admin)`` shape); a panel event reports + ``panel (Child)`` naming the tapped profile. The tapped child is + never hard-deleted, so the lookup below is a belt-and-braces + fallback to the stored id, never a silent rewrite of policy. + """ + if event.actor_source == "user": + return event.actor_user_id or "" + if actor_child is not None: + return f"panel ({actor_child.display_name})" + return f"panel ({event.actor_child_id})" + + +def _event_row( + event: CompletionEventRecord, + instance: QuestInstanceRecord, + child: ChildRecord, + quest_title: str, + actor_child: ChildRecord | None, +) -> HistoryRow: + """Join one stored event with its instance, child and definition.""" + return HistoryRow( + occurred_at=event.occurred_at, + event_type=event.event_type, + child_name=child.display_name, + child_id=event.child_id, + quest_title=quest_title, + instance_id=event.instance_id, + window=instance.window, + due_date=instance.due_date, + due_time=instance.due_time, + actor=_event_actor(event, actor_child), + was_on_time=event.was_on_time, + ) + + +def _missed_row( + instance: QuestInstanceRecord, + child: ChildRecord, + quest_title: str, +) -> HistoryRow: + """Build the derived row for one past-due open instance.""" + return HistoryRow( + occurred_at=None, + event_type=EVENT_MISSED, + child_name=child.display_name, + child_id=instance.child_id, + quest_title=quest_title, + instance_id=instance.id, + window=instance.window, + due_date=instance.due_date, + due_time=instance.due_time, + actor=ACTOR_NIGHTLY_SWEEP, + was_on_time=None, + ) + + +def _csv_values(row: HistoryRow) -> list[str]: + """Return one CSV row's cell values in :data:`CSV_COLUMNS` order. + + ``None`` cells serialize as empty (a missed row has no + ``occurred_at``); ``was_on_time`` serializes as ``true``/``false`` + when the event carries the flag and empty when it does not. + """ + return [ + row.occurred_at or "", + row.event_type, + row.child_name, + str(row.child_id), + row.quest_title, + str(row.instance_id), + row.window, + row.due_date, + row.due_time or "", + row.actor, + "" if row.was_on_time is None else str(row.was_on_time).lower(), + ] + + +def rows_to_csv(rows: list[HistoryRow]) -> str: + """Serialize history rows to CSV text with the documented header. + + The FIRST row is always :data:`CSV_COLUMNS`, so the header is + stable and deterministic; the :mod:`csv` module handles quoting + (titles or names containing commas or quotes are quoted per RFC + 4180) and CRLF line endings. + """ + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow(CSV_COLUMNS) + for row in rows: + writer.writerow(_csv_values(row)) + return buffer.getvalue() + + +async def query_history( + database: NestQuestDatabase, + filter_name: str, + start_date: str, + end_date: str, + *, + today: datetime.date | None = None, +) -> list[HistoryRow]: + """Return the history rows for ``filter_name`` over the closed range. + + Validates the filter and the strict ``YYYY-MM-DD`` range (raising + ``ValueError`` naming ``filter`` / ``start`` / ``end``) BEFORE any + read, then builds the rows: + + - ``all`` / ``reversals``: every child's events whose instances are + due in the range (:meth:`~.dao_instances.CompletionEventsDao.list_by_child_and_date_range`, + the Feature 13 due-date scope), ``reversals`` keeping only the + ``uncompleted`` rows. + - ``missed``: :func:`~.completion.list_missed_for_child` per child — + the SAME derived-state rule the coordinator uses, never a new one, + and never a stored event. + + Rows are ordered deterministically by ``due_date``, then instance, + then event id (the per-instance append order, so a completion and + its reversal keep their order). ``today`` pins the HA-local date + the missed derivation compares due dates against (the host calendar + date when omitted). The whole read runs under the task-reentrant + connection lock — :func:`list_missed_for_child` re-enters it — so + one answer is one snapshot. + """ + filter_name = _validate_filter(filter_name) + start_date, end_date = _validate_range(start_date, end_date) + today_date = _resolve_today(today) + + keyed: list[tuple[tuple[str, int, int], HistoryRow]] = [] + async with _reentrant_connection_lock(database): + children = await ChildrenDao(database).list_all() + children_by_id = {child.id: child for child in children} + instances = QuestInstancesDao(database) + definitions = QuestDefinitionsDao(database) + events = CompletionEventsDao(database) + titles: dict[int, str] = {} + + if filter_name == FILTER_MISSED: + for child in children: + for instance in await list_missed_for_child( + database, child.id, start_date, end_date, today=today_date + ): + if instance.definition_id not in titles: + definition = await definitions.get( + instance.definition_id + ) + assert definition is not None, ( + "a materialized instance's definition exists" + ) + titles[instance.definition_id] = definition.title + keyed.append( + ( + (instance.due_date, instance.id, 0), + _missed_row( + instance, + child, + titles[instance.definition_id], + ), + ) + ) + keyed.sort(key=lambda pair: pair[0]) + return [row for _, row in keyed] + + for child in children: + for event in await events.list_by_child_and_date_range( + child.id, start_date, end_date + ): + if filter_name == FILTER_REVERSALS and ( + event.event_type != EVENT_UNCOMPLETED + ): + continue + instance = await instances.get_by_id(event.instance_id) + assert instance is not None, ( + "an event's instance is never deleted (append-only " + "history keeps its references)" + ) + actor_child = ( + children_by_id.get(event.actor_child_id) + if event.actor_child_id is not None + else None + ) + if instance.definition_id not in titles: + definition = await definitions.get(instance.definition_id) + assert definition is not None, ( + "a materialized instance's definition exists" + ) + titles[instance.definition_id] = definition.title + keyed.append( + ( + (instance.due_date, instance.id, event.id), + _event_row( + event, + instance, + child, + titles[instance.definition_id], + actor_child, + ), + ) + ) + keyed.sort(key=lambda pair: pair[0]) + return [row for _, row in keyed] + + +async def export_history_csv( + database: NestQuestDatabase, + filter_name: str, + start_date: str, + end_date: str, + *, + today: datetime.date | None = None, +) -> str: + """Return the filtered history as CSV text, header row included. + + ONE query + serialization call so the CSV export carries exactly + the JSON query's filtered range and order — the same + :func:`query_history` validation, rows and :func:`rows_to_csv` + serializer, never a second implementation. + """ + rows = await query_history( + database, filter_name, start_date, end_date, today=today + ) + return rows_to_csv(rows) + + +__all__ = [ + "ACTOR_NIGHTLY_SWEEP", + "CSV_COLUMNS", + "EVENT_MISSED", + "EVENT_UNCOMPLETED", + "FILTERS", + "FILTER_ALL", + "FILTER_MISSED", + "FILTER_REVERSALS", + "HistoryRow", + "export_history_csv", + "query_history", + "rows_to_csv", +] diff --git a/tests/test_api_admin_history.py b/tests/test_api_admin_history.py new file mode 100644 index 0000000..5364f06 --- /dev/null +++ b/tests/test_api_admin_history.py @@ -0,0 +1,547 @@ +"""Admin-plane history route tests (task 2d2dda53). + +These tests exercise the admin plane's history query and CSV export +routes against the ASGI app with httpx, reusing the shared +local-RSA-key/stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so +an admin JWT genuinely authenticates — signature, issuer, audience, +expiry and the nestquest-admins group — with NO network access. The +household is seeded with :func:`tests.test_api_panel._seed_household` +through the SAME core copy the app uses (``nestquest_core.*``; the +two-copy caveat in api/nestquest_core.py), and the reversal the +history tests read is appended through that same copy's +:func:`nestquest_core.completion.uncomplete_instance` — so the rows +the routes report are the rows the core's own write paths produced. + +The seeded household (clock pinned to noon, the route's ``_local_now`` +pinned to the same instant so nothing straddles midnight): + +- Bo's ``Brush teeth`` TODAY instance: a ``completed`` event (panel + tap, on time) plus the appended ``uncompleted`` reversal — the + completion and the reversal the ``all`` and ``reversals`` filters + report. +- Ada's ``Pack bag`` and ``Stale chore`` YESTERDAY instances and Bo's + ``Brush teeth`` YESTERDAY instance: open, past due, no events — the + DERIVED missed rows the ``missed`` filter reports. + +Proven per the done-condition, everything read back through the API: + +- ``GET /api/v1/admin/history`` returns the expected rows for EACH + filter — ``all`` (the completed event AND the reversal), + ``reversals`` (the uncompleted event only) and ``missed`` (the + derived past-due open instances, actor ``nightly sweep``, no + ``occurred_at``) — and the range scopes by the INSTANCE's due date + (the Feature 13 rule), so the events sit under their instance's due + day, not the wall-clock day. +- ``GET /api/v1/admin/history.csv`` answers ``text/csv`` with a + ``Content-Disposition`` attachment filename, and the parsed body + (csv.reader) carries the documented header columns and exactly the + filtered rows. +- Authentication runs through the router's ONE ``require_admin`` + dependency: a non-admin JWT is 403 and an absent credential is 401 + on both routes. +- Malformed input — an unknown filter, a non-strict date, an inverted + range, a missing parameter — is 422 on both routes (never a 500). +""" +from __future__ import annotations + +import csv +import datetime +import importlib +import io +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from api import routes_admin +from api.database import _executor +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + SUBJECT, + AdminRunner, + jwks_for, + make_token, +) +from tests.test_api_panel import _seed_household + +# The completion, instance-DAO and db layers through the SAME core copy +# the app uses (nestquest_core.*, NOT the conftest-stubbed +# custom_components copy) — used for the tests' seeding only; the +# routes reach them through api.nestquest_core. +_completion = importlib.import_module("nestquest_core.completion") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_core_db = importlib.import_module("nestquest_core.db") + +#: The documented CSV columns (Admin spec §5 event-row fields), in +#: header order — asserted literally so the contract is pinned here, +#: independent of the core constant. +DOCUMENTED_CSV_COLUMNS = [ + "occurred_at", + "event_type", + "child_name", + "child_id", + "quest_title", + "instance_id", + "window", + "due_date", + "due_time", + "actor", + "was_on_time", +] + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _pinned_noon() -> tuple[datetime.date, datetime.datetime]: + """One host clock read pinned to 12:00 UTC that day. + + The seed's completion, the reversal and the route's clock all + anchor to this instant (the reversal five minutes later), so + nothing can straddle midnight mid-test. + """ + today = datetime.datetime.now().astimezone().date() + return today, datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC") + ) + + +@pytest.fixture +async def history_household(temp_db_path: str, monkeypatch) -> SimpleNamespace: + """A seeded household served by the admin-plane app, clock pinned. + + Beyond :func:`_seed_household`'s rows (Bo's today completion, the + past-due open yesterday instances), the fixture appends ONE + ``uncompleted`` reversal on Bo's today instance through the core + completion layer — attributed to the harness subject, stamped five + minutes after the pinned completion — and exposes the yesterday + instances the missed filter must report. + """ + today, pinned_now = _pinned_noon() + yesterday = today - datetime.timedelta(days=1) + seed = await _seed_household(temp_db_path, today, pinned_now) + + database = _core_db.NestQuestDatabase(_executor) + await database.open(temp_db_path) + try: + instances = _dao_instances.QuestInstancesDao(database) + ada_yesterday = await instances.list_by_child_and_date( + seed.ada.id, yesterday.isoformat() + ) + pack_bag_yesterday = next( + record for record in ada_yesterday if record.due_time == "10:00" + ) + bo_yesterday = await instances.list_by_child_and_date( + seed.bo.id, yesterday.isoformat() + ) + brush_teeth_yesterday = next( + record for record in bo_yesterday if record.due_time == "17:00" + ) + await _completion.uncomplete_instance( + database, + seed.brush_teeth_instance.id, + actor_source="user", + actor_user_id=SUBJECT, + now=pinned_now + datetime.timedelta(minutes=5), + today=today, + ) + finally: + await database.close() + + monkeypatch.setattr(routes_admin, "_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, + database=runner.app.state.db.database, + today=today, + yesterday=yesterday, + pack_bag_yesterday=pack_bag_yesterday, + brush_teeth_yesterday=brush_teeth_yesterday, + ) + + +# --- authentication: the ONE require_admin dependency guards both routes ---- + + +async def test_history_routes_refuse_non_admin_and_absent( + history_household: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401, both routes.""" + client = history_household.client + query = f"start={history_household.yesterday.isoformat()}" + query += f"&end={history_household.today.isoformat()}" + non_admin = make_token(groups=("some-other-group",)) + for path in ("/api/v1/admin/history", "/api/v1/admin/history.csv"): + forbidden = await client.get( + f"{path}?{query}", + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403, path + assert forbidden.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + unauthenticated = await client.get(f"{path}?{query}") + assert unauthenticated.status_code == 401, path + + +# --- the JSON history query: the expected rows per filter -------------------- + + +async def test_history_all_returns_completed_and_reversal( + history_household: SimpleNamespace, +) -> None: + """``all`` returns the completed event AND the reversal, in order. + + Both events belong to Bo's Brush teeth instance due TODAY, so the + due-date-scoped range [yesterday, today] reports them under today; + the rows are ordered by due date, then instance, then event id — + the completed row before its reversal. + """ + client = history_household.client + seed = history_household.seed + brush = seed.brush_teeth_instance + yesterday_iso = history_household.yesterday.isoformat() + today_iso = history_household.today.isoformat() + + response = await client.get( + "/api/v1/admin/history", + params={"filter": "all", "start": yesterday_iso, "end": today_iso}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["filter"] == "all" + assert body["start"] == yesterday_iso + assert body["end"] == today_iso + assert body["count"] == 2 + + completed, reversal = body["rows"] + assert completed == { + "occurred_at": f"{today_iso}T12:00:00+00:00", + "event_type": "completed", + "child_name": "Bo", + "child_id": brush.child_id, + "quest_title": "Brush teeth", + "instance_id": brush.id, + "window": "morning", + "due_date": today_iso, + "due_time": "17:00", + "actor": "panel (Bo)", + "was_on_time": True, + } + assert reversal == { + "occurred_at": f"{today_iso}T12:05:00+00:00", + "event_type": "uncompleted", + "child_name": "Bo", + "child_id": brush.child_id, + "quest_title": "Brush teeth", + "instance_id": brush.id, + "window": "morning", + "due_date": today_iso, + "due_time": "17:00", + "actor": SUBJECT, + "was_on_time": None, + } + + +async def test_history_reversals_returns_only_the_uncompleted_event( + history_household: SimpleNamespace, +) -> None: + """``reversals`` returns the uncompleted event only, user-attributed.""" + client = history_household.client + seed = history_household.seed + brush = seed.brush_teeth_instance + today_iso = history_household.today.isoformat() + + response = await client.get( + "/api/v1/admin/history", + params={ + "filter": "reversals", + "start": today_iso, + "end": today_iso, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["filter"] == "reversals" + assert body["count"] == 1 + (reversal,) = body["rows"] + assert reversal["event_type"] == "uncompleted" + assert reversal["instance_id"] == brush.id + assert reversal["actor"] == SUBJECT + assert reversal["occurred_at"] == f"{today_iso}T12:05:00+00:00" + assert reversal["was_on_time"] is None + + +async def test_history_missed_returns_derived_missed_instances( + history_household: SimpleNamespace, +) -> None: + """``missed`` returns the DERIVED past-due open instances. + + Three seeded instances are open and due yesterday: Ada's Pack bag + and Stale chore and Bo's Brush teeth. Each is a DERIVED row — + never a stored event: no ``occurred_at``, ``was_on_time`` None and + the spec's ``nightly sweep`` actor. + """ + client = history_household.client + seed = history_household.seed + yesterday_iso = history_household.yesterday.isoformat() + + response = await client.get( + "/api/v1/admin/history", + params={ + "filter": "missed", + "start": yesterday_iso, + "end": yesterday_iso, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["filter"] == "missed" + assert body["count"] == 3 + + rows = body["rows"] + assert all(row["event_type"] == "missed" for row in rows) + assert all(row["occurred_at"] is None for row in rows) + assert all(row["was_on_time"] is None for row in rows) + assert all(row["actor"] == "nightly sweep" for row in rows) + assert all(row["due_date"] == yesterday_iso for row in rows) + assert { + (row["child_name"], row["quest_title"], row["due_time"]) + for row in rows + } == { + ("Ada", "Pack bag", "10:00"), + ("Bo", "Brush teeth", "17:00"), + ("Ada", "Stale chore", "09:00"), + } + assert { + row["instance_id"] + for row in rows + } == { + history_household.pack_bag_yesterday.id, + history_household.brush_teeth_yesterday.id, + seed.stale_instance.id, + } + # Deterministic order: due date, then instance id. + keys = [(row["due_date"], row["instance_id"]) for row in rows] + assert keys == sorted(keys) + + +async def test_history_range_scopes_by_the_instance_due_date( + history_household: SimpleNamespace, +) -> None: + """The range scopes by the INSTANCE's due date, not the wall clock. + + Both events OCCURRED today but belong to the instance due today, so + a yesterday-only ``all`` range reports nothing; the missed filter + over a today-only range reports nothing either (a due-TODAY open + instance is not past due). + """ + client = history_household.client + yesterday_iso = history_household.yesterday.isoformat() + today_iso = history_household.today.isoformat() + + yesterday_all = await client.get( + "/api/v1/admin/history", + params={"filter": "all", "start": yesterday_iso, "end": yesterday_iso}, + headers=_admin_headers(), + ) + assert yesterday_all.status_code == 200 + assert yesterday_all.json()["count"] == 0 + + today_missed = await client.get( + "/api/v1/admin/history", + params={"filter": "missed", "start": today_iso, "end": today_iso}, + headers=_admin_headers(), + ) + assert today_missed.status_code == 200 + assert today_missed.json()["count"] == 0 + + +# --- the CSV export: documented columns and the filtered rows ---------------- + + +async def test_history_csv_carries_documented_columns_and_all_rows( + history_household: SimpleNamespace, +) -> None: + """The CSV export carries the documented header and the filtered rows. + + The body is parsed with csv.reader: the first row is the documented + column set, the remaining rows are exactly the ``all`` filter's + rows (the completion and its reversal) in the same order the JSON + query reports them. + """ + client = history_household.client + seed = history_household.seed + brush = seed.brush_teeth_instance + yesterday_iso = history_household.yesterday.isoformat() + today_iso = history_household.today.isoformat() + + response = await client.get( + "/api/v1/admin/history.csv", + params={"filter": "all", "start": yesterday_iso, "end": today_iso}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/csv") + disposition = response.headers["content-disposition"] + assert disposition.startswith("attachment;") + assert ( + f'filename="nestquest-history-{yesterday_iso}-to-{today_iso}.csv"' + in disposition + ) + + parsed = list(csv.reader(io.StringIO(response.text))) + assert parsed[0] == DOCUMENTED_CSV_COLUMNS + assert len(parsed) == 3 # header + completed + reversal + assert parsed[1] == [ + f"{today_iso}T12:00:00+00:00", + "completed", + "Bo", + str(brush.child_id), + "Brush teeth", + str(brush.id), + "morning", + today_iso, + "17:00", + "panel (Bo)", + "true", + ] + assert parsed[2] == [ + f"{today_iso}T12:05:00+00:00", + "uncompleted", + "Bo", + str(brush.child_id), + "Brush teeth", + str(brush.id), + "morning", + today_iso, + "17:00", + SUBJECT, + "", + ] + + +async def test_history_csv_missed_rows_are_derived_rows( + history_household: SimpleNamespace, +) -> None: + """The CSV export of the ``missed`` filter carries the derived rows. + + Same filtered range as the JSON query's missed answer: three rows + with an empty ``occurred_at`` (no event exists), the ``missed`` + event type and the ``nightly sweep`` actor. + """ + client = history_household.client + seed = history_household.seed + yesterday_iso = history_household.yesterday.isoformat() + + response = await client.get( + "/api/v1/admin/history.csv", + params={ + "filter": "missed", + "start": yesterday_iso, + "end": yesterday_iso, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + parsed = list(csv.reader(io.StringIO(response.text))) + assert parsed[0] == DOCUMENTED_CSV_COLUMNS + assert len(parsed) == 4 # header + the three derived missed rows + missed_rows = parsed[1:] + assert all(row[1] == "missed" for row in missed_rows) + assert all(row[0] == "" for row in missed_rows) + assert all(row[9] == "nightly sweep" for row in missed_rows) + assert all(row[10] == "" for row in missed_rows) + assert { + (row[2], row[4], row[8]) for row in missed_rows + } == { + ("Ada", "Pack bag", "10:00"), + ("Bo", "Brush teeth", "17:00"), + ("Ada", "Stale chore", "09:00"), + } + assert {row[5] for row in missed_rows} == { + str(history_household.pack_bag_yesterday.id), + str(history_household.brush_teeth_yesterday.id), + str(seed.stale_instance.id), + } + + +# --- validation: malformed filter and date range are 422, never 500 ---------- + + +@pytest.mark.parametrize( + "params", + [ + {"filter": "everything", "start": "2026-03-01", "end": "2026-03-02"}, + {"filter": "all", "start": "2026/03/01", "end": "2026-03-02"}, + {"filter": "all", "start": "2026-3-1", "end": "2026-03-02"}, + {"filter": "all", "start": "2026-03-02", "end": "2026-03-01"}, + {"filter": "all", "start": "not-a-date", "end": "2026-03-02"}, + ], +) +async def test_history_query_rejects_malformed_params_as_422( + history_household: SimpleNamespace, params: dict[str, str] +) -> None: + """An unknown filter or a rejected range is 422 naming the field.""" + client = history_household.client + response = await client.get( + "/api/v1/admin/history", params=params, headers=_admin_headers() + ) + assert response.status_code == 422, params + detail = response.json()["detail"] + assert any( + name in detail for name in ("filter", "start", "end") + ), detail + + +async def test_history_csv_rejects_malformed_params_as_422( + history_household: SimpleNamespace, +) -> None: + """The CSV route maps the same rejections to 422.""" + client = history_household.client + unknown_filter = await client.get( + "/api/v1/admin/history.csv", + params={"filter": "galaxy", "start": "2026-03-01", "end": "2026-03-02"}, + headers=_admin_headers(), + ) + assert unknown_filter.status_code == 422 + assert "filter" in unknown_filter.json()["detail"] + + inverted = await client.get( + "/api/v1/admin/history.csv", + params={"filter": "all", "start": "2026-03-02", "end": "2026-03-01"}, + headers=_admin_headers(), + ) + assert inverted.status_code == 422 + assert "end must be on or after start" in inverted.json()["detail"] + + +async def test_history_query_missing_params_are_422( + history_household: SimpleNamespace, +) -> None: + """A missing required query parameter is FastAPI's 422 (the shape).""" + client = history_household.client + no_params = await client.get( + "/api/v1/admin/history", headers=_admin_headers() + ) + assert no_params.status_code == 422 + missing_start = await client.get( + "/api/v1/admin/history", + params={"end": "2026-03-02"}, + headers=_admin_headers(), + ) + assert missing_start.status_code == 422 + missing_end = await client.get( + "/api/v1/admin/history", + params={"start": "2026-03-01"}, + headers=_admin_headers(), + ) + assert missing_end.status_code == 422