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.
- dhis2w_browser/__init__.py +58 -0
- dhis2w_browser/dashboard.py +358 -0
- dhis2w_browser/maps.py +202 -0
- dhis2w_browser/oauth2.py +187 -0
- dhis2w_browser/pat.py +145 -0
- dhis2w_browser/py.typed +0 -0
- dhis2w_browser/session.py +105 -0
- dhis2w_browser/visualization.py +218 -0
- dhis2w_browser-0.5.3.dist-info/METADATA +84 -0
- dhis2w_browser-0.5.3.dist-info/RECORD +11 -0
- dhis2w_browser-0.5.3.dist-info/WHEEL +4 -0
|
@@ -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
|
+
]
|