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

348 lines
12 KiB
Python

"""
C-3: Problem Management API 라우터
엔드포인트:
POST /api/problem/ — 문제 레코드 생성
GET /api/problem/ — 문제 목록
GET /api/problem/{prb_id} — 문제 상세
PATCH /api/problem/{prb_id} — 문제 수정
POST /api/problem/{prb_id}/rca — RCA 기록
POST /api/problem/{prb_id}/workaround — 임시 해결 기록
POST /api/problem/{prb_id}/resolve — 해결 처리
POST /api/problem/{prb_id}/close — 종결 처리
POST /api/problem/{prb_id}/notes — 활동 노트 추가
GET /api/problem/{prb_id}/notes — 활동 노트 목록
GET /api/problem/known-errors — Known Error DB
GET /api/problem/stats — Problem 통계
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import select, desc, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
User, UserRole,
ProblemRecord, ProblemRecordOut, ProblemRecordCreate,
ProblemNote, ProblemNoteCreate,
ProblemStatus, ProblemCategory,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/problem", tags=["problem"])
async def _next_problem_id(db: AsyncSession) -> str:
today = datetime.utcnow().strftime("%Y%m%d")
prefix = f"PRB-{today}-"
last = (await db.execute(
select(ProblemRecord.problem_id)
.where(ProblemRecord.problem_id.like(f"{prefix}%"))
.order_by(desc(ProblemRecord.problem_id)).limit(1)
)).scalar()
seq = 1 if not last else (int(last.split("-")[-1]) + 1 if last else 1)
return f"{prefix}{seq:04d}"
async def _get_problem(db: AsyncSession, prb_id: str) -> ProblemRecord:
prb = (await db.execute(
select(ProblemRecord).where(ProblemRecord.problem_id == prb_id)
)).scalars().first()
if not prb:
raise HTTPException(404, f"문제 레코드 {prb_id}를 찾을 수 없습니다.")
return prb
@router.post("/", response_model=ProblemRecordOut, status_code=201)
async def create_problem(
body: ProblemRecordCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""문제 레코드 생성."""
problem_id = await _next_problem_id(db)
prb = ProblemRecord(
problem_id = problem_id,
title = body.title,
description = body.description,
status = ProblemStatus.OPEN.value,
priority = body.priority.upper(),
category = body.category.upper(),
related_sr_ids = json.dumps(body.related_sr_ids or [], ensure_ascii=False),
ci_id = body.ci_id,
affected_users = body.affected_users,
incident_count = body.incident_count,
total_downtime_min = body.total_downtime_min,
created_by = current_user.username,
)
db.add(prb)
await db.commit()
await db.refresh(prb)
return prb
@router.get("/known-errors", response_model=List[ProblemRecordOut])
async def known_errors(
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""Known Error DB 조회."""
rows = (await db.execute(
select(ProblemRecord)
.where(ProblemRecord.known_error == True)
.order_by(desc(ProblemRecord.updated_at))
.limit(limit)
)).scalars().all()
return rows
@router.get("/stats")
async def problem_stats(
days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""Problem 통계."""
since = datetime.utcnow() - timedelta(days=days)
total = (await db.execute(
select(func.count()).select_from(ProblemRecord).where(ProblemRecord.created_at >= since)
)).scalar() or 0
by_status = {}
for st, cnt in (await db.execute(
select(ProblemRecord.status, func.count())
.where(ProblemRecord.created_at >= since)
.group_by(ProblemRecord.status)
)).all():
by_status[st] = cnt
known_err_count = (await db.execute(
select(func.count()).select_from(ProblemRecord)
.where(ProblemRecord.known_error == True)
)).scalar() or 0
return {
"period_days": days,
"total": total,
"by_status": by_status,
"known_errors": known_err_count,
"open": by_status.get("OPEN", 0),
"investigating": by_status.get("INVESTIGATING", 0),
"rca_done": by_status.get("RCA_DONE", 0),
}
@router.get("/", response_model=List[ProblemRecordOut])
async def list_problems(
status: Optional[str] = Query(None),
priority: Optional[str] = Query(None),
category: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""문제 레코드 목록."""
conditions = []
if status: conditions.append(ProblemRecord.status == status.upper())
if priority: conditions.append(ProblemRecord.priority == priority.upper())
if category: conditions.append(ProblemRecord.category == category.upper())
q = select(ProblemRecord)
if conditions:
q = q.where(and_(*conditions))
q = q.order_by(desc(ProblemRecord.created_at)).limit(limit).offset(offset)
return (await db.execute(q)).scalars().all()
@router.get("/{prb_id}", response_model=ProblemRecordOut)
async def get_problem(prb_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)):
return await _get_problem(db, prb_id)
@router.patch("/{prb_id}", response_model=ProblemRecordOut)
async def update_problem(
prb_id: str,
body: dict = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""문제 레코드 수정."""
prb = await _get_problem(db, prb_id)
for k, v in body.items():
if hasattr(prb, k) and k not in ("id", "problem_id", "created_at"):
setattr(prb, k, v)
await db.commit()
await db.refresh(prb)
return prb
@router.post("/{prb_id}/rca")
async def record_rca(
prb_id: str,
root_cause: str = Body(..., embed=True),
permanent_fix: Optional[str] = Body(None, embed=True),
known_error: bool = Body(False, embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""RCA (근본 원인 분석) 기록."""
prb = await _get_problem(db, prb_id)
prb.root_cause = root_cause
prb.permanent_fix = permanent_fix
prb.known_error = known_error
prb.status = ProblemStatus.RCA_DONE.value
prb.rca_completed_at = datetime.utcnow()
note = ProblemNote(
problem_id_fk = prb.id,
note_type = "RCA",
content = f"RCA 완료: {root_cause[:200]}",
author = current_user.username,
)
db.add(note)
await db.commit()
return {"problem_id": prb_id, "status": prb.status, "known_error": known_error}
@router.post("/{prb_id}/workaround")
async def record_workaround(
prb_id: str,
workaround: str = Body(..., embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""임시 해결 방법 기록."""
prb = await _get_problem(db, prb_id)
prb.workaround = workaround
if prb.status not in (ProblemStatus.RESOLVED.value, ProblemStatus.CLOSED.value):
prb.status = ProblemStatus.WORKAROUND.value
note = ProblemNote(
problem_id_fk = prb.id,
note_type = "WORKAROUND",
content = f"임시 해결책: {workaround[:200]}",
author = current_user.username,
)
db.add(note)
await db.commit()
return {"problem_id": prb_id, "status": prb.status}
@router.post("/{prb_id}/resolve")
async def resolve_problem(
prb_id: str,
resolution: Optional[str] = Body(None, embed=True),
rfc_id: Optional[str] = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""문제 해결 처리."""
prb = await _get_problem(db, prb_id)
prb.status = ProblemStatus.RESOLVED.value
prb.resolved_at = datetime.utcnow()
if resolution: prb.permanent_fix = resolution
if rfc_id: prb.rfc_id = rfc_id
note = ProblemNote(
problem_id_fk = prb.id,
note_type = "RESOLUTION",
content = resolution or "문제 해결 완료",
author = current_user.username,
)
db.add(note)
await db.commit()
return {"problem_id": prb_id, "status": prb.status}
@router.post("/{prb_id}/close")
async def close_problem(
prb_id: str,
note_text: Optional[str] = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""문제 종결 처리."""
prb = await _get_problem(db, prb_id)
if prb.status != ProblemStatus.RESOLVED.value:
raise HTTPException(400, f"RESOLVED 상태가 아닙니다: {prb.status}")
prb.status = ProblemStatus.CLOSED.value
prb.closed_at = datetime.utcnow()
await db.commit()
return {"problem_id": prb_id, "status": prb.status}
@router.post("/{prb_id}/notes")
async def add_note(
prb_id: str,
body: ProblemNoteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""활동 노트 추가."""
prb = await _get_problem(db, prb_id)
if prb.status == ProblemStatus.INVESTIGATING.value or prb.status == ProblemStatus.OPEN.value:
prb.status = ProblemStatus.INVESTIGATING.value
note = ProblemNote(
problem_id_fk = prb.id,
note_type = body.note_type.upper(),
content = body.content,
author = current_user.username,
)
db.add(note)
await db.commit()
return {"ok": True, "note_type": body.note_type}
@router.get("/{prb_id}/notes")
async def get_notes(
prb_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""활동 노트 목록."""
prb = await _get_problem(db, prb_id)
notes = (await db.execute(
select(ProblemNote)
.where(ProblemNote.problem_id_fk == prb.id)
.order_by(ProblemNote.created_at)
)).scalars().all()
return [
{"id": n.id, "note_type": n.note_type, "content": n.content,
"author": n.author, "created_at": n.created_at.isoformat()}
for n in notes
]
# ── G-5: 자동 RCA 분석 ────────────────────────────────────────────────────────
@router.post("/{prb_id}/auto-rca")
async def auto_rca_problem(
prb_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Ollama LLM으로 Problem RCA 초안 자동 생성 후 레코드에 저장."""
prb = await _get_problem(db, prb_id)
try:
from core.auto_rca import analyze_problem_rca
result = await analyze_problem_rca(prb.id, db)
except Exception as e:
raise HTTPException(500, f"RCA 분석 오류: {str(e)[:200]}")
rca_data = result["rca"]
prb.root_cause = rca_data.get("root_cause", "")
prb.permanent_fix = json.dumps(rca_data.get("prevention", []), ensure_ascii=False)
prb.rca_completed_at = datetime.utcnow()
prb.updated_at = datetime.utcnow()
await db.commit()
return result