dhis2w-browser 0.5.3__tar.gz
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.
- dhis2w_browser-0.5.3/PKG-INFO +84 -0
- dhis2w_browser-0.5.3/README.md +71 -0
- dhis2w_browser-0.5.3/pyproject.toml +17 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/__init__.py +58 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/dashboard.py +358 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/maps.py +202 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/oauth2.py +187 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/pat.py +145 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/py.typed +0 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/session.py +105 -0
- dhis2w_browser-0.5.3/src/dhis2w_browser/visualization.py +218 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dhis2w-browser
|
|
3
|
+
Version: 0.5.3
|
|
4
|
+
Summary: Playwright-based helpers for DHIS2 UI automation.
|
|
5
|
+
Author: Morten Hansen
|
|
6
|
+
Author-email: Morten Hansen <morten@winterop.com>
|
|
7
|
+
Requires-Dist: dhis2w-client>=0.5.0,<0.6
|
|
8
|
+
Requires-Dist: pillow>=12.2.0
|
|
9
|
+
Requires-Dist: playwright>=1.58
|
|
10
|
+
Requires-Dist: pydantic>=2.13
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# dhis2-browser
|
|
15
|
+
|
|
16
|
+
Playwright-based helpers for DHIS2 UI automation. Separate from `dhis2-client` so API-only callers never pull in Chromium.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv add 'dhis2-cli[browser]' # pulls dhis2-browser alongside the main CLI
|
|
22
|
+
playwright install chromium # one-off; pulls the actual browser driver
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Library-only consumers (no CLI) can install `dhis2-browser` on its own.
|
|
26
|
+
|
|
27
|
+
## Surface
|
|
28
|
+
|
|
29
|
+
The CLI lives on the main `dhis2` entry point as a plugin; there's no separate `dhis2-browser` binary. Workflows mount under `dhis2 browser <subcommand>`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
dhis2 browser pat --url http://localhost:8080 --username admin --password district
|
|
33
|
+
dhis2 browser dashboard screenshot --output-dir /tmp/out --only <uid>
|
|
34
|
+
dhis2 browser viz screenshot --output-dir /tmp/out --only <uid>
|
|
35
|
+
dhis2 browser map screenshot --output-dir /tmp/out --only <uid>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Library callers import from `dhis2_browser` directly:
|
|
39
|
+
|
|
40
|
+
| Entry point | Purpose |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `dhis2_browser.logged_in_page(url, username, password)` | Async context manager yielding a `(BrowserContext, Page)` tuple logged into DHIS2 via the React login form. |
|
|
43
|
+
| `dhis2_browser.session_from_cookie(url, jsessionid)` | Fast-path variant — inject a pre-minted `JSESSIONID` instead of driving the login form. |
|
|
44
|
+
| `dhis2_browser.create_pat(url, username, password, options=...)` | Mint a Personal Access Token V2 (`POST /api/apiToken`) through an authenticated browser session. Returns the `d2p_...` token string. DHIS2 only returns the token value once — store it immediately. |
|
|
45
|
+
| `dhis2_browser.drive_oauth2_login(profile_name, *, username, password)` | Run `dhis2 profile login <name> --no-browser` end-to-end — spawns the CLI, reads the authorize URL from its stderr, and drives Chromium through the DHIS2 React login + Spring AS consent screen + loopback redirect. Returns an `OAuth2LoginResult` model. |
|
|
46
|
+
| `dhis2_browser.drive_login_form(auth_url, *, username, password)` | Lower-level companion to `drive_oauth2_login` — navigates Chromium to an already-built authorize URL, fills the login form + consent screen, waits for the loopback redirect. For wiring Playwright into an in-process `OAuth2Auth.redirect_capturer`. |
|
|
47
|
+
| `dhis2_browser.capture_dashboard(...)` / `capture_visualization(...)` / `capture_map(...)` | Render a DHIS2 dashboard / chart / map as a PNG via the respective web app. Banner + background-trim helpers available for report-friendly output. |
|
|
48
|
+
|
|
49
|
+
## Headless vs headful
|
|
50
|
+
|
|
51
|
+
Headless by default. Two ways to flip to visible:
|
|
52
|
+
|
|
53
|
+
1. **Env var:** `DHIS2_HEADFUL=1` (or `true`/`yes`/`on`) — applies to every Playwright entry point in this package.
|
|
54
|
+
2. **Explicit kwarg:** `logged_in_page(..., headless=False)` — overrides env.
|
|
55
|
+
|
|
56
|
+
The `dhis2 browser pat` CLI command defaults to **headful** (`--headful`) so first-time users can watch the login; pass `--headless` to flip.
|
|
57
|
+
|
|
58
|
+
Why: automation wants headless for speed; humans debugging a flow want to see it. One env var covers every caller (CLI, library, tests) uniformly. See `docs/decisions.md` 2026-04-17.
|
|
59
|
+
|
|
60
|
+
## Example
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from dhis2_browser import PatOptions, create_pat
|
|
65
|
+
|
|
66
|
+
async def main() -> None:
|
|
67
|
+
token = await create_pat(
|
|
68
|
+
"http://localhost:8080",
|
|
69
|
+
"admin",
|
|
70
|
+
"district",
|
|
71
|
+
options=PatOptions(
|
|
72
|
+
name="automation-bot",
|
|
73
|
+
expires_in_days=90,
|
|
74
|
+
allowed_methods=["GET", "POST"],
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
print(token) # d2p_...
|
|
78
|
+
|
|
79
|
+
asyncio.run(main())
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Architecture
|
|
83
|
+
|
|
84
|
+
See `docs/architecture/browser.md` for the longer write-up: why PAT creation has to go through a browser (DHIS2 gates `/api/apiToken` behind a session cookie), how `logged_in_page` drives the React login form, and what's on the roadmap.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# dhis2-browser
|
|
2
|
+
|
|
3
|
+
Playwright-based helpers for DHIS2 UI automation. Separate from `dhis2-client` so API-only callers never pull in Chromium.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add 'dhis2-cli[browser]' # pulls dhis2-browser alongside the main CLI
|
|
9
|
+
playwright install chromium # one-off; pulls the actual browser driver
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Library-only consumers (no CLI) can install `dhis2-browser` on its own.
|
|
13
|
+
|
|
14
|
+
## Surface
|
|
15
|
+
|
|
16
|
+
The CLI lives on the main `dhis2` entry point as a plugin; there's no separate `dhis2-browser` binary. Workflows mount under `dhis2 browser <subcommand>`:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
dhis2 browser pat --url http://localhost:8080 --username admin --password district
|
|
20
|
+
dhis2 browser dashboard screenshot --output-dir /tmp/out --only <uid>
|
|
21
|
+
dhis2 browser viz screenshot --output-dir /tmp/out --only <uid>
|
|
22
|
+
dhis2 browser map screenshot --output-dir /tmp/out --only <uid>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Library callers import from `dhis2_browser` directly:
|
|
26
|
+
|
|
27
|
+
| Entry point | Purpose |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `dhis2_browser.logged_in_page(url, username, password)` | Async context manager yielding a `(BrowserContext, Page)` tuple logged into DHIS2 via the React login form. |
|
|
30
|
+
| `dhis2_browser.session_from_cookie(url, jsessionid)` | Fast-path variant — inject a pre-minted `JSESSIONID` instead of driving the login form. |
|
|
31
|
+
| `dhis2_browser.create_pat(url, username, password, options=...)` | Mint a Personal Access Token V2 (`POST /api/apiToken`) through an authenticated browser session. Returns the `d2p_...` token string. DHIS2 only returns the token value once — store it immediately. |
|
|
32
|
+
| `dhis2_browser.drive_oauth2_login(profile_name, *, username, password)` | Run `dhis2 profile login <name> --no-browser` end-to-end — spawns the CLI, reads the authorize URL from its stderr, and drives Chromium through the DHIS2 React login + Spring AS consent screen + loopback redirect. Returns an `OAuth2LoginResult` model. |
|
|
33
|
+
| `dhis2_browser.drive_login_form(auth_url, *, username, password)` | Lower-level companion to `drive_oauth2_login` — navigates Chromium to an already-built authorize URL, fills the login form + consent screen, waits for the loopback redirect. For wiring Playwright into an in-process `OAuth2Auth.redirect_capturer`. |
|
|
34
|
+
| `dhis2_browser.capture_dashboard(...)` / `capture_visualization(...)` / `capture_map(...)` | Render a DHIS2 dashboard / chart / map as a PNG via the respective web app. Banner + background-trim helpers available for report-friendly output. |
|
|
35
|
+
|
|
36
|
+
## Headless vs headful
|
|
37
|
+
|
|
38
|
+
Headless by default. Two ways to flip to visible:
|
|
39
|
+
|
|
40
|
+
1. **Env var:** `DHIS2_HEADFUL=1` (or `true`/`yes`/`on`) — applies to every Playwright entry point in this package.
|
|
41
|
+
2. **Explicit kwarg:** `logged_in_page(..., headless=False)` — overrides env.
|
|
42
|
+
|
|
43
|
+
The `dhis2 browser pat` CLI command defaults to **headful** (`--headful`) so first-time users can watch the login; pass `--headless` to flip.
|
|
44
|
+
|
|
45
|
+
Why: automation wants headless for speed; humans debugging a flow want to see it. One env var covers every caller (CLI, library, tests) uniformly. See `docs/decisions.md` 2026-04-17.
|
|
46
|
+
|
|
47
|
+
## Example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
from dhis2_browser import PatOptions, create_pat
|
|
52
|
+
|
|
53
|
+
async def main() -> None:
|
|
54
|
+
token = await create_pat(
|
|
55
|
+
"http://localhost:8080",
|
|
56
|
+
"admin",
|
|
57
|
+
"district",
|
|
58
|
+
options=PatOptions(
|
|
59
|
+
name="automation-bot",
|
|
60
|
+
expires_in_days=90,
|
|
61
|
+
allowed_methods=["GET", "POST"],
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
print(token) # d2p_...
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Architecture
|
|
70
|
+
|
|
71
|
+
See `docs/architecture/browser.md` for the longer write-up: why PAT creation has to go through a browser (DHIS2 gates `/api/apiToken` behind a session cookie), how `logged_in_page` drives the React login form, and what's on the roadmap.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dhis2w-browser"
|
|
3
|
+
version = "0.5.3"
|
|
4
|
+
description = "Playwright-based helpers for DHIS2 UI automation."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Morten Hansen", email = "morten@winterop.com" }]
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"dhis2w-client>=0.5.0,<0.6",
|
|
10
|
+
"pillow>=12.2.0",
|
|
11
|
+
"playwright>=1.58",
|
|
12
|
+
"pydantic>=2.13",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
@@ -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
|
+
)
|