zioinfo-mail/itsm/routers/si_projects.py
DESKTOP-TKLFCPR\ython e228faabf5 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

503 lines
19 KiB
Python

"""
SI 프로젝트 관리 API — 표준 방법론(분석→설계→구현→인도) 기반.
엔드포인트:
GET /api/si/projects — 프로젝트 목록
POST /api/si/projects — 프로젝트 생성 (단계별 체크리스트 자동 생성)
GET /api/si/projects/{id} — 프로젝트 상세
PATCH /api/si/projects/{id} — 프로젝트 수정
DELETE /api/si/projects/{id} — 프로젝트 삭제 (ADMIN)
PATCH /api/si/projects/{id}/phase — 단계 전환
GET /api/si/projects/{id}/checklist — 단계별 활동 체크리스트 조회
PATCH /api/si/projects/{id}/checklist/{cid} — 활동 완료 처리
GET /api/si/projects/{id}/summary — 진척 요약 (WBS/이슈/위험/TC)
POST /api/si/projects/{id}/convert-to-sm — SM 전환 (CMDB 자동 생성)
GET /api/si/projects/{id}/rtm — RTM (요구사항↔WBS↔TC 추적표)
"""
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, func as sqlfunc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
Institution, Server, SslHistory, PmSchedule,
SiProject, SiProjectCreate, SiProjectOut, SiProjectUpdate,
SiPhaseChecklist, SiPhaseChecklistOut, SiPhaseChecklistUpdate,
SiRequirement, WbsItem, ProjectIssue, ProjectRisk,
SiTestPlan, SiTestCase, SiDefect,
ProjectPhase, PHASE_DEFAULT_ACTIVITIES,
User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/si/projects", tags=["si-projects"])
_PHASE_ORDER = [
ProjectPhase.INITIATION,
ProjectPhase.ANALYSIS,
ProjectPhase.DESIGN,
ProjectPhase.IMPLEMENTATION,
ProjectPhase.DEPLOYMENT,
ProjectPhase.STABILIZATION,
ProjectPhase.CLOSED,
]
def _new_project_code() -> str:
return f"SI-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
def _calc_risk_level(score: int) -> str:
if score >= 7:
return "CRITICAL"
if score >= 5:
return "HIGH"
if score >= 3:
return "MEDIUM"
return "LOW"
# ── 목록 ──────────────────────────────────────────────────────────────────────
@router.get("", response_model=List[SiProjectOut])
async def list_si_projects(
inst_id: Optional[int] = Query(None),
phase: Optional[str] = Query(None),
keyword: Optional[str] = Query(None),
is_active: bool = Query(True),
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(SiProject).where(SiProject.is_active == is_active)
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
r_i = await db.execute(
select(Institution).where(Institution.inst_code == current_user.inst_code)
)
own = r_i.scalars().first()
q = q.where(SiProject.inst_id == own.id) if own else q.where(SiProject.id == -1)
elif inst_id:
q = q.where(SiProject.inst_id == inst_id)
if phase:
q = q.where(SiProject.phase == phase)
if keyword:
q = q.where(SiProject.project_name.contains(keyword))
q = q.order_by(desc(SiProject.created_at)).offset(skip).limit(limit)
result = await db.execute(q)
return result.scalars().all()
# ── 생성 ──────────────────────────────────────────────────────────────────────
@router.post("", response_model=SiProjectOut, status_code=201)
async def create_si_project(
body: SiProjectCreate,
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 이상 권한 필요")
proj = SiProject(
project_code = _new_project_code(),
project_name = body.project_name,
inst_id = body.inst_id,
phase = ProjectPhase.INITIATION,
description = body.description,
contract_amount = body.contract_amount,
planned_start = body.planned_start,
planned_end = body.planned_end,
pm_user = body.pm_user,
dev_lead = body.dev_lead,
customer_pm = body.customer_pm,
team_members = body.team_members,
budget_total = body.budget_total,
note = body.note,
created_by = current_user.username,
)
db.add(proj)
await db.flush() # ID 확보
# 4단계 기본 체크리스트 자동 생성
for phase, activities in PHASE_DEFAULT_ACTIVITIES.items():
for activity, name in activities:
db.add(SiPhaseChecklist(
project_id = proj.id,
phase = phase,
activity = activity,
activity_name = name,
))
await db.commit()
await db.refresh(proj)
logger.info("SI 프로젝트 생성: code=%s name=%s by=%s",
proj.project_code, proj.project_name, current_user.username)
return proj
# ── 상세 ──────────────────────────────────────────────────────────────────────
@router.get("/{project_id}", response_model=SiProjectOut)
async def get_si_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
proj = await _get_project_or_404(project_id, db)
_check_access(proj, current_user)
return proj
# ── 수정 ──────────────────────────────────────────────────────────────────────
@router.patch("/{project_id}", response_model=SiProjectOut)
async def update_si_project(
project_id: int,
body: SiProjectUpdate,
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 이상 권한 필요")
proj = await _get_project_or_404(project_id, db)
for field, val in body.model_dump(exclude_none=True).items():
setattr(proj, field, val)
await db.commit()
await db.refresh(proj)
return proj
# ── 삭제 ──────────────────────────────────────────────────────────────────────
@router.delete("/{project_id}", status_code=204)
async def delete_si_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한 필요")
proj = await _get_project_or_404(project_id, db)
await db.delete(proj)
await db.commit()
# ── 단계 전환 ─────────────────────────────────────────────────────────────────
@router.patch("/{project_id}/phase")
async def advance_phase(
project_id: int,
target_phase: ProjectPhase = Query(..., description="전환할 단계"),
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 이상 권한 필요")
proj = await _get_project_or_404(project_id, db)
# 현재 단계 미완료 항목 수집
current_items = (await db.execute(
select(SiPhaseChecklist).where(
SiPhaseChecklist.project_id == project_id,
SiPhaseChecklist.phase == proj.phase,
SiPhaseChecklist.is_done == False, # noqa: E712
)
)).scalars().all()
warnings = [item.activity_name for item in current_items]
proj.phase = target_phase
if target_phase == ProjectPhase.CLOSED:
from datetime import date
proj.actual_end = proj.actual_end or date.today()
await db.commit()
await db.refresh(proj)
return {
"project": SiProjectOut.model_validate(proj),
"warnings": warnings,
"message": f"단계 전환 완료: {target_phase}" + (
f" (미완료 활동 {len(warnings)}건 있음)" if warnings else ""
),
}
# ── 체크리스트 ────────────────────────────────────────────────────────────────
@router.get("/{project_id}/checklist", response_model=List[SiPhaseChecklistOut])
async def get_checklist(
project_id: int,
phase: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(SiPhaseChecklist).where(SiPhaseChecklist.project_id == project_id)
if phase:
q = q.where(SiPhaseChecklist.phase == phase)
q = q.order_by(SiPhaseChecklist.phase, SiPhaseChecklist.id)
return (await db.execute(q)).scalars().all()
@router.patch("/{project_id}/checklist/{checklist_id}", response_model=SiPhaseChecklistOut)
async def update_checklist_item(
project_id: int,
checklist_id: int,
body: SiPhaseChecklistUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (await db.execute(
select(SiPhaseChecklist).where(
SiPhaseChecklist.id == checklist_id,
SiPhaseChecklist.project_id == project_id,
)
)).scalars().first()
if not item:
raise HTTPException(404, "체크리스트 항목을 찾을 수 없습니다")
item.is_done = body.is_done
item.done_by = body.done_by or current_user.username
item.done_at = datetime.now() if body.is_done else None
if body.note is not None:
item.note = body.note
await db.commit()
await db.refresh(item)
return item
# ── 진척 요약 ─────────────────────────────────────────────────────────────────
@router.get("/{project_id}/summary")
async def get_project_summary(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""프로젝트 대시보드용 요약: WBS/이슈/위험/TC 현황."""
proj = await _get_project_or_404(project_id, db)
_check_access(proj, current_user)
# WBS 진척
wbs_rows = (await db.execute(
select(WbsItem).where(WbsItem.project_id == project_id, WbsItem.is_leaf == True) # noqa: E712
)).scalars().all()
total_wbs = len(wbs_rows)
wbs_done = sum(1 for w in wbs_rows if w.completion_pct == 100)
avg_pct = (sum(w.completion_pct for w in wbs_rows) // total_wbs) if total_wbs else 0
delayed = sum(1 for w in wbs_rows if w.status == "DELAYED")
# 이슈 현황
issues = (await db.execute(
select(ProjectIssue).where(ProjectIssue.project_id == project_id)
)).scalars().all()
issue_open = sum(1 for i in issues if i.status in ("OPEN", "IN_PROGRESS"))
issue_total = len(issues)
# 위험 현황
risks = (await db.execute(
select(ProjectRisk).where(ProjectRisk.project_id == project_id)
)).scalars().all()
risk_high = sum(1 for r in risks if r.risk_level in ("HIGH", "CRITICAL") and r.status == "OPEN")
# 요구사항 현황
reqs = (await db.execute(
select(SiRequirement).where(SiRequirement.project_id == project_id)
)).scalars().all()
req_total = len(reqs)
req_confirmed = sum(1 for r in reqs if r.status == "CONFIRMED")
# 테스트 현황
plans = (await db.execute(
select(SiTestPlan).where(SiTestPlan.project_id == project_id)
)).scalars().all()
tc_total = sum(p.total_cases for p in plans)
tc_pass = sum(p.pass_count for p in plans)
tc_fail = sum(p.fail_count for p in plans)
# 결함 현황
defects = (await db.execute(
select(SiDefect).where(SiDefect.project_id == project_id)
)).scalars().all()
defect_open = sum(1 for d in defects if d.status in ("OPEN", "ASSIGNED"))
defect_critical = sum(1 for d in defects if d.severity == "CRITICAL" and d.status not in ("CLOSED", "VERIFIED"))
# 체크리스트 완료율
checklists = (await db.execute(
select(SiPhaseChecklist).where(SiPhaseChecklist.project_id == project_id)
)).scalars().all()
cl_total = len(checklists)
cl_done = sum(1 for c in checklists if c.is_done)
return {
"project_id": project_id,
"project_name": proj.project_name,
"phase": proj.phase,
"health_status": proj.health_status,
"overall_progress": avg_pct,
"wbs": {
"total": total_wbs, "completed": wbs_done,
"avg_pct": avg_pct, "delayed": delayed,
},
"issues": {"total": issue_total, "open": issue_open},
"risks": {"total": len(risks), "high_open": risk_high},
"requirements": {"total": req_total, "confirmed": req_confirmed},
"tests": {
"total_cases": tc_total, "pass": tc_pass, "fail": tc_fail,
"pass_rate": (tc_pass * 100 // tc_total) if tc_total else 0,
},
"defects": {"open": defect_open, "critical_open": defect_critical},
"checklist": {
"total": cl_total, "done": cl_done,
"pct": (cl_done * 100 // cl_total) if cl_total else 0,
},
}
# ── RTM (Requirements Traceability Matrix) ────────────────────────────────────
@router.get("/{project_id}/rtm")
async def get_rtm(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""요구사항 → WBS → 테스트케이스 → 결함 추적 매트릭스."""
proj = await _get_project_or_404(project_id, db)
_check_access(proj, current_user)
reqs = (await db.execute(
select(SiRequirement).where(SiRequirement.project_id == project_id)
.order_by(SiRequirement.req_id)
)).scalars().all()
rows = []
for req in reqs:
wbs_title = None
if req.wbs_item_id:
wbs = (await db.execute(
select(WbsItem).where(WbsItem.id == req.wbs_item_id)
)).scalars().first()
wbs_title = f"{wbs.wbs_code} {wbs.title}" if wbs else None
tcs = (await db.execute(
select(SiTestCase).where(SiTestCase.req_id == req.id)
)).scalars().all()
defect_count = 0
for tc in tcs:
dc = (await db.execute(
select(SiDefect).where(SiDefect.tc_id == tc.id,
SiDefect.status.notin_(["CLOSED", "VERIFIED"]))
)).scalars().all()
defect_count += len(dc)
rows.append({
"req_id": req.req_id,
"req_title": req.title,
"req_type": req.req_type,
"req_status": req.status,
"wbs": wbs_title,
"tc_count": len(tcs),
"tc_pass": sum(1 for t in tcs if t.last_result == "PASS"),
"tc_fail": sum(1 for t in tcs if t.last_result == "FAIL"),
"open_defects": defect_count,
})
return {"project_id": project_id, "total": len(rows), "rows": rows}
# ── SM 전환 ───────────────────────────────────────────────────────────────────
@router.post("/{project_id}/convert-to-sm")
async def convert_to_sm(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SI 프로젝트 완료 → SM 전환: CMDB 서버 등록 + PM 스케줄 생성."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM 이상 권한 필요")
proj = await _get_project_or_404(project_id, db)
if proj.phase not in (ProjectPhase.STABILIZATION, ProjectPhase.CLOSED):
raise HTTPException(400, "안정화 또는 종료 단계인 프로젝트만 SM 전환 가능합니다")
if proj.converted_to_sm:
raise HTTPException(400, "이미 SM으로 전환된 프로젝트입니다")
if not proj.inst_id:
raise HTTPException(400, "기관(inst_id)이 지정되지 않아 SM 전환 불가합니다")
created_servers: List[str] = []
created_pm: List[str] = []
# 요구사항 중 서버 관련 내용으로 최소 CMDB 항목 생성
# (실제 프로젝트에서는 WBS/서버 정보를 별도 관리하지만, 기본 레코드만 생성)
default_server = Server(
inst_id = proj.inst_id,
server_name = f"{proj.project_name} 운영서버",
server_role = "WAS",
note = f"SI 프로젝트 [{proj.project_code}] SM 전환 자동 생성",
is_active = True,
)
db.add(default_server)
await db.flush()
created_servers.append(default_server.server_name)
# 분기 PM 스케줄 생성
pm_sched = PmSchedule(
schedule_name = f"{proj.project_name} 분기 PM",
inst_id = proj.inst_id,
server_id = default_server.id,
frequency = "QUARTERLY",
advance_days = 7,
assignee = proj.pm_user,
notify_before = True,
notify_after = True,
created_by = current_user.username,
)
db.add(pm_sched)
created_pm.append(pm_sched.schedule_name)
proj.converted_to_sm = True
proj.phase = ProjectPhase.CLOSED
await db.commit()
logger.info("SI→SM 전환: project=%s by=%s", proj.project_code, current_user.username)
return {
"message": "SM 전환 완료",
"project_code": proj.project_code,
"created_servers": created_servers,
"created_pm_schedules": created_pm,
}
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
async def _get_project_or_404(project_id: int, db: AsyncSession) -> SiProject:
proj = (await db.execute(
select(SiProject).where(SiProject.id == project_id)
)).scalars().first()
if not proj:
raise HTTPException(404, f"SI 프로젝트 {project_id}를 찾을 수 없습니다")
return proj
def _check_access(proj: SiProject, user: User) -> None:
if user.role == UserRole.CUSTOMER:
raise HTTPException(403, "고객 계정은 SI 프로젝트에 접근할 수 없습니다")