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
prismiq/middleware.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""HTTP middleware for Prismiq API.
|
|
2
|
+
|
|
3
|
+
This module provides middleware components for rate limiting, request
|
|
4
|
+
tracking, and other cross-cutting concerns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.responses import JSONResponse
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from starlette.requests import Request
|
|
21
|
+
from starlette.responses import Response
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimitConfig(BaseModel):
|
|
25
|
+
"""Configuration for rate limiting."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(strict=True)
|
|
28
|
+
|
|
29
|
+
requests_per_minute: int = 60
|
|
30
|
+
"""Maximum requests per minute per client."""
|
|
31
|
+
|
|
32
|
+
requests_per_hour: int = 1000
|
|
33
|
+
"""Maximum requests per hour per client."""
|
|
34
|
+
|
|
35
|
+
burst_size: int = 10
|
|
36
|
+
"""Maximum burst size (requests allowed in quick succession)."""
|
|
37
|
+
|
|
38
|
+
window_size_seconds: int = 60
|
|
39
|
+
"""Sliding window size in seconds for rate tracking."""
|
|
40
|
+
|
|
41
|
+
enabled: bool = True
|
|
42
|
+
"""Whether rate limiting is enabled."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TokenBucket:
|
|
47
|
+
"""Token bucket for rate limiting.
|
|
48
|
+
|
|
49
|
+
Implements a token bucket algorithm where tokens are added at a
|
|
50
|
+
fixed rate and consumed on each request. Allows for controlled
|
|
51
|
+
bursting.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
capacity: float
|
|
55
|
+
"""Maximum number of tokens in the bucket."""
|
|
56
|
+
|
|
57
|
+
refill_rate: float
|
|
58
|
+
"""Tokens added per second."""
|
|
59
|
+
|
|
60
|
+
tokens: float = field(default=0.0)
|
|
61
|
+
"""Current token count."""
|
|
62
|
+
|
|
63
|
+
last_update: float = field(default_factory=time.time)
|
|
64
|
+
"""Timestamp of last token update."""
|
|
65
|
+
|
|
66
|
+
def consume(self, tokens: int = 1) -> bool:
|
|
67
|
+
"""Attempt to consume tokens from the bucket.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
tokens: Number of tokens to consume.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if tokens were consumed, False if rate limited.
|
|
74
|
+
"""
|
|
75
|
+
self._refill()
|
|
76
|
+
|
|
77
|
+
if self.tokens >= tokens:
|
|
78
|
+
self.tokens -= tokens
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def _refill(self) -> None:
|
|
84
|
+
"""Refill tokens based on elapsed time."""
|
|
85
|
+
now = time.time()
|
|
86
|
+
elapsed = now - self.last_update
|
|
87
|
+
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
|
|
88
|
+
self.last_update = now
|
|
89
|
+
|
|
90
|
+
def time_until_available(self, tokens: int = 1) -> float:
|
|
91
|
+
"""Calculate time until the requested tokens are available.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
tokens: Number of tokens needed.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Time in seconds until tokens are available (0 if available now).
|
|
98
|
+
"""
|
|
99
|
+
self._refill()
|
|
100
|
+
|
|
101
|
+
if self.tokens >= tokens:
|
|
102
|
+
return 0.0
|
|
103
|
+
|
|
104
|
+
needed = tokens - self.tokens
|
|
105
|
+
return needed / self.refill_rate
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class SlidingWindowCounter:
|
|
110
|
+
"""Sliding window counter for rate limiting.
|
|
111
|
+
|
|
112
|
+
Tracks request counts in a sliding time window for more accurate
|
|
113
|
+
rate limiting than fixed windows.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
window_size: float
|
|
117
|
+
"""Window size in seconds."""
|
|
118
|
+
|
|
119
|
+
max_requests: int
|
|
120
|
+
"""Maximum requests allowed in the window."""
|
|
121
|
+
|
|
122
|
+
requests: list[float] = field(default_factory=list)
|
|
123
|
+
"""List of request timestamps."""
|
|
124
|
+
|
|
125
|
+
def record(self) -> bool:
|
|
126
|
+
"""Record a request and check if rate limited.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if request is allowed, False if rate limited.
|
|
130
|
+
"""
|
|
131
|
+
now = time.time()
|
|
132
|
+
self._cleanup(now)
|
|
133
|
+
|
|
134
|
+
if len(self.requests) >= self.max_requests:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
self.requests.append(now)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def _cleanup(self, now: float) -> None:
|
|
141
|
+
"""Remove requests outside the current window."""
|
|
142
|
+
cutoff = now - self.window_size
|
|
143
|
+
self.requests = [t for t in self.requests if t > cutoff]
|
|
144
|
+
|
|
145
|
+
def remaining(self) -> int:
|
|
146
|
+
"""Get remaining requests in current window."""
|
|
147
|
+
self._cleanup(time.time())
|
|
148
|
+
return max(0, self.max_requests - len(self.requests))
|
|
149
|
+
|
|
150
|
+
def reset_time(self) -> float:
|
|
151
|
+
"""Get time until oldest request expires from window."""
|
|
152
|
+
if not self.requests:
|
|
153
|
+
return 0.0
|
|
154
|
+
|
|
155
|
+
self._cleanup(time.time())
|
|
156
|
+
if not self.requests:
|
|
157
|
+
return 0.0
|
|
158
|
+
|
|
159
|
+
oldest = min(self.requests)
|
|
160
|
+
return max(0.0, oldest + self.window_size - time.time())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RateLimiter:
|
|
164
|
+
"""Rate limiter that combines token bucket and sliding window algorithms.
|
|
165
|
+
|
|
166
|
+
Uses token bucket for burst control and sliding window for sustained
|
|
167
|
+
rate limiting.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, config: RateLimitConfig | None = None) -> None:
|
|
171
|
+
"""Initialize rate limiter.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
config: Rate limit configuration.
|
|
175
|
+
"""
|
|
176
|
+
self._config = config or RateLimitConfig()
|
|
177
|
+
# Token buckets per client (for burst control)
|
|
178
|
+
self._buckets: dict[str, TokenBucket] = {}
|
|
179
|
+
# Sliding windows per client (for sustained rate)
|
|
180
|
+
self._windows: dict[str, SlidingWindowCounter] = {}
|
|
181
|
+
# Lock for thread-safe access
|
|
182
|
+
self._lock = asyncio.Lock()
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def config(self) -> RateLimitConfig:
|
|
186
|
+
"""Get rate limit configuration."""
|
|
187
|
+
return self._config
|
|
188
|
+
|
|
189
|
+
async def is_allowed(self, client_id: str) -> tuple[bool, dict[str, Any]]:
|
|
190
|
+
"""Check if a request from the client is allowed.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
client_id: Unique identifier for the client.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Tuple of (is_allowed, rate_limit_info).
|
|
197
|
+
"""
|
|
198
|
+
if not self._config.enabled:
|
|
199
|
+
return True, {"enabled": False}
|
|
200
|
+
|
|
201
|
+
async with self._lock:
|
|
202
|
+
# Get or create bucket for this client
|
|
203
|
+
if client_id not in self._buckets:
|
|
204
|
+
self._buckets[client_id] = TokenBucket(
|
|
205
|
+
capacity=float(self._config.burst_size),
|
|
206
|
+
refill_rate=self._config.requests_per_minute / 60.0,
|
|
207
|
+
tokens=float(self._config.burst_size),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Get or create sliding window for this client
|
|
211
|
+
if client_id not in self._windows:
|
|
212
|
+
self._windows[client_id] = SlidingWindowCounter(
|
|
213
|
+
window_size=float(self._config.window_size_seconds),
|
|
214
|
+
max_requests=self._config.requests_per_minute,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
bucket = self._buckets[client_id]
|
|
218
|
+
window = self._windows[client_id]
|
|
219
|
+
|
|
220
|
+
# Check both token bucket and sliding window
|
|
221
|
+
bucket_allowed = bucket.consume()
|
|
222
|
+
window_allowed = window.record() if bucket_allowed else False
|
|
223
|
+
|
|
224
|
+
info = {
|
|
225
|
+
"limit": self._config.requests_per_minute,
|
|
226
|
+
"remaining": window.remaining(),
|
|
227
|
+
"reset": window.reset_time(),
|
|
228
|
+
"retry_after": bucket.time_until_available() if not bucket_allowed else 0,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return bucket_allowed and window_allowed, info
|
|
232
|
+
|
|
233
|
+
async def reset(self, client_id: str) -> None:
|
|
234
|
+
"""Reset rate limits for a client.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
client_id: Client to reset.
|
|
238
|
+
"""
|
|
239
|
+
async with self._lock:
|
|
240
|
+
self._buckets.pop(client_id, None)
|
|
241
|
+
self._windows.pop(client_id, None)
|
|
242
|
+
|
|
243
|
+
async def reset_all(self) -> None:
|
|
244
|
+
"""Reset all rate limits."""
|
|
245
|
+
async with self._lock:
|
|
246
|
+
self._buckets.clear()
|
|
247
|
+
self._windows.clear()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
251
|
+
"""FastAPI/Starlette middleware for rate limiting.
|
|
252
|
+
|
|
253
|
+
Applies rate limiting based on client IP address or API key.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
app: Any,
|
|
259
|
+
rate_limiter: RateLimiter | None = None,
|
|
260
|
+
key_func: Callable[[Request], str] | None = None,
|
|
261
|
+
exclude_paths: list[str] | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Initialize rate limit middleware.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
app: ASGI application.
|
|
267
|
+
rate_limiter: Rate limiter instance. Creates default if not provided.
|
|
268
|
+
key_func: Function to extract client key from request. Defaults to IP.
|
|
269
|
+
exclude_paths: Paths to exclude from rate limiting.
|
|
270
|
+
"""
|
|
271
|
+
super().__init__(app)
|
|
272
|
+
self._limiter = rate_limiter or RateLimiter()
|
|
273
|
+
self._key_func = key_func or self._default_key_func
|
|
274
|
+
self._exclude_paths = set(exclude_paths or [])
|
|
275
|
+
|
|
276
|
+
def _default_key_func(self, request: Request) -> str:
|
|
277
|
+
"""Extract client key from request (default: IP address)."""
|
|
278
|
+
# Check for X-Forwarded-For header (proxy/load balancer)
|
|
279
|
+
forwarded = request.headers.get("x-forwarded-for")
|
|
280
|
+
if forwarded:
|
|
281
|
+
# Get first IP in the chain
|
|
282
|
+
return forwarded.split(",")[0].strip()
|
|
283
|
+
|
|
284
|
+
# Fall back to direct client IP
|
|
285
|
+
if request.client:
|
|
286
|
+
return request.client.host
|
|
287
|
+
|
|
288
|
+
return "unknown"
|
|
289
|
+
|
|
290
|
+
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
291
|
+
"""Process the request through rate limiting."""
|
|
292
|
+
# Skip rate limiting for excluded paths
|
|
293
|
+
if request.url.path in self._exclude_paths:
|
|
294
|
+
return await call_next(request)
|
|
295
|
+
|
|
296
|
+
client_id = self._key_func(request)
|
|
297
|
+
allowed, info = await self._limiter.is_allowed(client_id)
|
|
298
|
+
|
|
299
|
+
if not allowed:
|
|
300
|
+
return JSONResponse(
|
|
301
|
+
status_code=429,
|
|
302
|
+
content={
|
|
303
|
+
"error": "Too Many Requests",
|
|
304
|
+
"message": "Rate limit exceeded. Please try again later.",
|
|
305
|
+
"retry_after": info.get("retry_after", 60),
|
|
306
|
+
},
|
|
307
|
+
headers={
|
|
308
|
+
"X-RateLimit-Limit": str(info.get("limit", 0)),
|
|
309
|
+
"X-RateLimit-Remaining": str(info.get("remaining", 0)),
|
|
310
|
+
"X-RateLimit-Reset": str(int(info.get("reset", 0))),
|
|
311
|
+
"Retry-After": str(int(info.get("retry_after", 60))),
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Process request
|
|
316
|
+
response = await call_next(request)
|
|
317
|
+
|
|
318
|
+
# Add rate limit headers to response
|
|
319
|
+
response.headers["X-RateLimit-Limit"] = str(info.get("limit", 0))
|
|
320
|
+
response.headers["X-RateLimit-Remaining"] = str(info.get("remaining", 0))
|
|
321
|
+
response.headers["X-RateLimit-Reset"] = str(int(info.get("reset", 0)))
|
|
322
|
+
|
|
323
|
+
return response
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def create_rate_limiter(
|
|
327
|
+
requests_per_minute: int = 60,
|
|
328
|
+
burst_size: int = 10,
|
|
329
|
+
enabled: bool = True,
|
|
330
|
+
) -> RateLimiter:
|
|
331
|
+
"""Create a rate limiter with common defaults.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
requests_per_minute: Maximum requests per minute.
|
|
335
|
+
burst_size: Maximum burst size.
|
|
336
|
+
enabled: Whether rate limiting is enabled.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Configured RateLimiter instance.
|
|
340
|
+
"""
|
|
341
|
+
config = RateLimitConfig(
|
|
342
|
+
requests_per_minute=requests_per_minute,
|
|
343
|
+
burst_size=burst_size,
|
|
344
|
+
enabled=enabled,
|
|
345
|
+
)
|
|
346
|
+
return RateLimiter(config)
|
prismiq/permissions.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Permission checking functions for dashboards and widgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from prismiq.dashboards import Dashboard
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def can_view_dashboard(dashboard: Dashboard, user_id: str | None) -> bool:
|
|
9
|
+
"""Check if a user can view a dashboard.
|
|
10
|
+
|
|
11
|
+
A user can view a dashboard if:
|
|
12
|
+
1. The dashboard is public (is_public=True)
|
|
13
|
+
2. The user is the owner (owner_id matches)
|
|
14
|
+
3. The user is in allowed_viewers list
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
dashboard: The dashboard to check
|
|
18
|
+
user_id: The user attempting to view (None for anonymous)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
True if the user can view the dashboard
|
|
22
|
+
"""
|
|
23
|
+
# Public dashboards are viewable by anyone
|
|
24
|
+
if dashboard.is_public:
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
# Anonymous users can only view public dashboards
|
|
28
|
+
if user_id is None:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
# Owner can always view
|
|
32
|
+
if dashboard.owner_id == user_id:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
# Check allowed viewers list
|
|
36
|
+
return user_id in dashboard.allowed_viewers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def can_edit_dashboard(dashboard: Dashboard, user_id: str | None) -> bool:
|
|
40
|
+
"""Check if a user can edit a dashboard.
|
|
41
|
+
|
|
42
|
+
Only the owner can edit a dashboard.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
dashboard: The dashboard to check
|
|
46
|
+
user_id: The user attempting to edit (None for anonymous)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if the user can edit the dashboard
|
|
50
|
+
"""
|
|
51
|
+
if user_id is None:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
return dashboard.owner_id == user_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def can_delete_dashboard(dashboard: Dashboard, user_id: str | None) -> bool:
|
|
58
|
+
"""Check if a user can delete a dashboard.
|
|
59
|
+
|
|
60
|
+
Only the owner can delete a dashboard.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
dashboard: The dashboard to check
|
|
64
|
+
user_id: The user attempting to delete (None for anonymous)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if the user can delete the dashboard
|
|
68
|
+
"""
|
|
69
|
+
if user_id is None:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
return dashboard.owner_id == user_id
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def can_edit_widget(dashboard: Dashboard, user_id: str | None) -> bool:
|
|
76
|
+
"""Check if a user can edit widgets in a dashboard.
|
|
77
|
+
|
|
78
|
+
Requires dashboard edit permission.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
dashboard: The parent dashboard
|
|
82
|
+
user_id: The user attempting to edit
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if the user can edit widgets
|
|
86
|
+
"""
|
|
87
|
+
return can_edit_dashboard(dashboard, user_id)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Database persistence layer for Prismiq."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from prismiq.persistence.models import (
|
|
6
|
+
PrismiqBase,
|
|
7
|
+
PrismiqDashboard,
|
|
8
|
+
PrismiqPinnedDashboard,
|
|
9
|
+
PrismiqSavedQuery,
|
|
10
|
+
PrismiqWidget,
|
|
11
|
+
)
|
|
12
|
+
from prismiq.persistence.postgres_store import PostgresDashboardStore
|
|
13
|
+
from prismiq.persistence.saved_query_store import SavedQueryStore
|
|
14
|
+
from prismiq.persistence.setup import (
|
|
15
|
+
TableCreationError,
|
|
16
|
+
drop_tables,
|
|
17
|
+
ensure_tables,
|
|
18
|
+
ensure_tables_sync,
|
|
19
|
+
table_exists,
|
|
20
|
+
)
|
|
21
|
+
from prismiq.persistence.tables import (
|
|
22
|
+
dashboards_table,
|
|
23
|
+
metadata,
|
|
24
|
+
saved_queries_table,
|
|
25
|
+
widgets_table,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"PostgresDashboardStore",
|
|
30
|
+
"PrismiqBase",
|
|
31
|
+
"PrismiqDashboard",
|
|
32
|
+
"PrismiqPinnedDashboard",
|
|
33
|
+
"PrismiqSavedQuery",
|
|
34
|
+
"PrismiqWidget",
|
|
35
|
+
"SavedQueryStore",
|
|
36
|
+
"TableCreationError",
|
|
37
|
+
"dashboards_table",
|
|
38
|
+
"drop_tables",
|
|
39
|
+
"ensure_tables",
|
|
40
|
+
"ensure_tables_sync",
|
|
41
|
+
"metadata",
|
|
42
|
+
"saved_queries_table",
|
|
43
|
+
"table_exists",
|
|
44
|
+
"widgets_table",
|
|
45
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""SQLAlchemy declarative models for Prismiq tables.
|
|
2
|
+
|
|
3
|
+
These models are provided for integration with Alembic migrations
|
|
4
|
+
and programmatic table creation via ensure_tables_sync().
|
|
5
|
+
|
|
6
|
+
The declarative base (PrismiqBase) provides a separate metadata object
|
|
7
|
+
that can be combined with other SQLAlchemy metadata in multi-tenant
|
|
8
|
+
Alembic configurations.
|
|
9
|
+
|
|
10
|
+
Usage with Alembic:
|
|
11
|
+
from prismiq import PrismiqBase
|
|
12
|
+
|
|
13
|
+
# In env.py, add prismiq tables to target_metadata:
|
|
14
|
+
for table in PrismiqBase.metadata.tables.values():
|
|
15
|
+
table.to_metadata(target_metadata)
|
|
16
|
+
|
|
17
|
+
Usage with sync engines:
|
|
18
|
+
from prismiq import ensure_tables_sync
|
|
19
|
+
|
|
20
|
+
with engine.connect() as conn:
|
|
21
|
+
ensure_tables_sync(conn, schema_name="tenant_123")
|
|
22
|
+
conn.commit()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
|
31
|
+
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, TIMESTAMP
|
|
32
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PrismiqBase(DeclarativeBase):
|
|
36
|
+
"""Base class for all Prismiq models.
|
|
37
|
+
|
|
38
|
+
This provides a separate metadata object that can be combined with
|
|
39
|
+
other SQLAlchemy metadata in multi-tenant Alembic configurations.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _utcnow() -> datetime:
|
|
46
|
+
"""Return current UTC time with timezone."""
|
|
47
|
+
return datetime.now(timezone.utc)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PrismiqDashboard(PrismiqBase):
|
|
51
|
+
"""Dashboard model for storing dashboard metadata.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
id: Unique dashboard identifier (auto-increment integer)
|
|
55
|
+
tenant_id: Tenant identifier for multi-tenancy
|
|
56
|
+
name: Dashboard display name
|
|
57
|
+
description: Optional description
|
|
58
|
+
layout: Grid layout configuration (columns, rowHeight, margin)
|
|
59
|
+
filters: Dashboard-level filter definitions
|
|
60
|
+
owner_id: User who owns this dashboard
|
|
61
|
+
is_public: Whether dashboard is visible to all tenant users
|
|
62
|
+
allowed_viewers: List of user IDs with view permission
|
|
63
|
+
created_at: Creation timestamp
|
|
64
|
+
updated_at: Last modification timestamp
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
__tablename__ = "prismiq_dashboards"
|
|
68
|
+
|
|
69
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
70
|
+
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
71
|
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
72
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
73
|
+
layout: Mapped[dict[str, Any]] = mapped_column(
|
|
74
|
+
JSONB, nullable=False, default=lambda: {"columns": 12, "rowHeight": 50, "margin": [10, 10]}
|
|
75
|
+
)
|
|
76
|
+
filters: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
|
77
|
+
owner_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
78
|
+
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
79
|
+
allowed_viewers: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
|
80
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
81
|
+
TIMESTAMP(timezone=True), nullable=False, default=_utcnow
|
|
82
|
+
)
|
|
83
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
84
|
+
TIMESTAMP(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
__table_args__ = (
|
|
88
|
+
UniqueConstraint("tenant_id", "name", name="unique_dashboard_name_per_tenant"),
|
|
89
|
+
Index("idx_dashboards_tenant_id", "tenant_id"),
|
|
90
|
+
Index("idx_dashboards_owner_id", "tenant_id", "owner_id"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PrismiqWidget(PrismiqBase):
|
|
95
|
+
"""Widget model for storing widget metadata.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
id: Unique widget identifier (auto-increment integer)
|
|
99
|
+
dashboard_id: Parent dashboard ID (foreign key)
|
|
100
|
+
type: Widget type (bar, line, pie, table, text, etc.)
|
|
101
|
+
title: Widget display title
|
|
102
|
+
query: Query definition (null for text widgets)
|
|
103
|
+
position: Grid position {x, y, w, h}
|
|
104
|
+
config: Widget-specific configuration
|
|
105
|
+
created_at: Creation timestamp
|
|
106
|
+
updated_at: Last modification timestamp
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
__tablename__ = "prismiq_widgets"
|
|
110
|
+
|
|
111
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
112
|
+
dashboard_id: Mapped[int] = mapped_column(
|
|
113
|
+
Integer,
|
|
114
|
+
ForeignKey("prismiq_dashboards.id", ondelete="CASCADE"),
|
|
115
|
+
nullable=False,
|
|
116
|
+
)
|
|
117
|
+
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
118
|
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
119
|
+
query: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
|
120
|
+
position: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
121
|
+
config: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
|
122
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
123
|
+
TIMESTAMP(timezone=True), nullable=False, default=_utcnow
|
|
124
|
+
)
|
|
125
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
126
|
+
TIMESTAMP(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
__table_args__ = (Index("idx_widgets_dashboard_id", "dashboard_id"),)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class PrismiqSavedQuery(PrismiqBase):
|
|
133
|
+
"""Saved query model for reusable query definitions.
|
|
134
|
+
|
|
135
|
+
Attributes:
|
|
136
|
+
id: Unique query identifier (auto-increment integer)
|
|
137
|
+
tenant_id: Tenant identifier for multi-tenancy
|
|
138
|
+
name: Query display name
|
|
139
|
+
description: Optional description
|
|
140
|
+
query: Query definition
|
|
141
|
+
owner_id: User who owns this query
|
|
142
|
+
is_shared: Whether query is visible to other tenant users
|
|
143
|
+
created_at: Creation timestamp
|
|
144
|
+
updated_at: Last modification timestamp
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
__tablename__ = "prismiq_saved_queries"
|
|
148
|
+
|
|
149
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
150
|
+
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
151
|
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
152
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
153
|
+
query: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
154
|
+
owner_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
155
|
+
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
156
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
157
|
+
TIMESTAMP(timezone=True), nullable=False, default=_utcnow
|
|
158
|
+
)
|
|
159
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
160
|
+
TIMESTAMP(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
__table_args__ = (
|
|
164
|
+
UniqueConstraint("tenant_id", "name", name="unique_query_name_per_tenant"),
|
|
165
|
+
Index("idx_saved_queries_tenant", "tenant_id"),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PrismiqPinnedDashboard(PrismiqBase):
|
|
170
|
+
"""Tracks which dashboards are pinned to which contexts.
|
|
171
|
+
|
|
172
|
+
Pins allow users to save dashboards to system-defined contexts
|
|
173
|
+
(e.g., "dashboard", "accounts", "home") for quick access.
|
|
174
|
+
|
|
175
|
+
Attributes:
|
|
176
|
+
id: Unique pin identifier (auto-increment integer)
|
|
177
|
+
tenant_id: Tenant identifier for multi-tenancy
|
|
178
|
+
user_id: User who created the pin
|
|
179
|
+
dashboard_id: Dashboard that is pinned (foreign key)
|
|
180
|
+
context: Context identifier (e.g., "dashboard", "accounts")
|
|
181
|
+
position: Order position within the context (0-based)
|
|
182
|
+
pinned_at: Timestamp when the pin was created
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
__tablename__ = "prismiq_pinned_dashboards"
|
|
186
|
+
|
|
187
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
188
|
+
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
189
|
+
user_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
190
|
+
dashboard_id: Mapped[int] = mapped_column(
|
|
191
|
+
Integer,
|
|
192
|
+
ForeignKey("prismiq_dashboards.id", ondelete="CASCADE"),
|
|
193
|
+
nullable=False,
|
|
194
|
+
)
|
|
195
|
+
context: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
196
|
+
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
197
|
+
pinned_at: Mapped[datetime] = mapped_column(
|
|
198
|
+
TIMESTAMP(timezone=True), nullable=False, default=_utcnow
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
__table_args__ = (
|
|
202
|
+
# Each user can only pin a dashboard once per context
|
|
203
|
+
UniqueConstraint(
|
|
204
|
+
"tenant_id", "user_id", "dashboard_id", "context", name="unique_pin_per_context"
|
|
205
|
+
),
|
|
206
|
+
Index("idx_pinned_tenant_user_context", "tenant_id", "user_id", "context"),
|
|
207
|
+
Index("idx_pinned_dashboard", "dashboard_id"),
|
|
208
|
+
)
|