prismiq 0.1.0__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.
@@ -0,0 +1,1219 @@
1
+ """Dashboard storage implementations for Prismiq.
2
+
3
+ This module provides the DashboardStore protocol and implementations for
4
+ storing and retrieving dashboards and widgets.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import copy
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from typing import Protocol
14
+
15
+ from prismiq.dashboards import (
16
+ Dashboard,
17
+ DashboardCreate,
18
+ DashboardLayout,
19
+ DashboardUpdate,
20
+ Widget,
21
+ WidgetConfig,
22
+ WidgetCreate,
23
+ WidgetPosition,
24
+ WidgetUpdate,
25
+ )
26
+ from prismiq.pins import PinnedDashboard
27
+
28
+
29
+ def _utc_now() -> datetime:
30
+ """Get current UTC datetime (timezone-aware)."""
31
+ return datetime.now(timezone.utc)
32
+
33
+
34
+ class DashboardStore(Protocol):
35
+ """Abstract dashboard storage interface.
36
+
37
+ Implementations can store dashboards in memory, database, or other
38
+ backends. All operations are async to support different storage
39
+ backends. All operations require tenant_id for multi-tenant
40
+ isolation.
41
+ """
42
+
43
+ async def list_dashboards(
44
+ self,
45
+ tenant_id: str,
46
+ owner_id: str | None = None,
47
+ schema_name: str | None = None,
48
+ ) -> list[Dashboard]:
49
+ """List all dashboards for a tenant, optionally filtered by owner.
50
+
51
+ Args:
52
+ tenant_id: Tenant ID for isolation.
53
+ owner_id: Optional owner ID to filter by.
54
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
55
+
56
+ Returns:
57
+ List of dashboards.
58
+ """
59
+ ...
60
+
61
+ async def get_dashboard(
62
+ self,
63
+ dashboard_id: str,
64
+ tenant_id: str,
65
+ schema_name: str | None = None,
66
+ ) -> Dashboard | None:
67
+ """Get a dashboard by ID.
68
+
69
+ Args:
70
+ dashboard_id: The dashboard ID.
71
+ tenant_id: Tenant ID for isolation.
72
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
73
+
74
+ Returns:
75
+ The dashboard, or None if not found.
76
+ """
77
+ ...
78
+
79
+ async def create_dashboard(
80
+ self,
81
+ dashboard: DashboardCreate,
82
+ tenant_id: str,
83
+ owner_id: str | None = None,
84
+ schema_name: str | None = None,
85
+ ) -> Dashboard:
86
+ """Create a new dashboard.
87
+
88
+ Args:
89
+ dashboard: Dashboard creation data.
90
+ tenant_id: Tenant ID for isolation.
91
+ owner_id: Optional owner ID.
92
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
93
+
94
+ Returns:
95
+ The created dashboard with generated ID and timestamps.
96
+ """
97
+ ...
98
+
99
+ async def update_dashboard(
100
+ self,
101
+ dashboard_id: str,
102
+ update: DashboardUpdate,
103
+ tenant_id: str,
104
+ schema_name: str | None = None,
105
+ ) -> Dashboard | None:
106
+ """Update a dashboard.
107
+
108
+ Args:
109
+ dashboard_id: The dashboard ID.
110
+ update: Fields to update.
111
+ tenant_id: Tenant ID for isolation.
112
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
113
+
114
+ Returns:
115
+ The updated dashboard, or None if not found.
116
+ """
117
+ ...
118
+
119
+ async def delete_dashboard(
120
+ self,
121
+ dashboard_id: str,
122
+ tenant_id: str,
123
+ schema_name: str | None = None,
124
+ ) -> bool:
125
+ """Delete a dashboard.
126
+
127
+ Args:
128
+ dashboard_id: The dashboard ID.
129
+ tenant_id: Tenant ID for isolation.
130
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
131
+
132
+ Returns:
133
+ True if deleted, False if not found.
134
+ """
135
+ ...
136
+
137
+ async def add_widget(
138
+ self,
139
+ dashboard_id: str,
140
+ widget: WidgetCreate,
141
+ tenant_id: str,
142
+ schema_name: str | None = None,
143
+ ) -> Widget | None:
144
+ """Add a widget to a dashboard.
145
+
146
+ Args:
147
+ dashboard_id: The dashboard ID.
148
+ widget: Widget creation data.
149
+ tenant_id: Tenant ID for isolation.
150
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
151
+
152
+ Returns:
153
+ The created widget, or None if dashboard not found.
154
+ """
155
+ ...
156
+
157
+ async def get_widget(
158
+ self,
159
+ widget_id: str,
160
+ tenant_id: str,
161
+ schema_name: str | None = None,
162
+ ) -> Widget | None:
163
+ """Get a widget by ID.
164
+
165
+ Args:
166
+ widget_id: The widget ID.
167
+ tenant_id: Tenant ID for isolation.
168
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
169
+
170
+ Returns:
171
+ The widget, or None if not found.
172
+ """
173
+ ...
174
+
175
+ async def update_widget(
176
+ self,
177
+ widget_id: str,
178
+ update: WidgetUpdate,
179
+ tenant_id: str,
180
+ schema_name: str | None = None,
181
+ ) -> Widget | None:
182
+ """Update a widget.
183
+
184
+ Args:
185
+ widget_id: The widget ID.
186
+ update: Fields to update.
187
+ tenant_id: Tenant ID for isolation.
188
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
189
+
190
+ Returns:
191
+ The updated widget, or None if not found.
192
+ """
193
+ ...
194
+
195
+ async def delete_widget(
196
+ self,
197
+ widget_id: str,
198
+ tenant_id: str,
199
+ schema_name: str | None = None,
200
+ ) -> bool:
201
+ """Delete a widget.
202
+
203
+ Args:
204
+ widget_id: The widget ID.
205
+ tenant_id: Tenant ID for isolation.
206
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
207
+
208
+ Returns:
209
+ True if deleted, False if not found.
210
+ """
211
+ ...
212
+
213
+ async def duplicate_widget(
214
+ self,
215
+ widget_id: str,
216
+ tenant_id: str,
217
+ schema_name: str | None = None,
218
+ ) -> Widget | None:
219
+ """Duplicate a widget.
220
+
221
+ Args:
222
+ widget_id: The widget ID to duplicate.
223
+ tenant_id: Tenant ID for isolation.
224
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
225
+
226
+ Returns:
227
+ The new duplicated widget, or None if not found.
228
+ """
229
+ ...
230
+
231
+ async def update_widget_positions(
232
+ self,
233
+ dashboard_id: str,
234
+ positions: list[dict[str, object]],
235
+ tenant_id: str,
236
+ schema_name: str | None = None,
237
+ ) -> bool:
238
+ """Batch update widget positions.
239
+
240
+ Args:
241
+ dashboard_id: The dashboard ID.
242
+ positions: List of position updates with widget_id and position.
243
+ tenant_id: Tenant ID for isolation.
244
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
245
+
246
+ Returns:
247
+ True if updated, False if dashboard not found.
248
+ """
249
+ ...
250
+
251
+ # =========================================================================
252
+ # Pin Operations
253
+ # =========================================================================
254
+
255
+ async def pin_dashboard(
256
+ self,
257
+ dashboard_id: str,
258
+ context: str,
259
+ tenant_id: str,
260
+ user_id: str,
261
+ position: int | None = None,
262
+ schema_name: str | None = None,
263
+ ) -> PinnedDashboard:
264
+ """Pin a dashboard to a context.
265
+
266
+ Args:
267
+ dashboard_id: The dashboard ID to pin.
268
+ context: The context to pin to (e.g., "accounts", "dashboard").
269
+ tenant_id: Tenant ID for isolation.
270
+ user_id: User ID who is pinning.
271
+ position: Optional position. If None, appends at end.
272
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
273
+
274
+ Returns:
275
+ The created PinnedDashboard entry.
276
+
277
+ Raises:
278
+ ValueError: If dashboard not found or already pinned to context.
279
+ """
280
+ ...
281
+
282
+ async def unpin_dashboard(
283
+ self,
284
+ dashboard_id: str,
285
+ context: str,
286
+ tenant_id: str,
287
+ user_id: str,
288
+ schema_name: str | None = None,
289
+ ) -> bool:
290
+ """Unpin a dashboard from a context.
291
+
292
+ Args:
293
+ dashboard_id: The dashboard ID to unpin.
294
+ context: The context to unpin from.
295
+ tenant_id: Tenant ID for isolation.
296
+ user_id: User ID who owns the pin.
297
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
298
+
299
+ Returns:
300
+ True if unpinned, False if pin not found.
301
+ """
302
+ ...
303
+
304
+ async def get_pinned_dashboards(
305
+ self,
306
+ context: str,
307
+ tenant_id: str,
308
+ user_id: str,
309
+ schema_name: str | None = None,
310
+ ) -> list[Dashboard]:
311
+ """Get all dashboards pinned to a context.
312
+
313
+ Args:
314
+ context: The context to get pins for.
315
+ tenant_id: Tenant ID for isolation.
316
+ user_id: User ID who owns the pins.
317
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
318
+
319
+ Returns:
320
+ List of Dashboard objects, ordered by position.
321
+ """
322
+ ...
323
+
324
+ async def get_pin_contexts_for_dashboard(
325
+ self,
326
+ dashboard_id: str,
327
+ tenant_id: str,
328
+ user_id: str,
329
+ schema_name: str | None = None,
330
+ ) -> list[str]:
331
+ """Get all contexts where a dashboard is pinned.
332
+
333
+ Args:
334
+ dashboard_id: The dashboard ID.
335
+ tenant_id: Tenant ID for isolation.
336
+ user_id: User ID who owns the pins.
337
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
338
+
339
+ Returns:
340
+ List of context names where the dashboard is pinned.
341
+ """
342
+ ...
343
+
344
+ async def reorder_pins(
345
+ self,
346
+ context: str,
347
+ dashboard_ids: list[str],
348
+ tenant_id: str,
349
+ user_id: str,
350
+ schema_name: str | None = None,
351
+ ) -> bool:
352
+ """Reorder pinned dashboards in a context.
353
+
354
+ Args:
355
+ context: The context to reorder.
356
+ dashboard_ids: Ordered list of dashboard IDs (new order).
357
+ tenant_id: Tenant ID for isolation.
358
+ user_id: User ID who owns the pins.
359
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
360
+
361
+ Returns:
362
+ True if reordered successfully, False otherwise.
363
+ """
364
+ ...
365
+
366
+ async def is_dashboard_pinned(
367
+ self,
368
+ dashboard_id: str,
369
+ context: str,
370
+ tenant_id: str,
371
+ user_id: str,
372
+ schema_name: str | None = None,
373
+ ) -> bool:
374
+ """Check if a dashboard is pinned to a context.
375
+
376
+ Args:
377
+ dashboard_id: The dashboard ID.
378
+ context: The context to check.
379
+ tenant_id: Tenant ID for isolation.
380
+ user_id: User ID who owns the pins.
381
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
382
+
383
+ Returns:
384
+ True if pinned, False otherwise.
385
+ """
386
+ ...
387
+
388
+ async def get_pins_for_context(
389
+ self,
390
+ context: str,
391
+ tenant_id: str,
392
+ user_id: str,
393
+ schema_name: str | None = None,
394
+ ) -> list[PinnedDashboard]:
395
+ """Get pin entries for a context (for API responses).
396
+
397
+ Args:
398
+ context: The context to get pins for.
399
+ tenant_id: Tenant ID for isolation.
400
+ user_id: User ID who owns the pins.
401
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
402
+
403
+ Returns:
404
+ List of PinnedDashboard entries, ordered by position.
405
+ """
406
+ ...
407
+
408
+
409
+ class InMemoryDashboardStore:
410
+ """In-memory implementation of DashboardStore.
411
+
412
+ Stores dashboards in a dict for testing and development.
413
+ Thread-safe through asyncio locks.
414
+ Tenant isolation is simulated via tenant_id stored on dashboards.
415
+
416
+ Example:
417
+ >>> store = InMemoryDashboardStore()
418
+ >>> dashboard = await store.create_dashboard(
419
+ ... DashboardCreate(name="Sales Dashboard"),
420
+ ... tenant_id="tenant_123",
421
+ ... owner_id="user_123",
422
+ ... )
423
+ >>> widget = await store.add_widget(
424
+ ... dashboard.id,
425
+ ... WidgetCreate(type=WidgetType.TABLE, title="Data", position=...),
426
+ ... tenant_id="tenant_123",
427
+ ... )
428
+ """
429
+
430
+ def __init__(self) -> None:
431
+ """Initialize the in-memory store."""
432
+ self._dashboards: dict[str, Dashboard] = {}
433
+ self._dashboard_tenants: dict[str, str] = {} # dashboard_id -> tenant_id
434
+ self._widget_to_dashboard: dict[str, str] = {}
435
+ # Pins storage: key = (tenant_id, user_id, context), value = list of PinnedDashboard
436
+ self._pins: dict[tuple[str, str, str], list[PinnedDashboard]] = {}
437
+ self._lock = asyncio.Lock()
438
+
439
+ async def list_dashboards(
440
+ self,
441
+ tenant_id: str,
442
+ owner_id: str | None = None,
443
+ schema_name: str | None = None, # Ignored for in-memory store
444
+ ) -> list[Dashboard]:
445
+ """List all dashboards for a tenant, optionally filtered by owner.
446
+
447
+ Args:
448
+ tenant_id: Tenant ID for isolation.
449
+ owner_id: Optional owner ID to filter by.
450
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
451
+
452
+ Returns:
453
+ List of dashboards (deep copies).
454
+ """
455
+ async with self._lock:
456
+ # Filter by tenant
457
+ dashboards = [
458
+ d
459
+ for d in self._dashboards.values()
460
+ if self._dashboard_tenants.get(d.id) == tenant_id
461
+ ]
462
+ if owner_id is not None:
463
+ dashboards = [
464
+ d
465
+ for d in dashboards
466
+ if d.owner_id == owner_id or d.is_public or owner_id in d.allowed_viewers
467
+ ]
468
+ # Return deep copies to prevent external mutation
469
+ return [self._copy_dashboard(d) for d in dashboards]
470
+
471
+ async def get_dashboard(
472
+ self,
473
+ dashboard_id: str,
474
+ tenant_id: str,
475
+ schema_name: str | None = None, # Ignored for in-memory store
476
+ ) -> Dashboard | None:
477
+ """Get a dashboard by ID with tenant check.
478
+
479
+ Args:
480
+ dashboard_id: The dashboard ID.
481
+ tenant_id: Tenant ID for isolation.
482
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
483
+
484
+ Returns:
485
+ Deep copy of the dashboard, or None if not found.
486
+ """
487
+ async with self._lock:
488
+ dashboard = self._dashboards.get(dashboard_id)
489
+ if dashboard is None:
490
+ return None
491
+ # Check tenant ownership
492
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
493
+ return None
494
+ return self._copy_dashboard(dashboard)
495
+
496
+ async def create_dashboard(
497
+ self,
498
+ dashboard: DashboardCreate,
499
+ tenant_id: str,
500
+ owner_id: str | None = None,
501
+ schema_name: str | None = None, # Ignored for in-memory store
502
+ ) -> Dashboard:
503
+ """Create a new dashboard.
504
+
505
+ Args:
506
+ dashboard: Dashboard creation data.
507
+ tenant_id: Tenant ID for isolation.
508
+ owner_id: Optional owner ID.
509
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
510
+
511
+ Returns:
512
+ The created dashboard with generated ID and timestamps.
513
+ """
514
+ async with self._lock:
515
+ now = _utc_now()
516
+ new_dashboard = Dashboard(
517
+ id=str(uuid.uuid4()),
518
+ name=dashboard.name,
519
+ description=dashboard.description,
520
+ layout=dashboard.layout if dashboard.layout else DashboardLayout(),
521
+ widgets=[],
522
+ filters=[],
523
+ owner_id=owner_id,
524
+ created_at=now,
525
+ updated_at=now,
526
+ is_public=False,
527
+ allowed_viewers=[],
528
+ )
529
+ self._dashboards[new_dashboard.id] = new_dashboard
530
+ self._dashboard_tenants[new_dashboard.id] = tenant_id
531
+ return self._copy_dashboard(new_dashboard)
532
+
533
+ async def update_dashboard(
534
+ self,
535
+ dashboard_id: str,
536
+ update: DashboardUpdate,
537
+ tenant_id: str,
538
+ schema_name: str | None = None, # Ignored for in-memory store
539
+ ) -> Dashboard | None:
540
+ """Update a dashboard with tenant check.
541
+
542
+ Args:
543
+ dashboard_id: The dashboard ID.
544
+ update: Fields to update.
545
+ tenant_id: Tenant ID for isolation.
546
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
547
+
548
+ Returns:
549
+ The updated dashboard, or None if not found.
550
+ """
551
+ async with self._lock:
552
+ dashboard = self._dashboards.get(dashboard_id)
553
+ if dashboard is None:
554
+ return None
555
+ # Check tenant ownership
556
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
557
+ return None
558
+
559
+ # Build update data
560
+ update_data: dict[str, object] = {"updated_at": _utc_now()}
561
+ if update.name is not None:
562
+ update_data["name"] = update.name
563
+ if update.description is not None:
564
+ update_data["description"] = update.description
565
+ if update.layout is not None:
566
+ update_data["layout"] = update.layout
567
+ if update.filters is not None:
568
+ update_data["filters"] = update.filters
569
+ if update.is_public is not None:
570
+ update_data["is_public"] = update.is_public
571
+ if update.allowed_viewers is not None:
572
+ update_data["allowed_viewers"] = update.allowed_viewers
573
+
574
+ # Create updated dashboard
575
+ updated = dashboard.model_copy(update=update_data)
576
+ self._dashboards[dashboard_id] = updated
577
+ return self._copy_dashboard(updated)
578
+
579
+ async def delete_dashboard(
580
+ self,
581
+ dashboard_id: str,
582
+ tenant_id: str,
583
+ schema_name: str | None = None, # Ignored for in-memory store
584
+ ) -> bool:
585
+ """Delete a dashboard with tenant check.
586
+
587
+ Args:
588
+ dashboard_id: The dashboard ID.
589
+ tenant_id: Tenant ID for isolation.
590
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
591
+
592
+ Returns:
593
+ True if deleted, False if not found.
594
+ """
595
+ async with self._lock:
596
+ if dashboard_id not in self._dashboards:
597
+ return False
598
+ # Check tenant ownership
599
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
600
+ return False
601
+
602
+ # Remove widget mappings
603
+ dashboard = self._dashboards[dashboard_id]
604
+ for widget in dashboard.widgets:
605
+ self._widget_to_dashboard.pop(widget.id, None)
606
+
607
+ del self._dashboards[dashboard_id]
608
+ del self._dashboard_tenants[dashboard_id]
609
+ return True
610
+
611
+ async def add_widget(
612
+ self,
613
+ dashboard_id: str,
614
+ widget: WidgetCreate,
615
+ tenant_id: str,
616
+ schema_name: str | None = None, # Ignored for in-memory store
617
+ ) -> Widget | None:
618
+ """Add a widget to a dashboard with tenant check.
619
+
620
+ Args:
621
+ dashboard_id: The dashboard ID.
622
+ widget: Widget creation data.
623
+ tenant_id: Tenant ID for isolation.
624
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
625
+
626
+ Returns:
627
+ The created widget, or None if dashboard not found.
628
+ """
629
+ async with self._lock:
630
+ dashboard = self._dashboards.get(dashboard_id)
631
+ if dashboard is None:
632
+ return None
633
+ # Check tenant ownership
634
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
635
+ return None
636
+
637
+ now = _utc_now()
638
+ new_widget = Widget(
639
+ id=str(uuid.uuid4()),
640
+ type=widget.type,
641
+ title=widget.title,
642
+ query=widget.query,
643
+ position=widget.position,
644
+ config=widget.config if widget.config else WidgetConfig(),
645
+ created_at=now,
646
+ updated_at=now,
647
+ )
648
+
649
+ # Update dashboard with new widget
650
+ new_widgets = [*list(dashboard.widgets), new_widget]
651
+ updated_dashboard = dashboard.model_copy(
652
+ update={"widgets": new_widgets, "updated_at": now}
653
+ )
654
+ self._dashboards[dashboard_id] = updated_dashboard
655
+
656
+ # Track widget-to-dashboard mapping
657
+ self._widget_to_dashboard[new_widget.id] = dashboard_id
658
+
659
+ return self._copy_widget(new_widget)
660
+
661
+ async def get_widget(
662
+ self,
663
+ widget_id: str,
664
+ tenant_id: str,
665
+ schema_name: str | None = None, # Ignored for in-memory store
666
+ ) -> Widget | None:
667
+ """Get a widget by ID with tenant check.
668
+
669
+ Args:
670
+ widget_id: The widget ID.
671
+ tenant_id: Tenant ID for isolation.
672
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
673
+
674
+ Returns:
675
+ The widget, or None if not found.
676
+ """
677
+ async with self._lock:
678
+ dashboard_id = self._widget_to_dashboard.get(widget_id)
679
+ if dashboard_id is None:
680
+ return None
681
+ # Check tenant ownership
682
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
683
+ return None
684
+
685
+ dashboard = self._dashboards.get(dashboard_id)
686
+ if dashboard is None:
687
+ return None
688
+
689
+ for widget in dashboard.widgets:
690
+ if widget.id == widget_id:
691
+ return self._copy_widget(widget)
692
+ return None
693
+
694
+ async def update_widget(
695
+ self,
696
+ widget_id: str,
697
+ update: WidgetUpdate,
698
+ tenant_id: str,
699
+ schema_name: str | None = None, # Ignored for in-memory store
700
+ ) -> Widget | None:
701
+ """Update a widget with tenant check.
702
+
703
+ Args:
704
+ widget_id: The widget ID.
705
+ update: Fields to update.
706
+ tenant_id: Tenant ID for isolation.
707
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
708
+
709
+ Returns:
710
+ The updated widget, or None if not found.
711
+ """
712
+ async with self._lock:
713
+ dashboard_id = self._widget_to_dashboard.get(widget_id)
714
+ if dashboard_id is None:
715
+ return None
716
+ # Check tenant ownership
717
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
718
+ return None
719
+
720
+ dashboard = self._dashboards.get(dashboard_id)
721
+ if dashboard is None:
722
+ return None
723
+
724
+ # Find and update widget
725
+ now = _utc_now()
726
+ updated_widget: Widget | None = None
727
+ new_widgets: list[Widget] = []
728
+
729
+ for widget in dashboard.widgets:
730
+ if widget.id == widget_id:
731
+ update_data: dict[str, object] = {"updated_at": now}
732
+ if update.title is not None:
733
+ update_data["title"] = update.title
734
+ if update.query is not None:
735
+ update_data["query"] = update.query
736
+ if update.position is not None:
737
+ update_data["position"] = update.position
738
+ if update.config is not None:
739
+ update_data["config"] = update.config
740
+
741
+ updated_widget = widget.model_copy(update=update_data)
742
+ new_widgets.append(updated_widget)
743
+ else:
744
+ new_widgets.append(widget)
745
+
746
+ if updated_widget is None:
747
+ return None
748
+
749
+ # Update dashboard
750
+ updated_dashboard = dashboard.model_copy(
751
+ update={"widgets": new_widgets, "updated_at": now}
752
+ )
753
+ self._dashboards[dashboard_id] = updated_dashboard
754
+
755
+ return self._copy_widget(updated_widget)
756
+
757
+ async def delete_widget(
758
+ self,
759
+ widget_id: str,
760
+ tenant_id: str,
761
+ schema_name: str | None = None, # Ignored for in-memory store
762
+ ) -> bool:
763
+ """Delete a widget with tenant check.
764
+
765
+ Args:
766
+ widget_id: The widget ID.
767
+ tenant_id: Tenant ID for isolation.
768
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
769
+
770
+ Returns:
771
+ True if deleted, False if not found.
772
+ """
773
+ async with self._lock:
774
+ dashboard_id = self._widget_to_dashboard.get(widget_id)
775
+ if dashboard_id is None:
776
+ return False
777
+ # Check tenant ownership
778
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
779
+ return False
780
+
781
+ dashboard = self._dashboards.get(dashboard_id)
782
+ if dashboard is None:
783
+ return False
784
+
785
+ # Remove widget from list
786
+ new_widgets = [w for w in dashboard.widgets if w.id != widget_id]
787
+ if len(new_widgets) == len(dashboard.widgets):
788
+ return False # Widget not found in dashboard
789
+
790
+ # Update dashboard
791
+ now = _utc_now()
792
+ updated_dashboard = dashboard.model_copy(
793
+ update={"widgets": new_widgets, "updated_at": now}
794
+ )
795
+ self._dashboards[dashboard_id] = updated_dashboard
796
+
797
+ # Remove mapping
798
+ del self._widget_to_dashboard[widget_id]
799
+ return True
800
+
801
+ async def duplicate_widget(
802
+ self,
803
+ widget_id: str,
804
+ tenant_id: str,
805
+ schema_name: str | None = None, # Ignored for in-memory store
806
+ ) -> Widget | None:
807
+ """Duplicate a widget with tenant check.
808
+
809
+ Args:
810
+ widget_id: The widget ID to duplicate.
811
+ tenant_id: Tenant ID for isolation.
812
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
813
+
814
+ Returns:
815
+ The new duplicated widget, or None if not found.
816
+ """
817
+ async with self._lock:
818
+ dashboard_id = self._widget_to_dashboard.get(widget_id)
819
+ if dashboard_id is None:
820
+ return None
821
+ # Check tenant ownership
822
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
823
+ return None
824
+
825
+ dashboard = self._dashboards.get(dashboard_id)
826
+ if dashboard is None:
827
+ return None
828
+
829
+ # Find widget to duplicate
830
+ original_widget: Widget | None = None
831
+ for widget in dashboard.widgets:
832
+ if widget.id == widget_id:
833
+ original_widget = widget
834
+ break
835
+
836
+ if original_widget is None:
837
+ return None
838
+
839
+ # Create duplicate with new ID and timestamps
840
+ now = _utc_now()
841
+ new_widget = Widget(
842
+ id=str(uuid.uuid4()),
843
+ type=original_widget.type,
844
+ title=f"{original_widget.title} (Copy)",
845
+ query=copy.deepcopy(original_widget.query) if original_widget.query else None,
846
+ position=original_widget.position.model_copy(
847
+ update={"x": original_widget.position.x + 1}
848
+ ),
849
+ config=original_widget.config.model_copy(),
850
+ created_at=now,
851
+ updated_at=now,
852
+ )
853
+
854
+ # Add to dashboard
855
+ new_widgets = [*list(dashboard.widgets), new_widget]
856
+ updated_dashboard = dashboard.model_copy(
857
+ update={"widgets": new_widgets, "updated_at": now}
858
+ )
859
+ self._dashboards[dashboard_id] = updated_dashboard
860
+
861
+ # Track mapping
862
+ self._widget_to_dashboard[new_widget.id] = dashboard_id
863
+
864
+ return self._copy_widget(new_widget)
865
+
866
+ async def update_widget_positions(
867
+ self,
868
+ dashboard_id: str,
869
+ positions: list[dict[str, object]],
870
+ tenant_id: str,
871
+ schema_name: str | None = None, # Ignored for in-memory store
872
+ ) -> bool:
873
+ """Batch update widget positions with tenant check.
874
+
875
+ Args:
876
+ dashboard_id: The dashboard ID.
877
+ positions: List of position updates with widget_id and position.
878
+ tenant_id: Tenant ID for isolation.
879
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
880
+
881
+ Returns:
882
+ True if updated, False if dashboard not found.
883
+ """
884
+ async with self._lock:
885
+ dashboard = self._dashboards.get(dashboard_id)
886
+ if dashboard is None:
887
+ return False
888
+ # Check tenant ownership
889
+ if self._dashboard_tenants.get(dashboard_id) != tenant_id:
890
+ return False
891
+
892
+ # Build a map of widget_id -> new position
893
+ position_map: dict[str, WidgetPosition] = {}
894
+ for pos in positions:
895
+ widget_id = str(pos.get("widget_id") or pos.get("id", ""))
896
+ position_data = pos.get("position", pos)
897
+ if isinstance(position_data, dict):
898
+ position_map[widget_id] = WidgetPosition(
899
+ x=int(position_data.get("x", 0)), # type: ignore[arg-type]
900
+ y=int(position_data.get("y", 0)), # type: ignore[arg-type]
901
+ w=int(position_data.get("w", 4)), # type: ignore[arg-type]
902
+ h=int(position_data.get("h", 3)), # type: ignore[arg-type]
903
+ )
904
+
905
+ # Update widgets with new positions
906
+ now = _utc_now()
907
+ new_widgets: list[Widget] = []
908
+ for widget in dashboard.widgets:
909
+ if widget.id in position_map:
910
+ updated = widget.model_copy(
911
+ update={"position": position_map[widget.id], "updated_at": now}
912
+ )
913
+ new_widgets.append(updated)
914
+ else:
915
+ new_widgets.append(widget)
916
+
917
+ # Update dashboard
918
+ updated_dashboard = dashboard.model_copy(
919
+ update={"widgets": new_widgets, "updated_at": now}
920
+ )
921
+ self._dashboards[dashboard_id] = updated_dashboard
922
+ return True
923
+
924
+ def _copy_dashboard(self, dashboard: Dashboard) -> Dashboard:
925
+ """Create a deep copy of a dashboard."""
926
+ return Dashboard.model_validate(dashboard.model_dump())
927
+
928
+ def _copy_widget(self, widget: Widget) -> Widget:
929
+ """Create a deep copy of a widget."""
930
+ return Widget.model_validate(widget.model_dump())
931
+
932
+ # =========================================================================
933
+ # Pin Operations
934
+ # =========================================================================
935
+
936
+ async def pin_dashboard(
937
+ self,
938
+ dashboard_id: str,
939
+ context: str,
940
+ tenant_id: str,
941
+ user_id: str,
942
+ position: int | None = None,
943
+ schema_name: str | None = None, # Ignored for in-memory store
944
+ ) -> PinnedDashboard:
945
+ """Pin a dashboard to a context.
946
+
947
+ Args:
948
+ dashboard_id: The dashboard ID to pin.
949
+ context: The context to pin to.
950
+ tenant_id: Tenant ID for isolation.
951
+ user_id: User ID who is pinning.
952
+ position: Optional position. If None, appends at end.
953
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
954
+
955
+ Returns:
956
+ The created PinnedDashboard entry.
957
+
958
+ Raises:
959
+ ValueError: If dashboard not found or already pinned.
960
+ """
961
+ async with self._lock:
962
+ # Verify dashboard exists and belongs to tenant
963
+ dashboard = self._dashboards.get(dashboard_id)
964
+ if dashboard is None or self._dashboard_tenants.get(dashboard_id) != tenant_id:
965
+ raise ValueError(f"Dashboard '{dashboard_id}' not found")
966
+
967
+ key = (tenant_id, user_id, context)
968
+ pins = self._pins.get(key, [])
969
+
970
+ # Check if already pinned
971
+ for pin in pins:
972
+ if pin.dashboard_id == dashboard_id:
973
+ raise ValueError(
974
+ f"Dashboard '{dashboard_id}' already pinned to context '{context}'"
975
+ )
976
+
977
+ # Determine position
978
+ position = len(pins) if position is None else max(0, min(position, len(pins)))
979
+
980
+ # Create pin
981
+ now = _utc_now()
982
+ pin = PinnedDashboard(
983
+ id=str(uuid.uuid4()),
984
+ dashboard_id=dashboard_id,
985
+ context=context,
986
+ position=position,
987
+ pinned_at=now,
988
+ )
989
+
990
+ # Insert at position and reorder
991
+ pins.insert(position, pin)
992
+ for i, p in enumerate(pins):
993
+ if p.id != pin.id:
994
+ pins[i] = PinnedDashboard(
995
+ id=p.id,
996
+ dashboard_id=p.dashboard_id,
997
+ context=p.context,
998
+ position=i,
999
+ pinned_at=p.pinned_at,
1000
+ )
1001
+
1002
+ self._pins[key] = pins
1003
+ return pin
1004
+
1005
+ async def unpin_dashboard(
1006
+ self,
1007
+ dashboard_id: str,
1008
+ context: str,
1009
+ tenant_id: str,
1010
+ user_id: str,
1011
+ schema_name: str | None = None, # Ignored for in-memory store
1012
+ ) -> bool:
1013
+ """Unpin a dashboard from a context.
1014
+
1015
+ Args:
1016
+ dashboard_id: The dashboard ID to unpin.
1017
+ context: The context to unpin from.
1018
+ tenant_id: Tenant ID for isolation.
1019
+ user_id: User ID who owns the pin.
1020
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
1021
+
1022
+ Returns:
1023
+ True if unpinned, False if not found.
1024
+ """
1025
+ async with self._lock:
1026
+ key = (tenant_id, user_id, context)
1027
+ pins = self._pins.get(key, [])
1028
+
1029
+ # Find and remove the pin
1030
+ new_pins = [p for p in pins if p.dashboard_id != dashboard_id]
1031
+ if len(new_pins) == len(pins):
1032
+ return False
1033
+
1034
+ # Reorder remaining pins
1035
+ for i, p in enumerate(new_pins):
1036
+ new_pins[i] = PinnedDashboard(
1037
+ id=p.id,
1038
+ dashboard_id=p.dashboard_id,
1039
+ context=p.context,
1040
+ position=i,
1041
+ pinned_at=p.pinned_at,
1042
+ )
1043
+
1044
+ self._pins[key] = new_pins
1045
+ return True
1046
+
1047
+ async def get_pinned_dashboards(
1048
+ self,
1049
+ context: str,
1050
+ tenant_id: str,
1051
+ user_id: str,
1052
+ schema_name: str | None = None, # Ignored for in-memory store
1053
+ ) -> list[Dashboard]:
1054
+ """Get all dashboards pinned to a context.
1055
+
1056
+ Args:
1057
+ context: The context to get pins for.
1058
+ tenant_id: Tenant ID for isolation.
1059
+ user_id: User ID who owns the pins.
1060
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
1061
+
1062
+ Returns:
1063
+ List of Dashboard objects, ordered by position.
1064
+ """
1065
+ async with self._lock:
1066
+ key = (tenant_id, user_id, context)
1067
+ pins = self._pins.get(key, [])
1068
+
1069
+ # Sort by position and fetch dashboards
1070
+ sorted_pins = sorted(pins, key=lambda p: p.position)
1071
+ dashboards: list[Dashboard] = []
1072
+
1073
+ for pin in sorted_pins:
1074
+ dashboard = self._dashboards.get(pin.dashboard_id)
1075
+ if dashboard is not None:
1076
+ dashboards.append(self._copy_dashboard(dashboard))
1077
+
1078
+ return dashboards
1079
+
1080
+ async def get_pin_contexts_for_dashboard(
1081
+ self,
1082
+ dashboard_id: str,
1083
+ tenant_id: str,
1084
+ user_id: str,
1085
+ schema_name: str | None = None, # Ignored for in-memory store
1086
+ ) -> list[str]:
1087
+ """Get all contexts where a dashboard is pinned.
1088
+
1089
+ Args:
1090
+ dashboard_id: The dashboard ID.
1091
+ tenant_id: Tenant ID for isolation.
1092
+ user_id: User ID who owns the pins.
1093
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
1094
+
1095
+ Returns:
1096
+ List of context names.
1097
+ """
1098
+ async with self._lock:
1099
+ contexts: list[str] = []
1100
+
1101
+ for (t_id, u_id, ctx), pins in self._pins.items():
1102
+ if t_id == tenant_id and u_id == user_id:
1103
+ for pin in pins:
1104
+ if pin.dashboard_id == dashboard_id:
1105
+ contexts.append(ctx)
1106
+ break
1107
+
1108
+ return contexts
1109
+
1110
+ async def reorder_pins(
1111
+ self,
1112
+ context: str,
1113
+ dashboard_ids: list[str],
1114
+ tenant_id: str,
1115
+ user_id: str,
1116
+ schema_name: str | None = None, # Ignored for in-memory store
1117
+ ) -> bool:
1118
+ """Reorder pinned dashboards in a context.
1119
+
1120
+ Args:
1121
+ context: The context to reorder.
1122
+ dashboard_ids: Ordered list of dashboard IDs.
1123
+ tenant_id: Tenant ID for isolation.
1124
+ user_id: User ID who owns the pins.
1125
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
1126
+
1127
+ Returns:
1128
+ True if reordered, False otherwise.
1129
+ """
1130
+ async with self._lock:
1131
+ key = (tenant_id, user_id, context)
1132
+ pins = self._pins.get(key, [])
1133
+
1134
+ # Empty pins list is a successful no-op (matches Postgres store semantics)
1135
+ if not pins:
1136
+ return True
1137
+
1138
+ # Build map of dashboard_id -> pin
1139
+ pin_map = {p.dashboard_id: p for p in pins}
1140
+
1141
+ # Reorder based on dashboard_ids
1142
+ new_pins: list[PinnedDashboard] = []
1143
+ for i, d_id in enumerate(dashboard_ids):
1144
+ if d_id in pin_map:
1145
+ old_pin = pin_map[d_id]
1146
+ new_pins.append(
1147
+ PinnedDashboard(
1148
+ id=old_pin.id,
1149
+ dashboard_id=old_pin.dashboard_id,
1150
+ context=old_pin.context,
1151
+ position=i,
1152
+ pinned_at=old_pin.pinned_at,
1153
+ )
1154
+ )
1155
+
1156
+ # Add any pins not in dashboard_ids at the end
1157
+ for d_id, pin in pin_map.items():
1158
+ if d_id not in dashboard_ids:
1159
+ new_pins.append(
1160
+ PinnedDashboard(
1161
+ id=pin.id,
1162
+ dashboard_id=pin.dashboard_id,
1163
+ context=pin.context,
1164
+ position=len(new_pins),
1165
+ pinned_at=pin.pinned_at,
1166
+ )
1167
+ )
1168
+
1169
+ self._pins[key] = new_pins
1170
+ return True
1171
+
1172
+ async def is_dashboard_pinned(
1173
+ self,
1174
+ dashboard_id: str,
1175
+ context: str,
1176
+ tenant_id: str,
1177
+ user_id: str,
1178
+ schema_name: str | None = None, # Ignored for in-memory store
1179
+ ) -> bool:
1180
+ """Check if a dashboard is pinned to a context.
1181
+
1182
+ Args:
1183
+ dashboard_id: The dashboard ID.
1184
+ context: The context to check.
1185
+ tenant_id: Tenant ID for isolation.
1186
+ user_id: User ID who owns the pins.
1187
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
1188
+
1189
+ Returns:
1190
+ True if pinned, False otherwise.
1191
+ """
1192
+ async with self._lock:
1193
+ key = (tenant_id, user_id, context)
1194
+ pins = self._pins.get(key, [])
1195
+
1196
+ return any(pin.dashboard_id == dashboard_id for pin in pins)
1197
+
1198
+ async def get_pins_for_context(
1199
+ self,
1200
+ context: str,
1201
+ tenant_id: str,
1202
+ user_id: str,
1203
+ schema_name: str | None = None, # Ignored for in-memory store
1204
+ ) -> list[PinnedDashboard]:
1205
+ """Get pin entries for a context (for API responses).
1206
+
1207
+ Args:
1208
+ context: The context to get pins for.
1209
+ tenant_id: Tenant ID for isolation.
1210
+ user_id: User ID who owns the pins.
1211
+ schema_name: PostgreSQL schema name (ignored for in-memory store).
1212
+
1213
+ Returns:
1214
+ List of PinnedDashboard entries, ordered by position.
1215
+ """
1216
+ async with self._lock:
1217
+ key = (tenant_id, user_id, context)
1218
+ pins = self._pins.get(key, [])
1219
+ return sorted(pins, key=lambda p: p.position)