zioinfo-mail/workspace/guardia-itsm/routers/problem.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- 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>
2026-05-31 23:50:56 +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