# [DEF:backend.src.services.notifications.service:Module] # # @COMPLEXITY: 5 # @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]