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>
298 lines
11 KiB
Python
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
|