zioinfo-mail/workspace/guardia-itsm/routers/si_milestones.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- 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>
2026-05-31 23:50:56 +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