Coverage for src / lexigram / admin / dashboard / widgets.py: 46%

140 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Admin dashboard widget types and infrastructure. 

2 

3Provides WidgetType, WidgetConfig, DashboardConfig and supporting 

4store/registry infrastructure for admin dashboard assembly. 

5Widget definitions from lexigram-contracts are also re-exported here. 

6""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass, field 

11from datetime import datetime 

12from enum import StrEnum 

13from typing import Any, Protocol 

14 

15from lexigram.contracts.admin.types import ( 

16 DashboardWidgetDefinition, 

17 WidgetCategory, 

18 WidgetSize, 

19) 

20from lexigram.ui import el, render_to_string 

21 

22 

23class WidgetType(StrEnum): 

24 """Widget types for legacy dashboard builder.""" 

25 

26 METRIC = "metric" 

27 CHART = "chart" 

28 TABLE = "table" 

29 TEXT = "text" 

30 CUSTOM = "custom" 

31 STAT_CARD = "stat_card" 

32 ACTIVITY = "activity" 

33 HEALTH = "health" 

34 

35 

36@dataclass 

37class WidgetConfig: 

38 """Widget configuration.""" 

39 

40 id: str 

41 type: WidgetType 

42 title: str 

43 config: dict[str, Any] = field(default_factory=dict) 

44 position: dict[str, int] = field( 

45 default_factory=lambda: {"x": 0, "y": 0, "w": 1, "h": 1}, 

46 ) 

47 

48 def to_dict(self) -> dict[str, Any]: 

49 """Serialize to dictionary.""" 

50 return { 

51 "id": self.id, 

52 "type": self.type.value, 

53 "title": self.title, 

54 "config": self.config, 

55 "position": self.position, 

56 } 

57 

58 @classmethod 

59 def from_dict(cls, data: dict[str, Any]) -> WidgetConfig: 

60 """Deserialize from dictionary.""" 

61 return cls( 

62 id=data["id"], 

63 type=WidgetType(data["type"]), 

64 title=data["title"], 

65 config=data.get("config", {}), 

66 position=data.get("position", {"x": 0, "y": 0, "w": 1, "h": 1}), 

67 ) 

68 

69 

70@dataclass 

71class DashboardConfig: 

72 """Dashboard configuration.""" 

73 

74 id: str 

75 name: str 

76 widgets: list[WidgetConfig] = field(default_factory=list) 

77 layout: dict[str, Any] = field(default_factory=dict) 

78 created_at: datetime = field(default_factory=datetime.now) 

79 updated_at: datetime = field(default_factory=datetime.now) 

80 

81 def to_dict(self) -> dict[str, Any]: 

82 """Serialize to dictionary.""" 

83 return { 

84 "id": self.id, 

85 "name": self.name, 

86 "widgets": [w.to_dict() for w in self.widgets], 

87 "layout": self.layout, 

88 "created_at": self.created_at.isoformat(), 

89 "updated_at": self.updated_at.isoformat(), 

90 } 

91 

92 @classmethod 

93 def from_dict(cls, data: dict[str, Any]) -> DashboardConfig: 

94 """Deserialize from dictionary.""" 

95 return cls( 

96 id=data["id"], 

97 name=data["name"], 

98 widgets=[WidgetConfig.from_dict(w) for w in data.get("widgets", [])], 

99 layout=data.get("layout", {}), 

100 created_at=datetime.fromisoformat( 

101 data.get("created_at", datetime.now().isoformat()), 

102 ), 

103 updated_at=datetime.fromisoformat( 

104 data.get("updated_at", datetime.now().isoformat()), 

105 ), 

106 ) 

107 

108 

109class IWidget(Protocol): 

110 """Protocol for dashboard widgets.""" 

111 

112 async def render(self, config: dict[str, Any]) -> str: 

113 """Render widget HTML (async).""" 

114 ... 

115 

116 def get_default_config(self) -> dict[str, Any]: 

117 """Get default widget configuration.""" 

118 ... 

119 

120 

121class IDashboardStore(Protocol): 

122 """Protocol for dashboard persistence (async).""" 

123 

124 async def save(self, dashboard: DashboardConfig) -> bool: 

125 """Save dashboard configuration.""" 

126 ... 

127 

128 async def load(self, dashboard_id: str) -> DashboardConfig | None: 

129 """Load dashboard configuration.""" 

130 ... 

131 

132 async def list(self) -> list[DashboardConfig]: 

133 """List all dashboards.""" 

