guardia-itsm/routers/tasks.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

500 lines
18 KiB
Python

"""SR / Task CRUD + status transition endpoints."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.events import broadcast
from database import get_db
from models import (
AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus,
SRStatusUpdate, SRType, User, compute_log_hash
)
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
# Valid state transitions
_TRANSITIONS: dict[str, list[str]] = {
SRStatus.RECEIVED: [SRStatus.PARSED, SRStatus.REJECTED],
SRStatus.PARSED: [SRStatus.PENDING_APPROVAL, SRStatus.REJECTED],
SRStatus.PENDING_APPROVAL: [SRStatus.APPROVED, SRStatus.REJECTED],
SRStatus.APPROVED: [SRStatus.IN_PROGRESS, SRStatus.REJECTED],
SRStatus.IN_PROGRESS: [SRStatus.PENDING_PM_VALIDATION, SRStatus.FAILED_ROLLBACK],
SRStatus.PENDING_PM_VALIDATION:[SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK],
SRStatus.COMPLETED: [],
SRStatus.FAILED_ROLLBACK: [],
SRStatus.REJECTED: [],
}
def _new_sr_id() -> str:
return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
async def _write_audit(db: AsyncSession, sr_id: str, actor: str,
action: str, detail: str) -> None:
from sqlalchemy import select as sel
result = await db.execute(
sel(AuditLog).where(AuditLog.sr_id == sr_id)
.order_by(AuditLog.id.desc()).limit(1)
)
last = result.scalars().first()
prev_hash = last.log_hash if last else None
ts = datetime.now().isoformat()
log_hash = compute_log_hash(prev_hash, actor, action, detail, ts)
db.add(AuditLog(
sr_id=sr_id, actor=actor, action=action, detail=detail,
prev_hash=prev_hash, log_hash=log_hash
))
async def _apply_role_filter(q, current_user: User, db: AsyncSession):
"""CUSTOMER 역할이면 자신의 기관 SR만 조회되도록 필터링."""
from models import UserRole
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
inst_r = await db.execute(
select(Institution).where(Institution.inst_code == current_user.inst_code)
)
inst = inst_r.scalars().first()
if inst:
q = q.where(SRRequest.inst_id == inst.id)
else:
# 기관 정보 없으면 빈 결과 보장
q = q.where(SRRequest.id == -1)
return q
@router.get("", response_model=List[SROut])
async def list_tasks(
status: Optional[str] = Query(None),
sr_type: Optional[str] = Query(None),
priority: Optional[str]= Query(None),
keyword: Optional[str] = Query(None),
skip: int = 0,
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(SRRequest).order_by(SRRequest.created_at.desc())
q = await _apply_role_filter(q, current_user, db)
if status:
q = q.where(SRRequest.status == status)
if sr_type:
q = q.where(SRRequest.sr_type == sr_type)
if priority:
q = q.where(SRRequest.priority == priority)
if keyword:
q = q.where(SRRequest.title.contains(keyword))
q = q.offset(skip).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@router.get("/stats")
async def get_stats(db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)):
# 기관 필터 (CUSTOMER)
base_q = select(func.count(SRRequest.id))
filtered = await _apply_role_filter(select(SRRequest), current_user, db)
# subquery approach: get allowed sr ids
from sqlalchemy import and_
inst_filter = None
from models import UserRole
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
inst_r = await db.execute(
select(Institution).where(Institution.inst_code == current_user.inst_code)
)
inst = inst_r.scalars().first()
inst_filter = (SRRequest.inst_id == inst.id) if inst else (SRRequest.id == -1)
def _cnt(extra=None):
q = base_q
if inst_filter is not None:
q = q.where(inst_filter)
if extra is not None:
q = q.where(extra)
return q
total = (await db.execute(_cnt())).scalar() or 0
by_status: dict[str, int] = {}
for s in SRStatus:
cnt = (await db.execute(_cnt(SRRequest.status == s))).scalar() or 0
if cnt:
by_status[s.value] = cnt
by_type: dict[str, int] = {}
for t in SRType:
cnt = (await db.execute(_cnt(SRRequest.sr_type == t))).scalar() or 0
if cnt:
by_type[t.value] = cnt
return {"total": total, "by_status": by_status, "by_type": by_type}
@router.get("/{sr_id}", response_model=SROut)
async def get_task(sr_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = result.scalars().first()
if not sr:
raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
return sr
@router.post("", response_model=SROut, status_code=201)
async def create_task(payload: SRCreate, db: AsyncSession = Depends(get_db)):
inst_id = None
if payload.inst_code:
r = await db.execute(
select(Institution).where(Institution.inst_code == payload.inst_code)
)
inst = r.scalars().first()
if inst:
inst_id = inst.id
sr = SRRequest(
sr_id=_new_sr_id(),
inst_id=inst_id,
sr_type=payload.sr_type,
title=payload.title,
description=payload.description,
status=SRStatus.RECEIVED,
priority=payload.priority,
requested_by=payload.requested_by,
assigned_to=payload.assigned_to,
target_server=payload.target_server,
)
db.add(sr)
await db.flush()
await _write_audit(db, sr.sr_id, payload.requested_by, "SR_CREATED", f"SR 생성: {payload.title}")
# 담당자가 미지정이면 자동 배정 시도
if not sr.assigned_to:
from routers.assign import auto_assign_engine
assigned = await auto_assign_engine(db, sr)
if assigned:
sr.assigned_to = assigned
await _write_audit(db, sr.sr_id, "SYSTEM", "ENGINEER_ASSIGNED",
f"자동 배정: {assigned}")
# A-2: SLA 마감 시각 계산·저장
from core.sla import set_sla_on_create
await set_sla_on_create(sr.sr_id, db)
await db.refresh(sr)
await db.commit()
await db.refresh(sr)
# 실시간 이벤트 브로드캐스트
await broadcast("sr_created", {
"sr_id": sr.sr_id,
"title": sr.title,
"sr_type": sr.sr_type,
"priority": sr.priority,
"status": sr.status,
"sla_deadline": sr.sla_deadline.isoformat() if sr.sla_deadline else None,
})
# 알림 발송 (이메일 + 메신저) — 비동기 fire-and-forget
import asyncio as _asyncio
from core.notify import notify_sr_created as _notify_created
_asyncio.create_task(_notify_created(sr))
# 학습 루프: 재발 패턴 감지 — fire-and-forget
async def _detect_recurrence_bg():
from database import SessionLocal
from core.learning import detect_recurrence
try:
async with SessionLocal() as _db:
result = await detect_recurrence(
db = _db,
sr_id = sr.sr_id,
title = sr.title,
description = sr.description or "",
sr_type = sr.sr_type or "OTHER",
inst_id = sr.inst_id,
)
if result.get("recurrence_found"):
import logging
logging.getLogger(__name__).info(
"재발 감지: SR=%s 패턴=%d 횟수=%d%s",
sr.sr_id,
result["pattern_id"],
result["occurrence_count"],
" → Problem 격상" if result.get("escalated") else "",
)
except Exception as _e:
import logging
logging.getLogger(__name__).debug("재발 감지 오류 (무시): %s", _e)
_asyncio.create_task(_detect_recurrence_bg())
# G-7: AI 자동 분류 — fire-and-forget
async def _apply_ai_classification_bg():
from database import SessionLocal
from core.ticket_classifier import classify_ticket
import json as _json
try:
suggestion = await classify_ticket(sr.title, sr.description or "")
async with SessionLocal() as _db:
_sr = (await _db.execute(
select(SRRequest).where(SRRequest.sr_id == sr.sr_id)
)).scalars().first()
if _sr:
_sr.ai_suggestion = _json.dumps(suggestion, ensure_ascii=False)
await _db.commit()
except Exception:
pass
_asyncio.create_task(_apply_ai_classification_bg())
return sr
@router.patch("/{sr_id}/status", response_model=SROut)
async def update_status(sr_id: str, payload: SRStatusUpdate,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user)):
result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = result.scalars().first()
if not sr:
raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
allowed = _TRANSITIONS.get(SRStatus(sr.status), [])
if payload.status not in allowed:
raise HTTPException(400, detail=f"'{sr.status}''{payload.status}' 전이는 허용되지 않습니다.")
old_status = sr.status
sr.status = payload.status
sr.updated_at = datetime.now()
detail = payload.comment or f"상태 변경: {old_status}{payload.status}"
await _write_audit(db, sr_id, payload.actor, "STATUS_CHANGED", detail)
await db.commit()
await db.refresh(sr)
# 실시간 이벤트 브로드캐스트
await broadcast("sr_updated", {
"sr_id": sr_id,
"title": sr.title,
"old_status": old_status,
"new_status": sr.status,
"actor": payload.actor,
})
# 알림 발송 (COMPLETED / REJECTED / FAILED_ROLLBACK 시)
import asyncio as _asyncio
from core.notify import notify_sr_status_changed as _notify_changed
_asyncio.create_task(_notify_changed(sr, sr.status, payload.comment or ""))
return sr
# ── A-2: SLA 조회 엔드포인트 ──────────────────────────────────────────────────
@router.get("/{sr_id}/sla")
async def get_sla_status(
sr_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""
특정 SR의 SLA 현황 조회.
Returns:
{
"sr_id": "SR-...",
"priority": "HIGH",
"sla_deadline": "2026-05-26T14:00:00",
"sla_breached": false,
"remaining_minutes": 47,
"escalated_at": null,
"escalated_to": null
}
"""
from core.sla import sla_remaining_minutes
result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = result.scalars().first()
if not sr:
raise HTTPException(404, "SR을 찾을 수 없습니다.")
remaining = sla_remaining_minutes(sr.sla_deadline)
return {
"sr_id": sr.sr_id,
"priority": sr.priority,
"sla_deadline": sr.sla_deadline.isoformat() if sr.sla_deadline else None,
"sla_breached": sr.sla_breached,
"remaining_minutes": remaining,
"escalated_at": sr.escalated_at.isoformat() if sr.escalated_at else None,
"escalated_to": sr.escalated_to,
}
@router.get("/sla/violations")
async def list_sla_violations(
skip: int = 0,
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
SLA 위반 중인 SR 목록.
ADMIN / PM / ENGINEER 만 접근 가능.
"""
from models import UserRole
from sqlalchemy import and_
from core.sla import sla_remaining_minutes
if current_user.role == UserRole.CUSTOMER:
raise HTTPException(403, "권한이 없습니다.")
terminal = ["COMPLETED", "REJECTED", "FAILED_ROLLBACK"]
now = datetime.now()
q = (
select(SRRequest)
.where(
and_(
SRRequest.sla_deadline.isnot(None),
SRRequest.sla_deadline < now,
SRRequest.status.notin_(terminal),
)
)
.order_by(SRRequest.sla_deadline.asc())
.offset(skip).limit(limit)
)
rows = (await db.execute(q)).scalars().all()
return [
{
"sr_id": r.sr_id,
"title": r.title,
"priority": r.priority,
"status": r.status,
"assigned_to": r.assigned_to,
"sla_deadline": r.sla_deadline.isoformat() if r.sla_deadline else None,
"overdue_minutes": abs(sla_remaining_minutes(r.sla_deadline) or 0),
"sla_breached": r.sla_breached,
"escalated_to": r.escalated_to,
}
for r in rows
]
# ── G-7: AI 분류 결과 조회 ────────────────────────────────────────────────────
@router.get("/{sr_id}/ai-suggestion")
async def get_ai_suggestion(
sr_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""SR의 AI 자동 분류 제안 조회."""
import json as _json
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = r.scalars().first()
if not sr:
raise HTTPException(404, "SR을 찾을 수 없습니다.")
if not sr.ai_suggestion:
# 즉시 분류 요청
from core.ticket_classifier import classify_ticket
try:
suggestion = await classify_ticket(sr.title, sr.description or "")
sr.ai_suggestion = _json.dumps(suggestion, ensure_ascii=False)
await db.commit()
except Exception as e:
return {"sr_id": sr_id, "suggestion": None, "message": f"AI 분류 실패: {str(e)[:100]}"}
try:
return {"sr_id": sr_id, "suggestion": _json.loads(sr.ai_suggestion)}
except Exception:
return {"sr_id": sr_id, "suggestion": None}
# ── G-2: SR 대량 처리 ─────────────────────────────────────────────────────────
class BulkActionRequest(BaseModel):
sr_ids: List[str]
action: str # STATUS_CHANGE | ASSIGN | CLOSE | PRIORITY_CHANGE
params: Dict[str, Any] = {}
@router.post("/bulk")
async def bulk_sr_action(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
SR 대량 작업 (최대 100건).
action: STATUS_CHANGE | ASSIGN | CLOSE | PRIORITY_CHANGE
"""
from models import UserRole
if current_user.role == UserRole.CUSTOMER:
raise HTTPException(403, "대량 작업은 ADMIN/PM/ENGINEER만 가능합니다.")
if len(payload.sr_ids) > 100:
raise HTTPException(400, "한 번에 최대 100건까지 처리 가능합니다.")
results = []
success = 0
for sr_id in payload.sr_ids:
try:
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = r.scalars().first()
if not sr:
results.append({"sr_id": sr_id, "ok": False, "error": "SR을 찾을 수 없습니다."})
continue
action = payload.action.upper()
if action == "STATUS_CHANGE":
new_status = payload.params.get("status")
if not new_status:
raise ValueError("status 파라미터 필요")
allowed = _TRANSITIONS.get(SRStatus(sr.status), [])
if new_status not in allowed:
raise ValueError(f"'{sr.status}''{new_status}' 전이 불가")
sr.status = new_status
sr.updated_at = datetime.now()
note = payload.params.get("note", f"대량 상태변경: {new_status}")
await _write_audit(db, sr_id, current_user.username, "BULK_STATUS_CHANGE", note)
elif action == "ASSIGN":
assignee = payload.params.get("assignee")
if not assignee:
raise ValueError("assignee 파라미터 필요")
sr.assigned_to = assignee
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, current_user.username, "BULK_ASSIGN",
f"대량 배정: {assignee}")
elif action == "CLOSE":
if sr.status in (SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK):
raise ValueError(f"이미 종료된 SR: {sr.status}")
sr.status = SRStatus.COMPLETED
sr.updated_at = datetime.now()
note = payload.params.get("note", "대량 완료 처리")
await _write_audit(db, sr_id, current_user.username, "BULK_CLOSE", note)
elif action == "PRIORITY_CHANGE":
new_prio = payload.params.get("priority")
if not new_prio:
raise ValueError("priority 파라미터 필요")
sr.priority = new_prio
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, current_user.username, "BULK_PRIORITY_CHANGE",
f"우선순위 변경: {new_prio}")
else:
raise ValueError(f"알 수 없는 action: {action}")
await db.flush()
results.append({"sr_id": sr_id, "ok": True, "error": None})
success += 1
except Exception as e:
results.append({"sr_id": sr_id, "ok": False, "error": str(e)})
await db.commit()
return {
"total": len(payload.sr_ids),
"success": success,
"failed": len(payload.sr_ids) - success,
"results": results,
}