Coverage for src / lexigram / admin / services / user_dashboard.py: 40%

106 statements  

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

1"""Per-user custom dashboard builder. 

2 

3Wraps :class:`~lexigram.admin.services.dashboard.DashboardBuilder` with 

4per-user keying so each user can have their own personalised dashboard layout. 

5 

6Features: 

7* Per-user dashboard persistence (``user_id`` → :class:`DashboardConfig`) 

8* Add / remove / move / resize widgets per-user 

9* Reset to default (clears customisation, falls back to global config) 

10* Export / import layout as JSON (for copy/share) 

11* ``WidgetPlacement`` grid coordinates for drag-and-drop position tracking 

12 

13Usage:: 

14 

15 from lexigram.admin.services.user_dashboard import UserDashboardService 

16 

17 svc = UserDashboardService(builder) 

18 

19 layout = await svc.get_or_create("user-123") 

20 await svc.add_widget("user-123", "stat_card", {"title": "Revenue"}, col=0, row=0) 

21 await svc.move_widget("user-123", widget_id="w1", col=2, row=0) 

22 await svc.reset("user-123") 

23""" 

24 

25from __future__ import annotations 

26 

27from dataclasses import dataclass, field 

28from typing import Any 

29 

30from lexigram.logging import get_logger 

31 

32logger = get_logger(__name__) 

33 

34 

35@dataclass 

36class WidgetPlacement: 

37 """Grid position for a widget in the dashboard layout. 

38 

39 Attributes: 

40 widget_id: References a :class:`~lexigram.admin.services.dashboard.WidgetConfig` id. 

41 col: 0-based column index in the grid. 

42 row: 0-based row index. 

43 col_span: Number of columns the widget occupies (default 1). 

44 row_span: Number of rows the widget occupies (default 1). 

45 """ 

46 

47 widget_id: str 

48 col: int = 0 

49 row: int = 0 

50 col_span: int = 1 

51 row_span: int = 1 

52 

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

54 """Serialise to a JSON-safe dict.""" 

55 return { 

56 "widget_id": self.widget_id, 

57 "col": self.col, 

58 "row": self.row, 

59 "col_span": self.col_span, 

60 "row_span": self.row_span, 

61 } 

62 

63 @classmethod 

64 def from_dict(cls, data: dict[str, Any]) -> WidgetPlacement: 

65 """Deserialise from a dict.""" 

66 return cls( 

67 widget_id=data["widget_id"], 

68 col=data.get("col", 0), 

69 row=data.get("row", 0), 

70 col_span=data.get("col_span", 1), 

71 row_span=data.get("row_span", 1), 

72 ) 

73 

74 

75@dataclass 

76class UserDashboardLayout: 

77 """A user's personalised dashboard layout. 

78 

79 Attributes: 

80 user_id: The owner of this layout. 

81 dashboard_id: The base dashboard being personalised. 

82 placements: Ordered list of widget placements. 

83 hidden_widgets: Widget IDs the user has hidden. 

84 cols: Total number of grid columns (default 12 — Bootstrap-style). 

85 """ 

86 

87 user_id: str 

88 dashboard_id: str = "default" 

89 placements: list[WidgetPlacement] = field(default_factory=list) 

90 hidden_widgets: list[str] = field(default_factory=list) 

91 cols: int = 12 

92 

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

94 """Serialise to a JSON-safe dict.""" 

95 return { 

96 "user_id": self.user_id, 

97 "dashboard_id": self.dashboard_id, 

98 "placements": [p.to_dict() for p in self.placements], 

99 "hidden_widgets": list(self.hidden_widgets), 

100 "cols": self.cols, 

101 } 

102 

103 @classmethod 

104 def from_dict(cls, data: dict[str, Any]) -> UserDashboardLayout: 

105 """Deserialise from a dict.""" 

106 return cls( 

107 user_id=data["user_id"], 

108 dashboard_id=data.get("dashboard_id", "default"), 

109 placements=[ 

110 WidgetPlacement.from_dict(p) for p in data.get("placements", []) 

111 ], 

112 hidden_widgets=list(data.get("hidden_widgets", [])), 

113 cols=data.get("cols", 12), 

114 ) 

