код написан
This commit is contained in:
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