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.
- prismiq/__init__.py +543 -0
- prismiq/api.py +1889 -0
- prismiq/auth.py +108 -0
- prismiq/cache.py +527 -0
- prismiq/calculated_field_processor.py +231 -0
- prismiq/calculated_fields.py +819 -0
- prismiq/dashboard_store.py +1219 -0
- prismiq/dashboards.py +374 -0
- prismiq/dates.py +247 -0
- prismiq/engine.py +1315 -0
- prismiq/executor.py +345 -0
- prismiq/filter_merge.py +397 -0
- prismiq/formatting.py +298 -0
- prismiq/logging.py +489 -0
- prismiq/metrics.py +536 -0
- prismiq/middleware.py +346 -0
- prismiq/permissions.py +87 -0
- prismiq/persistence/__init__.py +45 -0
- prismiq/persistence/models.py +208 -0
- prismiq/persistence/postgres_store.py +1119 -0
- prismiq/persistence/saved_query_store.py +336 -0
- prismiq/persistence/schema.sql +95 -0
- prismiq/persistence/setup.py +222 -0
- prismiq/persistence/tables.py +76 -0
- prismiq/pins.py +72 -0
- prismiq/py.typed +0 -0
- prismiq/query.py +1233 -0
- prismiq/schema.py +333 -0
- prismiq/schema_config.py +354 -0
- prismiq/sql_utils.py +147 -0
- prismiq/sql_validator.py +219 -0
- prismiq/sqlalchemy_builder.py +577 -0
- prismiq/timeseries.py +410 -0
- prismiq/transforms.py +471 -0
- prismiq/trends.py +573 -0
- prismiq/types.py +688 -0
- prismiq-0.1.0.dist-info/METADATA +109 -0
- prismiq-0.1.0.dist-info/RECORD +39 -0
- prismiq-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)
|