115 

116 

117class UserDashboardService: 

118 """Manages per-user personalised dashboard layouts. 

119 

120 Args: 

121 builder: The shared :class:`~lexigram.admin.services.dashboard.DashboardBuilder` 

122 that holds widget registrations and base configs. 

123 default_dashboard_id: The dashboard ID to clone when a user has no 

124 custom layout yet (default ``"default"``). 

125 """ 

126 

127 def __init__( 

128 self, builder: Any = None, default_dashboard_id: str = "default" 

129 ) -> None: 

130 self._builder = builder 

131 self._default_id = default_dashboard_id 

132 self._layouts: dict[str, UserDashboardLayout] = {} 

133 

134 # ------------------------------------------------------------------ 

135 # Layout retrieval / creation 

136 # ------------------------------------------------------------------ 

137 

138 async def get_or_create(self, user_id: str) -> UserDashboardLayout: 

139 """Return the user's layout, creating a default one if none exists. 

140 

141 Args: 

142 user_id: User identifier. 

143 

144 Returns: 

145 :class:`UserDashboardLayout` for the user. 

146 """ 

147 if user_id not in self._layouts: 

148 layout = UserDashboardLayout(user_id=user_id, dashboard_id=self._default_id) 

149 # Copy placements from base dashboard if available 

150 if self._builder: 

151 base = await self._builder.get_dashboard(self._default_id) 

152 if base: 

153 for i, widget in enumerate(base.widgets): 

154 layout.placements.append( 

155 WidgetPlacement( 

156 widget_id=widget.id, 

157 col=0, 

158 row=i, 

159 ) 

160 ) 

161 self._layouts[user_id] = layout 

162 return self._layouts[user_id] 

163 

164 def get_layout(self, user_id: str) -> UserDashboardLayout | None: 

165 """Return the user's layout if it exists, ``None`` otherwise. 

166 

167 Args: 

168 user_id: User identifier. 

169 """ 

170 return self._layouts.get(user_id) 

171 

172 # ------------------------------------------------------------------ 

173 # Widget management 

174 # ------------------------------------------------------------------ 

175 

176 async def add_widget( 

177 self, 

178 user_id: str, 

179 widget_type: str, 

180 config: dict[str, Any] | None = None, 

181 *, 

182 col: int = 0, 

183 row: int = 0, 

184 col_span: int = 1, 

185 row_span: int = 1, 

186 widget_id: str | None = None, 

187 ) -> WidgetPlacement: 

188 """Add a widget to the user's layout at the given grid position. 

189 

190 Args: 

191 user_id: User identifier. 

192 widget_type: Widget type slug (e.g. ``"stat_card"``). 

193 config: Widget configuration passed to the renderer. 

194 col: Grid column index. 

195 row: Grid row index. 

196 col_span: Column span. 

197 row_span: Row span. 

198 widget_id: Optional explicit widget ID. Auto-generated if omitted. 

199 

200 Returns: 

201 The created :class:`WidgetPlacement`. 

202 """ 

203 layout = await self.get_or_create(user_id) 

204 

205 # Add widget to base dashboard if builder is available 

206 if self._builder: 

207 await self._builder.add_widget( 

208 layout.dashboard_id, 

209 widget_type, 

210 config or {}, 

211 widget_id=widget_id, 

212 ) 

213 # Use the last widget id from the base dashboard 

214 base = await self._builder.get_dashboard(layout.dashboard_id) 

215 if base and base.widgets: 

216 widget_id = base.widgets[-1].id 

217 

218 if not widget_id: 

219 import uuid 

220 

221 widget_id = str(uuid.uuid4())[:8] 

222 

223 placement = WidgetPlacement( 

224 widget_id=widget_id, 

225 col=col, 

226 row=row, 

227 col_span=col_span, 

228 row_span=row_span, 

229 ) 

230 layout.placements.append(placement) 

231 logger.debug( 

232 "User %s added widget %s at col=%d row=%d", user_id, widget_id, col, row 

233 ) 

234 return placement 

235 

236 async def remove_widget(self, user_id: str, widget_id: str) -> bool: 

