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,557 @@
1
+ """Notification channel implementations.
2
+
3
+ This module provides concrete implementations of notification channels
4
+ for Slack, Email, and Webhook notifications.
5
+
6
+ Each channel is registered with the ChannelRegistry and can be
7
+ instantiated dynamically based on channel type.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from email.mime.multipart import MIMEMultipart
13
+ from email.mime.text import MIMEText
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from .base import (
19
+ BaseNotificationChannel,
20
+ ChannelRegistry,
21
+ NotificationEvent,
22
+ )
23
+ from .events import (
24
+ DriftDetectedEvent,
25
+ ScheduleFailedEvent,
26
+ TestNotificationEvent,
27
+ ValidationFailedEvent,
28
+ )
29
+
30
+
31
+ @ChannelRegistry.register("slack")
32
+ class SlackChannel(BaseNotificationChannel):
33
+ """Slack notification channel using incoming webhooks.
34
+
35
+ Configuration:
36
+ webhook_url: Slack incoming webhook URL
37
+ channel: Optional channel override
38
+ username: Optional username override
39
+ icon_emoji: Optional emoji icon (e.g., ":robot:")
40
+
41
+ Example config:
42
+ {
43
+ "webhook_url": "https://hooks.slack.com/services/...",
44
+ "username": "Truthound Bot",
45
+ "icon_emoji": ":bar_chart:"
46
+ }
47
+ """
48
+
49
+ channel_type = "slack"
50
+
51
+ @classmethod
52
+ def get_config_schema(cls) -> dict[str, Any]:
53
+ """Get Slack channel configuration schema."""
54
+ return {
55
+ "webhook_url": {
56
+ "type": "string",
57
+ "required": True,
58
+ "description": "Slack incoming webhook URL",
59
+ },
60
+ "channel": {
61
+ "type": "string",
62
+ "required": False,
63
+ "description": "Channel override (e.g., #alerts)",
64
+ },
65
+ "username": {
66
+ "type": "string",
67
+ "required": False,
68
+ "description": "Bot username override",
69
+ },
70
+ "icon_emoji": {
71
+ "type": "string",
72
+ "required": False,
73
+ "description": "Emoji icon (e.g., :robot:)",
74
+ },
75
+ }
76
+
77
+ async def send(
78
+ self,
79
+ message: str,
80
+ event: NotificationEvent | None = None,
81
+ **kwargs: Any,
82
+ ) -> bool:
83
+ """Send notification to Slack.
84
+
85
+ Args:
86
+ message: Message text (supports Slack markdown).
87
+ event: Optional triggering event.
88
+ **kwargs: Additional Slack message options.
89
+
90
+ Returns:
91
+ True if message was sent successfully.
92
+ """
93
+ webhook_url = self.config["webhook_url"]
94
+
95
+ # Build payload
96
+ payload: dict[str, Any] = {
97
+ "text": message,
98
+ }
99
+
100
+ # Add blocks for rich formatting
101
+ blocks = self._build_blocks(message, event)
102
+ if blocks:
103
+ payload["blocks"] = blocks
104
+
105
+ # Add optional overrides
106
+ if self.config.get("channel"):
107
+ payload["channel"] = self.config["channel"]
108
+ if self.config.get("username"):
109
+ payload["username"] = self.config["username"]
110
+ if self.config.get("icon_emoji"):
111
+ payload["icon_emoji"] = self.config["icon_emoji"]
112
+
113
+ # Merge any additional kwargs
114
+ payload.update(kwargs)
115
+
116
+ async with httpx.AsyncClient(timeout=30.0) as client:
117
+ response = await client.post(webhook_url, json=payload)
118
+ return response.status_code == 200
119
+
120
+ def _build_blocks(
121
+ self,
122
+ message: str,
123
+ event: NotificationEvent | None,
124
+ ) -> list[dict[str, Any]]:
125
+ """Build Slack blocks for rich message formatting."""
126
+ blocks = []
127
+
128
+ # Main message section
129
+ blocks.append(
130
+ {
131
+ "type": "section",
132
+ "text": {"type": "mrkdwn", "text": message},
133
+ }
134
+ )
135
+
136
+ # Add context for specific event types
137
+ if isinstance(event, ValidationFailedEvent):
138
+ context_elements = [
139
+ {"type": "mrkdwn", "text": f"*Severity:* {event.severity}"},
140
+ {"type": "mrkdwn", "text": f"*Issues:* {event.total_issues}"},
141
+ ]
142
+ if event.validation_id:
143
+ context_elements.append(
144
+ {"type": "mrkdwn", "text": f"*ID:* `{event.validation_id[:8]}...`"}
145
+ )
146
+ blocks.append({"type": "context", "elements": context_elements})
147
+
148
+ elif isinstance(event, DriftDetectedEvent):
149
+ context_elements = [
150
+ {
151
+ "type": "mrkdwn",
152
+ "text": f"*Drift:* {event.drifted_columns}/{event.total_columns} columns",
153
+ },
154
+ {
155
+ "type": "mrkdwn",
156
+ "text": f"*Percentage:* {event.drift_percentage:.1f}%",
157
+ },
158
+ ]
159
+ blocks.append({"type": "context", "elements": context_elements})
160
+
161
+ return blocks
162
+
163
+ def format_message(self, event: NotificationEvent) -> str:
164
+ """Format message for Slack with mrkdwn syntax."""
165
+ if isinstance(event, ValidationFailedEvent):
166
+ emoji = ":rotating_light:" if event.has_critical else ":warning:"
167
+ return (
168
+ f"{emoji} *Validation Failed*\n\n"
169
+ f"*Source:* {event.source_name or 'Unknown'}\n"
170
+ f"*Severity:* {event.severity}\n"
171
+ f"*Total Issues:* {event.total_issues}"
172
+ )
173
+
174
+ elif isinstance(event, ScheduleFailedEvent):
175
+ return (
176
+ f":clock1: *Scheduled Validation Failed*\n\n"
177
+ f"*Schedule:* {event.schedule_name}\n"
178
+ f"*Source:* {event.source_name or 'Unknown'}\n"
179
+ f"*Error:* {event.error_message or 'Validation failed'}"
180
+ )
181
+
182
+ elif isinstance(event, DriftDetectedEvent):
183
+ emoji = ":chart_with_upwards_trend:" if event.has_high_drift else ":chart_with_downwards_trend:"
184
+ return (
185
+ f"{emoji} *Drift Detected*\n\n"
186
+ f"*Baseline:* {event.baseline_source_name}\n"
187
+ f"*Current:* {event.current_source_name}\n"
188
+ f"*Drifted Columns:* {event.drifted_columns}/{event.total_columns} "
189
+ f"({event.drift_percentage:.1f}%)"
190
+ )
191
+
192
+ elif isinstance(event, TestNotificationEvent):
193
+ return (
194
+ f":white_check_mark: *Test Notification*\n\n"
195
+ f"This is a test from truthound-dashboard.\n"
196
+ f"Channel: {event.channel_name}"
197
+ )
198
+
199
+ return self._default_format(event)
200
+
201
+
202
+ @ChannelRegistry.register("email")
203
+ class EmailChannel(BaseNotificationChannel):
204
+ """Email notification channel using SMTP.
205
+
206
+ Configuration:
207
+ smtp_host: SMTP server hostname
208
+ smtp_port: SMTP server port (default: 587)
209
+ smtp_username: SMTP authentication username
210
+ smtp_password: SMTP authentication password
211
+ from_email: Sender email address
212
+ recipients: List of recipient email addresses
213
+ use_tls: Whether to use TLS (default: True)
214
+
215
+ Example config:
216
+ {
217
+ "smtp_host": "smtp.gmail.com",
218
+ "smtp_port": 587,
219
+ "smtp_username": "user@gmail.com",
220
+ "smtp_password": "app-password",
221
+ "from_email": "alerts@example.com",
222
+ "recipients": ["admin@example.com"],
223
+ "use_tls": true
224
+ }
225
+ """
226
+
227
+ channel_type = "email"
228
+
229
+ @classmethod
230
+ def get_config_schema(cls) -> dict[str, Any]:
231
+ """Get Email channel configuration schema."""
232
+ return {
233
+ "smtp_host": {
234
+ "type": "string",
235
+ "required": True,
236
+ "description": "SMTP server hostname",
237
+ },
238
+ "smtp_port": {
239
+ "type": "integer",
240
+ "required": False,
241
+ "default": 587,
242
+ "description": "SMTP server port",
243
+ },
244
+ "smtp_username": {
245
+ "type": "string",
246
+ "required": False,
247
+ "description": "SMTP authentication username",
248
+ },
249
+ "smtp_password": {
250
+ "type": "string",
251
+ "required": False,
252
+ "secret": True,
253
+ "description": "SMTP authentication password",
254
+ },
255
+ "from_email": {
256
+ "type": "string",
257
+ "required": True,
258
+ "description": "Sender email address",
259
+ },
260
+ "recipients": {
261
+ "type": "array",
262
+ "required": True,
263
+ "items": {"type": "string"},
264
+ "description": "List of recipient email addresses",
265
+ },
266
+ "use_tls": {
267
+ "type": "boolean",
268
+ "required": False,
269
+ "default": True,
270
+ "description": "Use TLS encryption",
271
+ },
272
+ }
273
+
274
+ async def send(
275
+ self,
276
+ message: str,
277
+ event: NotificationEvent | None = None,
278
+ subject: str | None = None,
279
+ **kwargs: Any,
280
+ ) -> bool:
281
+ """Send notification via email.
282
+
283
+ Args:
284
+ message: Email body text.
285
+ event: Optional triggering event (used for subject).
286
+ subject: Optional subject override.
287
+ **kwargs: Additional options.
288
+
289
+ Returns:
290
+ True if email was sent successfully.
291
+ """
292
+ try:
293
+ import aiosmtplib
294
+ except ImportError:
295
+ raise ImportError(
296
+ "aiosmtplib is required for email notifications. "
297
+ "Install with: pip install aiosmtplib"
298
+ )
299
+
300
+ # Build subject
301
+ if subject is None:
302
+ subject = self._build_subject(event)
303
+
304
+ # Create message
305
+ msg = MIMEMultipart("alternative")
306
+ msg["Subject"] = subject
307
+ msg["From"] = self.config["from_email"]
308
+ msg["To"] = ", ".join(self.config["recipients"])
309
+
310
+ # Plain text version
311
+ text_part = MIMEText(message, "plain")
312
+ msg.attach(text_part)
313
+
314
+ # HTML version
315
+ html_content = self._build_html(message, event)
316
+ html_part = MIMEText(html_content, "html")
317
+ msg.attach(html_part)
318
+
319
+ # Send
320
+ await aiosmtplib.send(
321
+ msg,
322
+ hostname=self.config["smtp_host"],
323
+ port=self.config.get("smtp_port", 587),
324
+ username=self.config.get("smtp_username"),
325
+ password=self.config.get("smtp_password"),
326
+ use_tls=self.config.get("use_tls", True),
327
+ )
328
+
329
+ return True
330
+
331
+ def _build_subject(self, event: NotificationEvent | None) -> str:
332
+ """Build email subject from event."""
333
+ if event is None:
334
+ return "[Truthound] Notification"
335
+
336
+ if isinstance(event, ValidationFailedEvent):
337
+ severity = event.severity
338
+ return f"[Truthound] {severity} - Validation Failed: {event.source_name}"
339
+
340
+ elif isinstance(event, ScheduleFailedEvent):
341
+ return f"[Truthound] Schedule Failed: {event.schedule_name}"
342
+
343
+ elif isinstance(event, DriftDetectedEvent):
344
+ return f"[Truthound] Drift Detected: {event.baseline_source_name} → {event.current_source_name}"
345
+
346
+ elif isinstance(event, TestNotificationEvent):
347
+ return "[Truthound] Test Notification"
348
+
349
+ return f"[Truthound] {event.event_type}"
350
+
351
+ def _build_html(self, message: str, event: NotificationEvent | None) -> str:
352
+ """Build HTML email body."""
353
+ # Convert newlines to <br> for simple HTML
354
+ html_message = message.replace("\n", "<br>")
355
+
356
+ return f"""
357
+ <!DOCTYPE html>
358
+ <html>
359
+ <head>
360
+ <style>
361
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
362
+ .container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
363
+ .header {{ background: #fd9e4b; color: white; padding: 15px; border-radius: 5px 5px 0 0; }}
364
+ .content {{ background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }}
365
+ .footer {{ font-size: 12px; color: #666; margin-top: 20px; }}
366
+ </style>
367
+ </head>
368
+ <body>
369
+ <div class="container">
370
+ <div class="header">
371
+ <h2 style="margin: 0;">Truthound Dashboard</h2>
372
+ </div>
373
+ <div class="content">
374
+ <p>{html_message}</p>
375
+ </div>
376
+ <div class="footer">
377
+ <p>This notification was sent by Truthound Dashboard.</p>
378
+ </div>
379
+ </div>
380
+ </body>
381
+ </html>
382
+ """
383
+
384
+ def format_message(self, event: NotificationEvent) -> str:
385
+ """Format message for email (plain text)."""
386
+ if isinstance(event, ValidationFailedEvent):
387
+ return (
388
+ f"Validation Failed\n\n"
389
+ f"Source: {event.source_name or 'Unknown'}\n"
390
+ f"Severity: {event.severity}\n"
391
+ f"Total Issues: {event.total_issues}\n"
392
+ f"Validation ID: {event.validation_id}\n\n"
393
+ f"Please check the dashboard for details."
394
+ )
395
+
396
+ elif isinstance(event, ScheduleFailedEvent):
397
+ return (
398
+ f"Scheduled Validation Failed\n\n"
399
+ f"Schedule: {event.schedule_name}\n"
400
+ f"Source: {event.source_name or 'Unknown'}\n"
401
+ f"Error: {event.error_message or 'Validation failed'}\n\n"
402
+ f"Please check the dashboard for details."
403
+ )
404
+
405
+ elif isinstance(event, DriftDetectedEvent):
406
+ return (
407
+ f"Drift Detected\n\n"
408
+ f"Baseline: {event.baseline_source_name}\n"
409
+ f"Current: {event.current_source_name}\n"
410
+ f"Drifted Columns: {event.drifted_columns}/{event.total_columns} "
411
+ f"({event.drift_percentage:.1f}%)\n\n"
412
+ f"Please check the dashboard for details."
413
+ )
414
+
415
+ elif isinstance(event, TestNotificationEvent):
416
+ return (
417
+ f"Test Notification\n\n"
418
+ f"This is a test notification from truthound-dashboard.\n"
419
+ f"Channel: {event.channel_name}\n\n"
420
+ f"If you received this, your email channel is configured correctly."
421
+ )
422
+
423
+ return self._default_format(event)
424
+
425
+
426
+ @ChannelRegistry.register("webhook")
427
+ class WebhookChannel(BaseNotificationChannel):
428
+ """Generic webhook notification channel.
429
+
430
+ Sends JSON payloads to any HTTP endpoint.
431
+
432
+ Configuration:
433
+ url: Webhook endpoint URL
434
+ method: HTTP method (default: POST)
435
+ headers: Optional custom headers
436
+ include_event_data: Whether to include full event data (default: True)
437
+
438
+ Example config:
439
+ {
440
+ "url": "https://example.com/webhook",
441
+ "method": "POST",
442
+ "headers": {
443
+ "Authorization": "Bearer token",
444
+ "X-Custom-Header": "value"
445
+ }
446
+ }
447
+ """
448
+
449
+ channel_type = "webhook"
450
+
451
+ @classmethod
452
+ def get_config_schema(cls) -> dict[str, Any]:
453
+ """Get Webhook channel configuration schema."""
454
+ return {
455
+ "url": {
456
+ "type": "string",
457
+ "required": True,
458
+ "description": "Webhook endpoint URL",
459
+ },
460
+ "method": {
461
+ "type": "string",
462
+ "required": False,
463
+ "default": "POST",
464
+ "description": "HTTP method (GET, POST, PUT)",
465
+ },
466
+ "headers": {
467
+ "type": "object",
468
+ "required": False,
469
+ "description": "Custom HTTP headers",
470
+ },
471
+ "include_event_data": {
472
+ "type": "boolean",
473
+ "required": False,
474
+ "default": True,
475
+ "description": "Include full event data in payload",
476
+ },
477
+ }
478
+
479
+ async def send(
480
+ self,
481
+ message: str,
482
+ event: NotificationEvent | None = None,
483
+ payload: dict[str, Any] | None = None,
484
+ **kwargs: Any,
485
+ ) -> bool:
486
+ """Send notification via webhook.
487
+
488
+ Args:
489
+ message: Message text.
490
+ event: Optional triggering event.
491
+ payload: Optional custom payload (overrides default).
492
+ **kwargs: Additional options.
493
+
494
+ Returns:
495
+ True if webhook call was successful.
496
+ """
497
+ url = self.config["url"]
498
+ method = self.config.get("method", "POST").upper()
499
+ headers = self.config.get("headers", {})
500
+
501
+ # Build payload
502
+ if payload is None:
503
+ payload = self._build_payload(message, event)
504
+
505
+ async with httpx.AsyncClient(timeout=30.0) as client:
506
+ if method == "GET":
507
+ response = await client.get(url, headers=headers, params=payload)
508
+ elif method == "PUT":
509
+ response = await client.put(url, headers=headers, json=payload)
510
+ else: # Default to POST
511
+ response = await client.post(url, headers=headers, json=payload)
512
+
513
+ # Consider 2xx responses as success
514
+ return 200 <= response.status_code < 300
515
+
516
+ def _build_payload(
517
+ self,
518
+ message: str,
519
+ event: NotificationEvent | None,
520
+ ) -> dict[str, Any]:
521
+ """Build webhook payload."""
522
+ payload: dict[str, Any] = {
523
+ "message": message,
524
+ "channel_id": self.channel_id,
525
+ "channel_name": self.name,
526
+ }
527
+
528
+ if event and self.config.get("include_event_data", True):
529
+ payload["event"] = event.to_dict()
530
+ payload["event_type"] = event.event_type
531
+
532
+ return payload
533
+
534
+ def format_message(self, event: NotificationEvent) -> str:
535
+ """Format message for webhook (plain text)."""
536
+ if isinstance(event, ValidationFailedEvent):
537
+ return (
538
+ f"Validation failed for {event.source_name or 'Unknown'}: "
539
+ f"{event.total_issues} issues ({event.severity})"
540
+ )
541
+
542
+ elif isinstance(event, ScheduleFailedEvent):
543
+ return (
544
+ f"Schedule '{event.schedule_name}' failed for "
545
+ f"{event.source_name or 'Unknown'}"
546
+ )
547
+
548
+ elif isinstance(event, DriftDetectedEvent):
549
+ return (
550
+ f"Drift detected: {event.drifted_columns}/{event.total_columns} columns "
551
+ f"({event.drift_percentage:.1f}%)"
552
+ )
553
+
554
+ elif isinstance(event, TestNotificationEvent):
555
+ return f"Test notification from truthound-dashboard"
556
+
557
+ return self._default_format(event)