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/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]
|