237 """Remove a widget from the user's layout. 

238 

239 Args: 

240 user_id: User identifier. 

241 widget_id: Widget to remove. 

242 

243 Returns: 

244 ``True`` if removed, ``False`` if not found. 

245 """ 

246 layout = self._layouts.get(user_id) 

247 if layout is None: 

248 return False 

249 before = len(layout.placements) 

250 layout.placements = [p for p in layout.placements if p.widget_id != widget_id] 

251 return len(layout.placements) < before 

252 

253 async def move_widget( 

254 self, 

255 user_id: str, 

256 widget_id: str, 

257 *, 

258 col: int, 

259 row: int, 

260 ) -> bool: 

261 """Update the grid position of a widget. 

262 

263 Args: 

264 user_id: User identifier. 

265 widget_id: Widget to move. 

266 col: New column index. 

267 row: New row index. 

268 

269 Returns: 

270 ``True`` if moved, ``False`` if widget not found. 

271 """ 

272 layout = self._layouts.get(user_id) 

273 if layout is None: 

274 return False 

275 for p in layout.placements: 

276 if p.widget_id == widget_id: 

277 p.col = col 

278 p.row = row 

279 return True 

280 return False 

281 

282 async def resize_widget( 

283 self, 

284 user_id: str, 

285 widget_id: str, 

286 *, 

287 col_span: int, 

288 row_span: int, 

289 ) -> bool: 

290 """Update the span of a widget. 

291 

292 Args: 

293 user_id: User identifier. 

294 widget_id: Widget to resize. 

295 col_span: New column span. 

296 row_span: New row span. 

297 

298 Returns: 

299 ``True`` if resized, ``False`` if widget not found. 

300 """ 

301 layout = self._layouts.get(user_id) 

302 if layout is None: 

303 return False 

304 for p in layout.placements: 

305 if p.widget_id == widget_id: 

306 p.col_span = col_span 

307 p.row_span = row_span 

308 return True 

309 return False 

310 

311 async def hide_widget(self, user_id: str, widget_id: str) -> None: 

312 """Mark a widget as hidden for the user. 

313 

314 Args: 

315 user_id: User identifier. 

316 widget_id: Widget to hide. 

317 """ 

318 layout = await self.get_or_create(user_id) 

319 if widget_id not in layout.hidden_widgets: 

320 layout.hidden_widgets.append(widget_id) 

321 

322 async def show_widget(self, user_id: str, widget_id: str) -> None: 

323 """Un-hide a previously hidden widget. 

324 

325 Args: 

326 user_id: User identifier. 

327 widget_id: Widget to show. 

328 """ 

329 layout = self._layouts.get(user_id) 

330 if layout: 

331 layout.hidden_widgets = [w for w in layout.hidden_widgets if w != widget_id] 

332 

333 # ------------------------------------------------------------------ 

334 # Reset / export / import 

335 # ------------------------------------------------------------------ 

336 

337 async def reset(self, user_id: str) -> None: 

338 """Reset a user's layout to the default (discards customisation). 

339 

340 Args: 

341 user_id: User identifier. 

342 """ 

343 self._layouts.pop(user_id, None) 

344 logger.info("Dashboard layout reset for user %s", user_id) 

345 

346 def export_layout(self, user_id: str) -> dict[str, Any] | None: 

347 """Export a user's layout as a JSON-safe dict. 

348 

349 Args: 

350 user_id: User identifier. 

351 

352 Returns: 

353 Layout dict or ``None`` if user has no layout. 

354 """ 

355 layout = self._layouts.get(user_id) 

356 return layout.to_dict() if layout else None 

357 

358 def import_layout(self, data: dict[str, Any]) -> UserDashboardLayout: 

359 """Import a layout from a dict (e.g. previously exported). 

360 

361 Overwrites any existing layout for the user. 

362 

363 Args: 

364 data: Dict produced by :meth:`export_layout`. 

365 

366 Returns: 

367 The imported :class:`UserDashboardLayout`. 

368 """ 

369 layout = UserDashboardLayout.from_dict(data) 

370 self._layouts[layout.user_id] = layout 

371 return layout 

372 

373 

374__all__ = [ 

375 "UserDashboardLayout", 

376 "UserDashboardService", 

377 "WidgetPlacement", 

378]