dhis2w-browser 0.5.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ """Playwright-based helpers for DHIS2 UI automation."""
2
+
3
+ from dhis2w_browser.dashboard import (
4
+ CaptureResult,
5
+ DashboardTarget,
6
+ add_banner,
7
+ capture_dashboard,
8
+ slugify,
9
+ switch_dashboard,
10
+ trim_background,
11
+ )
12
+ from dhis2w_browser.maps import (
13
+ MapCaptureResult,
14
+ MapTarget,
15
+ add_map_banner,
16
+ capture_map,
17
+ slugify_map,
18
+ )
19
+ from dhis2w_browser.oauth2 import OAuth2LoginResult, drive_login_form, drive_oauth2_login
20
+ from dhis2w_browser.pat import PatAttribute, PatOptions, PatPayload, create_pat
21
+ from dhis2w_browser.session import logged_in_page, resolve_headless, session_from_cookie
22
+ from dhis2w_browser.visualization import (
23
+ VisualizationCaptureResult,
24
+ VisualizationTarget,
25
+ add_viz_banner,
26
+ capture_visualization,
27
+ slugify_viz,
28
+ )
29
+
30
+ __all__ = [
31
+ "CaptureResult",
32
+ "DashboardTarget",
33
+ "MapCaptureResult",
34
+ "MapTarget",
35
+ "OAuth2LoginResult",
36
+ "PatAttribute",
37
+ "PatOptions",
38
+ "PatPayload",
39
+ "VisualizationCaptureResult",
40
+ "VisualizationTarget",
41
+ "add_banner",
42
+ "add_map_banner",
43
+ "add_viz_banner",
44
+ "capture_dashboard",
45
+ "capture_map",
46
+ "capture_visualization",
47
+ "create_pat",
48
+ "drive_login_form",
49
+ "drive_oauth2_login",
50
+ "logged_in_page",
51
+ "resolve_headless",
52
+ "session_from_cookie",
53
+ "slugify",
54
+ "slugify_map",
55
+ "slugify_viz",
56
+ "switch_dashboard",
57
+ "trim_background",
58
+ ]
@@ -0,0 +1,358 @@
1
+ """Full-page screenshot helpers for DHIS2 dashboards.
2
+
3
+ The DHIS2 dashboard app is a React SPA where each dashboard item loads its
4
+ own plugin iframe. Three quirks the helpers in this module paper over:
5
+
6
+ 1. **Lazy-load via viewport intersection.** The app only materialises a
7
+ plugin iframe when its slot scrolls into view. Programmatic `scrollTop`
8
+ doesn't fire the intersection observers — real `mouse.wheel` events are
9
+ required.
10
+
11
+ 2. **Nested render detection.** There's no single "dashboard ready" event.
12
+ The only reliable way to know an item has rendered is to poke the inner
13
+ plugin iframe for substantial content (canvas / svg / leaflet /
14
+ highcharts / img / long text). A plateau detector covers items that
15
+ never fully render (data issues, bad expressions) so one stuck chart
16
+ doesn't block the whole capture.
17
+
18
+ 3. **Session-spanning hash navigation.** Switching dashboards via a fresh
19
+ URL forces a full reload (re-auth, re-init). Setting
20
+ `iframe.contentWindow.location.hash = '/{uid}'` swaps dashboards in
21
+ place — saves 3+ seconds per capture on big batches.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import re
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING
31
+
32
+ from PIL import Image, ImageDraw, ImageFont
33
+ from pydantic import BaseModel, ConfigDict
34
+
35
+ if TYPE_CHECKING:
36
+ from playwright.async_api import Page
37
+
38
+
39
+ class DashboardTarget(BaseModel):
40
+ """One dashboard the caller wants captured — uid + display name + item count."""
41
+
42
+ model_config = ConfigDict(frozen=True)
43
+
44
+ uid: str
45
+ display_name: str
46
+ item_count: int
47
+
48
+
49
+ class CaptureResult(BaseModel):
50
+ """Outcome of one dashboard capture — output file + render-completion stats."""
51
+
52
+ model_config = ConfigDict(frozen=True)
53
+
54
+ uid: str
55
+ display_name: str
56
+ output_path: Path
57
+ items_expected: int
58
+ items_rendered: int
59
+
60
+
61
+ def slugify(name: str) -> str:
62
+ """Produce a filename-safe slug from a dashboard display name."""
63
+ stripped = name.lower().strip()
64
+ stripped = re.sub(r"[^\w\s-]", "", stripped)
65
+ stripped = re.sub(r"[\s_-]+", "-", stripped)
66
+ return stripped.strip("-") or "dashboard"
67
+
68
+
69
+ async def hide_chrome(page: Page) -> None:
70
+ """Hide DHIS2's outer header + the inner dashboard-app toolbar via injected CSS.
71
+
72
+ The header is on the outer page; the dashboard toolbar is inside the
73
+ plugin-host iframe (DHIS2's apps are iframed off the apps shell).
74
+ """
75
+ await page.evaluate(
76
+ """() => {
77
+ const header = document.querySelector('header');
78
+ if (header) header.style.display = 'none';
79
+ const iframe = document.querySelector('iframe');
80
+ if (iframe) {
81
+ iframe.style.top = '0';
82
+ iframe.style.height = '100vh';
83
+ }
84
+ }"""
85
+ )
86
+ await page.evaluate(
87
+ """() => {
88
+ const iframe = document.querySelector('iframe');
89
+ if (!iframe || !iframe.contentDocument) return;
90
+ const doc = iframe.contentDocument;
91
+ const toolbar = doc.querySelector('[data-test="dashboard-bar"]')
92
+ || doc.querySelector('div[class*="toolbar"]');
93
+ if (toolbar) toolbar.style.display = 'none';
94
+ }"""
95
+ )
96
+
97
+
98
+ async def count_rendered_items(page: Page) -> int:
99
+ """Count `.react-grid-item` slots whose inner plugin iframe has rendered content.
100
+
101
+ "Rendered" = the inner iframe's body contains a canvas, an SVG with
102
+ substantive paths/rects, a leaflet/highcharts container, or an image, or
103
+ the body's innerText is non-trivial (long text items). Cross-origin
104
+ inner iframes throw on `contentDocument` access — swallow those; they're
105
+ either app-iframes we can't inspect or plugin fallbacks that don't
106
+ matter for render detection.
107
+ """
108
+ raw = await page.evaluate(
109
+ """() => {
110
+ const outer = document.querySelector('iframe');
111
+ if (!outer || !outer.contentDocument) return 0;
112
+ const items = outer.contentDocument.querySelectorAll('.react-grid-item');
113
+ let count = 0;
114
+ items.forEach(el => {
115
+ const inner = el.querySelector('iframe');
116
+ if (!inner) {
117
+ // Items without an inner iframe (text blocks, etc.) — check the
118
+ // slot itself.
119
+ if (el.innerText.trim().length > 30) count++;
120
+ return;
121
+ }
122
+ try {
123
+ const doc = inner.contentDocument;
124
+ if (!doc || !doc.body) return;
125
+ const has = doc.querySelector('canvas')
126
+ || doc.querySelector('svg path')
127
+ || doc.querySelector('svg rect')
128
+ || doc.querySelector('table td')
129
+ || doc.querySelector('[class*="leaflet"]')
130
+ || doc.querySelector('[class*="highcharts"]')
131
+ || doc.querySelector('video')
132
+ || doc.querySelector('img[src]')
133
+ || doc.body.innerText.trim().length > 100;
134
+ if (has) count++;
135
+ } catch {
136
+ // Cross-origin iframe — skip.
137
+ }
138
+ });
139
+ return count;
140
+ }"""
141
+ )
142
+ return int(raw) if isinstance(raw, (int, float)) else 0
143
+
144
+
145
+ async def wait_for_render(
146
+ page: Page,
147
+ expected: int,
148
+ *,
149
+ timeout_seconds: float = 90.0,
150
+ poll_seconds: float = 2.0,
151
+ plateau_polls: int = 3,
152
+ ) -> int:
153
+ """Block until `expected` items have rendered, or the count plateaus.
154
+
155
+ Returns the highest `count_rendered_items(page)` result observed. The
156
+ plateau detector gives up gracefully after `plateau_polls` consecutive
157
+ unchanged polls — one stuck chart doesn't stall the rest of the pipeline.
158
+ """
159
+ deadline = asyncio.get_event_loop().time() + timeout_seconds
160
+ rendered = 0
161
+ stable_count = 0
162
+ last_rendered = -1
163
+ while asyncio.get_event_loop().time() < deadline:
164
+ rendered = await count_rendered_items(page)
165
+ if rendered >= expected:
166
+ return rendered
167
+ if rendered == last_rendered:
168
+ stable_count += 1
169
+ if stable_count >= plateau_polls:
170
+ return rendered
171
+ else:
172
+ stable_count = 0
173
+ last_rendered = rendered
174
+ await asyncio.sleep(poll_seconds)
175
+ return rendered
176
+
177
+
178
+ async def get_content_height(page: Page) -> int:
179
+ """Measure the furthest `.react-grid-item.bottom` so the viewport sizes right."""
180
+ height = await page.evaluate(
181
+ """() => {
182
+ const iframe = document.querySelector('iframe');
183
+ if (!iframe || !iframe.contentDocument) return 2000;
184
+ const items = iframe.contentDocument.querySelectorAll('.react-grid-item');
185
+ let maxBottom = 0;
186
+ items.forEach(el => {
187
+ const rect = el.getBoundingClientRect();
188
+ if (rect.bottom > maxBottom) maxBottom = rect.bottom;
189
+ });
190
+ return maxBottom || 2000;
191
+ }"""
192
+ )
193
+ return int(height)
194
+
195
+
196
+ async def scroll_to_load_all_items(page: Page, *, steps: int = 20, step_pixels: int = 800) -> None:
197
+ """Scroll through the dashboard with real `mouse.wheel` events to trigger lazy loading.
198
+
199
+ Programmatic `scrollTop` doesn't fire DHIS2's intersection-observer hooks;
200
+ only real wheel events prompt the app to materialise plugin iframes that
201
+ are still offscreen. Clicks into the dashboard area first so the inner
202
+ iframe has focus, scrolls down to the bottom, then back up so the first
203
+ items are re-visible when the screenshot fires.
204
+ """
205
+ for _ in range(15):
206
+ count = await page.evaluate(
207
+ """() => {
208
+ const f = document.querySelector('iframe');
209
+ if (!f || !f.contentDocument) return 0;
210
+ return f.contentDocument.querySelectorAll('.react-grid-item').length;
211
+ }"""
212
+ )
213
+ if count > 0:
214
+ break
215
+ await asyncio.sleep(1)
216
+ await page.mouse.click(700, 400)
217
+ await asyncio.sleep(0.5)
218
+ for _ in range(steps):
219
+ await page.mouse.wheel(0, step_pixels)
220
+ await asyncio.sleep(0.4)
221
+ for _ in range(steps):
222
+ await page.mouse.wheel(0, -step_pixels)
223
+ await asyncio.sleep(0.15)
224
+ await asyncio.sleep(2)
225
+
226
+
227
+ async def switch_dashboard(page: Page, dashboard_uid: str, *, settle_seconds: float = 5.0) -> None:
228
+ """Swap the inner iframe's hash to the given dashboard UID — no full reload.
229
+
230
+ The dashboard app reads the hash off its own location and re-renders in
231
+ place, dropping the prior grid items and building new ones. Settling
232
+ time gives the grid time to materialise before the caller starts
233
+ checking render completion.
234
+ """
235
+ await page.evaluate(
236
+ f"""() => {{
237
+ const iframe = document.querySelector('iframe');
238
+ if (iframe && iframe.contentWindow) {{
239
+ iframe.contentWindow.location.hash = '/{dashboard_uid}';
240
+ }}
241
+ }}"""
242
+ )
243
+ await asyncio.sleep(settle_seconds)
244
+
245
+
246
+ def trim_background(path: Path) -> None:
247
+ """Crop uniform-colour edges off the bottom + right of a screenshot (in-place)."""
248
+ img = Image.open(path)
249
+ pixels = img.load()
250
+ if pixels is None: # pragma: no cover — Pillow always returns PixelAccess for PNGs
251
+ return
252
+ width, height = img.size
253
+ background = pixels[width - 1, height - 1]
254
+
255
+ crop_y = height
256
+ for y in range(height - 1, -1, -1):
257
+ if not all(pixels[x, y] == background for x in range(0, width, 10)):
258
+ crop_y = min(y + 20, height)
259
+ break
260
+
261
+ crop_x = width
262
+ for x in range(width - 1, -1, -1):
263
+ if not all(pixels[x, y] == background for y in range(0, crop_y, 10)):
264
+ crop_x = min(x + 20, width)
265
+ break
266
+
267
+ if crop_x < width or crop_y < height:
268
+ img.crop((0, 0, crop_x, crop_y)).save(path)
269
+
270
+
271
+ def add_banner(
272
+ path: Path,
273
+ dashboard_name: str,
274
+ *,
275
+ instance_url: str,
276
+ username: str,
277
+ item_count: int,
278
+ timestamp: datetime | None = None,
279
+ ) -> None:
280
+ """Prepend a dark info banner above the screenshot (in-place)."""
281
+ when = timestamp or datetime.now(tz=UTC)
282
+ img = Image.open(path)
283
+ width, height = img.size
284
+
285
+ banner_height = 36
286
+ banner = Image.new("RGB", (width, banner_height), (55, 63, 81))
287
+ draw = ImageDraw.Draw(banner)
288
+
289
+ font: ImageFont.ImageFont | ImageFont.FreeTypeFont
290
+ try:
291
+ font = ImageFont.truetype("/System/Library/Fonts/SFNSMono.ttf", 14)
292
+ except OSError:
293
+ try:
294
+ font = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 14)
295
+ except OSError:
296
+ font = ImageFont.load_default()
297
+
298
+ instance = re.sub(r"https?://", "", instance_url)
299
+ text = (
300
+ f" {dashboard_name} | {instance} | {item_count} items | {when.strftime('%Y-%m-%d %H:%M')} | {username}"
301
+ )
302
+ draw.text((10, 10), text, fill=(220, 225, 234), font=font)
303
+
304
+ result = Image.new("RGB", (width, banner_height + height))
305
+ result.paste(banner, (0, 0))
306
+ result.paste(img.convert("RGB"), (0, banner_height))
307
+ result.save(path)
308
+
309
+
310
+ async def capture_dashboard(
311
+ page: Page,
312
+ target: DashboardTarget,
313
+ output_path: Path,
314
+ *,
315
+ viewport_width: int = 1400,
316
+ viewport_height_expanded: int = 8000,
317
+ render_timeout_seconds: float = 90.0,
318
+ ) -> CaptureResult:
319
+ """Screenshot one dashboard, assuming `page` is already on the dashboard app.
320
+
321
+ Caller handles switching between dashboards (via `switch_dashboard`) or
322
+ initial navigation. This function handles: reset viewport → scroll-trigger
323
+ lazy loads → wait for renders → resize viewport to fit content → hide
324
+ chrome → capture full page.
325
+ """
326
+ await page.set_viewport_size({"width": viewport_width, "height": 900})
327
+
328
+ if target.item_count == 0:
329
+ # Empty dashboard — skip the scroll+wait dance; just hide chrome + snap.
330
+ await hide_chrome(page)
331
+ await page.screenshot(path=str(output_path), full_page=True)
332
+ return CaptureResult(
333
+ uid=target.uid,
334
+ display_name=target.display_name,
335
+ output_path=output_path,
336
+ items_expected=0,
337
+ items_rendered=0,
338
+ )
339
+
340
+ await scroll_to_load_all_items(page)
341
+ await page.set_viewport_size({"width": viewport_width, "height": viewport_height_expanded})
342
+ await asyncio.sleep(3)
343
+
344
+ rendered = await wait_for_render(page, target.item_count, timeout_seconds=render_timeout_seconds)
345
+
346
+ content_height = await get_content_height(page) + 50
347
+ await page.set_viewport_size({"width": viewport_width, "height": content_height})
348
+ await asyncio.sleep(2)
349
+
350
+ await hide_chrome(page)
351
+ await page.screenshot(path=str(output_path), full_page=True)
352
+ return CaptureResult(
353
+ uid=target.uid,
354
+ display_name=target.display_name,
355
+ output_path=output_path,
356
+ items_expected=target.item_count,
357
+ items_rendered=rendered,
358
+ )
dhis2w_browser/maps.py ADDED
@@ -0,0 +1,202 @@
1
+ """Single-map screenshot helpers — `capture_map`.
2
+
3
+ DHIS2 Maps render in the Maps app (`/dhis-web-maps/#/<uid>` → `/apps/maps`
4
+ on v42+) via MapLibre GL — a vector-map library that renders to a
5
+ `<canvas>` with lots of `<svg>` overlays for legends and controls.
6
+ `capture_map` mirrors the visualization-screenshot path: navigate,
7
+ wait for the canvas + MapLibre classes to appear, hide outer DHIS2
8
+ chrome, write a full-page PNG.
9
+
10
+ Same rendering-detection loop as the viz capture: look through the
11
+ inner iframe for substantive content (canvas / MapLibre classes /
12
+ SVG paths) with a plateau detector so a broken map doesn't stall
13
+ the batch.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import re
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+
24
+ from PIL import Image, ImageDraw, ImageFont
25
+ from pydantic import BaseModel, ConfigDict
26
+
27
+ if TYPE_CHECKING:
28
+ from playwright.async_api import Page
29
+
30
+
31
+ class MapTarget(BaseModel):
32
+ """One Map to capture — uid + display name for the banner + filename."""
33
+
34
+ model_config = ConfigDict(frozen=True)
35
+
36
+ uid: str
37
+ display_name: str
38
+
39
+
40
+ class MapCaptureResult(BaseModel):
41
+ """Outcome of one map capture — output file + rendered flag."""
42
+
43
+ model_config = ConfigDict(frozen=True)
44
+
45
+ uid: str
46
+ display_name: str
47
+ output_path: Path
48
+ rendered: bool
49
+
50
+
51
+ async def _hide_map_chrome(page: Page) -> None:
52
+ """Hide the outer DHIS2 header + any inner Maps toolbar."""
53
+ await page.evaluate(
54
+ """() => {
55
+ const header = document.querySelector('header');
56
+ if (header) header.style.display = 'none';
57
+ const iframe = document.querySelector('iframe');
58
+ if (iframe) {
59
+ iframe.style.top = '0';
60
+ iframe.style.height = '100vh';
61
+ }
62
+ }""",
63
+ )
64
+ await page.evaluate(
65
+ """() => {
66
+ const iframe = document.querySelector('iframe');
67
+ if (!iframe || !iframe.contentDocument) return;
68
+ const doc = iframe.contentDocument;
69
+ const suspects = [
70
+ '[data-test="app-titlebar"]',
71
+ '[data-test="dhis2-app-menu"]',
72
+ '[class*="AppTitle"]',
73
+ '[class*="MenuBar"]',
74
+ 'nav',
75
+ ];
76
+ suspects.forEach(sel => {
77
+ doc.querySelectorAll(sel).forEach(el => { el.style.display = 'none'; });
78
+ });
79
+ }""",
80
+ )
81
+
82
+
83
+ async def _has_rendered_map(page: Page) -> bool:
84
+ """True when the inner iframe has a rendered map canvas."""
85
+ result = await page.evaluate(
86
+ """() => {
87
+ const iframe = document.querySelector('iframe');
88
+ if (!iframe || !iframe.contentDocument) return false;
89
+ const doc = iframe.contentDocument;
90
+ if (!doc.body) return false;
91
+ // MapLibre / Leaflet canvases signal a map that's painted.
92
+ return Boolean(
93
+ doc.querySelector('canvas.maplibregl-canvas')
94
+ || doc.querySelector('[class*="maplibre"]')
95
+ || doc.querySelector('[class*="leaflet"]')
96
+ || doc.querySelector('canvas')
97
+ || doc.body.innerText.trim().length > 100,
98
+ );
99
+ }""",
100
+ )
101
+ return bool(result)
102
+
103
+
104
+ async def _wait_for_map_render(
105
+ page: Page,
106
+ *,
107
+ timeout_seconds: float = 60.0,
108
+ poll_seconds: float = 1.0,
109
+ ) -> bool:
110
+ """Block until the map renders or `timeout_seconds` elapses."""
111
+ deadline = asyncio.get_event_loop().time() + timeout_seconds
112
+ while asyncio.get_event_loop().time() < deadline:
113
+ if await _has_rendered_map(page):
114
+ return True
115
+ await asyncio.sleep(poll_seconds)
116
+ return await _has_rendered_map(page)
117
+
118
+
119
+ def slugify_map(name: str) -> str:
120
+ """Produce a filename-safe slug from a map display name."""
121
+ stripped = name.lower().strip()
122
+ stripped = re.sub(r"[^\w\s-]", "", stripped)
123
+ stripped = re.sub(r"[\s_-]+", "-", stripped)
124
+ return stripped.strip("-") or "map"
125
+
126
+
127
+ async def capture_map(
128
+ page: Page,
129
+ target: MapTarget,
130
+ output_path: Path,
131
+ *,
132
+ viewport_width: int = 1400,
133
+ viewport_height: int = 900,
134
+ render_timeout_seconds: float = 60.0,
135
+ tile_settle_seconds: float = 3.0,
136
+ ) -> MapCaptureResult:
137
+ """Screenshot one Map assuming `page` is already on the Maps app.
138
+
139
+ The basemap tiles and vector overlays animate in — after the initial
140
+ render detection, wait an extra `tile_settle_seconds` so the tiles
141
+ fully paint before the screenshot fires.
142
+ """
143
+ await page.set_viewport_size({"width": viewport_width, "height": viewport_height})
144
+ rendered = await _wait_for_map_render(page, timeout_seconds=render_timeout_seconds)
145
+ # Tiles + legends fade in; wait a bit more so the screenshot isn't mid-animation.
146
+ await asyncio.sleep(tile_settle_seconds)
147
+ await _hide_map_chrome(page)
148
+ await asyncio.sleep(1)
149
+ await page.screenshot(path=str(output_path), full_page=True)
150
+ return MapCaptureResult(
151
+ uid=target.uid,
152
+ display_name=target.display_name,
153
+ output_path=output_path,
154
+ rendered=rendered,
155
+ )
156
+
157
+
158
+ def add_map_banner(
159
+ path: Path,
160
+ display_name: str,
161
+ *,
162
+ instance_url: str,
163
+ username: str,
164
+ layer_count: int | None = None,
165
+ timestamp: datetime | None = None,
166
+ ) -> None:
167
+ """Prepend a dark info banner above the map screenshot (in-place)."""
168
+ when = timestamp or datetime.now(tz=UTC)
169
+ img = Image.open(path)
170
+ width, height = img.size
171
+
172
+ banner_height = 36
173
+ banner = Image.new("RGB", (width, banner_height), (55, 63, 81))
174
+ draw = ImageDraw.Draw(banner)
175
+
176
+ font: ImageFont.ImageFont | ImageFont.FreeTypeFont
177
+ try:
178
+ font = ImageFont.truetype("/System/Library/Fonts/SFNSMono.ttf", 14)
179
+ except OSError:
180
+ try:
181
+ font = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 14)
182
+ except OSError:
183
+ font = ImageFont.load_default()
184
+
185
+ instance = re.sub(r"https?://", "", instance_url)
186
+ layer_label = f" | {layer_count} layer{'s' if layer_count != 1 else ''}" if layer_count is not None else ""
187
+ text = f" {display_name}{layer_label} | {instance} | {when.strftime('%Y-%m-%d %H:%M')} | {username}"
188
+ draw.text((10, 10), text, fill=(220, 225, 234), font=font)
189
+
190
+ result = Image.new("RGB", (width, banner_height + height))
191
+ result.paste(banner, (0, 0))
192
+ result.paste(img.convert("RGB"), (0, banner_height))
193
+ result.save(path)
194
+
195
+
196
+ __all__ = [
197
+ "MapCaptureResult",
198
+ "MapTarget",
199
+ "add_map_banner",
200
+ "capture_map",
201
+ "slugify_map",
202
+ ]