Redesign the PDF report: landscape pages, headers/footers, icon, daily stats
Every report section now gets its own landscape A4 page (or, for Dagoverzicht, one page per day) via a GlucoseReportPDF(FPDF) subclass with a real header (section title, report version, insulin/pump/sensor type from new REPORT_* env vars) and footer (page number, generation timestamp, Nightscout link) on every page, plus an original SVG icon in the header — inspired by the LibreView AGP report. New "Insulineprofiel" PDF section mirrors the dashboard page (basal bar chart, ISF/carb-ratio segments, DIA). Each Dagoverzicht day now draws the same basal/bolus/carb/target overlays as the interactive charts (matplotlib port of the Plotly helpers from #30) plus a new per-day stats block (reporting.daily_stats: avg/min/max glucose, total/basal/bolus/SMB insulin, hypo/hyper counts and durations). Builds on #29 (profile data) and #30 (overlay logic/colors).
This commit is contained in:
@@ -1,17 +1,32 @@
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from pdf_export import (
|
||||
DAGOVERZICHT_MAX_DAYS,
|
||||
SECTION_AGP,
|
||||
SECTION_DAGOVERZICHT,
|
||||
SECTION_EVENTS,
|
||||
SECTION_INSULIN_PROFILE,
|
||||
SECTION_SUMMARY,
|
||||
_report_config,
|
||||
build_report_pdf,
|
||||
)
|
||||
|
||||
|
||||
def _page_count(pdf_bytes: bytes) -> int:
|
||||
"""Grof-maar-betrouwbaar paginatelling op de ruwe PDF-bytes: elke
|
||||
paginaobject bevat een '/Type /Page' (zonder de 's' van '/Type /Pages')
|
||||
in zijn objectdictionary, wat altijd onleesbaar-platte tekst blijft ook
|
||||
al staat contentstream-compressie aan — geen losse PDF-parser nodig voor
|
||||
deze structurele check (zelfde black-box-filosofie als de rest van deze
|
||||
testset: geen pixel-vergelijkingen, wel structuur)."""
|
||||
return len(re.findall(rb"/Type\s*/Page[^s]", pdf_bytes))
|
||||
|
||||
|
||||
def _sample_glucose():
|
||||
timestamps = pd.date_range("2026-01-01", periods=48, freq="30min")
|
||||
return pd.DataFrame({"timestamp": timestamps, "mmol": [6.0 + (i % 5) for i in range(48)]})
|
||||
@@ -90,3 +105,185 @@ def test_build_report_pdf_draws_hypo_and_hyper_charts_when_events_present():
|
||||
)
|
||||
assert pdf_bytes.startswith(b"%PDF")
|
||||
assert len(pdf_bytes) > 500
|
||||
|
||||
|
||||
def test_build_report_pdf_always_has_at_least_a_cover_page():
|
||||
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||
pdf_bytes = build_report_pdf([], empty, {}, date(2026, 1, 1), date(2026, 1, 2))
|
||||
assert _page_count(pdf_bytes) == 1
|
||||
|
||||
|
||||
def test_build_report_pdf_one_page_per_single_page_section():
|
||||
glucose = _sample_glucose()
|
||||
pdf_bytes = build_report_pdf(
|
||||
[SECTION_SUMMARY, SECTION_AGP], glucose, {}, date(2026, 1, 1), date(2026, 1, 2)
|
||||
)
|
||||
# cover + summary + agp = 3
|
||||
assert _page_count(pdf_bytes) == 3
|
||||
|
||||
|
||||
def test_build_report_pdf_one_page_per_day_for_dagoverzicht():
|
||||
timestamps = pd.date_range("2026-01-01", periods=3 * 24 * 12, freq="5min") # 3 dagen
|
||||
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
|
||||
pdf_bytes = build_report_pdf(
|
||||
[SECTION_DAGOVERZICHT], glucose, {}, date(2026, 1, 1), date(2026, 1, 3)
|
||||
)
|
||||
# cover + 3 dagen
|
||||
assert _page_count(pdf_bytes) == 4
|
||||
|
||||
|
||||
def test_build_report_pdf_caps_dagoverzicht_page_count_at_max_days():
|
||||
timestamps = pd.date_range("2026-01-01", periods=(DAGOVERZICHT_MAX_DAYS + 10) * 24, freq="1h")
|
||||
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
|
||||
pdf_bytes = build_report_pdf(
|
||||
[SECTION_DAGOVERZICHT], glucose, {}, timestamps[0].date(), timestamps[-1].date()
|
||||
)
|
||||
# cover + max. DAGOVERZICHT_MAX_DAYS dagpagina's, ondanks (DAGOVERZICHT_MAX_DAYS + 10) dagen aan data
|
||||
assert _page_count(pdf_bytes) == 1 + DAGOVERZICHT_MAX_DAYS
|
||||
|
||||
|
||||
def test_build_report_pdf_includes_insulin_profile_section_page():
|
||||
glucose = _sample_glucose()
|
||||
profile = {
|
||||
"name": "Default",
|
||||
"dia": 5.0,
|
||||
"basal": [{"minutes": 0, "value": 0.8}, {"minutes": 720, "value": 1.1}],
|
||||
"sens": [{"minutes": 0, "value": 2.8}],
|
||||
"carbratio": [{"minutes": 0, "value": 10.0}],
|
||||
"target_low": [{"minutes": 0, "value": 4.5}],
|
||||
"target_high": [{"minutes": 0, "value": 8.0}],
|
||||
}
|
||||
pdf_bytes = build_report_pdf(
|
||||
[SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile
|
||||
)
|
||||
# cover + insulineprofiel
|
||||
assert _page_count(pdf_bytes) == 2
|
||||
assert pdf_bytes.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_build_report_pdf_insulin_profile_section_handles_missing_profile():
|
||||
glucose = _sample_glucose()
|
||||
pdf_bytes = build_report_pdf(
|
||||
[SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=None
|
||||
)
|
||||
assert _page_count(pdf_bytes) == 2
|
||||
assert pdf_bytes.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_build_report_pdf_insulin_profile_section_renders_without_glucose():
|
||||
"""De Insulineprofiel-sectie gaat niet over glucosedata en moet dus ook
|
||||
renderen als er geen glucosemetingen zijn (in tegenstelling tot
|
||||
Samenvatting/AGP/Dagoverzicht, die wel glucosedata vereisen)."""
|
||||
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||
profile = {
|
||||
"name": "Default",
|
||||
"dia": 5.0,
|
||||
"basal": [{"minutes": 0, "value": 0.8}],
|
||||
"sens": [{"minutes": 0, "value": 2.8}],
|
||||
"carbratio": [{"minutes": 0, "value": 10.0}],
|
||||
"target_low": [],
|
||||
"target_high": [],
|
||||
}
|
||||
pdf_bytes = build_report_pdf(
|
||||
[SECTION_INSULIN_PROFILE], empty, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile
|
||||
)
|
||||
assert _page_count(pdf_bytes) == 2
|
||||
|
||||
|
||||
def test_build_report_pdf_dagoverzicht_uses_treatments_and_profile_without_error():
|
||||
"""Regressietest: de dagpagina moet basaal/bolus/koolhydraten/streefwaarde-
|
||||
overlays tekenen zonder te crashen als er echte treatments/profile worden
|
||||
meegegeven (in plaats van de lege defaults)."""
|
||||
timestamps = pd.date_range("2026-01-01", periods=24 * 12, freq="5min") # 1 dag
|
||||
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
|
||||
treatments = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"timestamp": pd.Timestamp("2026-01-01 08:00"),
|
||||
"eventType": "Meal Bolus",
|
||||
"insulin": 4.0,
|
||||
"carbs": 40.0,
|
||||
"type": "",
|
||||
"absolute": None,
|
||||
"duration": None,
|
||||
},
|
||||
{
|
||||
"timestamp": pd.Timestamp("2026-01-01 12:00"),
|
||||
"eventType": "Correction Bolus",
|
||||
"insulin": 0.3,
|
||||
"carbs": None,
|
||||
"type": "SMB",
|
||||
"absolute": None,
|
||||
"duration": None,
|
||||
},
|
||||
{
|
||||
"timestamp": pd.Timestamp("2026-01-01 06:00"),
|
||||
"eventType": "Temp Basal",
|
||||
"insulin": None,
|
||||
"carbs": None,
|
||||
"type": "",
|
||||
"absolute": 2.0,
|
||||
"duration": 60,
|
||||
},
|
||||
]
|
||||
)
|
||||
profile = {
|
||||
"name": "Default",
|
||||
"dia": 5.0,
|
||||
"basal": [{"minutes": 0, "value": 0.8}],
|
||||
"sens": [{"minutes": 0, "value": 2.8}],
|
||||
"carbratio": [{"minutes": 0, "value": 10.0}],
|
||||
"target_low": [{"minutes": 0, "value": 4.5}],
|
||||
"target_high": [{"minutes": 0, "value": 8.0}],
|
||||
}
|
||||
pdf_bytes = build_report_pdf(
|
||||
[SECTION_DAGOVERZICHT],
|
||||
glucose,
|
||||
{},
|
||||
date(2026, 1, 1),
|
||||
date(2026, 1, 1),
|
||||
treatments=treatments,
|
||||
profile=profile,
|
||||
)
|
||||
assert pdf_bytes.startswith(b"%PDF")
|
||||
assert _page_count(pdf_bytes) == 2 # cover + 1 dag
|
||||
|
||||
|
||||
def test_report_config_uses_defaults_when_env_vars_unset(monkeypatch):
|
||||
for var in ("REPORT_INSULIN_TYPE", "REPORT_PUMP_TYPE", "REPORT_SENSOR_TYPE", "NIGHTSCOUT_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
config = _report_config()
|
||||
assert config["insulin_type"] == "Onbekend insulinetype"
|
||||
assert config["pump_type"] == "Onbekende pomp"
|
||||
assert config["sensor_type"] == "Onbekende sensor"
|
||||
assert config["nightscout_url"] == ""
|
||||
|
||||
|
||||
def test_report_config_reads_env_vars_when_set(monkeypatch):
|
||||
monkeypatch.setenv("REPORT_INSULIN_TYPE", "Lyumjev 100U")
|
||||
monkeypatch.setenv("REPORT_PUMP_TYPE", "Omnipod DASH")
|
||||
monkeypatch.setenv("REPORT_SENSOR_TYPE", "Dexcom G7")
|
||||
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com/")
|
||||
config = _report_config()
|
||||
assert config["insulin_type"] == "Lyumjev 100U"
|
||||
assert config["pump_type"] == "Omnipod DASH"
|
||||
assert config["sensor_type"] == "Dexcom G7"
|
||||
assert config["nightscout_url"] == "https://example.com" # trailing slash gestript
|
||||
|
||||
|
||||
def test_icon_asset_exists():
|
||||
icon_path = Path(__file__).parent.parent / "assets" / "icon.svg"
|
||||
assert icon_path.exists()
|
||||
assert icon_path.read_text(encoding="utf-8").strip().startswith("<svg")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section", [SECTION_SUMMARY, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS])
|
||||
def test_all_sections_still_work_without_treatments_or_profile_args(section):
|
||||
"""Achterwaartse compatibiliteit: treatments/profile zijn optioneel — een
|
||||
aanroep zoals vóór deze PR (zonder die argumenten) moet blijven werken."""
|
||||
glucose = _glucose_with_hypo_and_hyper_events() if section == SECTION_EVENTS else _sample_glucose()
|
||||
events_summary = {"Hypo Level 1": {"count": 1, "avg_duration_minutes": 25.0, "events_per_week": 2.3}}
|
||||
pdf_bytes = build_report_pdf(
|
||||
[section], glucose, events_summary if section == SECTION_EVENTS else {}, date(2026, 1, 1), date(2026, 1, 4)
|
||||
)
|
||||
assert pdf_bytes.startswith(b"%PDF")
|
||||
|
||||
Reference in New Issue
Block a user