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>
503 lines
19 KiB
Python
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 프로젝트에 접근할 수 없습니다")
|