truthound-dashboard 1.0.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.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Notification dispatcher for orchestrating notification delivery.
|
|
2
|
+
|
|
3
|
+
The dispatcher coordinates between events, rules, and channels to
|
|
4
|
+
deliver notifications based on configured triggers.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
Event -> Dispatcher -> Rules -> Channels -> Delivery
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
dispatcher = get_dispatcher()
|
|
11
|
+
|
|
12
|
+
# Notify about validation failure
|
|
13
|
+
await dispatcher.notify_validation_failed(
|
|
14
|
+
source_id="source-123",
|
|
15
|
+
source_name="My Source",
|
|
16
|
+
validation_id="val-456",
|
|
17
|
+
has_critical=True,
|
|
18
|
+
has_high=False,
|
|
19
|
+
total_issues=5,
|
|
20
|
+
)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from sqlalchemy import select
|
|
30
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
31
|
+
|
|
32
|
+
from truthound_dashboard.db import (
|
|
33
|
+
NotificationChannel,
|
|
34
|
+
NotificationLog,
|
|
35
|
+
NotificationRule,
|
|
36
|
+
get_session,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from .base import (
|
|
40
|
+
BaseNotificationChannel,
|
|
41
|
+
ChannelRegistry,
|
|
42
|
+
NotificationEvent,
|
|
43
|
+
NotificationResult,
|
|
44
|
+
)
|
|
45
|
+
from .events import (
|
|
46
|
+
DriftDetectedEvent,
|
|
47
|
+
ScheduleFailedEvent,
|
|
48
|
+
TestNotificationEvent,
|
|
49
|
+
ValidationFailedEvent,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NotificationDispatcher:
|
|
56
|
+
"""Orchestrates notification delivery based on events and rules.
|
|
57
|
+
|
|
58
|
+
The dispatcher:
|
|
59
|
+
1. Receives notification events
|
|
60
|
+
2. Matches events against active rules
|
|
61
|
+
3. Resolves target channels from matching rules
|
|
62
|
+
4. Delivers notifications through channels
|
|
63
|
+
5. Logs delivery results
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
dispatcher = NotificationDispatcher(session)
|
|
67
|
+
|
|
68
|
+
# Send test notification
|
|
69
|
+
results = await dispatcher.test_channel(channel_id)
|
|
70
|
+
|
|
71
|
+
# Notify about events
|
|
72
|
+
await dispatcher.notify_validation_failed(...)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
76
|
+
"""Initialize the dispatcher.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
session: Database session for accessing rules and channels.
|
|
80
|
+
"""
|
|
81
|
+
self.session = session
|
|
82
|
+
|
|
83
|
+
async def dispatch(
|
|
84
|
+
self,
|
|
85
|
+
event: NotificationEvent,
|
|
86
|
+
*,
|
|
87
|
+
channel_ids: list[str] | None = None,
|
|
88
|
+
rule_id: str | None = None,
|
|
89
|
+
) -> list[NotificationResult]:
|
|
90
|
+
"""Dispatch a notification event to matching channels.
|
|
91
|
+
|
|
92
|
+
If channel_ids is provided, sends directly to those channels.
|
|
93
|
+
Otherwise, matches event against rules to find target channels.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
event: The notification event to dispatch.
|
|
97
|
+
channel_ids: Optional explicit channel IDs to send to.
|
|
98
|
+
rule_id: Optional rule ID that triggered this dispatch.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of delivery results for each channel.
|
|
102
|
+
"""
|
|
103
|
+
# Get target channels
|
|
104
|
+
if channel_ids:
|
|
105
|
+
channels = await self._get_channels_by_ids(channel_ids)
|
|
106
|
+
else:
|
|
107
|
+
channels = await self._get_channels_for_event(event)
|
|
108
|
+
|
|
109
|
+
if not channels:
|
|
110
|
+
logger.debug(f"No channels found for event: {event.event_type}")
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
# Dispatch to each channel
|
|
114
|
+
results = []
|
|
115
|
+
for channel_model in channels:
|
|
116
|
+
result = await self._send_to_channel(channel_model, event, rule_id)
|
|
117
|
+
results.append(result)
|
|
118
|
+
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
async def _get_channels_by_ids(
|
|
122
|
+
self, channel_ids: list[str]
|
|
123
|
+
) -> Sequence[NotificationChannel]:
|
|
124
|
+
"""Get channels by their IDs."""
|
|
125
|
+
result = await self.session.execute(
|
|
126
|
+
select(NotificationChannel)
|
|
127
|
+
.where(NotificationChannel.id.in_(channel_ids))
|
|
128
|
+
.where(NotificationChannel.is_active == True)
|
|
129
|
+
)
|
|
130
|
+
return result.scalars().all()
|
|
131
|
+
|
|
132
|
+
async def _get_channels_for_event(
|
|
133
|
+
self, event: NotificationEvent
|
|
134
|
+
) -> Sequence[NotificationChannel]:
|
|
135
|
+
"""Get channels that should receive this event based on rules."""
|
|
136
|
+
# Get matching rules
|
|
137
|
+
rules = await self._get_matching_rules(event)
|
|
138
|
+
|
|
139
|
+
if not rules:
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
# Collect unique channel IDs
|
|
143
|
+
channel_ids: set[str] = set()
|
|
144
|
+
for rule in rules:
|
|
145
|
+
channel_ids.update(rule.channel_ids)
|
|
146
|
+
|
|
147
|
+
if not channel_ids:
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
# Get active channels
|
|
151
|
+
return await self._get_channels_by_ids(list(channel_ids))
|
|
152
|
+
|
|
153
|
+
async def _get_matching_rules(
|
|
154
|
+
self, event: NotificationEvent
|
|
155
|
+
) -> Sequence[NotificationRule]:
|
|
156
|
+
"""Get rules that match the given event."""
|
|
157
|
+
# Map event types to rule conditions
|
|
158
|
+
condition_map = {
|
|
159
|
+
"validation_failed": ["validation_failed", "critical_issues", "high_issues"],
|
|
160
|
+
"schedule_failed": ["schedule_failed", "validation_failed"],
|
|
161
|
+
"drift_detected": ["drift_detected"],
|
|
162
|
+
"test": [],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
conditions = condition_map.get(event.event_type, [event.event_type])
|
|
166
|
+
|
|
167
|
+
if not conditions:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
# Query matching rules
|
|
171
|
+
result = await self.session.execute(
|
|
172
|
+
select(NotificationRule)
|
|
173
|
+
.where(NotificationRule.is_active == True)
|
|
174
|
+
.where(NotificationRule.condition.in_(conditions))
|
|
175
|
+
)
|
|
176
|
+
all_rules = result.scalars().all()
|
|
177
|
+
|
|
178
|
+
# Filter by event-specific conditions
|
|
179
|
+
matching_rules = []
|
|
180
|
+
for rule in all_rules:
|
|
181
|
+
if self._rule_matches_event(rule, event):
|
|
182
|
+
matching_rules.append(rule)
|
|
183
|
+
|
|
184
|
+
return matching_rules
|
|
185
|
+
|
|
186
|
+
def _rule_matches_event(
|
|
187
|
+
self, rule: NotificationRule, event: NotificationEvent
|
|
188
|
+
) -> bool:
|
|
189
|
+
"""Check if a rule matches the specific event."""
|
|
190
|
+
# Check source filter
|
|
191
|
+
if event.source_id and not rule.matches_source(event.source_id):
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
# Check condition-specific matching
|
|
195
|
+
if isinstance(event, ValidationFailedEvent):
|
|
196
|
+
if rule.condition == "critical_issues" and not event.has_critical:
|
|
197
|
+
return False
|
|
198
|
+
if rule.condition == "high_issues" and not (event.has_critical or event.has_high):
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
202
|
+
# Could add threshold checks here from rule.condition_config
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
async def _send_to_channel(
|
|
208
|
+
self,
|
|
209
|
+
channel_model: NotificationChannel,
|
|
210
|
+
event: NotificationEvent,
|
|
211
|
+
rule_id: str | None = None,
|
|
212
|
+
) -> NotificationResult:
|
|
213
|
+
"""Send notification to a specific channel."""
|
|
214
|
+
# Create channel instance
|
|
215
|
+
channel = ChannelRegistry.create(
|
|
216
|
+
channel_type=channel_model.type,
|
|
217
|
+
channel_id=channel_model.id,
|
|
218
|
+
name=channel_model.name,
|
|
219
|
+
config=channel_model.config,
|
|
220
|
+
is_active=channel_model.is_active,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if channel is None:
|
|
224
|
+
error = f"Unknown channel type: {channel_model.type}"
|
|
225
|
+
await self._log_delivery(channel_model.id, event, False, "", error, rule_id)
|
|
226
|
+
return NotificationResult(
|
|
227
|
+
success=False,
|
|
228
|
+
channel_id=channel_model.id,
|
|
229
|
+
channel_type=channel_model.type,
|
|
230
|
+
message="",
|
|
231
|
+
error=error,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Format message for this channel
|
|
235
|
+
message = channel.format_message(event)
|
|
236
|
+
|
|
237
|
+
# Send notification
|
|
238
|
+
result = await channel.send_with_result(message, event)
|
|
239
|
+
|
|
240
|
+
# Log delivery
|
|
241
|
+
await self._log_delivery(
|
|
242
|
+
channel_model.id,
|
|
243
|
+
event,
|
|
244
|
+
result.success,
|
|
245
|
+
message,
|
|
246
|
+
result.error,
|
|
247
|
+
rule_id,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
async def _log_delivery(
|
|
253
|
+
self,
|
|
254
|
+
channel_id: str,
|
|
255
|
+
event: NotificationEvent,
|
|
256
|
+
success: bool,
|
|
257
|
+
message: str,
|
|
258
|
+
error: str | None,
|
|
259
|
+
rule_id: str | None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Log notification delivery attempt."""
|
|
262
|
+
log = NotificationLog(
|
|
263
|
+
channel_id=channel_id,
|
|
264
|
+
rule_id=rule_id,
|
|
265
|
+
event_type=event.event_type,
|
|
266
|
+
event_data=event.to_dict(),
|
|
267
|
+
message=message[:1000] if message else "",
|
|
268
|
+
status="sent" if success else "failed",
|
|
269
|
+
error_message=error,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if success:
|
|
273
|
+
log.mark_sent()
|
|
274
|
+
else:
|
|
275
|
+
log.mark_failed(error or "Unknown error")
|
|
276
|
+
|
|
277
|
+
self.session.add(log)
|
|
278
|
+
await self.session.flush()
|
|
279
|
+
|
|
280
|
+
# =========================================================================
|
|
281
|
+
# Convenience methods for common events
|
|
282
|
+
# =========================================================================
|
|
283
|
+
|
|
284
|
+
async def notify_validation_failed(
|
|
285
|
+
self,
|
|
286
|
+
source_id: str,
|
|
287
|
+
source_name: str,
|
|
288
|
+
validation_id: str,
|
|
289
|
+
has_critical: bool = False,
|
|
290
|
+
has_high: bool = False,
|
|
291
|
+
total_issues: int = 0,
|
|
292
|
+
issues: list[dict[str, Any]] | None = None,
|
|
293
|
+
) -> list[NotificationResult]:
|
|
294
|
+
"""Send notifications for a validation failure.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
source_id: ID of the source that was validated.
|
|
298
|
+
source_name: Name of the source.
|
|
299
|
+
validation_id: ID of the validation run.
|
|
300
|
+
has_critical: Whether critical issues were found.
|
|
301
|
+
has_high: Whether high severity issues were found.
|
|
302
|
+
total_issues: Total number of issues.
|
|
303
|
+
issues: Optional list of issue details.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
List of delivery results.
|
|
307
|
+
"""
|
|
308
|
+
event = ValidationFailedEvent(
|
|
309
|
+
source_id=source_id,
|
|
310
|
+
source_name=source_name,
|
|
311
|
+
validation_id=validation_id,
|
|
312
|
+
has_critical=has_critical,
|
|
313
|
+
has_high=has_high,
|
|
314
|
+
total_issues=total_issues,
|
|
315
|
+
issues=issues or [],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return await self.dispatch(event)
|
|
319
|
+
|
|
320
|
+
async def notify_schedule_failed(
|
|
321
|
+
self,
|
|
322
|
+
source_id: str,
|
|
323
|
+
source_name: str,
|
|
324
|
+
schedule_id: str,
|
|
325
|
+
schedule_name: str,
|
|
326
|
+
validation_id: str | None = None,
|
|
327
|
+
error_message: str | None = None,
|
|
328
|
+
) -> list[NotificationResult]:
|
|
329
|
+
"""Send notifications for a scheduled validation failure.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
source_id: ID of the source.
|
|
333
|
+
source_name: Name of the source.
|
|
334
|
+
schedule_id: ID of the schedule.
|
|
335
|
+
schedule_name: Name of the schedule.
|
|
336
|
+
validation_id: Optional ID of the failed validation.
|
|
337
|
+
error_message: Optional error message.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
List of delivery results.
|
|
341
|
+
"""
|
|
342
|
+
event = ScheduleFailedEvent(
|
|
343
|
+
source_id=source_id,
|
|
344
|
+
source_name=source_name,
|
|
345
|
+
schedule_id=schedule_id,
|
|
346
|
+
schedule_name=schedule_name,
|
|
347
|
+
validation_id=validation_id,
|
|
348
|
+
error_message=error_message,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return await self.dispatch(event)
|
|
352
|
+
|
|
353
|
+
async def notify_drift_detected(
|
|
354
|
+
self,
|
|
355
|
+
comparison_id: str,
|
|
356
|
+
baseline_source_id: str,
|
|
357
|
+
baseline_source_name: str,
|
|
358
|
+
current_source_id: str,
|
|
359
|
+
current_source_name: str,
|
|
360
|
+
has_high_drift: bool = False,
|
|
361
|
+
drifted_columns: int = 0,
|
|
362
|
+
total_columns: int = 0,
|
|
363
|
+
) -> list[NotificationResult]:
|
|
364
|
+
"""Send notifications for drift detection.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
comparison_id: ID of the drift comparison.
|
|
368
|
+
baseline_source_id: ID of the baseline source.
|
|
369
|
+
baseline_source_name: Name of the baseline source.
|
|
370
|
+
current_source_id: ID of the current source.
|
|
371
|
+
current_source_name: Name of the current source.
|
|
372
|
+
has_high_drift: Whether high severity drift was detected.
|
|
373
|
+
drifted_columns: Number of columns with drift.
|
|
374
|
+
total_columns: Total columns compared.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
List of delivery results.
|
|
378
|
+
"""
|
|
379
|
+
event = DriftDetectedEvent(
|
|
380
|
+
source_id=baseline_source_id,
|
|
381
|
+
source_name=baseline_source_name,
|
|
382
|
+
comparison_id=comparison_id,
|
|
383
|
+
baseline_source_id=baseline_source_id,
|
|
384
|
+
baseline_source_name=baseline_source_name,
|
|
385
|
+
current_source_id=current_source_id,
|
|
386
|
+
current_source_name=current_source_name,
|
|
387
|
+
has_high_drift=has_high_drift,
|
|
388
|
+
drifted_columns=drifted_columns,
|
|
389
|
+
total_columns=total_columns,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return await self.dispatch(event)
|
|
393
|
+
|
|
394
|
+
async def test_channel(self, channel_id: str) -> NotificationResult:
|
|
395
|
+
"""Send a test notification to a specific channel.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
channel_id: ID of the channel to test.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Delivery result.
|
|
402
|
+
"""
|
|
403
|
+
# Get channel
|
|
404
|
+
result = await self.session.execute(
|
|
405
|
+
select(NotificationChannel).where(NotificationChannel.id == channel_id)
|
|
406
|
+
)
|
|
407
|
+
channel_model = result.scalar_one_or_none()
|
|
408
|
+
|
|
409
|
+
if channel_model is None:
|
|
410
|
+
return NotificationResult(
|
|
411
|
+
success=False,
|
|
412
|
+
channel_id=channel_id,
|
|
413
|
+
channel_type="unknown",
|
|
414
|
+
message="",
|
|
415
|
+
error="Channel not found",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
event = TestNotificationEvent(channel_name=channel_model.name)
|
|
419
|
+
|
|
420
|
+
return await self._send_to_channel(channel_model, event)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# =============================================================================
|
|
424
|
+
# Singleton management
|
|
425
|
+
# =============================================================================
|
|
426
|
+
|
|
427
|
+
_dispatcher: NotificationDispatcher | None = None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def get_dispatcher() -> NotificationDispatcher:
|
|
431
|
+
"""Get the notification dispatcher instance.
|
|
432
|
+
|
|
433
|
+
Creates a new dispatcher with a fresh database session.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
NotificationDispatcher instance.
|
|
437
|
+
"""
|
|
438
|
+
async with get_session() as session:
|
|
439
|
+
return NotificationDispatcher(session)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def create_dispatcher(session: AsyncSession) -> NotificationDispatcher:
|
|
443
|
+
"""Create a dispatcher with a specific session.
|
|
444
|
+
|
|
445
|
+
Use this when you need to share a session with other operations.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
session: Database session to use.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
NotificationDispatcher instance.
|
|
452
|
+
"""
|
|
453
|
+
return NotificationDispatcher(session)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Notification event types.
|
|
2
|
+
|
|
3
|
+
This module defines specific event types that can trigger notifications.
|
|
4
|
+
Each event type contains relevant data for message formatting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .base import NotificationEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ValidationFailedEvent(NotificationEvent):
|
|
18
|
+
"""Event triggered when a validation fails.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
validation_id: ID of the validation run.
|
|
22
|
+
has_critical: Whether critical issues were found.
|
|
23
|
+
has_high: Whether high severity issues were found.
|
|
24
|
+
total_issues: Total number of issues.
|
|
25
|
+
issues: List of issue details.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
event_type: str = field(default="validation_failed", init=False)
|
|
29
|
+
validation_id: str = ""
|
|
30
|
+
has_critical: bool = False
|
|
31
|
+
has_high: bool = False
|
|
32
|
+
total_issues: int = 0
|
|
33
|
+
issues: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def severity(self) -> str:
|
|
37
|
+
"""Get the highest severity level."""
|
|
38
|
+
if self.has_critical:
|
|
39
|
+
return "Critical"
|
|
40
|
+
if self.has_high:
|
|
41
|
+
return "High"
|
|
42
|
+
return "Medium"
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
"""Convert event to dictionary."""
|
|
46
|
+
base = super().to_dict()
|
|
47
|
+
base.update(
|
|
48
|
+
{
|
|
49
|
+
"validation_id": self.validation_id,
|
|
50
|
+
"has_critical": self.has_critical,
|
|
51
|
+
"has_high": self.has_high,
|
|
52
|
+
"total_issues": self.total_issues,
|
|
53
|
+
"severity": self.severity,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return base
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ScheduleFailedEvent(NotificationEvent):
|
|
61
|
+
"""Event triggered when a scheduled validation fails.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
schedule_id: ID of the schedule.
|
|
65
|
+
schedule_name: Name of the schedule.
|
|
66
|
+
validation_id: ID of the failed validation.
|
|
67
|
+
error_message: Error message if execution failed.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
event_type: str = field(default="schedule_failed", init=False)
|
|
71
|
+
schedule_id: str = ""
|
|
72
|
+
schedule_name: str = ""
|
|
73
|
+
validation_id: str | None = None
|
|
74
|
+
error_message: str | None = None
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
"""Convert event to dictionary."""
|
|
78
|
+
base = super().to_dict()
|
|
79
|
+
base.update(
|
|
80
|
+
{
|
|
81
|
+
"schedule_id": self.schedule_id,
|
|
82
|
+
"schedule_name": self.schedule_name,
|
|
83
|
+
"validation_id": self.validation_id,
|
|
84
|
+
"error_message": self.error_message,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return base
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class DriftDetectedEvent(NotificationEvent):
|
|
92
|
+
"""Event triggered when drift is detected between datasets.
|
|
93
|
+
|
|
94
|
+
Attributes:
|
|
95
|
+
comparison_id: ID of the drift comparison.
|
|
96
|
+
baseline_source_id: ID of the baseline source.
|
|
97
|
+
baseline_source_name: Name of the baseline source.
|
|
98
|
+
current_source_id: ID of the current source.
|
|
99
|
+
current_source_name: Name of the current source.
|
|
100
|
+
has_high_drift: Whether high severity drift was detected.
|
|
101
|
+
drifted_columns: Number of columns with drift.
|
|
102
|
+
total_columns: Total columns compared.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
event_type: str = field(default="drift_detected", init=False)
|
|
106
|
+
comparison_id: str = ""
|
|
107
|
+
baseline_source_id: str = ""
|
|
108
|
+
baseline_source_name: str = ""
|
|
109
|
+
current_source_id: str = ""
|
|
110
|
+
current_source_name: str = ""
|
|
111
|
+
has_high_drift: bool = False
|
|
112
|
+
drifted_columns: int = 0
|
|
113
|
+
total_columns: int = 0
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def drift_percentage(self) -> float:
|
|
117
|
+
"""Calculate drift percentage."""
|
|
118
|
+
if self.total_columns > 0:
|
|
119
|
+
return (self.drifted_columns / self.total_columns) * 100
|
|
120
|
+
return 0.0
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, Any]:
|
|
123
|
+
"""Convert event to dictionary."""
|
|
124
|
+
base = super().to_dict()
|
|
125
|
+
base.update(
|
|
126
|
+
{
|
|
127
|
+
"comparison_id": self.comparison_id,
|
|
128
|
+
"baseline_source_id": self.baseline_source_id,
|
|
129
|
+
"baseline_source_name": self.baseline_source_name,
|
|
130
|
+
"current_source_id": self.current_source_id,
|
|
131
|
+
"current_source_name": self.current_source_name,
|
|
132
|
+
"has_high_drift": self.has_high_drift,
|
|
133
|
+
"drifted_columns": self.drifted_columns,
|
|
134
|
+
"total_columns": self.total_columns,
|
|
135
|
+
"drift_percentage": round(self.drift_percentage, 2),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return base
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class TestNotificationEvent(NotificationEvent):
|
|
143
|
+
"""Event for testing notification channels.
|
|
144
|
+
|
|
145
|
+
Used when sending test notifications to verify channel configuration.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
event_type: str = field(default="test", init=False)
|
|
149
|
+
channel_name: str = ""
|
|
150
|
+
|
|
151
|
+
def to_dict(self) -> dict[str, Any]:
|
|
152
|
+
"""Convert event to dictionary."""
|
|
153
|
+
base = super().to_dict()
|
|
154
|
+
base.update({"channel_name": self.channel_name})
|
|
155
|
+
return base
|