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/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
+ )