feat: Telegram settings in DB with UI edit and test send
Store notification_channels (migration 004), effective config DB over env, PUT/test API endpoints, Settings form, and pass db session into ingest notify. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -64,9 +64,9 @@ def post_event(
|
||||
problem_created = False
|
||||
if created:
|
||||
problem, problem_created = maybe_create_problem(db, event)
|
||||
notify_event(event)
|
||||
notify_event(event, db=db)
|
||||
if problem is not None and problem_created:
|
||||
notify_problem(problem, event)
|
||||
notify_problem(problem, event, db=db)
|
||||
logger.info("ingest created event_id=%s type=%s host_id=%s", event.event_id, event.type, event.host_id)
|
||||
else:
|
||||
logger.info("ingest duplicate event_id=%s", event.event_id)
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.services.notification_settings import (
|
||||
VALID_SEVERITIES,
|
||||
TelegramConfig,
|
||||
get_effective_telegram_config,
|
||||
upsert_telegram_channel,
|
||||
)
|
||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
@@ -22,28 +30,77 @@ class TelegramSettingsResponse(BaseModel):
|
||||
chat_id_hint: str | None = None
|
||||
bot_token_hint: str | None = None
|
||||
min_severity: str
|
||||
source: str = Field(default="env", description="env until UI persistence (notif-12)")
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
telegram: TelegramSettingsResponse
|
||||
|
||||
|
||||
class TelegramSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
min_severity: str
|
||||
bot_token: str | None = None
|
||||
chat_id: str | None = None
|
||||
|
||||
|
||||
class TelegramTestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = "Тестовое сообщение отправлено в Telegram"
|
||||
|
||||
|
||||
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
|
||||
return TelegramSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||
min_severity=cfg.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||
def get_notification_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> NotificationSettingsResponse:
|
||||
settings = get_settings()
|
||||
token = settings.telegram_bot_token.strip()
|
||||
chat_id = settings.telegram_chat_id.strip()
|
||||
configured = bool(token and chat_id)
|
||||
return NotificationSettingsResponse(
|
||||
telegram=TelegramSettingsResponse(
|
||||
enabled=bool(settings.telegram_enabled and configured),
|
||||
configured=configured,
|
||||
chat_id_hint=_mask_secret(chat_id),
|
||||
bot_token_hint=_mask_secret(token),
|
||||
min_severity=settings.telegram_min_severity,
|
||||
source="env",
|
||||
cfg = get_effective_telegram_config(db)
|
||||
return NotificationSettingsResponse(telegram=_telegram_to_response(cfg))
|
||||
|
||||
|
||||
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
|
||||
def update_telegram_settings(
|
||||
body: TelegramSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> TelegramSettingsResponse:
|
||||
if body.min_severity not in VALID_SEVERITIES:
|
||||
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||
try:
|
||||
cfg = upsert_telegram_channel(
|
||||
db,
|
||||
enabled=body.enabled,
|
||||
min_severity=body.min_severity,
|
||||
bot_token=body.bot_token,
|
||||
chat_id=body.chat_id,
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return _telegram_to_response(cfg)
|
||||
|
||||
|
||||
@router.post("/notifications/telegram/test", response_model=TelegramTestResponse)
|
||||
def test_telegram_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> TelegramTestResponse:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
try:
|
||||
send_telegram_test_message(config=cfg)
|
||||
except TelegramNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except TelegramSendError as exc:
|
||||
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
||||
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||
return TelegramTestResponse()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.models.api_key import ApiKey
|
||||
from app.models.event import Event
|
||||
from app.models.host import Host
|
||||
from app.models.notification_channel import NotificationChannel
|
||||
from app.models.problem import Problem, ProblemEvent
|
||||
|
||||
__all__ = ["ApiKey", "Event", "Host", "Problem", "ProblemEvent"]
|
||||
__all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "Problem", "ProblemEvent"]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NotificationChannel(Base):
|
||||
__tablename__ = "notification_channels"
|
||||
|
||||
channel: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(default=False)
|
||||
min_severity: Mapped[str] = mapped_column(String(16), default="warning")
|
||||
bot_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Effective notification channel config (DB overrides env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.notification_channel import NotificationChannel
|
||||
|
||||
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
|
||||
CHANNEL_TELEGRAM = "telegram"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramConfig:
|
||||
enabled: bool
|
||||
bot_token: str
|
||||
chat_id: str
|
||||
min_severity: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.bot_token.strip() and self.chat_id.strip())
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.enabled and self.configured
|
||||
|
||||
|
||||
def _telegram_from_env() -> TelegramConfig:
|
||||
settings = get_settings()
|
||||
return TelegramConfig(
|
||||
enabled=bool(settings.telegram_enabled),
|
||||
bot_token=settings.telegram_bot_token.strip(),
|
||||
chat_id=settings.telegram_chat_id.strip(),
|
||||
min_severity=settings.telegram_min_severity.strip() or "high",
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_telegram_config(db: Session | None = None) -> TelegramConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_telegram_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
|
||||
env_cfg = _telegram_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
token = (row.bot_token or "").strip() or env_cfg.bot_token
|
||||
chat_id = (row.chat_id or "").strip() or env_cfg.chat_id
|
||||
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
|
||||
if min_sev not in VALID_SEVERITIES:
|
||||
min_sev = env_cfg.min_severity
|
||||
|
||||
return TelegramConfig(
|
||||
enabled=bool(row.enabled),
|
||||
bot_token=token,
|
||||
chat_id=chat_id,
|
||||
min_severity=min_sev,
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def upsert_telegram_channel(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
bot_token: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
min_severity: str | None = None,
|
||||
) -> TelegramConfig:
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
|
||||
env_cfg = _telegram_from_env()
|
||||
|
||||
if row is None:
|
||||
row = NotificationChannel(
|
||||
channel=CHANNEL_TELEGRAM,
|
||||
enabled=env_cfg.enabled,
|
||||
bot_token=env_cfg.bot_token or None,
|
||||
chat_id=env_cfg.chat_id or None,
|
||||
min_severity=env_cfg.min_severity,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
if min_severity is not None:
|
||||
if min_severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||
row.min_severity = min_severity
|
||||
if bot_token is not None and bot_token.strip():
|
||||
row.bot_token = bot_token.strip()
|
||||
if chat_id is not None and chat_id.strip():
|
||||
row.chat_id = chat_id.strip()
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_telegram_config(db)
|
||||
@@ -1,50 +1,74 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event, Problem
|
||||
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40}
|
||||
|
||||
|
||||
class TelegramNotConfiguredError(Exception):
|
||||
"""Telegram disabled or missing token/chat_id."""
|
||||
|
||||
|
||||
class TelegramSendError(Exception):
|
||||
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _severity_value(value: str) -> int:
|
||||
return SEVERITY_ORDER.get(value, 0)
|
||||
|
||||
|
||||
def _is_enabled() -> bool:
|
||||
settings = get_settings()
|
||||
return bool(
|
||||
settings.telegram_enabled
|
||||
and settings.telegram_bot_token.strip()
|
||||
and settings.telegram_chat_id.strip()
|
||||
)
|
||||
|
||||
|
||||
def _send_text(message: str) -> None:
|
||||
settings = get_settings()
|
||||
if not _is_enabled():
|
||||
def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None:
|
||||
cfg = config or get_effective_telegram_config()
|
||||
if not force and not cfg.active:
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{settings.telegram_bot_token}/sendMessage"
|
||||
payload = {"chat_id": settings.telegram_chat_id, "text": message, "disable_web_page_preview": True}
|
||||
if not cfg.bot_token or not cfg.chat_id:
|
||||
if force:
|
||||
raise TelegramNotConfiguredError("Telegram: не задан bot token или chat_id")
|
||||
return
|
||||
|
||||
url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage"
|
||||
payload = {"chat_id": cfg.chat_id, "text": message, "disable_web_page_preview": True}
|
||||
try:
|
||||
with httpx.Client(timeout=8.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||
raise TelegramSendError(f"Telegram API HTTP {exc.response.status_code}: {detail}", status_code=exc.response.status_code) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("telegram send failed")
|
||||
raise TelegramSendError(str(exc)) from exc
|
||||
|
||||
|
||||
def _should_notify_severity(severity: str) -> bool:
|
||||
settings = get_settings()
|
||||
min_level = _severity_value(settings.telegram_min_severity)
|
||||
def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None:
|
||||
cfg = config or get_effective_telegram_config()
|
||||
if not cfg.enabled:
|
||||
raise TelegramNotConfiguredError("Telegram отключён (enabled=false)")
|
||||
if not cfg.configured:
|
||||
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
|
||||
send_telegram_text(
|
||||
"✅ SAC: тестовое сообщение\nКанал Telegram для оповещений работает.",
|
||||
config=cfg,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def _should_notify_severity(severity: str, *, config: TelegramConfig) -> bool:
|
||||
min_level = _severity_value(config.min_severity)
|
||||
return _severity_value(severity) >= min_level
|
||||
|
||||
|
||||
def notify_event(event: Event) -> None:
|
||||
if not _should_notify_severity(event.severity):
|
||||
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
|
||||
return
|
||||
host = event.host.hostname if event.host else "unknown"
|
||||
message = (
|
||||
@@ -55,11 +79,15 @@ def notify_event(event: Event) -> None:
|
||||
f"Title: {event.title}\n"
|
||||
f"Summary: {event.summary}"
|
||||
)
|
||||
_send_text(message)
|
||||
try:
|
||||
send_telegram_text(message, config=cfg)
|
||||
except TelegramSendError:
|
||||
logger.exception("notify_event telegram failed event_id=%s", event.event_id)
|
||||
|
||||
|
||||
def notify_problem(problem: Problem, event: Event | None = None) -> None:
|
||||
if not _should_notify_severity(problem.severity):
|
||||
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
|
||||
return
|
||||
host = problem.host.hostname if problem.host else "unknown"
|
||||
related = ""
|
||||
@@ -73,4 +101,7 @@ def notify_problem(problem: Problem, event: Event | None = None) -> None:
|
||||
f"Title: {problem.title}\n"
|
||||
f"Summary: {problem.summary}{related}"
|
||||
)
|
||||
_send_text(message)
|
||||
try:
|
||||
send_telegram_text(message, config=cfg)
|
||||
except TelegramSendError:
|
||||
logger.exception("notify_problem telegram failed problem_id=%s", problem.id)
|
||||
|
||||
Reference in New Issue
Block a user