- Replaced @TIER: TRIVIAL with @COMPLEXITY: 1 - Replaced @TIER: STANDARD with @COMPLEXITY: 3 - Replaced @TIER: CRITICAL with @COMPLEXITY: 5 - Manually elevated specific critical/complex components to levels 2 and 4 - Ignored legacy, specs, and node_modules directories - Updated generated semantic map
146 lines
6.2 KiB
Python
146 lines
6.2 KiB
Python
# [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] |