134 ... 

135 

136 async def delete(self, dashboard_id: str) -> bool: 

137 """Delete dashboard.""" 

138 ... 

139 

140 

141class InMemoryDashboardStore: 

142 """In-memory dashboard storage implementation.""" 

143 

144 def __init__(self) -> None: 

145 """Initialize store.""" 

146 self.dashboards: dict[str, DashboardConfig] = {} 

147 

148 async def save(self, dashboard: DashboardConfig) -> bool: 

149 """Save dashboard.""" 

150 dashboard.updated_at = datetime.now() 

151 self.dashboards[dashboard.id] = dashboard 

152 return True 

153 

154 async def load(self, dashboard_id: str) -> DashboardConfig | None: 

155 """Load dashboard.""" 

156 return self.dashboards.get(dashboard_id) 

157 

158 async def list(self) -> list[DashboardConfig]: 

159 """List all dashboards.""" 

160 return list(self.dashboards.values()) 

161 

162 async def delete(self, dashboard_id: str) -> bool: 

163 """Delete dashboard.""" 

164 if dashboard_id in self.dashboards: 

165 del self.dashboards[dashboard_id] 

166 return True 

167 return False 

168 

169 

170class WidgetRegistry: 

171 """Registry for dashboard widget implementations.""" 

172 

173 def __init__(self) -> None: 

174 """Initialize registry.""" 

175 self._widgets: dict[str, type[IWidget]] = {} 

176 

177 def register(self, widget_type: str, widget_class: type[IWidget]) -> None: 

178 """Register widget type.""" 

179 self._widgets[widget_type] = widget_class 

180 

181 def get(self, widget_type: str) -> type[IWidget] | None: 

182 """Get widget class by type.""" 

183 return self._widgets.get(widget_type) 

184 

185 def list_types(self) -> list[str]: 

186 """List registered widget types.""" 

187 return list(self._widgets.keys()) 

188 

189 def create_widget(self, widget_type: str) -> IWidget | None: 

190 """Create widget instance.""" 

191 widget_class = self.get(widget_type) 

192 if widget_class: 

193 return widget_class() 

194 return None 

195 

196 def render_contributor_widgets( 

197 self, 

198 contributor_widgets: list[DashboardWidgetDefinition], 

199 width: str = "100%", 

200 ) -> str: 

201 """Render HTML for all contributor-supplied ``DashboardWidgetDefinition`` items. 

202 

203 For each widget definition the registry is consulted using the widget's 

204 ``name`` as the lookup key. If a matching ``IWidget`` class is found, 

205 a rich card is rendered that loads its content via an HTMX ``hx-get`` 

206 request to the definition's ``render_endpoint``. When no match is 

207 found a lightweight ``<div class="widget-placeholder">`` card is 

208 rendered instead so contributors can always see their widget slot. 

209 

210 Widget sizing from ``DashboardWidgetDefinition.size`` maps to CSS 

211 grid column spans (SMALL=1, MEDIUM=2, LARGE=3, FULL=4). If 

212 ``refresh_interval_seconds`` is set, the card body auto-refreshes 

213 via ``hx-trigger="every <N>ms"``. 

214 

215 Args: 

216 contributor_widgets: Widget definitions supplied by contributors. 

217 width: CSS ``width`` value applied to each card wrapper. 

218 

219 Returns: 

220 Concatenated HTML string for all widget cards. 

221 """ 

222 if not contributor_widgets: 

223 return render_to_string( 

224 el( 

225 "div", 

226 el( 

227 "div", 

228 class_="text-muted-foreground text-lg mb-1", 

229 ), 

230 el( 

231 "p", 

232 "No contributor widgets configured.", 

233 class_="text-sm text-muted-foreground", 

234 ), 

235 class_="widget-empty-state bg-muted border border-border rounded-lg p-6 text-center", 

236 ) 

237 ) 

238 

239 parts: list[str] = [] 

240 for widget_index, widget_def in enumerate(contributor_widgets): 

241 # Map widget size to grid column span 

242 size_col_map = { 

243 WidgetSize.SMALL: "", 

244 WidgetSize.MEDIUM: "lg:col-span-2", 

245 WidgetSize.LARGE: "lg:col-span-3", 

246 WidgetSize.FULL: "lg:col-span-4", 

247 } 

248 col_span = size_col_map.get(widget_def.size, "") 

249 

250 # Build refresh trigger if interval is set 

251 refresh_trigger = "" 

