Files
ss-tools/backend/src/services/notifications/service.py
busya 274510fc38 refactor(semantics): migrate legacy @TIER to @COMPLEXITY annotations
- 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
2026-03-16 10:06:44 +03:00

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]