код написан
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# [DEF:backend.src.services.notifications.__tests__.test_notification_service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Unit tests for NotificationService routing and dispatch logic.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import time
|
||||
|
||||
from src.models.llm import ValidationRecord, ValidationPolicy
|
||||
from src.models.profile import UserDashboardPreference
|
||||
from src.models.auth import User
|
||||
from src.services.notifications.service import NotificationService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_manager():
|
||||
cm = MagicMock()
|
||||
cm.get_payload.return_value = {
|
||||
"notifications": {
|
||||
"smtp": {"host": "localhost", "port": 25, "from_email": "test@example.com"},
|
||||
"telegram": {"bot_token": "test_token"}
|
||||
}
|
||||
}
|
||||
return cm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_db, mock_config_manager):
|
||||
return NotificationService(mock_db, mock_config_manager)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_notify_fail_only(service):
|
||||
record = ValidationRecord(status="FAIL")
|
||||
policy = ValidationPolicy(alert_condition="FAIL_ONLY")
|
||||
assert service._should_notify(record, policy) is True
|
||||
|
||||
record.status = "WARN"
|
||||
assert service._should_notify(record, policy) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_notify_warn_and_fail(service):
|
||||
policy = ValidationPolicy(alert_condition="WARN_AND_FAIL")
|
||||
|
||||
record = ValidationRecord(status="FAIL")
|
||||
assert service._should_notify(record, policy) is True
|
||||
|
||||
record.status = "WARN"
|
||||
assert service._should_notify(record, policy) is True
|
||||
|
||||
record.status = "PASS"
|
||||
assert service._should_notify(record, policy) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_targets_owner_routing(service, mock_db):
|
||||
record = ValidationRecord(dashboard_id="dash-1", environment_id="env-1")
|
||||
|
||||
user = User(email="user@example.com")
|
||||
pref = UserDashboardPreference(
|
||||
user=user,
|
||||
telegram_id="12345",
|
||||
notify_on_fail=True,
|
||||
superset_username="user1"
|
||||
)
|
||||
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [pref]
|
||||
|
||||
targets = service._resolve_targets(record, None)
|
||||
|
||||
assert ("TELEGRAM", "12345") in targets
|
||||
assert ("SMTP", "user@example.com") in targets
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_targets_custom_channels(service):
|
||||
record = ValidationRecord(status="FAIL")
|
||||
policy = ValidationPolicy(
|
||||
notify_owners=False,
|
||||
custom_channels=[{"type": "SLACK", "target": "#alerts"}]
|
||||
)
|
||||
|
||||
targets = service._resolve_targets(record, policy)
|
||||
assert targets == [("SLACK", "#alerts")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_report_skips_if_no_notify(service):
|
||||
record = ValidationRecord(status="PASS")
|
||||
policy = ValidationPolicy(alert_condition="FAIL_ONLY")
|
||||
|
||||
with patch.object(service, "_resolve_targets") as mock_resolve:
|
||||
await service.dispatch_report(record, policy)
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_report_calls_providers(service, mock_db):
|
||||
record = ValidationRecord(id="rec-1", status="FAIL", summary="Bad", issues=[])
|
||||
|
||||
# Mock providers
|
||||
service._initialize_providers()
|
||||
service._providers["TELEGRAM"] = AsyncMock()
|
||||
service._providers["SMTP"] = AsyncMock()
|
||||
|
||||
# Mock targets
|
||||
with patch.object(service, "_resolve_targets") as mock_resolve:
|
||||
mock_resolve.return_value = [("TELEGRAM", "123"), ("SMTP", "a@b.com")]
|
||||
await service.dispatch_report(record, None)
|
||||
|
||||
service._providers["TELEGRAM"].send.assert_called_once()
|
||||
service._providers["SMTP"].send.assert_called_once()
|
||||
|
||||
# [/DEF:backend.src.services.notifications.__tests__.test_notification_service:Module]
|
||||
123
backend/src/services/notifications/providers.py
Normal file
123
backend/src/services/notifications/providers.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# [DEF:backend.src.services.notifications.providers:Module]
|
||||
#
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: notifications, providers, smtp, slack, telegram, abstraction
|
||||
# @PURPOSE: Defines abstract base and concrete implementations for external notification delivery.
|
||||
# @LAYER: Infra
|
||||
#
|
||||
# @INVARIANT: Providers must be stateless and resilient to network failures.
|
||||
# @INVARIANT: Sensitive credentials must be handled via encrypted config.
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional
|
||||
import requests
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from ...core.logger import logger
|
||||
|
||||
|
||||
# [DEF:NotificationProvider:Class]
|
||||
# @PURPOSE: Abstract base class for all notification providers.
|
||||
class NotificationProvider(ABC):
|
||||
@abstractmethod
|
||||
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""
|
||||
Send a notification to a specific target.
|
||||
:param target: Recipient identifier (email, channel ID, user ID).
|
||||
:param subject: Notification subject or title.
|
||||
:param body: Main content of the notification.
|
||||
:param context: Additional metadata for the provider.
|
||||
:return: True if successfully dispatched.
|
||||
"""
|
||||
pass
|
||||
# [/DEF:NotificationProvider:Class]
|
||||
|
||||
|
||||
# [DEF:SMTPProvider:Class]
|
||||
# @PURPOSE: Delivers notifications via SMTP.
|
||||
class SMTPProvider(NotificationProvider):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.host = config.get("host")
|
||||
self.port = int(config.get("port", 587))
|
||||
self.username = config.get("username")
|
||||
self.password = config.get("password")
|
||||
self.from_email = config.get("from_email")
|
||||
self.use_tls = config.get("use_tls", True)
|
||||
|
||||
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = self.from_email
|
||||
msg["To"] = target
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
|
||||
server = smtplib.SMTP(self.host, self.port)
|
||||
if self.use_tls:
|
||||
server.starttls()
|
||||
if self.username and self.password:
|
||||
server.login(self.username, self.password)
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[SMTPProvider][FAILED] Failed to send email to {target}: {e}")
|
||||
return False
|
||||
# [/DEF:SMTPProvider:Class]
|
||||
|
||||
|
||||
# [DEF:TelegramProvider:Class]
|
||||
# @PURPOSE: Delivers notifications via Telegram Bot API.
|
||||
class TelegramProvider(NotificationProvider):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.bot_token = config.get("bot_token")
|
||||
|
||||
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
|
||||
if not self.bot_token:
|
||||
logger.error("[TelegramProvider][FAILED] Bot token not configured")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": target,
|
||||
"text": f"*{subject}*\n\n{body}",
|
||||
"parse_mode": "Markdown"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[TelegramProvider][FAILED] Failed to send Telegram message to {target}: {e}")
|
||||
return False
|
||||
# [/DEF:TelegramProvider:Class]
|
||||
|
||||
|
||||
# [DEF:SlackProvider:Class]
|
||||
# @PURPOSE: Delivers notifications via Slack Webhooks or API.
|
||||
class SlackProvider(NotificationProvider):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.webhook_url = config.get("webhook_url")
|
||||
|
||||
async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool:
|
||||
if not self.webhook_url:
|
||||
logger.error("[SlackProvider][FAILED] Webhook URL not configured")
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"text": f"*{subject}*\n{body}"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.webhook_url, json=payload, timeout=10)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[SlackProvider][FAILED] Failed to send Slack message: {e}")
|
||||
return False
|
||||
# [/DEF:SlackProvider:Class]
|
||||
|
||||
# [/DEF:backend.src.services.notifications.providers:Module]
|
||||
146
backend/src/services/notifications/service.py
Normal file
146
backend/src/services/notifications/service.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# [DEF:backend.src.services.notifications.service:Module]
|
||||
#
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: notifications, service, routing, dispatch, background-tasks
|
||||
# @PURPOSE: Orchestrates notification routing based on user preferences and policy context.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.notifications.providers
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.profile_service
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.llm
|
||||
#
|
||||
# @INVARIANT: Notifications are dispatched asynchronously via BackgroundTasks.
|
||||
# @INVARIANT: Missing profile or provider config must not crash the pipeline.
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...models.llm import ValidationRecord, ValidationPolicy
|
||||
from ...models.profile import UserDashboardPreference
|
||||
from .providers import SMTPProvider, TelegramProvider, SlackProvider, NotificationProvider
|
||||
|
||||
|
||||
# [DEF:NotificationService:Class]
|
||||
# @PURPOSE: Routes validation reports to appropriate users and channels.
|
||||
class NotificationService:
|
||||
def __init__(self, db: Session, config_manager: ConfigManager):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self._providers: Dict[str, NotificationProvider] = {}
|
||||
self._initialized = False
|
||||
|
||||
def _initialize_providers(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# In a real implementation, we would fetch these from NotificationConfig model
|
||||
# For now, we'll use a placeholder initialization logic
|
||||
# T033 will implement the UI/API for this.
|
||||
configs = self.config_manager.get_payload().get("notifications", {})
|
||||
|
||||
if "smtp" in configs:
|
||||
self._providers["SMTP"] = SMTPProvider(configs["smtp"])
|
||||
if "telegram" in configs:
|
||||
self._providers["TELEGRAM"] = TelegramProvider(configs["telegram"])
|
||||
if "slack" in configs:
|
||||
self._providers["SLACK"] = SlackProvider(configs["slack"])
|
||||
|
||||
self._initialized = True
|
||||
|
||||
async def dispatch_report(
|
||||
self,
|
||||
record: ValidationRecord,
|
||||
policy: Optional[ValidationPolicy] = None,
|
||||
background_tasks: Optional[BackgroundTasks] = None
|
||||
):
|
||||
"""
|
||||
Route a validation record to owners and custom channels.
|
||||
@PRE: record is persisted.
|
||||
@POST: Dispatches async tasks for each resolved target.
|
||||
"""
|
||||
with belief_scope("NotificationService.dispatch_report", f"record_id={record.id}"):
|
||||
self._initialize_providers()
|
||||
|
||||
# 1. Determine if we should notify based on status and policy
|
||||
should_notify = self._should_notify(record, policy)
|
||||
if not should_notify:
|
||||
logger.reason(f"[REASON] Notification skipped for record {record.id} (status={record.status})")
|
||||
return
|
||||
|
||||
# 2. Resolve targets (Owners + Custom Channels)
|
||||
targets = self._resolve_targets(record, policy)
|
||||
|
||||
# 3. Dispatch
|
||||
subject = f"Dashboard Health Alert: {record.status}"
|
||||
body = self._build_body(record)
|
||||
|
||||
for channel_type, recipient in targets:
|
||||
provider = self._providers.get(channel_type)
|
||||
if not provider:
|
||||
logger.warning(f"[NotificationService][EXPLORE] Unsupported or unconfigured channel: {channel_type}")
|
||||
continue
|
||||
|
||||
if background_tasks:
|
||||
background_tasks.add_task(provider.send, recipient, subject, body)
|
||||
else:
|
||||
# Fallback to sync for tests or if no background_tasks provided
|
||||
await provider.send(recipient, subject, body)
|
||||
|
||||
def _should_notify(self, record: ValidationRecord, policy: Optional[ValidationPolicy]) -> bool:
|
||||
condition = policy.alert_condition if policy else "FAIL_ONLY"
|
||||
|
||||
if condition == "ALWAYS":
|
||||
return True
|
||||
if condition == "WARN_AND_FAIL":
|
||||
return record.status in ("WARN", "FAIL")
|
||||
return record.status == "FAIL"
|
||||
|
||||
def _resolve_targets(self, record: ValidationRecord, policy: Optional[ValidationPolicy]) -> List[tuple]:
|
||||
targets = []
|
||||
|
||||
# Owner routing
|
||||
if not policy or policy.notify_owners:
|
||||
owners = self._find_dashboard_owners(record)
|
||||
for owner_pref in owners:
|
||||
if not owner_pref.notify_on_fail:
|
||||
continue
|
||||
|
||||
if owner_pref.telegram_id:
|
||||
targets.append(("TELEGRAM", owner_pref.telegram_id))
|
||||
|
||||
email = owner_pref.email_address or getattr(owner_pref.user, "email", None)
|
||||
if email:
|
||||
targets.append(("SMTP", email))
|
||||
|
||||
# Custom channels from policy
|
||||
if policy and policy.custom_channels:
|
||||
for channel in policy.custom_channels:
|
||||
# channel format: {"type": "SLACK", "target": "#alerts"}
|
||||
targets.append((channel.get("type"), channel.get("target")))
|
||||
|
||||
return targets
|
||||
|
||||
def _find_dashboard_owners(self, record: ValidationRecord) -> List[UserDashboardPreference]:
|
||||
# This is a simplified owner lookup.
|
||||
# In a real scenario, we'd query Superset for owners, then match them to our UserDashboardPreference.
|
||||
# For now, we'll return all users who have bound this dashboard's environment and have a username.
|
||||
|
||||
# Placeholder: return all preferences that have a superset_username
|
||||
# (In production, we'd filter by actual ownership from Superset metadata)
|
||||
return self.db.query(UserDashboardPreference).filter(
|
||||
UserDashboardPreference.superset_username != None
|
||||
).all()
|
||||
|
||||
def _build_body(self, record: ValidationRecord) -> str:
|
||||
return (
|
||||
f"Dashboard ID: {record.dashboard_id}\n"
|
||||
f"Environment: {record.environment_id}\n"
|
||||
f"Status: {record.status}\n\n"
|
||||
f"Summary: {record.summary}\n\n"
|
||||
f"Issues found: {len(record.issues)}"
|
||||
)
|
||||
# [/DEF:NotificationService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.notifications.service:Module]
|
||||
Reference in New Issue
Block a user