252 if ( 

253 widget_def.refresh_interval_seconds 

254 and widget_def.refresh_interval_seconds > 0 

255 ): 

256 interval_ms = widget_def.refresh_interval_seconds * 1000 

257 refresh_trigger = f"every {interval_ms}ms" 

258 

259 # Resolve a matching IWidget class from the registry. 

260 # Primary key is the definition name; fall back to category value. 

261 lookup_key = getattr(widget_def, "widget_type", widget_def.name) 

262 widget_class = self.get(lookup_key) or self.get(widget_def.name) 

263 

264 # Build common HTMX trigger: load on page render, plus optional polling. 

265 # Initial loads are staggered so the dashboard does not fire every 

266 # widget request at once — that would saturate the browser's HTTP/1.1 

267 # connection pool (~6 per origin) and starve sidebar navigation 

268 # requests for the whole drain. 

269 load_trigger = f"load delay:{widget_index * 350}ms" 

270 if refresh_trigger: 

271 load_trigger = f"{load_trigger}, {refresh_trigger}" 

272 

273 # Build the title bar with optional icon and config cog. 

274 icon_html = ( 

275 el("span", widget_def.icon, class_="widget-icon mr-1") 

276 if widget_def.icon 

277 else None 

278 ) 

279 title_children: list[Any] = [] 

280 if icon_html is not None: 

281 title_children.append(icon_html) 

282 title_children.append(widget_def.title) 

283 

284 cog = el( 

285 "button", 

286 "⚙", 

287 **{ 

288 "hx-get": f"/admin/core/widgets/{widget_def.name}/config", 

289 "hx-target": "#slide-over-container", 

290 "hx-swap": "innerHTML", 

291 "hx-push-url": "false", 

292 }, 

293 class_="opacity-0 group-hover:opacity-100 transition-opacity ml-auto text-muted-foreground hover:text-muted-foreground text-sm cursor-pointer", 

294 ) 

295 title_row = el( 

296 "div", 

297 *title_children, 

298 cog, 

299 class_="widget-title text-sm font-semibold text-foreground mb-2 flex items-center", 

300 ) 

301 

302 # Contributor label and description 

303 subtitle_parts: list[str] = [] 

304 if widget_def.contributor: 

305 subtitle_parts.append(widget_def.contributor) 

306 if widget_def.description: 

307 subtitle_parts.append(widget_def.description) 

308 

309 # Loading skeleton shown while HTMX request is in flight 

310 skeleton = el( 

311 "div", 

312 el("div", class_="h-4 bg-muted rounded w-3/4 mb-2"), 

313 el("div", class_="h-4 bg-muted rounded w-1/2 mb-2"), 

314 el("div", class_="h-4 bg-muted rounded w-5/6"), 

315 class_="animate-pulse py-2", 

316 ) 

317 

318 card_children: list[Any] = [ 

319 title_row, 

320 ] 

321 

322 if subtitle_parts: 

323 card_children.append( 

324 el( 

325 "div", 

326 " · ".join(subtitle_parts), 

327 class_="text-xs text-muted-foreground mb-3", 

328 ), 

329 ) 

330 

331 body_kwargs: dict[str, Any] = { 

332 "class": "widget-body", 

333 "id": f"widget-{widget_def.name}-body", 

334 "hx-get": widget_def.render_endpoint, 

335 "hx-trigger": load_trigger, 

336 "hx-swap": "innerHTML", 

337 } 

338 card_children.append( 

339 el( 

340 "div", 

341 skeleton, 

342 **body_kwargs, 

343 ), 

344 ) 

345 

346 card = el( 

347 "div", 

348 *card_children, 

349 id=f"widget-card-{widget_def.name}", 

350 data_widget_name=widget_def.name, 

351 class_=f"widget-card bg-card rounded-lg shadow p-4 group {col_span}".strip(), 

352 style=f"width:{width};", 

353 ) 

354 

355 parts.append(render_to_string(card)) 

356 

357 return "".join(parts) 

358 

359 

360def render_dashboard_widgets( 

361 definitions: list[DashboardWidgetDefinition], 

362 registry: WidgetRegistry, 

363) -> str: 

364 """Render all dashboard widget definitions using the provided registry. 

365 

366 Convenience wrapper around :meth:`WidgetRegistry.render_contributor_widgets` 

367 for callers that already hold a ``WidgetRegistry`` instance and a list of 

368 ``DashboardWidgetDefinition`` objects. 

369 

370 Args: 

371 definitions: Widget definitions to render. 

372 registry: Widget registry to look up implementations. 

373 

374 Returns: 

375 Concatenated HTML string for all widget cards. 

376 """ 

