- 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>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""
|
||
위험 관리 API — 확률×영향 매트릭스, 자동 이슈 전환.
|
||
|
||
엔드포인트:
|
||
GET /api/si/projects/{pid}/risks — 위험 목록 (레벨 필터)
|
||
POST /api/si/projects/{pid}/risks — 위험 등록
|
||
GET /api/si/projects/{pid}/risks/{id} — 위험 상세
|
||
PATCH /api/si/projects/{pid}/risks/{id} — 위험 수정
|
||
DELETE /api/si/projects/{pid}/risks/{id} — 위험 삭제
|
||
POST /api/si/projects/{pid}/risks/{id}/convert-to-issue — 위험 → 이슈 전환
|
||
GET /api/si/projects/{pid}/risks/matrix — 위험 매트릭스 (3×3 히트맵용)
|
||
|
||
위험 점수: probability_level × impact_level (1~9)
|
||
9 → CRITICAL
|
||
5~8 → HIGH (HIGH×HIGH=9 시 자동 이슈 전환 알림)
|
||
3~4 → MEDIUM
|
||
1~2 → LOW
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import List, Optional
|
||
from uuid import uuid4
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy import select, desc
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from core.auth import get_current_user
|
||
from database import get_db
|
||
from models import (
|
||
SiProject, ProjectRisk, ProjectIssue,
|
||
ProjectRiskCreate, ProjectRiskOut, ProjectRiskUpdate,
|
||
IssueType, IssueStatus, RiskLevel, RiskStatus,
|
||
User, UserRole,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/si/projects", tags=["si-risks"])
|
||
|
||
|
||
def _new_risk_id() -> str:
|
||
return f"RSK-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}"
|
||
|
||
|
||
def _new_issue_id() -> str:
|
||
return f"ISS-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}"
|
||
|
||
|
||
def _calc_risk_level(score: int) -> str:
|
||
if score >= 9:
|
||
return RiskLevel.CRITICAL
|
||
if score >= 5:
|
||
return RiskLevel.HIGH
|
||
if score >= 3:
|
||
return RiskLevel.MEDIUM
|
||
return RiskLevel.LOW
|
||
|
||
|
||
# ── 목록 ──────────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/{project_id}/risks", response_model=List[ProjectRiskOut])
|
||
async def list_risks(
|
||
project_id: int,
|
||
risk_level: Optional[str] = Query(None),
|
||
status: Optional[str] = Query(None),
|
||
category: Optional[str] = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
await _assert_project(project_id, db)
|
||
q = select(ProjectRisk).where(ProjectRisk.project_id == project_id)
|
||
|
||
if risk_level:
|
||
q = q.where(ProjectRisk.risk_level == risk_level)
|
||
if status:
|
||
q = q.where(ProjectRisk.status == status)
|
||
if category:
|
||
q = q.where(ProjectRisk.category == category)
|
||
|
||
q = q.order_by(desc(ProjectRisk.risk_score))
|
||
return (await db.execute(q)).scalars().all()
|
||
|
||
|
||
# ── 생성 ──────────────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/{project_id}/risks", response_model=ProjectRiskOut, status_code=201)
|
||
async def create_risk(
|
||
project_id: int,
|
||
body: ProjectRiskCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
await _assert_project(project_id, db)
|
||
|
||
score = body.probability_level * body.impact_level
|
||
level = _calc_risk_level(score)
|
||
|
||
risk = ProjectRisk(
|
||
risk_id = _new_risk_id(),
|
||
project_id = project_id,
|
||
title = body.title,
|
||
description = body.description,
|
||
category = body.category,
|
||
probability_level = body.probability_level,
|
||
impact_level = body.impact_level,
|
||
risk_score = score,
|
||
risk_level = level,
|
||
mitigation_plan = body.mitigation_plan,
|
||
contingency_plan = body.contingency_plan,
|
||
owner = body.owner,
|
||
due_date = body.due_date,
|
||
)
|
||
db.add(risk)
|
||
await db.commit()
|
||
await db.refresh(risk)
|
||
|
||
# CRITICAL 위험 자동 알림
|
||
if level in (RiskLevel.CRITICAL, RiskLevel.HIGH):
|
||
logger.warning(
|
||
"고위험 등록: project_id=%d risk=%s level=%s score=%d",
|
||
project_id, risk.risk_id, level, score,
|
||
)
|
||
|
||
return risk
|
||
|
||
|
||
# ── 상세 ──────────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/{project_id}/risks/{risk_id}", response_model=ProjectRiskOut)
|
||
async def get_risk(
|
||
project_id: int, risk_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
return await _get_risk_or_404(project_id, risk_id, db)
|
||
|
||
|
||
# ── 수정 ──────────────────────────────────────────────────────────────────────
|
||
|
||
@router.patch("/{project_id}/risks/{risk_id}", response_model=ProjectRiskOut)
|
||
async def update_risk(
|
||
project_id: int, risk_id: int,
|
||
body: ProjectRiskUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
risk = await _get_risk_or_404(project_id, risk_id, db)
|
||
|
||
data = body.model_dump(exclude_none=True)
|
||
for field, val in data.items():
|
||
setattr(risk, field, val)
|
||
|
||
# 확률/영향 변경 시 점수 재계산
|
||
if "probability_level" in data or "impact_level" in data:
|
||
risk.risk_score = risk.probability_level * risk.impact_level
|
||
risk.risk_level = _calc_risk_level(risk.risk_score)
|
||
|
||
await db.commit()
|
||
await db.refresh(risk)
|
||
return risk
|
||
|
||
|
||
# ── 위험 → 이슈 전환 ──────────────────────────────────────────────────────────
|
||
|
||
@router.post("/{project_id}/risks/{risk_id}/convert-to-issue")
|
||
async def convert_risk_to_issue(
|
||
project_id: int, risk_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""위험이 실제 발생했을 때 이슈로 전환."""
|
||
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
||
raise HTTPException(403, "PM 이상 권한 필요")
|
||
risk = await _get_risk_or_404(project_id, risk_id, db)
|
||
|
||
if risk.converted_to_issue:
|
||
raise HTTPException(400, "이미 이슈로 전환된 위험입니다")
|
||
|
||
issue = ProjectIssue(
|
||
issue_id = _new_issue_id(),
|
||
project_id = project_id,
|
||
issue_type = IssueType.OTHER,
|
||
title = f"[위험 전환] {risk.title}",
|
||
description = risk.description,
|
||
priority = "HIGH" if risk.risk_level in ("HIGH", "CRITICAL") else "MEDIUM",
|
||
raised_by = current_user.username,
|
||
impact = f"위험 점수: {risk.risk_score} ({risk.risk_level})\n완화 계획: {risk.mitigation_plan or '없음'}",
|
||
from_risk_id = risk.id,
|
||
)
|
||
db.add(issue)
|
||
|
||
risk.converted_to_issue = True
|
||
risk.status = RiskStatus.OPEN
|
||
|
||
await db.commit()
|
||
await db.refresh(issue)
|
||
return {"message": "위험이 이슈로 전환되었습니다", "issue_id": issue.issue_id}
|
||
|
||
|
||
# ── 삭제 ──────────────────────────────────────────────────────────────────────
|
||
|
||
@router.delete("/{project_id}/risks/{risk_id}", status_code=204)
|
||
async def delete_risk(
|
||
project_id: int, risk_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
||
raise HTTPException(403, "PM 이상 권한 필요")
|
||
risk = await _get_risk_or_404(project_id, risk_id, db)
|
||
await db.delete(risk)
|
||
await db.commit()
|
||
|
||
|
||
# ── 위험 매트릭스 ─────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/{project_id}/risks/matrix")
|
||
async def get_risk_matrix(
|
||
project_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""3×3 위험 매트릭스 히트맵 데이터. 셀별 위험 목록 반환."""
|
||
await _assert_project(project_id, db)
|
||
risks = (await db.execute(
|
||
select(ProjectRisk).where(
|
||
ProjectRisk.project_id == project_id,
|
||
ProjectRisk.status == RiskStatus.OPEN,
|
||
)
|
||
)).scalars().all()
|
||
|
||
# 3×3 매트릭스 초기화 (probability × impact)
|
||
matrix: dict = {}
|
||
for p in (1, 2, 3):
|
||
for i in (1, 2, 3):
|
||
matrix[f"{p}x{i}"] = {
|
||
"probability": p, "impact": i,
|
||
"score": p * i,
|
||
"level": _calc_risk_level(p * i),
|
||
"risks": [],
|
||
}
|
||
|
||
for r in risks:
|
||
key = f"{r.probability_level}x{r.impact_level}"
|
||
if key in matrix:
|
||
matrix[key]["risks"].append({
|
||
"id": r.id,
|
||
"risk_id": r.risk_id,
|
||
"title": r.title,
|
||
"owner": r.owner,
|
||
})
|
||
|
||
return {
|
||
"project_id": project_id,
|
||
"open_risks": len(risks),
|
||
"matrix": list(matrix.values()),
|
||
}
|
||
|
||
|
||
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
|
||
|
||
async def _assert_project(project_id: int, db: AsyncSession) -> None:
|
||
if not (await db.execute(
|
||
select(SiProject).where(SiProject.id == project_id)
|
||
)).scalars().first():
|
||
raise HTTPException(404, f"SI 프로젝트 {project_id}를 찾을 수 없습니다")
|
||
|
||
|
||
async def _get_risk_or_404(project_id: int, risk_id: int, db: AsyncSession) -> ProjectRisk:
|
||
risk = (await db.execute(
|
||
select(ProjectRisk).where(
|
||
ProjectRisk.id == risk_id,
|
||
ProjectRisk.project_id == project_id,
|
||
)
|
||
)).scalars().first()
|
||
if not risk:
|
||
raise HTTPException(404, "위험을 찾을 수 없습니다")
|
||
return risk
|