truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.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/api/alerts.py +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +437 -10
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +11 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
"""Notification action implementations.
|
|
2
|
+
|
|
3
|
+
Provides actions for sending notifications to various platforms:
|
|
4
|
+
- Slack (via webhook or Bot API)
|
|
5
|
+
- Email (via SMTP or API services)
|
|
6
|
+
- Microsoft Teams
|
|
7
|
+
- Discord
|
|
8
|
+
- Telegram
|
|
9
|
+
- PagerDuty
|
|
10
|
+
|
|
11
|
+
These actions are loosely coupled from truthound and can be used
|
|
12
|
+
independently of the checkpoint system.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from truthound_dashboard.core.interfaces.actions import (
|
|
24
|
+
ActionConfig,
|
|
25
|
+
ActionContext,
|
|
26
|
+
ActionResult,
|
|
27
|
+
ActionStatus,
|
|
28
|
+
BaseAction,
|
|
29
|
+
NotifyCondition,
|
|
30
|
+
register_action,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# Slack Notification
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SlackNotificationConfig(ActionConfig):
|
|
43
|
+
"""Configuration for Slack notifications.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
webhook_url: Slack webhook URL.
|
|
47
|
+
channel: Override channel (optional).
|
|
48
|
+
username: Bot username.
|
|
49
|
+
icon_emoji: Bot icon emoji.
|
|
50
|
+
mention_users: Users to mention on failure.
|
|
51
|
+
mention_groups: Groups to mention on failure.
|
|
52
|
+
include_summary: Include issue summary.
|
|
53
|
+
include_details: Include detailed issues.
|
|
54
|
+
max_issues_shown: Max issues to show in message.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
webhook_url: str = ""
|
|
58
|
+
channel: str | None = None
|
|
59
|
+
username: str = "Truthound Bot"
|
|
60
|
+
icon_emoji: str = ":bar_chart:"
|
|
61
|
+
mention_users: list[str] = field(default_factory=list)
|
|
62
|
+
mention_groups: list[str] = field(default_factory=list)
|
|
63
|
+
include_summary: bool = True
|
|
64
|
+
include_details: bool = True
|
|
65
|
+
max_issues_shown: int = 5
|
|
66
|
+
|
|
67
|
+
def __post_init__(self):
|
|
68
|
+
self.name = self.name or "slack"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@register_action("slack")
|
|
72
|
+
class SlackNotificationAction(BaseAction):
|
|
73
|
+
"""Slack notification action via webhook.
|
|
74
|
+
|
|
75
|
+
Sends formatted messages to Slack when validation completes.
|
|
76
|
+
Supports mentions, custom formatting, and rich block layouts.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
action = SlackNotificationAction(
|
|
80
|
+
webhook_url="https://hooks.slack.com/services/...",
|
|
81
|
+
notify_on=NotifyCondition.FAILURE,
|
|
82
|
+
)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
webhook_url: str = "",
|
|
88
|
+
channel: str | None = None,
|
|
89
|
+
username: str = "Truthound Bot",
|
|
90
|
+
notify_on: NotifyCondition = NotifyCondition.FAILURE,
|
|
91
|
+
config: SlackNotificationConfig | dict[str, Any] | None = None,
|
|
92
|
+
**kwargs: Any,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Initialize Slack action.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
webhook_url: Slack webhook URL.
|
|
98
|
+
channel: Override channel.
|
|
99
|
+
username: Bot username.
|
|
100
|
+
notify_on: When to send notifications.
|
|
101
|
+
config: Full configuration object.
|
|
102
|
+
**kwargs: Additional configuration.
|
|
103
|
+
"""
|
|
104
|
+
if config is None:
|
|
105
|
+
config = SlackNotificationConfig(
|
|
106
|
+
webhook_url=webhook_url,
|
|
107
|
+
channel=channel,
|
|
108
|
+
username=username,
|
|
109
|
+
notify_on=notify_on,
|
|
110
|
+
**kwargs,
|
|
111
|
+
)
|
|
112
|
+
elif isinstance(config, dict):
|
|
113
|
+
config = SlackNotificationConfig(**config)
|
|
114
|
+
|
|
115
|
+
super().__init__(config)
|
|
116
|
+
self._slack_config: SlackNotificationConfig = config
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def action_type(self) -> str:
|
|
120
|
+
return "notification"
|
|
121
|
+
|
|
122
|
+
def _do_execute(self, context: ActionContext) -> ActionResult:
|
|
123
|
+
"""Send Slack notification."""
|
|
124
|
+
import httpx
|
|
125
|
+
|
|
126
|
+
result = context.checkpoint_result
|
|
127
|
+
payload = self._build_payload(context)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
|
131
|
+
response = client.post(
|
|
132
|
+
self._slack_config.webhook_url,
|
|
133
|
+
json=payload,
|
|
134
|
+
)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
|
|
137
|
+
return ActionResult(
|
|
138
|
+
action_name=self.name,
|
|
139
|
+
action_type=self.action_type,
|
|
140
|
+
status=ActionStatus.SUCCESS,
|
|
141
|
+
message=f"Slack notification sent for {result.checkpoint_name}",
|
|
142
|
+
details={"channel": self._slack_config.channel},
|
|
143
|
+
)
|
|
144
|
+
except httpx.HTTPError as e:
|
|
145
|
+
return ActionResult(
|
|
146
|
+
action_name=self.name,
|
|
147
|
+
action_type=self.action_type,
|
|
148
|
+
status=ActionStatus.FAILURE,
|
|
149
|
+
message=f"Failed to send Slack notification: {str(e)}",
|
|
150
|
+
error=str(e),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _build_payload(self, context: ActionContext) -> dict[str, Any]:
|
|
154
|
+
"""Build Slack webhook payload with blocks."""
|
|
155
|
+
result = context.checkpoint_result
|
|
156
|
+
status = result.status.value
|
|
157
|
+
|
|
158
|
+
# Determine color and emoji based on status
|
|
159
|
+
if status == "success":
|
|
160
|
+
color = "#36a64f"
|
|
161
|
+
emoji = ":white_check_mark:"
|
|
162
|
+
elif status in ("failure", "error"):
|
|
163
|
+
color = "#dc3545"
|
|
164
|
+
emoji = ":x:"
|
|
165
|
+
elif status == "warning":
|
|
166
|
+
color = "#ffc107"
|
|
167
|
+
emoji = ":warning:"
|
|
168
|
+
else:
|
|
169
|
+
color = "#6c757d"
|
|
170
|
+
emoji = ":grey_question:"
|
|
171
|
+
|
|
172
|
+
# Build blocks
|
|
173
|
+
blocks = [
|
|
174
|
+
{
|
|
175
|
+
"type": "header",
|
|
176
|
+
"text": {
|
|
177
|
+
"type": "plain_text",
|
|
178
|
+
"text": f"{emoji} Validation {status.title()}: {result.checkpoint_name}",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"type": "section",
|
|
183
|
+
"fields": [
|
|
184
|
+
{"type": "mrkdwn", "text": f"*Source:*\n{result.source_name}"},
|
|
185
|
+
{"type": "mrkdwn", "text": f"*Status:*\n{status.title()}"},
|
|
186
|
+
{"type": "mrkdwn", "text": f"*Rows:*\n{result.row_count:,}"},
|
|
187
|
+
{"type": "mrkdwn", "text": f"*Issues:*\n{result.issue_count:,}"},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
# Add summary if enabled
|
|
193
|
+
if self._slack_config.include_summary and result.issue_count > 0:
|
|
194
|
+
summary_text = (
|
|
195
|
+
f":red_circle: Critical: {result.critical_count} "
|
|
196
|
+
f":orange_circle: High: {result.high_count} "
|
|
197
|
+
f":yellow_circle: Medium: {result.medium_count} "
|
|
198
|
+
f":white_circle: Low: {result.low_count}"
|
|
199
|
+
)
|
|
200
|
+
blocks.append({
|
|
201
|
+
"type": "section",
|
|
202
|
+
"text": {"type": "mrkdwn", "text": summary_text},
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
# Add issue details if enabled
|
|
206
|
+
if (
|
|
207
|
+
self._slack_config.include_details
|
|
208
|
+
and result.issues
|
|
209
|
+
and result.issue_count > 0
|
|
210
|
+
):
|
|
211
|
+
issues_text = "*Top Issues:*\n"
|
|
212
|
+
for issue in result.issues[: self._slack_config.max_issues_shown]:
|
|
213
|
+
col = issue.get("column", "N/A")
|
|
214
|
+
issue_type = issue.get("issue_type", "unknown")
|
|
215
|
+
count = issue.get("count", 0)
|
|
216
|
+
issues_text += f"• `{col}`: {issue_type} ({count:,} rows)\n"
|
|
217
|
+
|
|
218
|
+
if result.issue_count > self._slack_config.max_issues_shown:
|
|
219
|
+
issues_text += f"_...and {result.issue_count - self._slack_config.max_issues_shown} more_"
|
|
220
|
+
|
|
221
|
+
blocks.append({
|
|
222
|
+
"type": "section",
|
|
223
|
+
"text": {"type": "mrkdwn", "text": issues_text},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
# Add mentions for failures
|
|
227
|
+
if status in ("failure", "error") and (
|
|
228
|
+
self._slack_config.mention_users or self._slack_config.mention_groups
|
|
229
|
+
):
|
|
230
|
+
mentions = []
|
|
231
|
+
for user in self._slack_config.mention_users:
|
|
232
|
+
mentions.append(f"<@{user}>")
|
|
233
|
+
for group in self._slack_config.mention_groups:
|
|
234
|
+
mentions.append(f"<!subteam^{group}>")
|
|
235
|
+
|
|
236
|
+
blocks.append({
|
|
237
|
+
"type": "section",
|
|
238
|
+
"text": {"type": "mrkdwn", "text": " ".join(mentions)},
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
# Add timestamp
|
|
242
|
+
blocks.append({
|
|
243
|
+
"type": "context",
|
|
244
|
+
"elements": [
|
|
245
|
+
{
|
|
246
|
+
"type": "mrkdwn",
|
|
247
|
+
"text": f"Run ID: `{result.run_id}` | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
payload = {
|
|
253
|
+
"username": self._slack_config.username,
|
|
254
|
+
"icon_emoji": self._slack_config.icon_emoji,
|
|
255
|
+
"attachments": [{"color": color, "blocks": blocks}],
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if self._slack_config.channel:
|
|
259
|
+
payload["channel"] = self._slack_config.channel
|
|
260
|
+
|
|
261
|
+
return payload
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# =============================================================================
|
|
265
|
+
# Email Notification
|
|
266
|
+
# =============================================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class EmailNotificationConfig(ActionConfig):
|
|
271
|
+
"""Configuration for email notifications.
|
|
272
|
+
|
|
273
|
+
Attributes:
|
|
274
|
+
smtp_host: SMTP server host.
|
|
275
|
+
smtp_port: SMTP server port.
|
|
276
|
+
smtp_username: SMTP username.
|
|
277
|
+
smtp_password: SMTP password.
|
|
278
|
+
smtp_use_tls: Use TLS encryption.
|
|
279
|
+
from_email: Sender email address.
|
|
280
|
+
to_emails: Recipient email addresses.
|
|
281
|
+
cc_emails: CC email addresses.
|
|
282
|
+
subject_template: Email subject template.
|
|
283
|
+
body_template: Email body template (HTML).
|
|
284
|
+
include_attachment: Attach detailed report.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
smtp_host: str = ""
|
|
288
|
+
smtp_port: int = 587
|
|
289
|
+
smtp_username: str = ""
|
|
290
|
+
smtp_password: str = ""
|
|
291
|
+
smtp_use_tls: bool = True
|
|
292
|
+
from_email: str = ""
|
|
293
|
+
to_emails: list[str] = field(default_factory=list)
|
|
294
|
+
cc_emails: list[str] = field(default_factory=list)
|
|
295
|
+
subject_template: str = "[{status}] Validation: {checkpoint_name}"
|
|
296
|
+
body_template: str = ""
|
|
297
|
+
include_attachment: bool = False
|
|
298
|
+
|
|
299
|
+
def __post_init__(self):
|
|
300
|
+
self.name = self.name or "email"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@register_action("email")
|
|
304
|
+
class EmailNotificationAction(BaseAction):
|
|
305
|
+
"""Email notification action via SMTP.
|
|
306
|
+
|
|
307
|
+
Sends formatted HTML emails when validation completes.
|
|
308
|
+
Supports templates, attachments, and CC recipients.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __init__(
|
|
312
|
+
self,
|
|
313
|
+
smtp_host: str = "",
|
|
314
|
+
smtp_port: int = 587,
|
|
315
|
+
from_email: str = "",
|
|
316
|
+
to_emails: list[str] | None = None,
|
|
317
|
+
notify_on: NotifyCondition = NotifyCondition.FAILURE,
|
|
318
|
+
config: EmailNotificationConfig | dict[str, Any] | None = None,
|
|
319
|
+
**kwargs: Any,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Initialize email action."""
|
|
322
|
+
if config is None:
|
|
323
|
+
config = EmailNotificationConfig(
|
|
324
|
+
smtp_host=smtp_host,
|
|
325
|
+
smtp_port=smtp_port,
|
|
326
|
+
from_email=from_email,
|
|
327
|
+
to_emails=to_emails or [],
|
|
328
|
+
notify_on=notify_on,
|
|
329
|
+
**kwargs,
|
|
330
|
+
)
|
|
331
|
+
elif isinstance(config, dict):
|
|
332
|
+
config = EmailNotificationConfig(**config)
|
|
333
|
+
|
|
334
|
+
super().__init__(config)
|
|
335
|
+
self._email_config: EmailNotificationConfig = config
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def action_type(self) -> str:
|
|
339
|
+
return "notification"
|
|
340
|
+
|
|
341
|
+
def _do_execute(self, context: ActionContext) -> ActionResult:
|
|
342
|
+
"""Send email notification."""
|
|
343
|
+
import smtplib
|
|
344
|
+
from email.mime.multipart import MIMEMultipart
|
|
345
|
+
from email.mime.text import MIMEText
|
|
346
|
+
|
|
347
|
+
result = context.checkpoint_result
|
|
348
|
+
|
|
349
|
+
# Build subject
|
|
350
|
+
subject = self._email_config.subject_template.format(
|
|
351
|
+
status=result.status.value.upper(),
|
|
352
|
+
checkpoint_name=result.checkpoint_name,
|
|
353
|
+
source_name=result.source_name,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Build body
|
|
357
|
+
body = self._build_html_body(context)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
msg = MIMEMultipart("alternative")
|
|
361
|
+
msg["Subject"] = subject
|
|
362
|
+
msg["From"] = self._email_config.from_email
|
|
363
|
+
msg["To"] = ", ".join(self._email_config.to_emails)
|
|
364
|
+
if self._email_config.cc_emails:
|
|
365
|
+
msg["Cc"] = ", ".join(self._email_config.cc_emails)
|
|
366
|
+
|
|
367
|
+
msg.attach(MIMEText(body, "html"))
|
|
368
|
+
|
|
369
|
+
# Connect and send
|
|
370
|
+
with smtplib.SMTP(
|
|
371
|
+
self._email_config.smtp_host,
|
|
372
|
+
self._email_config.smtp_port,
|
|
373
|
+
) as server:
|
|
374
|
+
if self._email_config.smtp_use_tls:
|
|
375
|
+
server.starttls()
|
|
376
|
+
if self._email_config.smtp_username:
|
|
377
|
+
server.login(
|
|
378
|
+
self._email_config.smtp_username,
|
|
379
|
+
self._email_config.smtp_password,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
recipients = (
|
|
383
|
+
self._email_config.to_emails + self._email_config.cc_emails
|
|
384
|
+
)
|
|
385
|
+
server.sendmail(
|
|
386
|
+
self._email_config.from_email,
|
|
387
|
+
recipients,
|
|
388
|
+
msg.as_string(),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return ActionResult(
|
|
392
|
+
action_name=self.name,
|
|
393
|
+
action_type=self.action_type,
|
|
394
|
+
status=ActionStatus.SUCCESS,
|
|
395
|
+
message=f"Email sent to {len(self._email_config.to_emails)} recipients",
|
|
396
|
+
details={"recipients": self._email_config.to_emails},
|
|
397
|
+
)
|
|
398
|
+
except Exception as e:
|
|
399
|
+
return ActionResult(
|
|
400
|
+
action_name=self.name,
|
|
401
|
+
action_type=self.action_type,
|
|
402
|
+
status=ActionStatus.FAILURE,
|
|
403
|
+
message=f"Failed to send email: {str(e)}",
|
|
404
|
+
error=str(e),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def _build_html_body(self, context: ActionContext) -> str:
|
|
408
|
+
"""Build HTML email body."""
|
|
409
|
+
result = context.checkpoint_result
|
|
410
|
+
status = result.status.value
|
|
411
|
+
|
|
412
|
+
# Determine color
|
|
413
|
+
if status == "success":
|
|
414
|
+
status_color = "#28a745"
|
|
415
|
+
elif status in ("failure", "error"):
|
|
416
|
+
status_color = "#dc3545"
|
|
417
|
+
else:
|
|
418
|
+
status_color = "#ffc107"
|
|
419
|
+
|
|
420
|
+
html = f"""
|
|
421
|
+
<!DOCTYPE html>
|
|
422
|
+
<html>
|
|
423
|
+
<head>
|
|
424
|
+
<style>
|
|
425
|
+
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
|
|
426
|
+
.header {{ background-color: {status_color}; color: white; padding: 20px; }}
|
|
427
|
+
.content {{ padding: 20px; }}
|
|
428
|
+
.summary {{ background-color: #f8f9fa; padding: 15px; margin: 15px 0; }}
|
|
429
|
+
table {{ border-collapse: collapse; width: 100%; }}
|
|
430
|
+
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
|
431
|
+
th {{ background-color: #f2f2f2; }}
|
|
432
|
+
</style>
|
|
433
|
+
</head>
|
|
434
|
+
<body>
|
|
435
|
+
<div class="header">
|
|
436
|
+
<h1>Validation {status.title()}</h1>
|
|
437
|
+
<p>{result.checkpoint_name}</p>
|
|
438
|
+
</div>
|
|
439
|
+
<div class="content">
|
|
440
|
+
<div class="summary">
|
|
441
|
+
<h3>Summary</h3>
|
|
442
|
+
<p><strong>Source:</strong> {result.source_name}</p>
|
|
443
|
+
<p><strong>Rows:</strong> {result.row_count:,}</p>
|
|
444
|
+
<p><strong>Columns:</strong> {result.column_count}</p>
|
|
445
|
+
<p><strong>Total Issues:</strong> {result.issue_count:,}</p>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<h3>Issue Breakdown</h3>
|
|
449
|
+
<table>
|
|
450
|
+
<tr>
|
|
451
|
+
<th>Severity</th>
|
|
452
|
+
<th>Count</th>
|
|
453
|
+
</tr>
|
|
454
|
+
<tr><td>Critical</td><td>{result.critical_count}</td></tr>
|
|
455
|
+
<tr><td>High</td><td>{result.high_count}</td></tr>
|
|
456
|
+
<tr><td>Medium</td><td>{result.medium_count}</td></tr>
|
|
457
|
+
<tr><td>Low</td><td>{result.low_count}</td></tr>
|
|
458
|
+
</table>
|
|
459
|
+
|
|
460
|
+
<p style="margin-top: 20px; color: #6c757d;">
|
|
461
|
+
Run ID: {result.run_id}<br>
|
|
462
|
+
Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
|
463
|
+
</p>
|
|
464
|
+
</div>
|
|
465
|
+
</body>
|
|
466
|
+
</html>
|
|
467
|
+
"""
|
|
468
|
+
return html
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# =============================================================================
|
|
472
|
+
# Microsoft Teams Notification
|
|
473
|
+
# =============================================================================
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@dataclass
|
|
477
|
+
class TeamsNotificationConfig(ActionConfig):
|
|
478
|
+
"""Configuration for Microsoft Teams notifications."""
|
|
479
|
+
|
|
480
|
+
webhook_url: str = ""
|
|
481
|
+
mention_users: list[str] = field(default_factory=list)
|
|
482
|
+
include_summary: bool = True
|
|
483
|
+
|
|
484
|
+
def __post_init__(self):
|
|
485
|
+
self.name = self.name or "teams"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@register_action("teams")
|
|
489
|
+
class TeamsNotificationAction(BaseAction):
|
|
490
|
+
"""Microsoft Teams notification action via webhook."""
|
|
491
|
+
|
|
492
|
+
def __init__(
|
|
493
|
+
self,
|
|
494
|
+
webhook_url: str = "",
|
|
495
|
+
notify_on: NotifyCondition = NotifyCondition.FAILURE,
|
|
496
|
+
config: TeamsNotificationConfig | dict[str, Any] | None = None,
|
|
497
|
+
**kwargs: Any,
|
|
498
|
+
) -> None:
|
|
499
|
+
if config is None:
|
|
500
|
+
config = TeamsNotificationConfig(
|
|
501
|
+
webhook_url=webhook_url,
|
|
502
|
+
notify_on=notify_on,
|
|
503
|
+
**kwargs,
|
|
504
|
+
)
|
|
505
|
+
elif isinstance(config, dict):
|
|
506
|
+
config = TeamsNotificationConfig(**config)
|
|
507
|
+
|
|
508
|
+
super().__init__(config)
|
|
509
|
+
self._teams_config: TeamsNotificationConfig = config
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def action_type(self) -> str:
|
|
513
|
+
return "notification"
|
|
514
|
+
|
|
515
|
+
def _do_execute(self, context: ActionContext) -> ActionResult:
|
|
516
|
+
"""Send Teams notification."""
|
|
517
|
+
import httpx
|
|
518
|
+
|
|
519
|
+
result = context.checkpoint_result
|
|
520
|
+
payload = self._build_adaptive_card(context)
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
|
524
|
+
response = client.post(
|
|
525
|
+
self._teams_config.webhook_url,
|
|
526
|
+
json=payload,
|
|
527
|
+
)
|
|
528
|
+
response.raise_for_status()
|
|
529
|
+
|
|
530
|
+
return ActionResult(
|
|
531
|
+
action_name=self.name,
|
|
532
|
+
action_type=self.action_type,
|
|
533
|
+
status=ActionStatus.SUCCESS,
|
|
534
|
+
message=f"Teams notification sent for {result.checkpoint_name}",
|
|
535
|
+
)
|
|
536
|
+
except httpx.HTTPError as e:
|
|
537
|
+
return ActionResult(
|
|
538
|
+
action_name=self.name,
|
|
539
|
+
action_type=self.action_type,
|
|
540
|
+
status=ActionStatus.FAILURE,
|
|
541
|
+
message=f"Failed to send Teams notification: {str(e)}",
|
|
542
|
+
error=str(e),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def _build_adaptive_card(self, context: ActionContext) -> dict[str, Any]:
|
|
546
|
+
"""Build Teams Adaptive Card payload."""
|
|
547
|
+
result = context.checkpoint_result
|
|
548
|
+
status = result.status.value
|
|
549
|
+
|
|
550
|
+
if status == "success":
|
|
551
|
+
theme_color = "00FF00"
|
|
552
|
+
elif status in ("failure", "error"):
|
|
553
|
+
theme_color = "FF0000"
|
|
554
|
+
else:
|
|
555
|
+
theme_color = "FFA500"
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
"@type": "MessageCard",
|
|
559
|
+
"@context": "http://schema.org/extensions",
|
|
560
|
+
"themeColor": theme_color,
|
|
561
|
+
"summary": f"Validation {status}: {result.checkpoint_name}",
|
|
562
|
+
"sections": [
|
|
563
|
+
{
|
|
564
|
+
"activityTitle": f"Validation {status.title()}: {result.checkpoint_name}",
|
|
565
|
+
"facts": [
|
|
566
|
+
{"name": "Source", "value": result.source_name},
|
|
567
|
+
{"name": "Status", "value": status.title()},
|
|
568
|
+
{"name": "Rows", "value": f"{result.row_count:,}"},
|
|
569
|
+
{"name": "Issues", "value": f"{result.issue_count:,}"},
|
|
570
|
+
{"name": "Critical", "value": str(result.critical_count)},
|
|
571
|
+
{"name": "High", "value": str(result.high_count)},
|
|
572
|
+
],
|
|
573
|
+
}
|
|
574
|
+
],
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# =============================================================================
|
|
579
|
+
# Discord Notification
|
|
580
|
+
# =============================================================================
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@dataclass
|
|
584
|
+
class DiscordNotificationConfig(ActionConfig):
|
|
585
|
+
"""Configuration for Discord notifications."""
|
|
586
|
+
|
|
587
|
+
webhook_url: str = ""
|
|
588
|
+
username: str = "Truthound Bot"
|
|
589
|
+
avatar_url: str = ""
|
|
590
|
+
mention_roles: list[str] = field(default_factory=list)
|
|
591
|
+
|
|
592
|
+
def __post_init__(self):
|
|
593
|
+
self.name = self.name or "discord"
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@register_action("discord")
|
|
597
|
+
class DiscordNotificationAction(BaseAction):
|
|
598
|
+
"""Discord notification action via webhook."""
|
|
599
|
+
|
|
600
|
+
def __init__(
|
|
601
|
+
self,
|
|
602
|
+
webhook_url: str = "",
|
|
603
|
+
notify_on: NotifyCondition = NotifyCondition.FAILURE,
|
|
604
|
+
config: DiscordNotificationConfig | dict[str, Any] | None = None,
|
|
605
|
+
**kwargs: Any,
|
|
606
|
+
) -> None:
|
|
607
|
+
if config is None:
|
|
608
|
+
config = DiscordNotificationConfig(
|
|
609
|
+
webhook_url=webhook_url,
|
|
610
|
+
notify_on=notify_on,
|
|
611
|
+
**kwargs,
|
|
612
|
+
)
|
|
613
|
+
elif isinstance(config, dict):
|
|
614
|
+
config = DiscordNotificationConfig(**config)
|
|
615
|
+
|
|
616
|
+
super().__init__(config)
|
|
617
|
+
self._discord_config: DiscordNotificationConfig = config
|
|
618
|
+
|
|
619
|
+
@property
|
|
620
|
+
def action_type(self) -> str:
|
|
621
|
+
return "notification"
|
|
622
|
+
|
|
623
|
+
def _do_execute(self, context: ActionContext) -> ActionResult:
|
|
624
|
+
"""Send Discord notification."""
|
|
625
|
+
import httpx
|
|
626
|
+
|
|
627
|
+
result = context.checkpoint_result
|
|
628
|
+
payload = self._build_embed(context)
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
|
632
|
+
response = client.post(
|
|
633
|
+
self._discord_config.webhook_url,
|
|
634
|
+
json=payload,
|
|
635
|
+
)
|
|
636
|
+
response.raise_for_status()
|
|
637
|
+
|
|
638
|
+
return ActionResult(
|
|
639
|
+
action_name=self.name,
|
|
640
|
+
action_type=self.action_type,
|
|
641
|
+
status=ActionStatus.SUCCESS,
|
|
642
|
+
message=f"Discord notification sent for {result.checkpoint_name}",
|
|
643
|
+
)
|
|
644
|
+
except httpx.HTTPError as e:
|
|
645
|
+
return ActionResult(
|
|
646
|
+
action_name=self.name,
|
|
647
|
+
action_type=self.action_type,
|
|
648
|
+
status=ActionStatus.FAILURE,
|
|
649
|
+
message=f"Failed to send Discord notification: {str(e)}",
|
|
650
|
+
error=str(e),
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def _build_embed(self, context: ActionContext) -> dict[str, Any]:
|
|
654
|
+
"""Build Discord embed payload."""
|
|
655
|
+
result = context.checkpoint_result
|
|
656
|
+
status = result.status.value
|
|
657
|
+
|
|
658
|
+
if status == "success":
|
|
659
|
+
color = 0x28A745
|
|
660
|
+
elif status in ("failure", "error"):
|
|
661
|
+
color = 0xDC3545
|
|
662
|
+
else:
|
|
663
|
+
color = 0xFFC107
|
|
664
|
+
|
|
665
|
+
embed = {
|
|
666
|
+
"title": f"Validation {status.title()}: {result.checkpoint_name}",
|
|
667
|
+
"color": color,
|
|
668
|
+
"fields": [
|
|
669
|
+
{"name": "Source", "value": result.source_name, "inline": True},
|
|
670
|
+
{"name": "Status", "value": status.title(), "inline": True},
|
|
671
|
+
{"name": "Rows", "value": f"{result.row_count:,}", "inline": True},
|
|
672
|
+
{"name": "Issues", "value": f"{result.issue_count:,}", "inline": True},
|
|
673
|
+
{"name": "Critical", "value": str(result.critical_count), "inline": True},
|
|
674
|
+
{"name": "High", "value": str(result.high_count), "inline": True},
|
|
675
|
+
],
|
|
676
|
+
"footer": {"text": f"Run ID: {result.run_id}"},
|
|
677
|
+
"timestamp": datetime.now().isoformat(),
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
payload = {
|
|
681
|
+
"username": self._discord_config.username,
|
|
682
|
+
"embeds": [embed],
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if self._discord_config.avatar_url:
|
|
686
|
+
payload["avatar_url"] = self._discord_config.avatar_url
|
|
687
|
+
|
|
688
|
+
# Add role mentions
|
|
689
|
+
if self._discord_config.mention_roles and status in ("failure", "error"):
|
|
690
|
+
mentions = " ".join(f"<@&{role}>" for role in self._discord_config.mention_roles)
|
|
691
|
+
payload["content"] = mentions
|
|
692
|
+
|
|
693
|
+
return payload
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# =============================================================================
|
|
697
|
+
# Telegram Notification
|
|
698
|
+
# =============================================================================
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@dataclass
|
|
702
|
+
class TelegramNotificationConfig(ActionConfig):
|
|
703
|
+
"""Configuration for Telegram notifications."""
|
|
704
|
+
|
|
705
|
+
bot_token: str = ""
|
|
706
|
+
chat_id: str = ""
|
|
707
|
+
parse_mode: str = "HTML"
|
|
708
|
+
|
|
709
|
+
def __post_init__(self):
|
|
710
|
+
self.name = self.name or "telegram"
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@register_action("telegram")
|
|
714
|
+
class TelegramNotificationAction(BaseAction):
|
|
715
|
+
"""Telegram notification action via Bot API."""
|
|
716
|
+
|
|
717
|
+
def __init__(
|
|
718
|
+
self,
|
|
719
|
+
bot_token: str = "",
|
|
720
|
+
chat_id: str = "",
|
|
721
|
+
notify_on: NotifyCondition = NotifyCondition.FAILURE,
|
|
722
|
+
config: TelegramNotificationConfig | dict[str, Any] | None = None,
|
|
723
|
+
**kwargs: Any,
|
|
724
|
+
) -> None:
|
|
725
|
+
if config is None:
|
|
726
|
+
config = TelegramNotificationConfig(
|
|
727
|
+
bot_token=bot_token,
|
|
728
|
+
chat_id=chat_id,
|
|
729
|
+
notify_on=notify_on,
|
|
730
|
+
**kwargs,
|
|
731
|
+
)
|
|
732
|
+
elif isinstance(config, dict):
|
|
733
|
+
config = TelegramNotificationConfig(**config)
|
|
734
|
+
|
|
735
|
+
super().__init__(config)
|
|
736
|
+
self._telegram_config: TelegramNotificationConfig = config
|
|
737
|
+
|
|
738
|
+
@property
|
|
739
|
+
def action_type(self) -> str:
|
|
740
|
+
return "notification"
|
|
741
|
+
|
|
742
|
+
def _do_execute(self, context: ActionContext) -> ActionResult:
|
|
743
|
+
"""Send Telegram notification."""
|
|
744
|
+
import httpx
|
|
745
|
+
|
|
746
|
+
result = context.checkpoint_result
|
|
747
|
+
message = self._build_message(context)
|
|
748
|
+
|
|
749
|
+
url = f"https://api.telegram.org/bot{self._telegram_config.bot_token}/sendMessage"
|
|
750
|
+
payload = {
|
|
751
|
+
"chat_id": self._telegram_config.chat_id,
|
|
752
|
+
"text": message,
|
|
753
|
+
"parse_mode": self._telegram_config.parse_mode,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
|
758
|
+
response = client.post(url, json=payload)
|
|
759
|
+
response.raise_for_status()
|
|
760
|
+
|
|
761
|
+
return ActionResult(
|
|
762
|
+
action_name=self.name,
|
|
763
|
+
action_type=self.action_type,
|
|
764
|
+
status=ActionStatus.SUCCESS,
|
|
765
|
+
message=f"Telegram notification sent for {result.checkpoint_name}",
|
|
766
|
+
)
|
|
767
|
+
except httpx.HTTPError as e:
|
|
768
|
+
return ActionResult(
|
|
769
|
+
action_name=self.name,
|
|
770
|
+
action_type=self.action_type,
|
|
771
|
+
status=ActionStatus.FAILURE,
|
|
772
|
+
message=f"Failed to send Telegram notification: {str(e)}",
|
|
773
|
+
error=str(e),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def _build_message(self, context: ActionContext) -> str:
|
|
777
|
+
"""Build Telegram message."""
|
|
778
|
+
result = context.checkpoint_result
|
|
779
|
+
status = result.status.value
|
|
780
|
+
|
|
781
|
+
if status == "success":
|
|
782
|
+
emoji = "✅"
|
|
783
|
+
elif status in ("failure", "error"):
|
|
784
|
+
emoji = "❌"
|
|
785
|
+
else:
|
|
786
|
+
emoji = "⚠️"
|
|
787
|
+
|
|
788
|
+
return f"""
|
|
789
|
+
{emoji} <b>Validation {status.title()}</b>
|
|
790
|
+
|
|
791
|
+
<b>Checkpoint:</b> {result.checkpoint_name}
|
|
792
|
+
<b>Source:</b> {result.source_name}
|
|
793
|
+
<b>Status:</b> {status}
|
|
794
|
+
|
|
795
|
+
<b>Summary:</b>
|
|
796
|
+
• Rows: {result.row_count:,}
|
|
797
|
+
• Issues: {result.issue_count:,}
|
|
798
|
+
- Critical: {result.critical_count}
|
|
799
|
+
- High: {result.high_count}
|
|
800
|
+
- Medium: {result.medium_count}
|
|
801
|
+
- Low: {result.low_count}
|
|
802
|
+
|
|
803
|
+
<code>Run ID: {result.run_id}</code>
|
|
804
|
+
""".strip()
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# =============================================================================
|
|
808
|
+
# PagerDuty Notification
|
|
809
|
+
# =============================================================================
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@dataclass
|
|
813
|
+
class PagerDutyNotificationConfig(ActionConfig):
|
|
814
|
+
"""Configuration for PagerDuty notifications."""
|
|
815
|
+
|
|
816
|
+
routing_key: str = "" # Events API v2 routing key
|
|
817
|
+
severity: str = "critical" # critical, error, warning, info
|
|
818
|
+
dedup_key_prefix: str = "truthound"
|
|
819
|
+
|
|
820
|
+
def __post_init__(self):
|
|
821
|
+
self.name = self.name or "pagerduty"
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@register_action("pagerduty")
|
|
825
|
+
class PagerDutyNotificationAction(BaseAction):
|
|
826
|
+
"""PagerDuty notification action via Events API v2.
|
|
827
|
+
|
|
828
|
+
Creates incidents in PagerDuty for validation failures.
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
def __init__(
|
|
832
|
+
self,
|
|
833
|
+
routing_key: str = "",
|
|
834
|
+
severity: str = "critical",
|
|
835
|
+
notify_on: NotifyCondition = NotifyCondition.FAILURE,
|
|
836
|
+
config: PagerDutyNotificationConfig | dict[str, Any] | None = None,
|
|
837
|
+
**kwargs: Any,
|
|
838
|
+
) -> None:
|
|
839
|
+
if config is None:
|
|
840
|
+
config = PagerDutyNotificationConfig(
|
|
841
|
+
routing_key=routing_key,
|
|
842
|
+
severity=severity,
|
|
843
|
+
notify_on=notify_on,
|
|
844
|
+
**kwargs,
|
|
845
|
+
)
|
|
846
|
+
elif isinstance(config, dict):
|
|
847
|
+
config = PagerDutyNotificationConfig(**config)
|
|
848
|
+
|
|
849
|
+
super().__init__(config)
|
|
850
|
+
self._pagerduty_config: PagerDutyNotificationConfig = config
|
|
851
|
+
|
|
852
|
+
@property
|
|
853
|
+
def action_type(self) -> str:
|
|
854
|
+
return "notification"
|
|
855
|
+
|
|
856
|
+
def _do_execute(self, context: ActionContext) -> ActionResult:
|
|
857
|
+
"""Send PagerDuty alert."""
|
|
858
|
+
import httpx
|
|
859
|
+
|
|
860
|
+
result = context.checkpoint_result
|
|
861
|
+
payload = self._build_event(context)
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
|
865
|
+
response = client.post(
|
|
866
|
+
"https://events.pagerduty.com/v2/enqueue",
|
|
867
|
+
json=payload,
|
|
868
|
+
)
|
|
869
|
+
response.raise_for_status()
|
|
870
|
+
|
|
871
|
+
return ActionResult(
|
|
872
|
+
action_name=self.name,
|
|
873
|
+
action_type=self.action_type,
|
|
874
|
+
status=ActionStatus.SUCCESS,
|
|
875
|
+
message=f"PagerDuty alert created for {result.checkpoint_name}",
|
|
876
|
+
)
|
|
877
|
+
except httpx.HTTPError as e:
|
|
878
|
+
return ActionResult(
|
|
879
|
+
action_name=self.name,
|
|
880
|
+
action_type=self.action_type,
|
|
881
|
+
status=ActionStatus.FAILURE,
|
|
882
|
+
message=f"Failed to create PagerDuty alert: {str(e)}",
|
|
883
|
+
error=str(e),
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
def _build_event(self, context: ActionContext) -> dict[str, Any]:
|
|
887
|
+
"""Build PagerDuty Events API v2 payload."""
|
|
888
|
+
result = context.checkpoint_result
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
"routing_key": self._pagerduty_config.routing_key,
|
|
892
|
+
"event_action": "trigger",
|
|
893
|
+
"dedup_key": f"{self._pagerduty_config.dedup_key_prefix}-{result.checkpoint_name}-{result.source_name}",
|
|
894
|
+
"payload": {
|
|
895
|
+
"summary": f"Data validation failed: {result.checkpoint_name}",
|
|
896
|
+
"severity": self._pagerduty_config.severity,
|
|
897
|
+
"source": result.source_name,
|
|
898
|
+
"timestamp": datetime.now().isoformat(),
|
|
899
|
+
"custom_details": {
|
|
900
|
+
"checkpoint": result.checkpoint_name,
|
|
901
|
+
"source": result.source_name,
|
|
902
|
+
"status": result.status.value,
|
|
903
|
+
"rows": result.row_count,
|
|
904
|
+
"total_issues": result.issue_count,
|
|
905
|
+
"critical_issues": result.critical_count,
|
|
906
|
+
"high_issues": result.high_count,
|
|
907
|
+
"run_id": result.run_id,
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
}
|