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,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
|