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

298 lines
11 KiB
Python

"""
마일스톤 & 산출물 관리 API.
엔드포인트:
Milestones:
GET /api/si/projects/{pid}/milestones — 마일스톤 목록
POST /api/si/projects/{pid}/milestones — 마일스톤 등록
GET /api/si/projects/{pid}/milestones/{id} — 상세
PATCH /api/si/projects/{pid}/milestones/{id} — 수정
POST /api/si/projects/{pid}/milestones/{id}/complete — 완료 처리
DELETE /api/si/projects/{pid}/milestones/{id} — 삭제
Deliverables:
GET /api/si/projects/{pid}/deliverables — 산출물 목록
POST /api/si/projects/{pid}/deliverables — 산출물 등록
PATCH /api/si/projects/{pid}/deliverables/{id} — 수정
PATCH /api/si/projects/{pid}/deliverables/{id}/submit — 제출 처리
PATCH /api/si/projects/{pid}/deliverables/{id}/approve — 승인 처리
DELETE /api/si/projects/{pid}/deliverables/{id} — 삭제
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
SiProject,
ProjectMilestone, ProjectMilestoneCreate, ProjectMilestoneOut, ProjectMilestoneUpdate,
ProjectDeliverable, ProjectDeliverableCreate, ProjectDeliverableOut, ProjectDeliverableUpdate,
User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/si/projects", tags=["si-milestones"])
# ═══════════════════════════════════════════════════════════════════════════════
# 마일스톤
# ═══════════════════════════════════════════════════════════════════════════════
@router.get("/{project_id}/milestones", response_model=List[ProjectMilestoneOut])
async def list_milestones(
project_id: int,
phase: Optional[str] = Query(None),
is_completed: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _assert_project(project_id, db)
q = select(ProjectMilestone).where(ProjectMilestone.project_id == project_id)
if phase:
q = q.where(ProjectMilestone.phase == phase)
if is_completed is not None:
q = q.where(ProjectMilestone.is_completed == is_completed)
q = q.order_by(ProjectMilestone.planned_date)
return (await db.execute(q)).scalars().all()
@router.post("/{project_id}/milestones", response_model=ProjectMilestoneOut, status_code=201)
async def create_milestone(
project_id: int,
body: ProjectMilestoneCreate,
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 이상 권한 필요")
await _assert_project(project_id, db)
ms = ProjectMilestone(
project_id = project_id,
phase = body.phase,
title = body.title,
description = body.description,
planned_date = body.planned_date,
note = body.note,
)
db.add(ms)
await db.commit()
await db.refresh(ms)
return ms
@router.get("/{project_id}/milestones/{ms_id}", response_model=ProjectMilestoneOut)
async def get_milestone(
project_id: int, ms_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await _get_milestone_or_404(project_id, ms_id, db)
@router.patch("/{project_id}/milestones/{ms_id}", response_model=ProjectMilestoneOut)
async def update_milestone(
project_id: int, ms_id: int,
body: ProjectMilestoneUpdate,
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 이상 권한 필요")
ms = await _get_milestone_or_404(project_id, ms_id, db)
for field, val in body.model_dump(exclude_none=True).items():
setattr(ms, field, val)
await db.commit()
await db.refresh(ms)
return ms
@router.post("/{project_id}/milestones/{ms_id}/complete", response_model=ProjectMilestoneOut)
async def complete_milestone(
project_id: int, ms_id: int,
actual_date: Optional[date] = Query(None),
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 이상 권한 필요")
ms = await _get_milestone_or_404(project_id, ms_id, db)
ms.is_completed = True
ms.actual_date = actual_date or date.today()
ms.completed_by = current_user.username
await db.commit()
await db.refresh(ms)
return ms
@router.delete("/{project_id}/milestones/{ms_id}", status_code=204)
async def delete_milestone(
project_id: int, ms_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 이상 권한 필요")
ms = await _get_milestone_or_404(project_id, ms_id, db)
await db.delete(ms)
await db.commit()
# ═══════════════════════════════════════════════════════════════════════════════
# 산출물
# ═══════════════════════════════════════════════════════════════════════════════
@router.get("/{project_id}/deliverables", response_model=List[ProjectDeliverableOut])
async def list_deliverables(
project_id: int,
phase: Optional[str] = Query(None),
is_submitted: Optional[bool] = Query(None),
is_approved: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _assert_project(project_id, db)
q = select(ProjectDeliverable).where(ProjectDeliverable.project_id == project_id)
if phase:
q = q.where(ProjectDeliverable.phase == phase)
if is_submitted is not None:
q = q.where(ProjectDeliverable.is_submitted == is_submitted)
if is_approved is not None:
q = q.where(ProjectDeliverable.is_approved == is_approved)
q = q.order_by(ProjectDeliverable.due_date)
return (await db.execute(q)).scalars().all()
@router.post("/{project_id}/deliverables", response_model=ProjectDeliverableOut, status_code=201)
async def create_deliverable(
project_id: int,
body: ProjectDeliverableCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _assert_project(project_id, db)
deliv = ProjectDeliverable(
project_id = project_id,
milestone_id = body.milestone_id,
phase = body.phase,
activity = body.activity,
title = body.title,
doc_type = body.doc_type,
due_date = body.due_date,
version = body.version,
note = body.note,
)
db.add(deliv)
await db.commit()
await db.refresh(deliv)
return deliv
@router.patch("/{project_id}/deliverables/{deliv_id}", response_model=ProjectDeliverableOut)
async def update_deliverable(
project_id: int, deliv_id: int,
body: ProjectDeliverableUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
deliv = await _get_deliv_or_404(project_id, deliv_id, db)
for field, val in body.model_dump(exclude_none=True).items():
setattr(deliv, field, val)
await db.commit()
await db.refresh(deliv)
return deliv
@router.patch("/{project_id}/deliverables/{deliv_id}/submit", response_model=ProjectDeliverableOut)
async def submit_deliverable(
project_id: int, deliv_id: int,
submitted_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
deliv = await _get_deliv_or_404(project_id, deliv_id, db)
deliv.is_submitted = True
deliv.submitted_date = submitted_date or date.today()
await db.commit()
await db.refresh(deliv)
return deliv
@router.patch("/{project_id}/deliverables/{deliv_id}/approve", response_model=ProjectDeliverableOut)
async def approve_deliverable(
project_id: int, deliv_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 이상 권한 필요")
deliv = await _get_deliv_or_404(project_id, deliv_id, db)
if not deliv.is_submitted:
raise HTTPException(400, "제출되지 않은 산출물은 승인할 수 없습니다")
deliv.is_approved = True
deliv.approved_by = current_user.username
await db.commit()
await db.refresh(deliv)
return deliv
@router.delete("/{project_id}/deliverables/{deliv_id}", status_code=204)
async def delete_deliverable(
project_id: int, deliv_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 이상 권한 필요")
deliv = await _get_deliv_or_404(project_id, deliv_id, db)
await db.delete(deliv)
await db.commit()
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
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_milestone_or_404(project_id: int, ms_id: int, db: AsyncSession) -> ProjectMilestone:
ms = (await db.execute(
select(ProjectMilestone).where(
ProjectMilestone.id == ms_id,
ProjectMilestone.project_id == project_id,
)
)).scalars().first()
if not ms:
raise HTTPException(404, "마일스톤을 찾을 수 없습니다")
return ms
async def _get_deliv_or_404(project_id: int, deliv_id: int, db: AsyncSession) -> ProjectDeliverable:
deliv = (await db.execute(
select(ProjectDeliverable).where(
ProjectDeliverable.id == deliv_id,
ProjectDeliverable.project_id == project_id,
)
)).scalars().first()
if not deliv:
raise HTTPException(404, "산출물을 찾을 수 없습니다")
return deliv