377 return registry.render_contributor_widgets(definitions) 

378 

379 

380from lexigram.admin.dashboard.widget_types import ConfigField 

381 

382 

383def render_widget_config_popup( 

384 widget_name: str, 

385 title: str, 

386 fields: list[ConfigField], 

387 current_values: dict[str, Any], 

388 enabled: bool = True, 

389) -> str: 

390 """Render HTML for a widget config dialog.""" 

391 rows: list[Any] = [ 

392 el( 

393 "label", 

394 el( 

395 "input", 

396 type_="checkbox", 

397 name="enabled", 

398 checked="checked" if enabled else None, 

399 ), 

400 " Show on dashboard", 

401 class_="flex items-center gap-2 text-sm mb-4", 

402 ) 

403 ] 

404 

405 for f in fields: 

406 if isinstance(f, dict): 

407 f = ConfigField(**f) 

408 value = current_values.get(f.name, f.default) 

409 rows.append( 

410 el( 

411 "div", 

412 el("label", f.label, class_="block text-sm font-medium mb-1"), 

413 _render_field_input(f, value), 

414 class_="mb-3", 

415 ), 

416 ) 

417 

418 from lexigram.admin.ui.organisms.admin_slide_over import ( 

419 render_slide_over_fragment, 

420 ) 

421 

422 form = el( 

423 "form", 

424 *rows, 

425 el("input", type_="hidden", name="widget_name", value=widget_name), 

426 id=f"widget-config-form-{widget_name}", 

427 **{ 

428 "hx-post": "/admin/core/widgets/config", 

429 "hx-swap": "none", 

430 "hx-on:htmx:after-request": "if(event.detail.successful){window.location.reload();}", 

431 }, 

432 class_="space-y-3", 

433 ) 

434 

435 return render_slide_over_fragment( 

436 title=f"Configure: {title}", 

437 subtitle="Update this widget's settings.", 

438 content=form, 

439 size="md", 

440 footer=[ 

441 el( 

442 "button", 

443 "Cancel", 

444 type_="button", 

445 **{"x-on:click": "open = false"}, 

446 class_="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-foreground bg-card border border-border hover:bg-muted transition-colors", 

447 ), 

448 el( 

449 "button", 

450 "Save", 

451 type_="submit", 

452 form=f"widget-config-form-{widget_name}", 

453 class_="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors", 

454 ), 

455 ], 

456 ) 

457 

458 

459def _render_field_input( 

460 field: ConfigField, current: Any, widget_name: str | None = None 

461) -> str: 

462 prefix = f"param__{widget_name}__" if widget_name else "param_" 

463 if field.type == "select" and field.options: 

464 opts = [ 

465 el( 

466 "option", 

467 label, 

468 value=str(val), 

469 selected="selected" if val == current else None, 

470 ) 

471 for val, label in field.options 

472 ] 

473 return str( 

474 el( 

475 "select", 

476 *opts, 

477 name=f"{prefix}{field.name}", 

478 class_="w-full border rounded px-2 py-1 text-sm", 

479 ) 

480 ) 

481 if field.type == "number": 

482 return str( 

483 el( 

484 "input", 

485 type_="number", 

486 name=f"{prefix}{field.name}", 

487 value=str(current) if current is not None else "", 

488 class_="w-full border rounded px-2 py-1 text-sm", 

489 ) 

490 ) 

491 if field.type == "boolean": 

492 return str( 

493 el( 

494 "input", 

495 type_="checkbox", 

496 name=f"{prefix}{field.name}", 

497 checked="checked" if current else None, 

498 ) 

499 ) 

500 return str( 

501 el( 

502 "input", 

503 type_="text", 

504 name=f"{prefix}{field.name}", 

505 value=str(current) if current is not None else "", 

506 class_="w-full border rounded px-2 py-1 text-sm", 

507 ) 

508 ) 

509 

510 

511__all__ = [ 

512 "ConfigField", 

513 "DashboardConfig", 

514 "DashboardWidgetDefinition", 

515 "IDashboardStore", 

516 "IWidget", 

517 "InMemoryDashboardStore", 

518 "WidgetCategory", 

519 "WidgetConfig", 

520 "WidgetRegistry", 

521 "WidgetSize", 

522 "WidgetType", 

523 "render_dashboard_widgets", 

524 "render_widget_config_popup", 

525]