Admin routes: history query and CSV export
The admin History screen (ADMIN-SPEC 1g) had no API: the read primitives existed (append-only completion-event reads, derived-state missed logic) but nothing assembled them, so the admin PWA could not render the event log or export it. Feature 13's export service was superseded by the Admin PWA and remains unimplemented by design. Add a Home-Assistant-free core history layer (core/history.py) that owns every rule so the two routes cannot drift: one query over the closed due-date range [start, end] for filter all (every completion event) / reversals (uncompleted only) / missed (DERIVED past-due open instances via the existing completion.list_missed_for_child rule -- never stored, never appended); strict ISO date and filter validation raising ValueError naming filter/start/end; and a csv-module serializer with a stable documented header (ADMIN-SPEC 5 event-row fields) so the CSV export is the SAME filtered range as the query, through one export_history_csv call. Admin routes GET /api/v1/admin/history and /history.csv are thin adapters on the existing admin router (the one require_admin dependency; no new auth check), mapping malformed input to 422 and never 500. completion_events stays append-only: history only reads. Tests (tests/test_api_admin_history.py) drive the ASGI app as an admin via the shared JWT harness: seeded completion + reversal + past-due open instances prove each filter's rows, the CSV header/rows via csv.reader, due-date range scoping, 403/401 refusals, and 422s for unknown filter, non-strict dates and inverted ranges.
This commit is contained in:
@@ -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",
|
||||
|
||||
+196
-1
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user