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 # meest recente ~1150 metingen terug — nog geen 4 dagen — terwijl er wel
# degelijk weken oudere data in Nightscout beschikbaar bleek te zijn). Eén # degelijk weken oudere data in Nightscout beschikbaar bleek te zijn). Eén
# verzoek is dus niet genoeg voor meerdaagse rapportages; _paginated_get() # 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 # `since`-grens bereikt is of de server minder dan een volle pagina teruggeeft
# (= geen oudere data meer beschikbaar, wat verschilt van "afgeknipt"). # (= 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 _PAGE_SIZE = 1000
_MAX_PAGES = 150 # 150 000 documenten; ruim boven wat een 90-dagenrapport nodig heeft _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): class NightscoutConfigError(RuntimeError):
"""NIGHTSCOUT_URL ontbreekt of is ongeldig.""" """NIGHTSCOUT_URL ontbreekt of is ongeldig."""
@@ -88,44 +107,62 @@ def _get(path: str, params: dict) -> list:
return response.json() 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. """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 Zie de toelichting bij _PAGE_SIZE hierboven voor waarom dit niet één
verzoek met een groot `count` kan zijn (BUG: dat leverde bij een verzoek met een groot `count` kan zijn, en waarom `cursor_field` per
meerdaags rapport stilzwijgend alleen de laatste ~3-4 dagen op, ongeacht endpoint verschilt en nooit wordt weggelaten (BUG, zowel de originele
de gekozen periode). Elke pagina wordt opgevraagd met `find[date][$lte]` count-truncatie als treatments' verborgen ~4-dagenvenster zonder
net onder de oudste timestamp van de vorige pagina, zodat elk document expliciet find[created_at]-filter). Elke pagina wordt opgevraagd met een
precies één keer terugkomt. Stopt zodra de oudste timestamp in een pagina cursor net onder de oudste timestamp van de vorige pagina, zodat elk
op of voorbij `since` ligt, of zodra de server minder dan een volle document precies één keer terugkomt. Stopt zodra de oudste timestamp in
pagina teruggeeft (dan is er geen oudere data meer, punt). 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_dt = until if until is not None else datetime.now(timezone.utc)
cursor_ms = int(until.timestamp() * 1000) if until is not None else None
results: list = [] results: list = []
for _ in range(_MAX_PAGES): for _ in range(_MAX_PAGES):
params = {"count": _PAGE_SIZE} params = {"count": _PAGE_SIZE, f"find[{cursor_field}][$lte]": _format_cursor(cursor_dt, cursor_field)}
if cursor_ms is not None:
params["find[date][$lte]"] = cursor_ms
page = _get(path, params) page = _get(path, params)
if not page: if not page:
break break
results.extend(page) results.extend(page)
dates = [item["date"] for item in page if "date" in item] item_times = [t for t in (_parse_item_time(item, cursor_field) for item in page) if t is not None]
if not dates: if not item_times:
break break
oldest_ms = min(dates) oldest_dt = min(item_times)
if oldest_ms <= since_ms or len(page) < _PAGE_SIZE: if oldest_dt <= since or len(page) < _PAGE_SIZE:
break break
cursor_ms = oldest_ms - 1 cursor_dt = oldest_dt - timedelta(milliseconds=1)
return results return results
def _entries_since(since: datetime, until: datetime | None = None) -> pd.DataFrame: def _entries_since(since: datetime, until: datetime | None = None) -> pd.DataFrame:
"""Haal entries op sinds `since` (en optioneel tot `until`), lokaal gefilterd.""" """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: if not data:
return pd.DataFrame(columns=["timestamp", "mmol"]) 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: def _treatments_since(since: datetime, until: datetime | None = None) -> pd.DataFrame:
"""Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd.""" """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: if not data:
return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) 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], 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(result) == 5
assert len(calls) == 1 # één pagina was genoeg, geen verdere paginering nodig 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) monkeypatch.setattr(ns.requests, "get", fake_get)
since = now - timedelta(days=365) # ruim voorbij alle test-pagina's 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 call_count["n"] == 4 # 3 volle pagina's + 1 korte slotpagina
assert len(result) == 3 * page_size + 10 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) monkeypatch.setattr(ns.requests, "get", fake_get)
since = now - timedelta(hours=6) # ruim binnen de eerste pagina 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 calls["n"] == 1
assert len(result) == page_size # filtering op since gebeurt pas in _entries_since, niet hier 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 len(result) == len(all_entries)
assert result["timestamp"].min().date() == start_date assert result["timestamp"].min().date() == start_date
assert result["timestamp"].max().date() == end_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]