- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
479 lines
20 KiB
Python
479 lines
20 KiB
Python
"""
|
||
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))
|