라우터 (667개 엔드포인트, P3 신규 69개):
- multimodal.py: llava 이미지 분석 + 에러 자동 분류
- learning_loop.py: Ollama 파인튜닝 + 품질 지표
- ai_insights.py: 주간 인사이트 + 반복 패턴 + 개선 권고
- container_alerts.py: Docker 이상 감지 → SR 자동 생성
- ncloud.py: NCloud API (서버/LB/스토리지/비용)
- billing.py: 구독 플랜 + 사용량 측정 + 청구서
- servicenow.py: ServiceNow CMDB/Incident 양방향 연동
- erp_connector.py: 그룹웨어/HR ERP 연동 + 결재 웹훅
- kakao_notify.py: 카카오 알림톡 + 대량 발송
- auto_report.py: Excel/PDF 보고서 자동 생성·다운로드
- benchmark.py: 기관 간 익명 벤치마킹 (완전 익명화)
- cohort_analysis.py: 도입 코호트 + 리텐션 + 기능 도입률
DB 모델 (14개 신규 테이블):
tb_learning_run, tb_container_alert_{rule,log},
tb_ncloud_config, tb_subscription, tb_invoice,
tb_servicenow_{config,mapping}, tb_erp_config,
tb_kakao_{config,notify_log}, tb_report_{record,schedule},
tb_benchmark_contrib
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
6.5 KiB
Python
152 lines
6.5 KiB
Python
"""
|
|
ServiceNow 연동 커넥터
|
|
|
|
기능:
|
|
- ServiceNow CMDB CI 목록 조회
|
|
- Incident 양방향 동기화
|
|
- GUARDiA SR → ServiceNow Incident 생성
|
|
- ServiceNow Change Request 조회
|
|
|
|
엔드포인트:
|
|
POST /api/servicenow/config — 연동 설정
|
|
GET /api/servicenow/config — 설정 조회
|
|
POST /api/servicenow/test — 연결 테스트
|
|
GET /api/servicenow/incidents — Incident 목록
|
|
POST /api/servicenow/sync/{sr_id} — SR → ServiceNow Incident 생성
|
|
GET /api/servicenow/cmdb — CMDB CI 목록
|
|
GET /api/servicenow/changes — Change Request 목록
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user, require_admin_role
|
|
from database import get_db
|
|
from models import User, SRRequest, ServiceNowConfig, ServiceNowMapping # 신규
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/servicenow", tags=["ServiceNow"])
|
|
|
|
PRIORITY_MAP = {"HIGH": "1", "MEDIUM": "2", "LOW": "3"}
|
|
|
|
|
|
class SNowConfigCreate(BaseModel):
|
|
instance_url: str = Field(..., description="https://company.service-now.com")
|
|
username: str
|
|
password: str
|
|
assignment_group: Optional[str] = None
|
|
|
|
|
|
async def _snow_request(cfg: ServiceNowConfig, method: str, path: str,
|
|
payload: Optional[dict] = None) -> Optional[dict]:
|
|
url = f"{cfg.instance_url.rstrip('/')}/api/now/{path}"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15, verify=False) as client:
|
|
r = await getattr(client, method.lower())(
|
|
url, json=payload,
|
|
auth=(cfg.username, cfg.password_enc),
|
|
headers={"Content-Type": "application/json", "Accept": "application/json"}
|
|
)
|
|
return r.json() if r.status_code in (200, 201) else None
|
|
except Exception as e:
|
|
logger.error(f"ServiceNow API 실패: {e}")
|
|
return None
|
|
|
|
|
|
@router.post("/config")
|
|
async def save_config(
|
|
req: SNowConfigCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_admin_role),
|
|
):
|
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
|
cfg = row.scalar_one_or_none()
|
|
if cfg:
|
|
cfg.instance_url = req.instance_url; cfg.username = req.username
|
|
cfg.password_enc = req.password; cfg.assignment_group = req.assignment_group
|
|
else:
|
|
cfg = ServiceNowConfig(
|
|
tenant_id=user.tenant_id, instance_url=req.instance_url,
|
|
username=req.username, password_enc=req.password,
|
|
assignment_group=req.assignment_group, is_active=True, created_at=datetime.utcnow()
|
|
)
|
|
db.add(cfg)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/test")
|
|
async def test_connection(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
|
cfg = row.scalar_one_or_none()
|
|
if not cfg: raise HTTPException(404, "설정 없음")
|
|
data = await _snow_request(cfg, "GET", "table/sys_user?sysparm_limit=1")
|
|
return {"ok": bool(data), "instance": cfg.instance_url}
|
|
|
|
|
|
@router.get("/incidents")
|
|
async def list_incidents(limit: int = 20, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
|
cfg = row.scalar_one_or_none()
|
|
if not cfg: raise HTTPException(404, "설정 없음")
|
|
data = await _snow_request(cfg, "GET", f"table/incident?sysparm_limit={limit}&sysparm_fields=number,short_description,state,priority,opened_at")
|
|
records = (data or {}).get("result", [])
|
|
return [{"number": r.get("number"), "title": r.get("short_description"),
|
|
"state": r.get("state"), "priority": r.get("priority"), "opened_at": r.get("opened_at")} for r in records]
|
|
|
|
|
|
@router.post("/sync/{sr_id}")
|
|
async def sync_to_servicenow(sr_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
"""SR → ServiceNow Incident 생성."""
|
|
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id))
|
|
sr = sr_row.scalar_one_or_none()
|
|
if not sr: raise HTTPException(404, "SR 없음")
|
|
cfg_row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
|
cfg = cfg_row.scalar_one_or_none()
|
|
if not cfg: raise HTTPException(404, "ServiceNow 설정 없음")
|
|
|
|
payload = {
|
|
"short_description": f"[GUARDiA SR-{sr.id}] {sr.title}",
|
|
"description": sr.description or "",
|
|
"impact": PRIORITY_MAP.get((sr.priority or "MEDIUM").upper(), "2"),
|
|
"urgency": PRIORITY_MAP.get((sr.priority or "MEDIUM").upper(), "2"),
|
|
}
|
|
if cfg.assignment_group:
|
|
payload["assignment_group"] = {"name": cfg.assignment_group}
|
|
|
|
data = await _snow_request(cfg, "POST", "table/incident", payload)
|
|
if data and data.get("result"):
|
|
sn_number = data["result"].get("number", "")
|
|
mapping = ServiceNowMapping(sr_id=sr.id, snow_number=sn_number, config_id=cfg.id, synced_at=datetime.utcnow())
|
|
db.add(mapping)
|
|
await db.commit()
|
|
return {"ok": True, "snow_number": sn_number}
|
|
raise HTTPException(500, "ServiceNow Incident 생성 실패")
|
|
|
|
|
|
@router.get("/cmdb")
|
|
async def list_cmdb(limit: int = 50, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
|
cfg = row.scalar_one_or_none()
|
|
if not cfg: raise HTTPException(404, "설정 없음")
|
|
data = await _snow_request(cfg, "GET", f"table/cmdb_ci_server?sysparm_limit={limit}&sysparm_fields=name,ip_address,os,status")
|
|
return (data or {}).get("result", [])
|
|
|
|
|
|
@router.get("/changes")
|
|
async def list_changes(limit: int = 20, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
|
|
cfg = row.scalar_one_or_none()
|
|
if not cfg: raise HTTPException(404, "설정 없음")
|
|
data = await _snow_request(cfg, "GET", f"table/change_request?sysparm_limit={limit}&sysparm_fields=number,short_description,state,type,scheduled_start_date")
|
|
return (data or {}).get("result", [])
|