Coverage for src/lexigram/admin/services/user_dashboard.py: 88%
106 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Per-user custom dashboard builder.
3Wraps :class:`~lexigram.admin.services.dashboard.DashboardBuilder` with
4per-user keying so each user can have their own personalised dashboard layout.
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
13Usage::
15 from lexigram.admin.services.user_dashboard import UserDashboardService
17 svc = UserDashboardService(builder)
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"""
25from __future__ import annotations
27from dataclasses import dataclass, field
28from typing import Any
30from lexigram.logging import get_logger
32logger = get_logger(__name__)
35@dataclass
36class WidgetPlacement:
37 """Grid position for a widget in the dashboard layout.
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 """
47 widget_id: str
48 col: int = 0
49 row: int = 0
50 col_span: int = 1
51 row_span: int = 1
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 }
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 )
75@dataclass
76class UserDashboardLayout:
77 """A user's personalised dashboard layout.
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 """
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
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 }
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 )
117class UserDashboardService:
118 """Manages per-user personalised dashboard layouts.
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 """
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] = {}
134 # ------------------------------------------------------------------
135 # Layout retrieval / creation
136 # ------------------------------------------------------------------
138 async def get_or_create(self, user_id: str) -> UserDashboardLayout:
139 """Return the user's layout, creating a default one if none exists.
141 Args:
142 user_id: User identifier.
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]
164 def get_layout(self, user_id: str) -> UserDashboardLayout | None:
165 """Return the user's layout if it exists, ``None`` otherwise.
167 Args:
168 user_id: User identifier.
169 """
170 return self._layouts.get(user_id)
172 # ------------------------------------------------------------------
173 # Widget management
174 # ------------------------------------------------------------------
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.
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.
200 Returns:
201 The created :class:`WidgetPlacement`.
202 """
203 layout = await self.get_or_create(user_id)
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
218 if not widget_id:
219 import uuid
221 widget_id = str(uuid.uuid4())[:8]
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
236 async def remove_widget(self, user_id: str, widget_id: str) -> bool:
237 """Remove a widget from the user's layout.
239 Args:
240 user_id: User identifier.
241 widget_id: Widget to remove.
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
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.
263 Args:
264 user_id: User identifier.
265 widget_id: Widget to move.
266 col: New column index.
267 row: New row index.
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
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.
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.
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
311 async def hide_widget(self, user_id: str, widget_id: str) -> None:
312 """Mark a widget as hidden for the user.
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)
322 async def show_widget(self, user_id: str, widget_id: str) -> None:
323 """Un-hide a previously hidden widget.
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]
333 # ------------------------------------------------------------------
334 # Reset / export / import
335 # ------------------------------------------------------------------
337 async def reset(self, user_id: str) -> None:
338 """Reset a user's layout to the default (discards customisation).
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)
346 def export_layout(self, user_id: str) -> dict[str, Any] | None:
347 """Export a user's layout as a JSON-safe dict.
349 Args:
350 user_id: User identifier.
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
358 def import_layout(self, data: dict[str, Any]) -> UserDashboardLayout:
359 """Import a layout from a dict (e.g. previously exported).
361 Overwrites any existing layout for the user.
363 Args:
364 data: Dict produced by :meth:`export_layout`.
366 Returns:
367 The imported :class:`UserDashboardLayout`.
368 """
369 layout = UserDashboardLayout.from_dict(data)
370 self._layouts[layout.user_id] = layout
371 return layout
374__all__ = [
375 "UserDashboardLayout",
376 "UserDashboardService",
377 "WidgetPlacement",
378]