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/dashboards.py ADDED
@@ -0,0 +1,374 @@
1
+ """Dashboard and Widget models for Prismiq.
2
+
3
+ This module provides Pydantic models for dashboards, widgets, and
4
+ filters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from prismiq.types import QueryDefinition
16
+
17
+
18
+ def _utc_now() -> datetime:
19
+ """Get current UTC datetime (timezone-aware)."""
20
+ return datetime.now(timezone.utc)
21
+
22
+
23
+ # ============================================================================
24
+ # Widget Types
25
+ # ============================================================================
26
+
27
+
28
+ class WidgetType(str, Enum):
29
+ """Types of dashboard widgets."""
30
+
31
+ METRIC = "metric"
32
+ BAR_CHART = "bar_chart"
33
+ LINE_CHART = "line_chart"
34
+ AREA_CHART = "area_chart"
35
+ PIE_CHART = "pie_chart"
36
+ SCATTER_CHART = "scatter_chart"
37
+ TABLE = "table"
38
+ TEXT = "text"
39
+
40
+
41
+ class WidgetPosition(BaseModel):
42
+ """Widget position in grid layout."""
43
+
44
+ model_config = ConfigDict()
45
+
46
+ x: int = Field(ge=0)
47
+ """X position in grid units."""
48
+
49
+ y: int = Field(ge=0)
50
+ """Y position in grid units."""
51
+
52
+ w: int = Field(ge=1)
53
+ """Width in grid units."""
54
+
55
+ h: int = Field(ge=1)
56
+ """Height in grid units."""
57
+
58
+
59
+ class WidgetConfig(BaseModel):
60
+ """Widget-specific configuration."""
61
+
62
+ model_config = ConfigDict()
63
+
64
+ # Chart-specific options
65
+ x_axis: str | None = None
66
+ """Column to use for X axis."""
67
+
68
+ y_axis: list[str] | None = None
69
+ """Columns to use for Y axis (multi-series)."""
70
+
71
+ orientation: str | None = None
72
+ """Chart orientation: 'horizontal' or 'vertical'."""
73
+
74
+ stacked: bool | None = None
75
+ """Whether to stack bars/areas."""
76
+
77
+ show_legend: bool | None = None
78
+ """Whether to show chart legend."""
79
+
80
+ show_data_labels: bool | None = None
81
+ """Whether to show data labels on chart."""
82
+
83
+ colors: list[str] | None = None
84
+ """Custom color palette for the chart."""
85
+
86
+ # MetricCard options
87
+ format: str | None = None
88
+ """Number format for metric values."""
89
+
90
+ trend_comparison: str | None = None
91
+ """Period for trend comparison."""
92
+
93
+ # Table options
94
+ page_size: int | None = None
95
+ """Number of rows per page."""
96
+
97
+ sortable: bool | None = None
98
+ """Whether table columns are sortable."""
99
+
100
+ # Text options
101
+ content: str | None = None
102
+ """Text content for text widgets."""
103
+
104
+ markdown: bool | None = None
105
+ """Whether to render content as markdown."""
106
+
107
+
108
+ class Widget(BaseModel):
109
+ """A dashboard widget."""
110
+
111
+ model_config = ConfigDict()
112
+
113
+ id: str
114
+ """Unique widget identifier."""
115
+
116
+ type: WidgetType
117
+ """Type of widget."""
118
+
119
+ title: str
120
+ """Widget title."""
121
+
122
+ query: QueryDefinition | None = None
123
+ """Query definition for data. None for text widgets."""
124
+
125
+ position: WidgetPosition
126
+ """Position and size in grid layout."""
127
+
128
+ config: WidgetConfig = Field(default_factory=WidgetConfig)
129
+ """Widget-specific configuration."""
130
+
131
+ created_at: datetime = Field(default_factory=_utc_now)
132
+ """When the widget was created."""
133
+
134
+ updated_at: datetime = Field(default_factory=_utc_now)
135
+ """When the widget was last updated."""
136
+
137
+
138
+ # ============================================================================
139
+ # Dashboard Filter Types
140
+ # ============================================================================
141
+
142
+
143
+ class DashboardFilterType(str, Enum):
144
+ """Types of dashboard filters."""
145
+
146
+ DATE_RANGE = "date_range"
147
+ SELECT = "select"
148
+ MULTI_SELECT = "multi_select"
149
+ TEXT = "text"
150
+ NUMBER_RANGE = "number_range"
151
+
152
+
153
+ class DashboardFilter(BaseModel):
154
+ """A global dashboard filter."""
155
+
156
+ model_config = ConfigDict()
157
+
158
+ id: str
159
+ """Unique filter identifier."""
160
+
161
+ type: DashboardFilterType
162
+ """Type of filter."""
163
+
164
+ label: str
165
+ """Display label for the filter."""
166
+
167
+ field: str
168
+ """Column to filter."""
169
+
170
+ table: str | None = None
171
+ """Table name (if ambiguous across tables)."""
172
+
173
+ # Type-specific options
174
+ default_value: Any | None = None
175
+ """Default value for the filter."""
176
+
177
+ options: list[dict[str, str]] | None = None
178
+ """Available options for select types."""
179
+
180
+ date_preset: str | None = None
181
+ """Date preset for date_range type."""
182
+
183
+ dynamic: bool | None = None
184
+ """Whether to load options dynamically from the database."""
185
+
186
+
187
+ # ============================================================================
188
+ # Dashboard Layout
189
+ # ============================================================================
190
+
191
+
192
+ class DashboardLayout(BaseModel):
193
+ """Dashboard layout configuration."""
194
+
195
+ model_config = ConfigDict()
196
+
197
+ columns: int = 12
198
+ """Number of grid columns."""
199
+
200
+ row_height: int = 50
201
+ """Height of each grid row in pixels."""
202
+
203
+ margin: tuple[int, int] = (10, 10)
204
+ """Margin between widgets (x, y)."""
205
+
206
+ compact_type: str | None = "vertical"
207
+ """Compaction direction: 'vertical', 'horizontal', or None."""
208
+
209
+
210
+ # ============================================================================
211
+ # Dashboard Model
212
+ # ============================================================================
213
+
214
+
215
+ class Dashboard(BaseModel):
216
+ """A complete dashboard."""
217
+
218
+ model_config = ConfigDict()
219
+
220
+ id: str
221
+ """Unique dashboard identifier."""
222
+
223
+ name: str
224
+ """Dashboard name."""
225
+
226
+ description: str | None = None
227
+ """Dashboard description."""
228
+
229
+ layout: DashboardLayout = Field(default_factory=DashboardLayout)
230
+ """Layout configuration."""
231
+
232
+ widgets: list[Widget] = Field(default_factory=list)
233
+ """Widgets in the dashboard."""
234
+
235
+ filters: list[DashboardFilter] = Field(default_factory=list)
236
+ """Global filters for the dashboard."""
237
+
238
+ owner_id: str | None = None
239
+ """ID of the dashboard owner."""
240
+
241
+ created_at: datetime = Field(default_factory=_utc_now)
242
+ """When the dashboard was created."""
243
+
244
+ updated_at: datetime = Field(default_factory=_utc_now)
245
+ """When the dashboard was last updated."""
246
+
247
+ # Permissions
248
+ is_public: bool = False
249
+ """Whether the dashboard is publicly accessible."""
250
+
251
+ allowed_viewers: list[str] = Field(default_factory=list)
252
+ """List of user IDs allowed to view this dashboard."""
253
+
254
+ def get_widget(self, widget_id: str) -> Widget | None:
255
+ """Get a widget by ID."""
256
+ for widget in self.widgets:
257
+ if widget.id == widget_id:
258
+ return widget
259
+ return None
260
+
261
+
262
+ # ============================================================================
263
+ # DTOs for CRUD operations
264
+ # ============================================================================
265
+
266
+
267
+ class DashboardCreate(BaseModel):
268
+ """DTO for creating a dashboard."""
269
+
270
+ model_config = ConfigDict()
271
+
272
+ name: str
273
+ """Dashboard name."""
274
+
275
+ description: str | None = None
276
+ """Dashboard description."""
277
+
278
+ layout: DashboardLayout | None = None
279
+ """Optional layout configuration."""
280
+
281
+
282
+ class DashboardUpdate(BaseModel):
283
+ """DTO for updating a dashboard."""
284
+
285
+ model_config = ConfigDict()
286
+
287
+ name: str | None = None
288
+ """New dashboard name."""
289
+
290
+ description: str | None = None
291
+ """New dashboard description."""
292
+
293
+ layout: DashboardLayout | None = None
294
+ """New layout configuration."""
295
+
296
+ filters: list[DashboardFilter] | None = None
297
+ """New dashboard filters."""
298
+
299
+ is_public: bool | None = None
300
+ """New public visibility setting."""
301
+
302
+ allowed_viewers: list[str] | None = None
303
+ """New list of allowed viewers."""
304
+
305
+ widgets: list[Widget] | None = None
306
+ """Full list of widgets to replace existing widgets."""
307
+
308
+
309
+ class WidgetCreate(BaseModel):
310
+ """DTO for creating a widget."""
311
+
312
+ model_config = ConfigDict()
313
+
314
+ type: WidgetType
315
+ """Type of widget."""
316
+
317
+ title: str
318
+ """Widget title."""
319
+
320
+ query: QueryDefinition | None = None
321
+ """Query definition for data."""
322
+
323
+ position: WidgetPosition
324
+ """Position and size in grid layout."""
325
+
326
+ config: WidgetConfig | None = None
327
+ """Widget-specific configuration."""
328
+
329
+
330
+ class WidgetUpdate(BaseModel):
331
+ """DTO for updating a widget."""
332
+
333
+ model_config = ConfigDict()
334
+
335
+ title: str | None = None
336
+ """New widget title."""
337
+
338
+ query: QueryDefinition | None = None
339
+ """New query definition."""
340
+
341
+ position: WidgetPosition | None = None
342
+ """New position and size."""
343
+
344
+ config: WidgetConfig | None = None
345
+ """New widget configuration."""
346
+
347
+
348
+ # ============================================================================
349
+ # Export Format
350
+ # ============================================================================
351
+
352
+
353
+ class DashboardExport(BaseModel):
354
+ """Export format for dashboards."""
355
+
356
+ model_config = ConfigDict()
357
+
358
+ version: str = "1.0"
359
+ """Export format version for future compatibility."""
360
+
361
+ name: str
362
+ """Dashboard name."""
363
+
364
+ description: str | None = None
365
+ """Dashboard description."""
366
+
367
+ layout: DashboardLayout
368
+ """Layout configuration."""
369
+
370
+ widgets: list[dict[str, Any]]
371
+ """Widget data without IDs."""
372
+
373
+ filters: list[DashboardFilter]
374
+ """Dashboard filters."""
prismiq/dates.py ADDED
@@ -0,0 +1,247 @@
1
+ """Date/time utilities for Prismiq analytics.
2
+
3
+ This module provides utilities for handling relative date expressions
4
+ and date manipulation commonly used in dashboard filters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import calendar
10
+ from datetime import date, datetime, timedelta
11
+ from enum import Enum
12
+
13
+
14
+ class DatePreset(str, Enum):
15
+ """Relative date presets for dashboard filters."""
16
+
17
+ TODAY = "today"
18
+ YESTERDAY = "yesterday"
19
+ LAST_7_DAYS = "last_7_days"
20
+ LAST_30_DAYS = "last_30_days"
21
+ THIS_WEEK = "this_week"
22
+ LAST_WEEK = "last_week"
23
+ THIS_MONTH = "this_month"
24
+ LAST_MONTH = "last_month"
25
+ THIS_QUARTER = "this_quarter"
26
+ LAST_QUARTER = "last_quarter"
27
+ THIS_YEAR = "this_year"
28
+ LAST_YEAR = "last_year"
29
+ ALL_TIME = "all_time"
30
+
31
+
32
+ def resolve_date_preset(preset: DatePreset, reference: date | None = None) -> tuple[date, date]:
33
+ """Convert a date preset to a concrete (start_date, end_date) tuple.
34
+
35
+ Args:
36
+ preset: The relative date preset to resolve.
37
+ reference: Reference date for calculations. Defaults to today.
38
+
39
+ Returns:
40
+ Tuple of (start_date, end_date) representing the date range.
41
+
42
+ Example:
43
+ >>> resolve_date_preset(DatePreset.LAST_7_DAYS, date(2024, 1, 15))
44
+ (date(2024, 1, 9), date(2024, 1, 15))
45
+ """
46
+ ref = reference or date.today()
47
+
48
+ if preset == DatePreset.TODAY:
49
+ return ref, ref
50
+
51
+ if preset == DatePreset.YESTERDAY:
52
+ yesterday = ref - timedelta(days=1)
53
+ return yesterday, yesterday
54
+
55
+ if preset == DatePreset.LAST_7_DAYS:
56
+ start = ref - timedelta(days=6)
57
+ return start, ref
58
+
59
+ if preset == DatePreset.LAST_30_DAYS:
60
+ start = ref - timedelta(days=29)
61
+ return start, ref
62
+
63
+ if preset == DatePreset.THIS_WEEK:
64
+ # Week starts on Monday (weekday() = 0)
65
+ start = ref - timedelta(days=ref.weekday())
66
+ return start, ref
67
+
68
+ if preset == DatePreset.LAST_WEEK:
69
+ # Find start of this week, then go back 7 days
70
+ this_week_start = ref - timedelta(days=ref.weekday())
71
+ last_week_start = this_week_start - timedelta(days=7)
72
+ last_week_end = this_week_start - timedelta(days=1)
73
+ return last_week_start, last_week_end
74
+
75
+ if preset == DatePreset.THIS_MONTH:
76
+ start = ref.replace(day=1)
77
+ return start, ref
78
+
79
+ if preset == DatePreset.LAST_MONTH:
80
+ # First day of this month
81
+ this_month_start = ref.replace(day=1)
82
+ # Last day of previous month
83
+ last_month_end = this_month_start - timedelta(days=1)
84
+ # First day of previous month
85
+ last_month_start = last_month_end.replace(day=1)
86
+ return last_month_start, last_month_end
87
+
88
+ if preset == DatePreset.THIS_QUARTER:
89
+ quarter = (ref.month - 1) // 3
90
+ start_month = quarter * 3 + 1
91
+ start = ref.replace(month=start_month, day=1)
92
+ return start, ref
93
+
94
+ if preset == DatePreset.LAST_QUARTER:
95
+ # Find start of current quarter
96
+ current_quarter = (ref.month - 1) // 3
97
+ current_quarter_start_month = current_quarter * 3 + 1
98
+
99
+ # Go to previous quarter
100
+ if current_quarter == 0:
101
+ # Q1 -> Q4 of previous year
102
+ last_quarter_start = ref.replace(year=ref.year - 1, month=10, day=1)
103
+ last_quarter_end = ref.replace(year=ref.year - 1, month=12, day=31)
104
+ else:
105
+ last_quarter_start_month = current_quarter_start_month - 3
106
+ last_quarter_end_month = current_quarter_start_month - 1
107
+ last_quarter_start = ref.replace(month=last_quarter_start_month, day=1)
108
+ last_day = calendar.monthrange(ref.year, last_quarter_end_month)[1]
109
+ last_quarter_end = ref.replace(month=last_quarter_end_month, day=last_day)
110
+
111
+ return last_quarter_start, last_quarter_end
112
+
113
+ if preset == DatePreset.THIS_YEAR:
114
+ start = ref.replace(month=1, day=1)
115
+ return start, ref
116
+
117
+ if preset == DatePreset.LAST_YEAR:
118
+ start = ref.replace(year=ref.year - 1, month=1, day=1)
119
+ end = ref.replace(year=ref.year - 1, month=12, day=31)
120
+ return start, end
121
+
122
+ if preset == DatePreset.ALL_TIME:
123
+ # Use a very early date as start
124
+ return date(1970, 1, 1), ref
125
+
126
+ # Default fallback (should not reach here)
127
+ return ref, ref
128
+
129
+
130
+ def date_trunc(unit: str, dt: datetime) -> datetime:
131
+ """Truncate datetime to the specified unit.
132
+
133
+ Args:
134
+ unit: Truncation unit - one of 'day', 'week', 'month', 'quarter', 'year'.
135
+ dt: The datetime to truncate.
136
+
137
+ Returns:
138
+ Truncated datetime.
139
+
140
+ Raises:
141
+ ValueError: If an invalid unit is provided.
142
+
143
+ Example:
144
+ >>> date_trunc("month", datetime(2024, 3, 15, 10, 30, 45))
145
+ datetime(2024, 3, 1, 0, 0, 0)
146
+ """
147
+ unit_lower = unit.lower()
148
+
149
+ if unit_lower == "day":
150
+ return dt.replace(hour=0, minute=0, second=0, microsecond=0)
151
+
152
+ if unit_lower == "week":
153
+ # Week starts on Monday
154
+ days_since_monday = dt.weekday()
155
+ week_start = dt - timedelta(days=days_since_monday)
156
+ return week_start.replace(hour=0, minute=0, second=0, microsecond=0)
157
+
158
+ if unit_lower == "month":
159
+ return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
160
+
161
+ if unit_lower == "quarter":
162
+ quarter = (dt.month - 1) // 3
163
+ quarter_start_month = quarter * 3 + 1
164
+ return dt.replace(
165
+ month=quarter_start_month, day=1, hour=0, minute=0, second=0, microsecond=0
166
+ )
167
+
168
+ if unit_lower == "year":
169
+ return dt.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
170
+
171
+ raise ValueError(
172
+ f"Invalid truncation unit: {unit}. Must be one of: day, week, month, quarter, year"
173
+ )
174
+
175
+
176
+ def date_add(dt: date, years: int = 0, months: int = 0, days: int = 0) -> date:
177
+ """Add years, months, and days to a date.
178
+
179
+ Handles edge cases like adding months that would result in invalid dates
180
+ (e.g., Jan 31 + 1 month becomes Feb 28/29).
181
+
182
+ Args:
183
+ dt: The base date.
184
+ years: Number of years to add (can be negative).
185
+ months: Number of months to add (can be negative).
186
+ days: Number of days to add (can be negative).
187
+
188
+ Returns:
189
+ New date with the additions applied.
190
+
191
+ Example:
192
+ >>> date_add(date(2024, 1, 31), months=1)
193
+ date(2024, 2, 29) # Leap year, clamps to valid day
194
+ """
195
+ # First add years and months
196
+ new_year = dt.year + years
197
+ new_month = dt.month + months
198
+
199
+ # Handle month overflow/underflow
200
+ while new_month > 12:
201
+ new_month -= 12
202
+ new_year += 1
203
+ while new_month < 1:
204
+ new_month += 12
205
+ new_year -= 1
206
+
207
+ # Clamp day to valid range for the new month
208
+ max_day = calendar.monthrange(new_year, new_month)[1]
209
+ new_day = min(dt.day, max_day)
210
+
211
+ result = date(new_year, new_month, new_day)
212
+
213
+ # Then add days
214
+ if days != 0:
215
+ result = result + timedelta(days=days)
216
+
217
+ return result
218
+
219
+
220
+ def get_date_range_sql(preset: DatePreset, column: str) -> tuple[str, list[date]]:
221
+ """Generate SQL WHERE clause for a date preset.
222
+
223
+ Args:
224
+ preset: The date preset to generate SQL for.
225
+ column: The column name to filter on.
226
+
227
+ Returns:
228
+ Tuple of (sql_fragment, params) where params uses positional placeholders.
229
+ The sql_fragment uses $1, $2 style placeholders.
230
+
231
+ Example:
232
+ >>> get_date_range_sql(DatePreset.LAST_7_DAYS, "order_date")
233
+ ('"order_date" >= $1 AND "order_date" <= $2', [date(2024, 1, 9), date(2024, 1, 15)])
234
+ """
235
+ start_date, end_date = resolve_date_preset(preset)
236
+
237
+ # Quote the column name to prevent SQL injection
238
+ escaped_column = column.replace('"', '""')
239
+ quoted_column = f'"{escaped_column}"'
240
+
241
+ if preset == DatePreset.ALL_TIME:
242
+ # For ALL_TIME, we only need the upper bound
243
+ sql = f"{quoted_column} <= $1"
244
+ return sql, [end_date]
245
+
246
+ sql = f"{quoted_column} >= $1 AND {quoted_column} <= $2"
247
+ return sql, [start_date, end_date]