From 699848b479f706902bfdbc479a2c2d190d30f31f Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 1 Aug 2026 21:43:08 +0200 Subject: [PATCH] fix: treatments endpoint needs a created_at cursor, not date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated the "only ~4 days of treatments" report via direct MongoDB access (issue #13): the collection has 123k+ documents, no capping, no TTL index — the full year of history is in the database. The actual cause: /api/v1/treatments.json filters/sorts on the string field `created_at`, not the numeric `date` field /api/v1/entries uses. Paginating with find[date][$lte] (or no filter at all) silently caps treatments to an internal ~4-day window even though every document has a valid `date` field; find[created_at][$lte] correctly walks back through the full history. _paginated_get() now takes an explicit cursor_field per endpoint and always sends the cursor from the first request (never omitted), so that hidden default window can't trigger. Verified against the real instance: a 92-day treatments fetch now returns 39,403 rows (was ~700). --- nightscout_client.py | 79 +++++++++++++++++++------- tests/test_nightscout_client.py | 98 ++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 24 deletions(-) diff --git a/nightscout_client.py b/nightscout_client.py index 4dd7a85..6b38b94 100644 --- a/nightscout_client.py +++ b/nightscout_client.py @@ -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"]) diff --git a/tests/test_nightscout_client.py b/tests/test_nightscout_client.py index daa977b..adbd37f 100644 --- a/tests/test_nightscout_client.py +++ b/tests/test_nightscout_client.py @@ -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]