код написан

This commit is contained in:
2026-03-10 12:00:18 +03:00
parent 82435822eb
commit 31717870e3
57 changed files with 53951 additions and 4909 deletions

View File

@@ -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]

View 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]

View 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]