Fix treatments API pagination cursor field (created_at, not date) #21

Merged
willemjan merged 1 commits from dev into main 2026-08-01 19:43:40 +00:00
2 changed files with 153 additions and 24 deletions
+58 -21
View File
@@ -38,12 +38,31 @@ _REQUEST_TIMEOUT = 10
# meest recente ~1150 metingen terug — nog geen 4 dagen — terwijl er wel
# degelijk weken oudere data in Nightscout beschikbaar bleek te zijn). Eén
# verzoek is dus niet genoeg voor meerdaagse rapportages; _paginated_get()
# loopt terug in de tijd met `find[date][$lte]`-cursors tot de gevraagde
# loopt terug in de tijd met `find[...][$lte]`-cursors tot de gevraagde
# `since`-grens bereikt is of de server minder dan een volle pagina teruggeeft
# (= geen oudere data meer beschikbaar, wat verschilt van "afgeknipt").
#
# De twee endpoints filteren op een ANDER veld, en /treatments heeft nog een
# addertje: entries filtert/sorteert op het numerieke `date` (epoch ms) en
# geeft zonder filter gewoon de meest recente `count` terug. treatments
# filtert/sorteert op het string-veld `created_at` (ISO 8601) — filteren op
# `date` levert daar stil een lege pagina op zodra je voorbij een intern
# venster van ~4 dagen gaat, ook al bevatten de documenten prima een geldig
# `date`-veld (geverifieerd rechtstreeks in MongoDB: alle 123k+ treatments
# hebben een numeriek `date`, en zonder enig filter geeft het endpoint alsnog
# stilzwijgend niet meer dan ~4 dagen terug). Zodra je expliciet op
# `created_at` filtert — zelfs met "nu" als bovengrens op de eerste pagina —
# werkt paginering wél correct en is de volledige geschiedenis (maanden,
# geverifieerd in de database) gewoon opvraagbaar. Vandaar: de cursor wordt
# nooit weggelaten (ook niet op de allereerste pagina), en welk veld
# gebruikt wordt is per endpoint expliciet ("date" voor entries,
# "created_at" voor treatments) — zie ENTRIES_CURSOR_FIELD/TREATMENTS_CURSOR_FIELD.
_PAGE_SIZE = 1000
_MAX_PAGES = 150 # 150 000 documenten; ruim boven wat een 90-dagenrapport nodig heeft
ENTRIES_CURSOR_FIELD = "date"
TREATMENTS_CURSOR_FIELD = "created_at"
class NightscoutConfigError(RuntimeError):
"""NIGHTSCOUT_URL ontbreekt of is ongeldig."""
@@ -88,44 +107,62 @@ def _get(path: str, params: dict) -> list:
return response.json()
def _paginated_get(path: str, since: datetime, until: datetime | None = None) -> list:
def _format_cursor(cursor_dt: datetime, cursor_field: str) -> int | str:
"""Zet een cursor-datetime om naar de vorm die het gekozen veld verwacht."""
if cursor_field == ENTRIES_CURSOR_FIELD:
return int(cursor_dt.timestamp() * 1000)
# created_at: milliseconde-precisie ISO 8601 met Z-suffix, exact het
# formaat dat Nightscout zelf in created_at opslaat.
return cursor_dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _parse_item_time(item: dict, cursor_field: str) -> datetime | None:
"""Lees het tijdstip van een document uit het veld waar dit endpoint op filtert."""
if cursor_field not in item:
return None
if cursor_field == ENTRIES_CURSOR_FIELD:
return datetime.fromtimestamp(item[cursor_field] / 1000, tz=timezone.utc)
return datetime.fromisoformat(item[cursor_field].replace("Z", "+00:00"))
def _paginated_get(
path: str, since: datetime, until: datetime | None, cursor_field: str
) -> list:
"""Haal alle documenten op tussen `since` en `until` (of tot nu), pagina voor pagina.
Zie de toelichting bij _PAGE_SIZE hierboven voor waarom dit niet één
verzoek met een groot `count` kan zijn (BUG: dat leverde bij een
meerdaags rapport stilzwijgend alleen de laatste ~3-4 dagen op, ongeacht
de gekozen periode). Elke pagina wordt opgevraagd met `find[date][$lte]`
net onder de oudste timestamp van de vorige pagina, zodat elk document
precies één keer terugkomt. Stopt zodra de oudste timestamp in een pagina
op of voorbij `since` ligt, of zodra de server minder dan een volle
pagina teruggeeft (dan is er geen oudere data meer, punt).
verzoek met een groot `count` kan zijn, en waarom `cursor_field` per
endpoint verschilt en nooit wordt weggelaten (BUG, zowel de originele
count-truncatie als treatments' verborgen ~4-dagenvenster zonder
expliciet find[created_at]-filter). Elke pagina wordt opgevraagd met een
cursor net onder de oudste timestamp van de vorige pagina, zodat elk
document precies één keer terugkomt. Stopt zodra de oudste timestamp in
een pagina op of voorbij `since` ligt, of zodra de server minder dan een
volle pagina teruggeeft (dan is er geen oudere data meer, punt).
"""
since_ms = int(since.timestamp() * 1000)
cursor_ms = int(until.timestamp() * 1000) if until is not None else None
cursor_dt = until if until is not None else datetime.now(timezone.utc)
results: list = []
for _ in range(_MAX_PAGES):
params = {"count": _PAGE_SIZE}
if cursor_ms is not None:
params["find[date][$lte]"] = cursor_ms
params = {"count": _PAGE_SIZE, f"find[{cursor_field}][$lte]": _format_cursor(cursor_dt, cursor_field)}
page = _get(path, params)
if not page:
break
results.extend(page)
dates = [item["date"] for item in page if "date" in item]
if not dates:
item_times = [t for t in (_parse_item_time(item, cursor_field) for item in page) if t is not None]
if not item_times:
break
oldest_ms = min(dates)
if oldest_ms <= since_ms or len(page) < _PAGE_SIZE:
oldest_dt = min(item_times)
if oldest_dt <= since or len(page) < _PAGE_SIZE:
break
cursor_ms = oldest_ms - 1
cursor_dt = oldest_dt - timedelta(milliseconds=1)
return results
def _entries_since(since: datetime, until: datetime | None = None) -> pd.DataFrame:
"""Haal entries op sinds `since` (en optioneel tot `until`), lokaal gefilterd."""
data = _paginated_get("/api/v1/entries/sgv.json", since, until)
data = _paginated_get("/api/v1/entries/sgv.json", since, until, ENTRIES_CURSOR_FIELD)
if not data:
return pd.DataFrame(columns=["timestamp", "mmol"])
@@ -141,7 +178,7 @@ def _entries_since(since: datetime, until: datetime | None = None) -> pd.DataFra
def _treatments_since(since: datetime, until: datetime | None = None) -> pd.DataFrame:
"""Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd."""
data = _paginated_get("/api/v1/treatments.json", since, until)
data = _paginated_get("/api/v1/treatments.json", since, until, TREATMENTS_CURSOR_FIELD)
if not data:
return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"])
+95 -3
View File
@@ -159,7 +159,9 @@ def test_paginated_get_stops_when_a_short_page_is_returned(monkeypatch):
lambda *a, **k: (calls.append(k), FakeResponse(payload))[1],
)
result = ns._paginated_get("/api/v1/entries/sgv.json", now - timedelta(days=1))
result = ns._paginated_get(
"/api/v1/entries/sgv.json", now - timedelta(days=1), None, ns.ENTRIES_CURSOR_FIELD
)
assert len(result) == 5
assert len(calls) == 1 # één pagina was genoeg, geen verdere paginering nodig
@@ -192,7 +194,9 @@ def test_paginated_get_walks_back_multiple_pages_until_since_is_reached(monkeypa
monkeypatch.setattr(ns.requests, "get", fake_get)
since = now - timedelta(days=365) # ruim voorbij alle test-pagina's
result = ns._paginated_get("/api/v1/entries/sgv.json", since)
result = ns._paginated_get(
"/api/v1/entries/sgv.json", since, None, ns.ENTRIES_CURSOR_FIELD
)
assert call_count["n"] == 4 # 3 volle pagina's + 1 korte slotpagina
assert len(result) == 3 * page_size + 10
@@ -216,7 +220,9 @@ def test_paginated_get_stops_at_since_boundary_without_fetching_further(monkeypa
monkeypatch.setattr(ns.requests, "get", fake_get)
since = now - timedelta(hours=6) # ruim binnen de eerste pagina
result = ns._paginated_get("/api/v1/entries/sgv.json", since)
result = ns._paginated_get(
"/api/v1/entries/sgv.json", since, None, ns.ENTRIES_CURSOR_FIELD
)
assert calls["n"] == 1
assert len(result) == page_size # filtering op since gebeurt pas in _entries_since, niet hier
@@ -255,3 +261,89 @@ def test_fetch_entries_range_retrieves_data_spanning_multiple_pages(monkeypatch)
assert len(result) == len(all_entries)
assert result["timestamp"].min().date() == start_date
assert result["timestamp"].max().date() == end_date
def _treatment(dt, event_type="Temp Basal"):
return {
"created_at": dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
"date": int(dt.timestamp() * 1000),
"eventType": event_type,
}
def test_fetch_treatments_range_retrieves_full_history_even_with_a_hidden_server_side_window(
monkeypatch,
):
"""Regressietest voor de echte productiebug (2026-07-31): treatments.json
filtert/sorteert intern op `created_at` (string), niet op `date`
(numeriek) zoals entries. Filteren op `date` — of helemaal geen filter
meegeven — leverde stilzwijgend hooguit de laatste ~4 dagen op, ook al
bevatten de documenten een geldig `date`-veld en stond de rest van de
geschiedenis (maanden) gewoon in de database (geverifieerd rechtstreeks
in MongoDB: 123k+ documenten, geen TTL-index, geen capped collection).
Deze test simuleert precies dat servergedrag: een find[date]-filter (of
het ontbreken van elk filter) geeft alleen de laatste 4 dagen terug;
alleen find[created_at] geeft de volledige, langere geschiedenis.
"""
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False)
monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False)
end_date = date(2026, 7, 31)
start_date = date(2026, 5, 1) # 92 dagen, ruim voorbij het 4-dagen-venster
until = datetime.combine(end_date, datetime.max.time(), tzinfo=timezone.utc)
since = datetime.combine(start_date, datetime.min.time(), tzinfo=timezone.utc)
all_treatments = []
t = until
while t >= since:
all_treatments.append(_treatment(t))
t -= timedelta(minutes=30)
hidden_window_start = until - timedelta(days=4)
page_size = ns._PAGE_SIZE
def fake_get(*_args, **kwargs):
params = kwargs["params"]
cursor = params.get("find[created_at][$lte]")
if cursor is None:
# geen (of het verkeerde) filter -> verborgen 4-dagenvenster
pool = [e for e in all_treatments if e["created_at"] >= hidden_window_start.strftime(
"%Y-%m-%dT%H:%M:%S.%f"
)[:-3] + "Z"]
else:
pool = [e for e in all_treatments if e["created_at"] <= cursor]
pool = sorted(pool, key=lambda e: e["created_at"], reverse=True)
return FakeResponse(pool[:page_size])
monkeypatch.setattr(ns.requests, "get", fake_get)
result = ns.fetch_treatments_range(start_date, end_date)
assert len(result) == len(all_treatments)
assert result["timestamp"].min().date() == start_date
assert result["timestamp"].max().date() == end_date
def test_paginated_get_uses_created_at_field_for_treatments_cursor(monkeypatch):
"""Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld
— moet `find[created_at][$lte]` zijn, nooit `find[date][$lte]`, voor het
treatments-endpoint."""
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
now = datetime.now(timezone.utc)
payload = [_treatment(now - timedelta(minutes=30 * i)) for i in range(5)]
seen_params = []
def fake_get(*_a, **kwargs):
seen_params.append(kwargs["params"])
return FakeResponse(payload)
monkeypatch.setattr(ns.requests, "get", fake_get)
ns._paginated_get(
"/api/v1/treatments.json", now - timedelta(days=1), None, ns.TREATMENTS_CURSOR_FIELD
)
assert "find[created_at][$lte]" in seen_params[0]
assert "find[date][$lte]" not in seen_params[0]