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.
Files changed (62) hide show
  1. truthound_dashboard/__init__.py +11 -0
  2. truthound_dashboard/__main__.py +6 -0
  3. truthound_dashboard/api/__init__.py +15 -0
  4. truthound_dashboard/api/deps.py +153 -0
  5. truthound_dashboard/api/drift.py +179 -0
  6. truthound_dashboard/api/error_handlers.py +287 -0
  7. truthound_dashboard/api/health.py +78 -0
  8. truthound_dashboard/api/history.py +62 -0
  9. truthound_dashboard/api/middleware.py +626 -0
  10. truthound_dashboard/api/notifications.py +561 -0
  11. truthound_dashboard/api/profile.py +52 -0
  12. truthound_dashboard/api/router.py +83 -0
  13. truthound_dashboard/api/rules.py +277 -0
  14. truthound_dashboard/api/schedules.py +329 -0
  15. truthound_dashboard/api/schemas.py +136 -0
  16. truthound_dashboard/api/sources.py +229 -0
  17. truthound_dashboard/api/validations.py +125 -0
  18. truthound_dashboard/cli.py +226 -0
  19. truthound_dashboard/config.py +132 -0
  20. truthound_dashboard/core/__init__.py +264 -0
  21. truthound_dashboard/core/base.py +185 -0
  22. truthound_dashboard/core/cache.py +479 -0
  23. truthound_dashboard/core/connections.py +331 -0
  24. truthound_dashboard/core/encryption.py +409 -0
  25. truthound_dashboard/core/exceptions.py +627 -0
  26. truthound_dashboard/core/logging.py +488 -0
  27. truthound_dashboard/core/maintenance.py +542 -0
  28. truthound_dashboard/core/notifications/__init__.py +56 -0
  29. truthound_dashboard/core/notifications/base.py +390 -0
  30. truthound_dashboard/core/notifications/channels.py +557 -0
  31. truthound_dashboard/core/notifications/dispatcher.py +453 -0
  32. truthound_dashboard/core/notifications/events.py +155 -0
  33. truthound_dashboard/core/notifications/service.py +744 -0
  34. truthound_dashboard/core/sampling.py +626 -0
  35. truthound_dashboard/core/scheduler.py +311 -0
  36. truthound_dashboard/core/services.py +1531 -0
  37. truthound_dashboard/core/truthound_adapter.py +659 -0
  38. truthound_dashboard/db/__init__.py +67 -0
  39. truthound_dashboard/db/base.py +108 -0
  40. truthound_dashboard/db/database.py +196 -0
  41. truthound_dashboard/db/models.py +732 -0
  42. truthound_dashboard/db/repository.py +237 -0
  43. truthound_dashboard/main.py +309 -0
  44. truthound_dashboard/schemas/__init__.py +150 -0
  45. truthound_dashboard/schemas/base.py +96 -0
  46. truthound_dashboard/schemas/drift.py +118 -0
  47. truthound_dashboard/schemas/history.py +74 -0
  48. truthound_dashboard/schemas/profile.py +91 -0
  49. truthound_dashboard/schemas/rule.py +199 -0
  50. truthound_dashboard/schemas/schedule.py +88 -0
  51. truthound_dashboard/schemas/schema.py +121 -0
  52. truthound_dashboard/schemas/source.py +138 -0
  53. truthound_dashboard/schemas/validation.py +192 -0
  54. truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
  55. truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
  56. truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
  57. truthound_dashboard/static/index.html +15 -0
  58. truthound_dashboard/static/mockServiceWorker.js +349 -0
  59. truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
  60. truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
  61. truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
  62. 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