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,1119 @@
1
+ """PostgreSQL-backed dashboard storage with tenant isolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from datetime import datetime, timezone
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from sqlalchemy import (
11
+ Column,
12
+ Integer,
13
+ MetaData,
14
+ String,
15
+ Table,
16
+ delete,
17
+ exists,
18
+ func,
19
+ insert,
20
+ not_,
21
+ select,
22
+ update,
23
+ )
24
+ from sqlalchemy.dialects.postgresql import TIMESTAMP
25
+
26
+ from prismiq.dashboards import (
27
+ Dashboard,
28
+ DashboardCreate,
29
+ DashboardFilter,
30
+ DashboardLayout,
31
+ DashboardUpdate,
32
+ Widget,
33
+ WidgetConfig,
34
+ WidgetCreate,
35
+ WidgetPosition,
36
+ WidgetType,
37
+ WidgetUpdate,
38
+ )
39
+ from prismiq.pins import PinnedDashboard
40
+ from prismiq.types import QueryDefinition
41
+
42
+ if TYPE_CHECKING:
43
+ from asyncpg import Pool # type: ignore[import-not-found]
44
+
45
+ _logger = logging.getLogger(__name__)
46
+
47
+ # SQLAlchemy Table definition for pinned dashboards (used for query generation)
48
+ # quote=True ensures all identifiers are double-quoted in generated SQL
49
+ # Note: IDs are Integer (autoincrement) to match Alembic migration
50
+ _metadata = MetaData()
51
+ _pinned_dashboards_table = Table(
52
+ "prismiq_pinned_dashboards",
53
+ _metadata,
54
+ Column("id", Integer, primary_key=True, autoincrement=True, quote=True),
55
+ Column("tenant_id", String(255), nullable=False, quote=True),
56
+ Column("user_id", String(255), nullable=False, quote=True),
57
+ Column("dashboard_id", Integer, nullable=False, quote=True),
58
+ Column("context", String(100), nullable=False, quote=True),
59
+ Column("position", Integer, nullable=False, quote=True),
60
+ Column("pinned_at", TIMESTAMP(timezone=True), nullable=False, quote=True),
61
+ quote=True,
62
+ )
63
+
64
+
65
+ class PostgresDashboardStore:
66
+ """PostgreSQL-backed dashboard storage with tenant isolation.
67
+
68
+ All operations are scoped to a tenant_id for multi-tenant security.
69
+ Supports per-tenant PostgreSQL schema isolation via schema_name
70
+ parameter.
71
+ """
72
+
73
+ def __init__(self, pool: Pool) -> None:
74
+ """Initialize PostgresDashboardStore.
75
+
76
+ Args:
77
+ pool: asyncpg connection pool
78
+ """
79
+ self._pool = pool
80
+
81
+ async def _set_search_path(self, conn: Any, schema_name: str | None) -> None:
82
+ """Set PostgreSQL search_path for schema isolation.
83
+
84
+ Uses session-scoped set_config so the search_path persists across
85
+ statements on the same connection.
86
+
87
+ Args:
88
+ conn: asyncpg connection
89
+ schema_name: Schema name to use, or None for default (public)
90
+ """
91
+ if schema_name:
92
+ # Build search_path value with safely quoted schema identifier
93
+ # Double any embedded double-quotes to escape them in the identifier
94
+ escaped_schema = schema_name.replace('"', '""')
95
+ search_path_value = f'"{escaped_schema}", "public"'
96
+ _logger.debug("[postgres_store] Setting search_path to: %s", search_path_value)
97
+ await conn.fetchval("SELECT set_config('search_path', $1, false)", search_path_value)
98
+ else:
99
+ # Explicitly set to public when no schema_name provided
100
+ _logger.debug('[postgres_store] Setting search_path to: "public"')
101
+ await conn.fetchval("SELECT set_config('search_path', $1, false)", '"public"')
102
+
103
+ # -------------------------------------------------------------------------
104
+ # Dashboard Operations
105
+ # -------------------------------------------------------------------------
106
+
107
+ async def list_dashboards(
108
+ self,
109
+ tenant_id: str,
110
+ owner_id: str | None = None,
111
+ schema_name: str | None = None,
112
+ ) -> list[Dashboard]:
113
+ """List all dashboards for a tenant.
114
+
115
+ Args:
116
+ tenant_id: Tenant ID for isolation.
117
+ owner_id: Optional owner ID to filter by access.
118
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
119
+ """
120
+ query = """
121
+ SELECT
122
+ d.id,
123
+ d.tenant_id,
124
+ d.name,
125
+ d.description,
126
+ d.layout,
127
+ d.filters,
128
+ d.owner_id,
129
+ d.is_public,
130
+ d.allowed_viewers,
131
+ d.created_at,
132
+ d.updated_at,
133
+ COALESCE(
134
+ json_agg(
135
+ json_build_object(
136
+ 'id', w.id,
137
+ 'type', w.type,
138
+ 'title', w.title,
139
+ 'query', w.query,
140
+ 'position', w.position,
141
+ 'config', w.config,
142
+ 'created_at', w.created_at,
143
+ 'updated_at', w.updated_at
144
+ )
145
+ ORDER BY (w.position->>'y')::int, (w.position->>'x')::int
146
+ ) FILTER (WHERE w.id IS NOT NULL),
147
+ '[]'
148
+ ) as widgets
149
+ FROM prismiq_dashboards d
150
+ LEFT JOIN prismiq_widgets w ON w.dashboard_id = d.id
151
+ WHERE d.tenant_id = $1
152
+ """
153
+ params: list[Any] = [tenant_id]
154
+
155
+ if owner_id:
156
+ query += """
157
+ AND (
158
+ d.owner_id = $2
159
+ OR d.is_public = TRUE
160
+ OR $2 = ANY(d.allowed_viewers)
161
+ )
162
+ """
163
+ params.append(owner_id)
164
+
165
+ query += " GROUP BY d.id ORDER BY d.updated_at DESC"
166
+
167
+ async with self._pool.acquire() as conn:
168
+ await self._set_search_path(conn, schema_name)
169
+ # Debug: verify current search_path
170
+ current_path = await conn.fetchval("SHOW search_path")
171
+ _logger.info(f"[postgres_store] Current search_path: {current_path}")
172
+ rows = await conn.fetch(query, *params)
173
+ return [self._row_to_dashboard(row) for row in rows]
174
+
175
+ async def get_dashboard(
176
+ self,
177
+ dashboard_id: str,
178
+ tenant_id: str,
179
+ schema_name: str | None = None,
180
+ ) -> Dashboard | None:
181
+ """Get a dashboard by ID with tenant check.
182
+
183
+ Args:
184
+ dashboard_id: The dashboard ID.
185
+ tenant_id: Tenant ID for isolation.
186
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
187
+ """
188
+ query = """
189
+ SELECT
190
+ d.id,
191
+ d.tenant_id,
192
+ d.name,
193
+ d.description,
194
+ d.layout,
195
+ d.filters,
196
+ d.owner_id,
197
+ d.is_public,
198
+ d.allowed_viewers,
199
+ d.created_at,
200
+ d.updated_at,
201
+ COALESCE(
202
+ json_agg(
203
+ json_build_object(
204
+ 'id', w.id,
205
+ 'type', w.type,
206
+ 'title', w.title,
207
+ 'query', w.query,
208
+ 'position', w.position,
209
+ 'config', w.config,
210
+ 'created_at', w.created_at,
211
+ 'updated_at', w.updated_at
212
+ )
213
+ ORDER BY (w.position->>'y')::int, (w.position->>'x')::int
214
+ ) FILTER (WHERE w.id IS NOT NULL),
215
+ '[]'
216
+ ) as widgets
217
+ FROM prismiq_dashboards d
218
+ LEFT JOIN prismiq_widgets w ON w.dashboard_id = d.id
219
+ WHERE d.id = $1 AND d.tenant_id = $2
220
+ GROUP BY d.id
221
+ """
222
+ async with self._pool.acquire() as conn:
223
+ await self._set_search_path(conn, schema_name)
224
+ row = await conn.fetchrow(query, int(dashboard_id), tenant_id)
225
+ if not row:
226
+ return None
227
+ return self._row_to_dashboard(row)
228
+
229
+ async def create_dashboard(
230
+ self,
231
+ dashboard: DashboardCreate,
232
+ tenant_id: str,
233
+ owner_id: str | None = None,
234
+ schema_name: str | None = None,
235
+ ) -> Dashboard:
236
+ """Create a new dashboard.
237
+
238
+ Args:
239
+ dashboard: Dashboard data to create.
240
+ tenant_id: Tenant ID for isolation.
241
+ owner_id: Optional owner ID.
242
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
243
+ """
244
+ now = datetime.now(timezone.utc)
245
+ layout = dashboard.layout or DashboardLayout()
246
+
247
+ # Don't specify id - let PostgreSQL SERIAL auto-generate it
248
+ query = """
249
+ INSERT INTO prismiq_dashboards
250
+ (tenant_id, name, description, layout, filters, owner_id, is_public, allowed_viewers, created_at, updated_at)
251
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
252
+ RETURNING *
253
+ """
254
+ async with self._pool.acquire() as conn:
255
+ await self._set_search_path(conn, schema_name)
256
+ row = await conn.fetchrow(
257
+ query,
258
+ tenant_id,
259
+ dashboard.name,
260
+ dashboard.description,
261
+ json.dumps(layout.model_dump()),
262
+ json.dumps([]), # Empty filters initially
263
+ owner_id,
264
+ False, # is_public default
265
+ [], # allowed_viewers default
266
+ now,
267
+ now,
268
+ )
269
+ return self._row_to_dashboard(row, widgets=[])
270
+
271
+ async def update_dashboard(
272
+ self,
273
+ dashboard_id: str,
274
+ update: DashboardUpdate,
275
+ tenant_id: str,
276
+ schema_name: str | None = None,
277
+ ) -> Dashboard | None:
278
+ """Update a dashboard with tenant check.
279
+
280
+ Args:
281
+ dashboard_id: The dashboard ID to update.
282
+ update: Update data.
283
+ tenant_id: Tenant ID for isolation.
284
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
285
+ """
286
+ # Build dynamic UPDATE based on provided fields
287
+ updates: list[str] = []
288
+ params: list[Any] = []
289
+ param_num = 1
290
+
291
+ if update.name is not None:
292
+ updates.append(f"name = ${param_num}")
293
+ params.append(update.name)
294
+ param_num += 1
295
+
296
+ if update.description is not None:
297
+ updates.append(f"description = ${param_num}")
298
+ params.append(update.description)
299
+ param_num += 1
300
+
301
+ if update.layout is not None:
302
+ updates.append(f"layout = ${param_num}")
303
+ params.append(json.dumps(update.layout.model_dump()))
304
+ param_num += 1
305
+
306
+ if update.filters is not None:
307
+ updates.append(f"filters = ${param_num}")
308
+ params.append(json.dumps([f.model_dump() for f in update.filters]))
309
+ param_num += 1
310
+
311
+ if update.is_public is not None:
312
+ updates.append(f"is_public = ${param_num}")
313
+ params.append(update.is_public)
314
+ param_num += 1
315
+
316
+ if update.allowed_viewers is not None:
317
+ updates.append(f"allowed_viewers = ${param_num}")
318
+ params.append(update.allowed_viewers)
319
+ param_num += 1
320
+
321
+ async with self._pool.acquire() as conn:
322
+ await self._set_search_path(conn, schema_name)
323
+ # Handle widgets update if provided (replace all widgets)
324
+ if update.widgets is not None:
325
+ # Delete existing widgets
326
+ await conn.execute(
327
+ "DELETE FROM prismiq_widgets WHERE dashboard_id = $1",
328
+ int(dashboard_id),
329
+ )
330
+ # Insert new widgets (let autoincrement generate IDs)
331
+ for widget in update.widgets:
332
+ await conn.execute(
333
+ """
334
+ INSERT INTO "prismiq_widgets" (
335
+ "dashboard_id", "title", "type", "query", "config", "position",
336
+ "created_at", "updated_at"
337
+ ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
338
+ """,
339
+ int(dashboard_id),
340
+ widget.title,
341
+ widget.type.value,
342
+ json.dumps(widget.query.model_dump()) if widget.query else None,
343
+ json.dumps(widget.config.model_dump()) if widget.config else None,
344
+ json.dumps(widget.position.model_dump()) if widget.position else None,
345
+ )
346
+
347
+ if not updates:
348
+ # No dashboard metadata updates, just return current dashboard
349
+ return await self.get_dashboard(dashboard_id, tenant_id, schema_name)
350
+
351
+ # Add dashboard_id and tenant_id as final params
352
+ params.extend([int(dashboard_id), tenant_id])
353
+
354
+ # Column names in `updates` are hardcoded above, not user input
355
+ query = f"""
356
+ UPDATE prismiq_dashboards
357
+ SET {", ".join(updates)}
358
+ WHERE id = ${param_num} AND tenant_id = ${param_num + 1}
359
+ RETURNING *
360
+ """ # noqa: S608
361
+
362
+ row = await conn.fetchrow(query, *params)
363
+ if not row:
364
+ return None
365
+ # Fetch with widgets
366
+ return await self.get_dashboard(dashboard_id, tenant_id, schema_name)
367
+
368
+ async def delete_dashboard(
369
+ self,
370
+ dashboard_id: str,
371
+ tenant_id: str,
372
+ schema_name: str | None = None,
373
+ ) -> bool:
374
+ """Delete a dashboard with tenant check.
375
+
376
+ Widgets cascade delete.
377
+
378
+ Args:
379
+ dashboard_id: The dashboard ID to delete.
380
+ tenant_id: Tenant ID for isolation.
381
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
382
+ """
383
+ query = "DELETE FROM prismiq_dashboards WHERE id = $1 AND tenant_id = $2"
384
+ async with self._pool.acquire() as conn:
385
+ await self._set_search_path(conn, schema_name)
386
+ result = await conn.execute(query, int(dashboard_id), tenant_id)
387
+ return result == "DELETE 1"
388
+
389
+ # -------------------------------------------------------------------------
390
+ # Widget Operations
391
+ # -------------------------------------------------------------------------
392
+
393
+ async def add_widget(
394
+ self,
395
+ dashboard_id: str,
396
+ widget: WidgetCreate,
397
+ tenant_id: str,
398
+ schema_name: str | None = None,
399
+ ) -> Widget | None:
400
+ """Add a widget to a dashboard with tenant check.
401
+
402
+ Args:
403
+ dashboard_id: The dashboard ID to add to.
404
+ widget: Widget data to create.
405
+ tenant_id: Tenant ID for isolation.
406
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
407
+ """
408
+ # Verify dashboard belongs to tenant
409
+ dashboard = await self.get_dashboard(dashboard_id, tenant_id, schema_name)
410
+ if not dashboard:
411
+ return None
412
+
413
+ now = datetime.now(timezone.utc)
414
+
415
+ # Don't specify id - let PostgreSQL SERIAL auto-generate it
416
+ query = """
417
+ INSERT INTO prismiq_widgets
418
+ (dashboard_id, type, title, query, position, config, created_at, updated_at)
419
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
420
+ RETURNING *
421
+ """
422
+ async with self._pool.acquire() as conn:
423
+ await self._set_search_path(conn, schema_name)
424
+ row = await conn.fetchrow(
425
+ query,
426
+ int(dashboard_id),
427
+ widget.type.value,
428
+ widget.title,
429
+ json.dumps(widget.query.model_dump()) if widget.query else None,
430
+ json.dumps(widget.position.model_dump()),
431
+ json.dumps((widget.config or WidgetConfig()).model_dump()),
432
+ now,
433
+ now,
434
+ )
435
+ return self._row_to_widget(row)
436
+
437
+ async def get_widget(
438
+ self,
439
+ widget_id: str,
440
+ tenant_id: str,
441
+ schema_name: str | None = None,
442
+ ) -> Widget | None:
443
+ """Get a widget by ID with tenant check via dashboard.
444
+
445
+ Args:
446
+ widget_id: The widget ID.
447
+ tenant_id: Tenant ID for isolation.
448
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
449
+ """
450
+ query = """
451
+ SELECT w.*
452
+ FROM prismiq_widgets w
453
+ JOIN prismiq_dashboards d ON d.id = w.dashboard_id
454
+ WHERE w.id = $1 AND d.tenant_id = $2
455
+ """
456
+ async with self._pool.acquire() as conn:
457
+ await self._set_search_path(conn, schema_name)
458
+ row = await conn.fetchrow(query, int(widget_id), tenant_id)
459
+ if not row:
460
+ return None
461
+ return self._row_to_widget(row)
462
+
463
+ async def update_widget(
464
+ self,
465
+ widget_id: str,
466
+ update: WidgetUpdate,
467
+ tenant_id: str,
468
+ schema_name: str | None = None,
469
+ ) -> Widget | None:
470
+ """Update a widget with tenant check.
471
+
472
+ Args:
473
+ widget_id: The widget ID to update.
474
+ update: Update data.
475
+ tenant_id: Tenant ID for isolation.
476
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
477
+ """
478
+ # Build dynamic UPDATE
479
+ updates: list[str] = []
480
+ params: list[Any] = []
481
+ param_num = 1
482
+
483
+ if update.title is not None:
484
+ updates.append(f"title = ${param_num}")
485
+ params.append(update.title)
486
+ param_num += 1
487
+
488
+ if update.query is not None:
489
+ updates.append(f"query = ${param_num}")
490
+ params.append(json.dumps(update.query.model_dump()))
491
+ param_num += 1
492
+
493
+ if update.position is not None:
494
+ updates.append(f"position = ${param_num}")
495
+ params.append(json.dumps(update.position.model_dump()))
496
+ param_num += 1
497
+
498
+ if update.config is not None:
499
+ updates.append(f"config = ${param_num}")
500
+ params.append(json.dumps(update.config.model_dump()))
501
+ param_num += 1
502
+
503
+ if not updates:
504
+ return await self.get_widget(widget_id, tenant_id, schema_name)
505
+
506
+ params.extend([int(widget_id), tenant_id])
507
+
508
+ # Column names in `updates` are hardcoded above, not user input
509
+ query = f"""
510
+ UPDATE prismiq_widgets w
511
+ SET {", ".join(updates)}
512
+ FROM prismiq_dashboards d
513
+ WHERE w.dashboard_id = d.id
514
+ AND w.id = ${param_num}
515
+ AND d.tenant_id = ${param_num + 1}
516
+ RETURNING w.*
517
+ """ # noqa: S608
518
+
519
+ async with self._pool.acquire() as conn:
520
+ await self._set_search_path(conn, schema_name)
521
+ row = await conn.fetchrow(query, *params)
522
+ if not row:
523
+ return None
524
+ return self._row_to_widget(row)
525
+
526
+ async def delete_widget(
527
+ self,
528
+ widget_id: str,
529
+ tenant_id: str,
530
+ schema_name: str | None = None,
531
+ ) -> bool:
532
+ """Delete a widget with tenant check.
533
+
534
+ Args:
535
+ widget_id: The widget ID to delete.
536
+ tenant_id: Tenant ID for isolation.
537
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
538
+ """
539
+ query = """
540
+ DELETE FROM prismiq_widgets w
541
+ USING prismiq_dashboards d
542
+ WHERE w.dashboard_id = d.id
543
+ AND w.id = $1
544
+ AND d.tenant_id = $2
545
+ """
546
+ async with self._pool.acquire() as conn:
547
+ await self._set_search_path(conn, schema_name)
548
+ result = await conn.execute(query, int(widget_id), tenant_id)
549
+ return result == "DELETE 1"
550
+
551
+ async def duplicate_widget(
552
+ self,
553
+ widget_id: str,
554
+ tenant_id: str,
555
+ schema_name: str | None = None,
556
+ ) -> Widget | None:
557
+ """Duplicate a widget with tenant check.
558
+
559
+ Args:
560
+ widget_id: The widget ID to duplicate.
561
+ tenant_id: Tenant ID for isolation.
562
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
563
+ """
564
+ # Get the original widget
565
+ original = await self.get_widget(widget_id, tenant_id, schema_name)
566
+ if not original:
567
+ return None
568
+
569
+ # Get the dashboard_id from the original widget
570
+ query = """
571
+ SELECT dashboard_id FROM prismiq_widgets WHERE id = $1
572
+ """
573
+ async with self._pool.acquire() as conn:
574
+ await self._set_search_path(conn, schema_name)
575
+ row = await conn.fetchrow(query, int(widget_id))
576
+ if not row:
577
+ return None
578
+ dashboard_id = row["dashboard_id"] # Keep as int
579
+
580
+ # Create a new widget with copied data
581
+ now = datetime.now(timezone.utc)
582
+
583
+ # Offset position slightly
584
+ new_position = WidgetPosition(
585
+ x=original.position.x + 1,
586
+ y=original.position.y,
587
+ w=original.position.w,
588
+ h=original.position.h,
589
+ )
590
+
591
+ # Don't specify id - let PostgreSQL SERIAL auto-generate it
592
+ insert_query = """
593
+ INSERT INTO prismiq_widgets
594
+ (dashboard_id, type, title, query, position, config, created_at, updated_at)
595
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
596
+ RETURNING *
597
+ """
598
+ async with self._pool.acquire() as conn:
599
+ await self._set_search_path(conn, schema_name)
600
+ row = await conn.fetchrow(
601
+ insert_query,
602
+ int(dashboard_id),
603
+ original.type.value,
604
+ f"{original.title} (Copy)",
605
+ json.dumps(original.query.model_dump()) if original.query else None,
606
+ json.dumps(new_position.model_dump()),
607
+ json.dumps(original.config.model_dump()),
608
+ now,
609
+ now,
610
+ )
611
+ return self._row_to_widget(row)
612
+
613
+ async def update_widget_positions(
614
+ self,
615
+ dashboard_id: str,
616
+ positions: list[dict[str, Any]],
617
+ tenant_id: str,
618
+ schema_name: str | None = None,
619
+ ) -> bool:
620
+ """Batch update widget positions with tenant check.
621
+
622
+ Args:
623
+ dashboard_id: The dashboard ID.
624
+ positions: List of position updates.
625
+ tenant_id: Tenant ID for isolation.
626
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
627
+ """
628
+ # Verify dashboard belongs to tenant
629
+ dashboard = await self.get_dashboard(dashboard_id, tenant_id, schema_name)
630
+ if not dashboard:
631
+ return False
632
+
633
+ async with self._pool.acquire() as conn, conn.transaction():
634
+ await self._set_search_path(conn, schema_name)
635
+ for pos in positions:
636
+ widget_id = pos.get("widget_id") or pos.get("id")
637
+ if widget_id is None:
638
+ continue
639
+ position = pos.get("position", pos)
640
+ await conn.execute(
641
+ """
642
+ UPDATE prismiq_widgets
643
+ SET position = $1
644
+ WHERE id = $2 AND dashboard_id = $3
645
+ """,
646
+ json.dumps(
647
+ {
648
+ "x": position.get("x", 0),
649
+ "y": position.get("y", 0),
650
+ "w": position.get("w", 4),
651
+ "h": position.get("h", 3),
652
+ }
653
+ ),
654
+ int(widget_id),
655
+ int(dashboard_id),
656
+ )
657
+ return True
658
+
659
+ # -------------------------------------------------------------------------
660
+ # Helper Methods
661
+ # -------------------------------------------------------------------------
662
+
663
+ def _row_to_dashboard(
664
+ self,
665
+ row: Any,
666
+ widgets: list[Widget] | None = None,
667
+ ) -> Dashboard:
668
+ """Convert a database row to a Dashboard model."""
669
+ # Parse widgets from JSON if present
670
+ if widgets is None:
671
+ widgets_data = row.get("widgets", [])
672
+ if isinstance(widgets_data, str):
673
+ widgets_data = json.loads(widgets_data)
674
+ widgets = [self._dict_to_widget(w) for w in widgets_data if w]
675
+
676
+ # Parse layout
677
+ layout_data = row["layout"]
678
+ if isinstance(layout_data, str):
679
+ layout_data = json.loads(layout_data)
680
+
681
+ # Parse filters
682
+ filters_data = row["filters"]
683
+ if isinstance(filters_data, str):
684
+ filters_data = json.loads(filters_data)
685
+
686
+ return Dashboard(
687
+ id=str(row["id"]),
688
+ name=row["name"],
689
+ description=row.get("description"),
690
+ layout=DashboardLayout(**layout_data),
691
+ filters=[DashboardFilter(**f) for f in filters_data],
692
+ widgets=widgets,
693
+ owner_id=row.get("owner_id"),
694
+ is_public=row.get("is_public", False),
695
+ allowed_viewers=list(row.get("allowed_viewers", [])),
696
+ created_at=row.get("created_at"),
697
+ updated_at=row.get("updated_at"),
698
+ )
699
+
700
+ def _row_to_widget(self, row: Any) -> Widget:
701
+ """Convert a database row to a Widget model."""
702
+ position_data = row["position"]
703
+ if isinstance(position_data, str):
704
+ position_data = json.loads(position_data)
705
+
706
+ query_data = row.get("query")
707
+ if isinstance(query_data, str):
708
+ query_data = json.loads(query_data)
709
+
710
+ config_data = row.get("config", {})
711
+ if isinstance(config_data, str):
712
+ config_data = json.loads(config_data)
713
+
714
+ return Widget(
715
+ id=str(row["id"]),
716
+ type=WidgetType(row["type"]),
717
+ title=row["title"],
718
+ query=QueryDefinition(**query_data) if query_data else None,
719
+ position=WidgetPosition(**position_data),
720
+ config=WidgetConfig(**config_data) if config_data else WidgetConfig(),
721
+ created_at=row.get("created_at"),
722
+ updated_at=row.get("updated_at"),
723
+ )
724
+
725
+ def _dict_to_widget(self, data: dict[str, Any]) -> Widget:
726
+ """Convert a dictionary to a Widget model."""
727
+ query_data = data.get("query")
728
+ config_data = data.get("config", {})
729
+ now = datetime.now(timezone.utc)
730
+
731
+ return Widget(
732
+ id=str(data["id"]),
733
+ type=WidgetType(data["type"]),
734
+ title=data["title"],
735
+ query=QueryDefinition(**query_data) if query_data else None,
736
+ position=WidgetPosition(**data["position"]),
737
+ config=WidgetConfig(**config_data) if config_data else WidgetConfig(),
738
+ created_at=data.get("created_at") or now,
739
+ updated_at=data.get("updated_at") or now,
740
+ )
741
+
742
+ # -------------------------------------------------------------------------
743
+ # Pin Operations
744
+ # -------------------------------------------------------------------------
745
+
746
+ async def pin_dashboard(
747
+ self,
748
+ dashboard_id: str,
749
+ context: str,
750
+ tenant_id: str,
751
+ user_id: str,
752
+ position: int | None = None,
753
+ schema_name: str | None = None,
754
+ ) -> PinnedDashboard:
755
+ """Pin a dashboard to a context.
756
+
757
+ Args:
758
+ dashboard_id: The dashboard ID to pin.
759
+ context: The context to pin to.
760
+ tenant_id: Tenant ID for isolation.
761
+ user_id: User ID who is pinning.
762
+ position: Optional position. If None, appends at end.
763
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
764
+
765
+ Returns:
766
+ The created PinnedDashboard entry.
767
+
768
+ Raises:
769
+ ValueError: If dashboard not found or already pinned.
770
+ """
771
+ # Verify dashboard exists and belongs to tenant
772
+ dashboard = await self.get_dashboard(dashboard_id, tenant_id, schema_name)
773
+ if not dashboard:
774
+ raise ValueError(f"Dashboard '{dashboard_id}' not found")
775
+
776
+ t = _pinned_dashboards_table
777
+
778
+ async with self._pool.acquire() as conn:
779
+ await self._set_search_path(conn, schema_name)
780
+ # Determine position if not provided using SQLAlchemy Core
781
+ if position is None:
782
+ max_pos_query = select(func.coalesce(func.max(t.c.position) + 1, 0)).where(
783
+ t.c.tenant_id == tenant_id,
784
+ t.c.user_id == user_id,
785
+ t.c.context == context,
786
+ )
787
+ sql, params = self._compile_query(max_pos_query)
788
+ result = await conn.fetchval(sql, *params)
789
+ position = int(result)
790
+
791
+ now = datetime.now(timezone.utc)
792
+
793
+ # Build INSERT using SQLAlchemy Core (let autoincrement generate id)
794
+ insert_stmt = (
795
+ insert(t)
796
+ .values(
797
+ tenant_id=tenant_id,
798
+ user_id=user_id,
799
+ dashboard_id=int(dashboard_id),
800
+ context=context,
801
+ position=position,
802
+ pinned_at=now,
803
+ )
804
+ .returning(*t.c)
805
+ )
806
+ insert_sql, insert_params = self._compile_query(insert_stmt)
807
+
808
+ try:
809
+ row = await conn.fetchrow(insert_sql, *insert_params)
810
+ except Exception as e:
811
+ # Unique constraint violation means already pinned
812
+ if "unique_pin_per_context" in str(e):
813
+ raise ValueError(
814
+ f"Dashboard '{dashboard_id}' already pinned to context '{context}'"
815
+ ) from e
816
+ raise
817
+
818
+ return self._row_to_pinned_dashboard(row)
819
+
820
+ async def unpin_dashboard(
821
+ self,
822
+ dashboard_id: str,
823
+ context: str,
824
+ tenant_id: str,
825
+ user_id: str,
826
+ schema_name: str | None = None,
827
+ ) -> bool:
828
+ """Unpin a dashboard from a context.
829
+
830
+ Args:
831
+ dashboard_id: The dashboard ID to unpin.
832
+ context: The context to unpin from.
833
+ tenant_id: Tenant ID for isolation.
834
+ user_id: User ID who owns the pin.
835
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
836
+
837
+ Returns:
838
+ True if unpinned, False if not found.
839
+ """
840
+ t = _pinned_dashboards_table
841
+ stmt = delete(t).where(
842
+ t.c.tenant_id == tenant_id,
843
+ t.c.user_id == user_id,
844
+ t.c.dashboard_id == int(dashboard_id),
845
+ t.c.context == context,
846
+ )
847
+ sql, params = self._compile_query(stmt)
848
+
849
+ async with self._pool.acquire() as conn:
850
+ await self._set_search_path(conn, schema_name)
851
+ result = await conn.execute(sql, *params)
852
+ return result == "DELETE 1"
853
+
854
+ async def get_pinned_dashboards(
855
+ self,
856
+ context: str,
857
+ tenant_id: str,
858
+ user_id: str,
859
+ schema_name: str | None = None,
860
+ ) -> list[Dashboard]:
861
+ """Get all dashboards pinned to a context.
862
+
863
+ Args:
864
+ context: The context to get pins for.
865
+ tenant_id: Tenant ID for isolation.
866
+ user_id: User ID who owns the pins.
867
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
868
+
869
+ Returns:
870
+ List of Dashboard objects, ordered by position.
871
+ """
872
+ # Get pinned dashboard IDs in order
873
+ t = _pinned_dashboards_table
874
+ stmt = (
875
+ select(t.c.dashboard_id)
876
+ .where(
877
+ t.c.tenant_id == tenant_id,
878
+ t.c.user_id == user_id,
879
+ t.c.context == context,
880
+ )
881
+ .order_by(t.c.position)
882
+ )
883
+ sql, params = self._compile_query(stmt)
884
+
885
+ async with self._pool.acquire() as conn:
886
+ await self._set_search_path(conn, schema_name)
887
+ rows = await conn.fetch(sql, *params)
888
+
889
+ # Fetch each dashboard
890
+ dashboards: list[Dashboard] = []
891
+ for row in rows:
892
+ dashboard = await self.get_dashboard(str(row["dashboard_id"]), tenant_id, schema_name)
893
+ if dashboard:
894
+ dashboards.append(dashboard)
895
+
896
+ return dashboards
897
+
898
+ async def get_pin_contexts_for_dashboard(
899
+ self,
900
+ dashboard_id: str,
901
+ tenant_id: str,
902
+ user_id: str,
903
+ schema_name: str | None = None,
904
+ ) -> list[str]:
905
+ """Get all contexts where a dashboard is pinned.
906
+
907
+ Args:
908
+ dashboard_id: The dashboard ID.
909
+ tenant_id: Tenant ID for isolation.
910
+ user_id: User ID who owns the pins.
911
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
912
+
913
+ Returns:
914
+ List of context names.
915
+ """
916
+ t = _pinned_dashboards_table
917
+ stmt = (
918
+ select(t.c.context)
919
+ .where(
920
+ t.c.tenant_id == tenant_id,
921
+ t.c.user_id == user_id,
922
+ t.c.dashboard_id == int(dashboard_id),
923
+ )
924
+ .order_by(t.c.context)
925
+ )
926
+ sql, params = self._compile_query(stmt)
927
+
928
+ async with self._pool.acquire() as conn:
929
+ await self._set_search_path(conn, schema_name)
930
+ rows = await conn.fetch(sql, *params)
931
+ return [row["context"] for row in rows]
932
+
933
+ async def reorder_pins(
934
+ self,
935
+ context: str,
936
+ dashboard_ids: list[str],
937
+ tenant_id: str,
938
+ user_id: str,
939
+ schema_name: str | None = None,
940
+ ) -> bool:
941
+ """Reorder pinned dashboards in a context.
942
+
943
+ Pins specified in dashboard_ids get positions 0..N-1 in that order.
944
+ Any remaining pins not in dashboard_ids retain their relative order
945
+ and get positions starting at N.
946
+
947
+ Args:
948
+ context: The context to reorder.
949
+ dashboard_ids: Ordered list of dashboard IDs for the new order.
950
+ tenant_id: Tenant ID for isolation.
951
+ user_id: User ID who owns the pins.
952
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
953
+
954
+ Returns:
955
+ True if reordered, False otherwise.
956
+ """
957
+ t = _pinned_dashboards_table
958
+
959
+ # Convert provided IDs to UUIDs
960
+ provided_ids = [int(d_id) for d_id in dashboard_ids]
961
+
962
+ async with self._pool.acquire() as conn, conn.transaction():
963
+ await self._set_search_path(conn, schema_name)
964
+ # First, get any remaining pins not in dashboard_ids, ordered by current position
965
+ if provided_ids:
966
+ remaining_stmt = (
967
+ select(t.c.dashboard_id)
968
+ .where(
969
+ t.c.tenant_id == tenant_id,
970
+ t.c.user_id == user_id,
971
+ t.c.context == context,
972
+ not_(t.c.dashboard_id.in_(provided_ids)),
973
+ )
974
+ .order_by(t.c.position)
975
+ )
976
+ else:
977
+ # No provided IDs, get all pins ordered by position
978
+ remaining_stmt = (
979
+ select(t.c.dashboard_id)
980
+ .where(
981
+ t.c.tenant_id == tenant_id,
982
+ t.c.user_id == user_id,
983
+ t.c.context == context,
984
+ )
985
+ .order_by(t.c.position)
986
+ )
987
+
988
+ remaining_sql, remaining_params = self._compile_query(remaining_stmt)
989
+ remaining_rows = await conn.fetch(remaining_sql, *remaining_params)
990
+ remaining_uuids = [row["dashboard_id"] for row in remaining_rows]
991
+
992
+ # Build combined list: provided IDs first, then remaining IDs
993
+ all_uuids = provided_ids + remaining_uuids
994
+
995
+ # Update positions for all pins
996
+ for i, d_uuid in enumerate(all_uuids):
997
+ stmt = (
998
+ update(t)
999
+ .where(
1000
+ t.c.tenant_id == tenant_id,
1001
+ t.c.user_id == user_id,
1002
+ t.c.context == context,
1003
+ t.c.dashboard_id == d_uuid,
1004
+ )
1005
+ .values(position=i)
1006
+ )
1007
+ sql, params = self._compile_query(stmt)
1008
+ await conn.execute(sql, *params)
1009
+
1010
+ return True
1011
+
1012
+ async def is_dashboard_pinned(
1013
+ self,
1014
+ dashboard_id: str,
1015
+ context: str,
1016
+ tenant_id: str,
1017
+ user_id: str,
1018
+ schema_name: str | None = None,
1019
+ ) -> bool:
1020
+ """Check if a dashboard is pinned to a context.
1021
+
1022
+ Args:
1023
+ dashboard_id: The dashboard ID.
1024
+ context: The context to check.
1025
+ tenant_id: Tenant ID for isolation.
1026
+ user_id: User ID who owns the pins.
1027
+ schema_name: PostgreSQL schema name for per-tenant schema isolation.
1028
+
1029
+ Returns:
1030
+ True if pinned, False otherwise.
1031
+ """
1032
+ t = _pinned_dashboards_table
1033
+ subquery = select(t.c.id).where(
1034
+ t.c.tenant_id == tenant_id,
1035
+ t.c.user_id == user_id,
1036
+ t.c.context == context,
1037
+ t.c.dashboard_id == int(dashboard_id),
1038
+ )
1039
+ stmt = select(exists(subquery))
1040
+ sql, params = self._compile_query(stmt)
1041
+
1042
+ async with self._pool.acquire() as conn:
1043
+ await self._set_search_path(conn, schema_name)
1044
+ result = await conn.fetchval(sql, *params)
1045
+ return bool(result)
1046
+
1047
+ async def get_pins_for_context(
1048
+ self,
1049
+ context: str,
1050
+ tenant_id: str,
1051
+ user_id: str,
1052
+ schema_name: str | None = None,
1053
+ ) -> list[PinnedDashboard]:
1054
+ """Get pin entries for a context (for API responses).
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 for per-tenant schema isolation.
1061
+
1062
+ Returns:
1063
+ List of PinnedDashboard entries, ordered by position.
1064
+ """
1065
+ t = _pinned_dashboards_table
1066
+ stmt = (
1067
+ select(t)
1068
+ .where(
1069
+ t.c.tenant_id == tenant_id,
1070
+ t.c.user_id == user_id,
1071
+ t.c.context == context,
1072
+ )
1073
+ .order_by(t.c.position)
1074
+ )
1075
+ sql, params = self._compile_query(stmt)
1076
+
1077
+ async with self._pool.acquire() as conn:
1078
+ await self._set_search_path(conn, schema_name)
1079
+ rows = await conn.fetch(sql, *params)
1080
+ return [self._row_to_pinned_dashboard(row) for row in rows]
1081
+
1082
+ def _row_to_pinned_dashboard(self, row: Any) -> PinnedDashboard:
1083
+ """Convert a database row to a PinnedDashboard model."""
1084
+ return PinnedDashboard(
1085
+ id=str(row["id"]),
1086
+ dashboard_id=str(row["dashboard_id"]),
1087
+ context=row["context"],
1088
+ position=row["position"],
1089
+ pinned_at=row["pinned_at"],
1090
+ )
1091
+
1092
+ @staticmethod
1093
+ def _compile_query(stmt: Any) -> tuple[str, list[Any]]:
1094
+ """Compile a SQLAlchemy statement for asyncpg execution.
1095
+
1096
+ Converts SQLAlchemy Core statements to SQL strings with positional
1097
+ parameters ($1, $2, etc.) compatible with asyncpg.
1098
+
1099
+ Args:
1100
+ stmt: SQLAlchemy Core statement (select, insert, etc.)
1101
+
1102
+ Returns:
1103
+ Tuple of (sql_string, list_of_parameters)
1104
+ """
1105
+ from sqlalchemy.dialects import postgresql
1106
+
1107
+ dialect = postgresql.dialect(paramstyle="numeric")
1108
+ compiled = stmt.compile(dialect=dialect, compile_kwargs={"literal_binds": False})
1109
+ sql = str(compiled)
1110
+
1111
+ # Extract parameters in the order they appear in the SQL
1112
+ # The compiled.positiontup gives param names in order for positional dialects
1113
+ if hasattr(compiled, "positiontup") and compiled.positiontup:
1114
+ params = [compiled.params[name] for name in compiled.positiontup]
1115
+ else:
1116
+ # Fallback: params dict should be ordered in Python 3.7+
1117
+ params = list(compiled.params.values())
1118
+
1119
+ return sql, params