guardia-itsm/core/notify.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

479 lines
20 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
GUARDiA ITSM 알림 모듈 — 이메일(SMTP) + 메신저(Webhook) 자동 발송.
설정은 환경 변수로 관리 (외부 API 호출 금지, 온프레미스 전용):
이메일 설정:
SMTP_HOST = "" # 빈 문자열이면 이메일 비활성화
SMTP_PORT = "25"
SMTP_USER = ""
SMTP_PASSWORD = ""
SMTP_FROM = "noreply@guardia.local"
SMTP_TLS = "false" # SSL/TLS (포트 465 등)
SMTP_STARTTLS = "false" # STARTTLS (포트 587 등)
메신저 설정:
MESSENGER_WEBHOOK = "http://localhost:8000/api/messenger/webhook"
MESSENGER_ENABLED = "true"
MESSENGER_OPS_ROOM = "ops" # 운영팀 알림 채널
알림 트리거:
NOTIFY_ON_CREATED = "true" # SR 접수 시 고객 이메일 발송
NOTIFY_STATUSES = "COMPLETED,REJECTED,FAILED_ROLLBACK" # 상태 변경 시 발송
"""
import asyncio
import os
import smtplib
import socket
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
try:
import httpx
_HTTPX = True
except ImportError:
_HTTPX = False
# ── 환경변수 설정 ──────────────────────────────────────────────────────────────
def _env(key: str, default: str = "") -> str:
return os.environ.get(key, default).strip()
def _env_bool(key: str, default: bool = True) -> bool:
v = os.environ.get(key, "").strip().lower()
if v in ("1", "true", "yes", "on"):
return True
if v in ("0", "false", "no", "off"):
return False
return default
# ── 이메일 HTML 템플릿 ─────────────────────────────────────────────────────────
_BRAND_COLOR = "#1E3A5F"
def _html_email(title: str, body_html: str, sr_id: str = "", status_badge: str = "") -> str:
badge_html = f'<span style="background:{_status_color(status_badge)};color:#fff;padding:3px 10px;border-radius:12px;font-size:13px;font-weight:bold">{status_badge}</span>' if status_badge else ""
return f"""<!DOCTYPE html>
<html lang="ko">
<head><meta charset="UTF-8">
<style>
body{{font-family:'맑은 고딕',Arial,sans-serif;background:#f4f6f9;margin:0;padding:0}}
.wrap{{max-width:600px;margin:30px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1)}}
.hdr{{background:{_BRAND_COLOR};padding:24px 32px;color:#fff}}
.hdr h1{{margin:0;font-size:20px;font-weight:700}}
.hdr .sub{{margin:4px 0 0;font-size:13px;opacity:.8}}
.body{{padding:28px 32px;color:#333;line-height:1.6}}
.sr-id{{font-family:monospace;background:#f0f4f8;padding:4px 10px;border-radius:4px;font-size:14px;color:{_BRAND_COLOR}}}
.info-table{{width:100%;border-collapse:collapse;margin:16px 0}}
.info-table th{{text-align:left;width:30%;padding:8px 12px;background:#f7f9fc;color:#555;font-size:13px;border:1px solid #e0e6ed}}
.info-table td{{padding:8px 12px;font-size:14px;border:1px solid #e0e6ed}}
.result-box{{background:#f7f9fc;border-left:4px solid {_BRAND_COLOR};padding:12px 16px;margin:16px 0;border-radius:0 4px 4px 0;font-size:14px;white-space:pre-wrap}}
.ftr{{background:#f4f6f9;padding:16px 32px;text-align:center;font-size:12px;color:#888}}
</style>
</head>
<body>
<div class="wrap">
<div class="hdr">
<h1>GUARDiA ITSM</h1>
<div class="sub">인프라 서비스 요청 관리 시스템</div>
</div>
<div class="body">
<p style="font-size:17px;font-weight:bold;margin-top:0">{title} {badge_html}</p>
{body_html}
</div>
<div class="ftr">본 메일은 GUARDiA ITSM에서 자동 발송되었습니다. 문의: guardia@your-org.local</div>
</div>
</body>
</html>"""
def _status_color(status: str) -> str:
return {
"COMPLETED": "#1a7f4b",
"REJECTED": "#c0392b",
"FAILED_ROLLBACK": "#e67e22",
"IN_PROGRESS": "#2980b9",
"APPROVED": "#27ae60",
"PENDING_APPROVAL": "#8e44ad",
"RECEIVED": "#2c3e50",
}.get(status, "#555")
def _status_ko(status: str) -> str:
return {
"RECEIVED": "접수",
"PARSED": "분석 완료",
"PENDING_APPROVAL": "승인 대기",
"APPROVED": "승인됨",
"IN_PROGRESS": "처리 중",
"PENDING_PM_VALIDATION": "PM 검토 중",
"COMPLETED": "처리 완료",
"FAILED_ROLLBACK": "처리 실패 (롤백)",
"REJECTED": "반려",
}.get(status, status)
# ── SMTP 발송 ─────────────────────────────────────────────────────────────────
def _smtp_send_sync(to_addrs: list[str], subject: str, html_body: str) -> None:
"""동기 SMTP 발송 — asyncio.to_thread()로 호출."""
host = _env("SMTP_HOST")
if not host:
raise RuntimeError("SMTP_HOST not configured")
port = int(_env("SMTP_PORT", "25"))
user = _env("SMTP_USER")
password = _env("SMTP_PASSWORD")
from_addr = _env("SMTP_FROM", "noreply@guardia.local")
use_tls = _env_bool("SMTP_TLS", False)
use_start = _env_bool("SMTP_STARTTLS", False)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = ", ".join(to_addrs)
msg.attach(MIMEText(html_body, "html", "utf-8"))
timeout = 10 # seconds
if use_tls:
with smtplib.SMTP_SSL(host, port, timeout=timeout) as s:
if user:
s.login(user, password)
s.sendmail(from_addr, to_addrs, msg.as_string())
else:
with smtplib.SMTP(host, port, timeout=timeout) as s:
if use_start:
s.ehlo()
s.starttls()
s.ehlo()
if user:
s.login(user, password)
s.sendmail(from_addr, to_addrs, msg.as_string())
async def send_email(to_addrs: list[str], subject: str, html_body: str) -> tuple[bool, str]:
"""이메일 비동기 발송. (성공여부, 오류메시지) 반환."""
if not to_addrs:
return False, "수신자 없음"
host = _env("SMTP_HOST")
if not host:
return False, "SMTP_HOST 미설정"
try:
await asyncio.to_thread(_smtp_send_sync, to_addrs, subject, html_body)
return True, ""
except (smtplib.SMTPException, socket.error, OSError, RuntimeError) as exc:
return False, str(exc)[:200]
# ── 메신저 Webhook 발송 ───────────────────────────────────────────────────────
async def send_messenger(room: str, payload: dict) -> tuple[bool, str]:
"""메신저 webhook 비동기 발송. (성공여부, 오류메시지) 반환."""
if not _HTTPX:
return False, "httpx 미설치"
webhook = _env("MESSENGER_WEBHOOK", "http://localhost:8000/api/messenger/webhook")
if not webhook:
return False, "MESSENGER_WEBHOOK 미설정"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(webhook, json={"room": room, **payload})
return True, ""
except Exception as exc:
return False, str(exc)[:200]
# ── 알림 이력 저장 ─────────────────────────────────────────────────────────────
async def _log(sr_id: Optional[str], channel: str, recipient: str,
subject: str, ok: bool, error: str) -> None:
"""발송 결과를 tb_notification_log에 기록 (독립 세션 사용)."""
try:
from database import SessionLocal
from models import NotificationLog
async with SessionLocal() as db:
db.add(NotificationLog(
sr_id=sr_id, channel=channel, recipient=recipient,
subject=subject,
status="SENT" if ok else "FAILED",
error_msg=error if not ok else None,
))
await db.commit()
except Exception:
pass # 알림 로그 실패는 묵인
# ── 수신자 조회 ────────────────────────────────────────────────────────────────
async def _get_recipient_emails(requested_by: str, inst_id: Optional[int]) -> list[str]:
"""SR의 요청자 이메일 + 기관 주 담당자 이메일 수집 (독립 세션 사용)."""
from database import SessionLocal
from sqlalchemy import select
from models import User, InstContact
addrs: list[str] = []
async with SessionLocal() as db:
# 1. 요청자 User 이메일
try:
r = await db.execute(select(User).where(User.username == requested_by))
user = r.scalars().first()
if user and user.email:
addrs.append(user.email.strip())
except Exception:
pass
# 2. 기관 주 담당자 이메일
if inst_id:
try:
r = await db.execute(
select(InstContact).where(
InstContact.inst_id == inst_id,
InstContact.is_primary == True,
InstContact.is_active == True,
).limit(1)
)
contact = r.scalars().first()
if contact and contact.email:
e = contact.email.strip()
if e not in addrs:
addrs.append(e)
except Exception:
pass
return addrs
# ── 공개 알림 함수 ─────────────────────────────────────────────────────────────
# sr 객체는 ORM 세션이 닫힌 뒤에도 동작하도록 필요한 필드 값만 사용
async def notify_sr_created(sr, db=None) -> None:
"""SR 접수 시 고객에게 접수 확인 이메일 + 메신저 알림 발송."""
if not _env_bool("NOTIFY_ON_CREATED", True):
return
# ORM 세션 닫혀도 안전하게 필드 캡처
sr_id = sr.sr_id
title = sr.title
sr_type = sr.sr_type
prio = sr.priority
req_by = sr.requested_by
inst_id = sr.inst_id
# ── 이메일 ─────────────────────────────────────────────────────────────
addrs = await _get_recipient_emails(req_by, inst_id)
if addrs:
subject = f"[GUARDiA] {sr_id} 접수 확인 — {title}"
body_html = f"""
<p>안녕하세요.</p>
<p>서비스 요청이 정상적으로 접수되었습니다. 담당 엔지니어가 배정되면 작업을 시작합니다.</p>
<table class="info-table">
<tr><th>SR 번호</th> <td><span class="sr-id">{sr_id}</span></td></tr>
<tr><th>제목</th> <td>{title}</td></tr>
<tr><th>유형</th> <td>{sr_type}</td></tr>
<tr><th>우선순위</th> <td>{prio}</td></tr>
<tr><th>요청자</th> <td>{req_by}</td></tr>
<tr><th>접수 시각</th><td>{datetime.now().strftime('%Y-%m-%d %H:%M')}</td></tr>
</table>
<p style="color:#666;font-size:13px">진행 상황은 GUARDiA ITSM 포털에서 실시간으로 확인하실 수 있습니다.</p>"""
html = _html_email("SR 접수 확인", body_html, sr_id, "RECEIVED")
ok, err = await send_email(addrs, subject, html)
asyncio.create_task(_log(sr_id, "EMAIL", ", ".join(addrs), subject, ok, err))
# ── 메신저 ─────────────────────────────────────────────────────────────
if _env_bool("MESSENGER_ENABLED", True):
room = _env("MESSENGER_OPS_ROOM", "ops")
ok, err = await send_messenger(room, {
"event": "sr_created",
"sr_id": sr_id,
"title": title,
"sr_type": sr_type,
"priority": prio,
"requested_by": req_by,
"message": f"[접수] {sr_id}{title} ({sr_type} / {prio})",
})
asyncio.create_task(_log(sr_id, "MESSENGER", room, f"SR 접수: {sr_id}", ok, err))
async def notify_sr_status_changed(sr, new_status: str, summary: str, db=None) -> None:
"""SR 상태 변경 시 이메일 + 메신저 알림 발송."""
notify_statuses = _env("NOTIFY_STATUSES", "COMPLETED,REJECTED,FAILED_ROLLBACK").split(",")
if new_status not in notify_statuses:
return
# 필드 캡처
sr_id = sr.sr_id
title = sr.title
sr_type = sr.sr_type
inst_id = sr.inst_id
req_by = sr.requested_by
status_ko = _status_ko(new_status)
color = _status_color(new_status)
# ── 이메일 ─────────────────────────────────────────────────────────────
addrs = await _get_recipient_emails(req_by, inst_id)
if addrs:
subject = f"[GUARDiA] {sr_id} {status_ko}{title}"
if new_status == "COMPLETED":
intro = "서비스 요청이 정상적으로 처리 완료되었습니다."
elif new_status == "REJECTED":
intro = "서비스 요청이 반려되었습니다. 내용을 확인 후 재요청 바랍니다."
else:
intro = "서비스 요청 처리 중 오류가 발생하여 롤백되었습니다. 담당자에게 문의해 주세요."
body_html = f"""
<p>안녕하세요.</p>
<p>{intro}</p>
<table class="info-table">
<tr><th>SR 번호</th> <td><span class="sr-id">{sr_id}</span></td></tr>
<tr><th>제목</th> <td>{title}</td></tr>
<tr><th>처리 상태</th><td><strong style="color:{color}">{status_ko}</strong></td></tr>
<tr><th>처리 시각</th><td>{datetime.now().strftime('%Y-%m-%d %H:%M')}</td></tr>
</table>
<p><strong>처리 결과 요약</strong></p>
<div class="result-box">{summary or "상세 결과 없음"}</div>
<p style="color:#666;font-size:13px">세부 처리 내역은 GUARDiA ITSM 포털에서 확인하실 수 있습니다.</p>"""
html = _html_email(f"SR {status_ko}", body_html, sr_id, new_status)
ok, err = await send_email(addrs, subject, html)
asyncio.create_task(_log(sr_id, "EMAIL", ", ".join(addrs), subject, ok, err))
# ── 메신저 ─────────────────────────────────────────────────────────────
if _env_bool("MESSENGER_ENABLED", True):
room = _env("MESSENGER_OPS_ROOM", "ops")
emoji = {"COMPLETED": "", "REJECTED": "", "FAILED_ROLLBACK": "⚠️"}.get(new_status, "")
ok, err = await send_messenger(room, {
"event": "sr_status_changed",
"sr_id": sr_id,
"title": title,
"sr_type": sr_type,
"new_status": new_status,
"result_summary": summary[:200] if summary else "",
"message": f"{emoji} [{status_ko}] {sr_id}{title}\n{summary[:100] if summary else ''}",
})
asyncio.create_task(_log(sr_id, "MESSENGER", room,
f"SR {status_ko}: {sr_id}", ok, err))
# ═══════════════════════════════════════════════════════════════════════════════
# ── A-3: 배포 승인 모바일 알림 ──────────────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════════
async def notify_deploy_approval_required(
session_id: int,
sr_id: Optional[str],
project_name: str,
approvers: list[str],
approve_url: str = "",
) -> None:
"""
배포 승인 대기 알림.
vibe 세션이 PENDING_APPROVAL 상태에 진입할 때 호출.
지정된 승인자(approvers)들에게 메신저 + 이메일 알림 발송.
Args:
session_id: VibeSession.id
sr_id: 연관된 SR ID (없을 수 있음)
project_name: 배포 대상 프로젝트명
approvers: 승인 담당자 username 목록 (User.email로 변환)
approve_url: 승인 링크 (딥링크 또는 웹 URL)
"""
from database import SessionLocal
from sqlalchemy import select
from models import User
subject = f"[GUARDiA] 배포 승인 요청 — {project_name} (세션 #{session_id})"
ops_room = _env("MESSENGER_OPS_ROOM", "ops")
approve_room = _env("MESSENGER_APPROVE_ROOM", ops_room)
# 메신저 알림
msg = (
f"🚀 배포 승인 요청\n"
f"프로젝트: {project_name}\n"
f"세션 ID: #{session_id}\n"
f"SR: {sr_id or '(없음)'}\n"
f"승인 필요 담당자: {', '.join(approvers)}\n"
f"승인 링크: {approve_url or '(관리 UI 확인)'}"
)
ok, err = await send_messenger(approve_room, {
"event": "deploy_approval_required",
"session_id": session_id,
"project": project_name,
"approvers": approvers,
"approve_url": approve_url,
"type": "text",
"text": msg,
})
# 이메일 알림 (approvers → User.email 조회)
if approvers:
try:
async with SessionLocal() as db:
result = await db.execute(
select(User).where(User.username.in_(approvers))
)
users = result.scalars().all()
email_addrs = [u.email for u in users if u.email]
if email_addrs:
body_html = f"""
<p>안녕하세요.</p>
<p>배포 승인이 필요합니다.</p>
<table class="info-table">
<tr><th>프로젝트</th> <td>{project_name}</td></tr>
<tr><th>세션 ID</th> <td>#{session_id}</td></tr>
<tr><th>SR 번호</th> <td>{sr_id or '(없음)'}</td></tr>
<tr><th>요청 시각</th> <td>{datetime.now().strftime('%Y-%m-%d %H:%M')}</td></tr>
</table>
<p style="margin-top:16px">
<a href="{approve_url}" style="background:#2563eb;color:#fff;padding:10px 20px;border-radius:4px;text-decoration:none;font-weight:bold">
배포 승인하기
</a>
</p>
<p style="color:#888;font-size:13px">위 버튼이 작동하지 않으면 GUARDiA ITSM 관리 UI에서 직접 승인해 주세요.</p>
"""
html = _html_email(f"배포 승인 요청: {project_name}", body_html, str(session_id))
email_ok, email_err = await send_email(email_addrs, subject, html)
asyncio.create_task(_log(sr_id, "EMAIL", ",".join(email_addrs), subject, email_ok, email_err))
except Exception:
pass
async def notify_deploy_completed(
session_id: int,
sr_id: Optional[str],
project_name: str,
success: bool,
summary: str = "",
) -> None:
"""
배포 완료/실패 알림.
vibe 세션이 COMPLETED 또는 FAILED 상태에 진입할 때 호출.
"""
ops_room = _env("MESSENGER_OPS_ROOM", "ops")
emoji = "" if success else ""
status_text = "완료" if success else "실패"
msg = (
f"{emoji} 배포 {status_text}\n"
f"프로젝트: {project_name}\n"
f"세션 ID: #{session_id}\n"
f"SR: {sr_id or '(없음)'}\n"
f"일시: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
+ (f"\n결과: {summary[:200]}" if summary else "")
)
ok, err = await send_messenger(ops_room, {
"event": "deploy_completed",
"session_id": session_id,
"project": project_name,
"success": success,
"type": "text",
"text": msg,
})
asyncio.create_task(_log(sr_id, "MESSENGER", ops_room,
f"배포 {status_text}: {project_name}", ok, err))