zioinfo-mail/workspace/guardia-itsm/routers/deliverables.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

264 lines
9.9 KiB
Python

"""
SI 프로젝트 산출물(Deliverable) 관리 API
엔드포인트:
GET /api/si/projects/{pid}/deliverables — 산출물 목록
POST /api/si/projects/{pid}/deliverables — 산출물 등록
GET /api/si/projects/{pid}/deliverables/{id} — 산출물 상세
PATCH /api/si/projects/{pid}/deliverables/{id} — 산출물 수정
DELETE /api/si/projects/{pid}/deliverables/{id} — 산출물 삭제
POST /api/si/projects/{pid}/deliverables/{id}/submit — 제출 처리
POST /api/si/projects/{pid}/deliverables/{id}/review — 검토 결과 등록
GET /api/si/projects/{pid}/deliverables/summary — 제출 현황 요약
"""
from __future__ import annotations
import logging
from datetime import datetime, date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
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 (
Deliverable, DeliverableCreate, DeliverableOut, DeliverableUpdate,
DeliverableStatus, SiProject, User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/si/projects", tags=["deliverables"])
class ReviewRequest(BaseModel):
result: str # APPROVED | REJECTED
comment: Optional[str] = None
async def _get_project(db: AsyncSession, pid: int) -> SiProject:
proj = await db.get(SiProject, pid)
if not proj:
raise HTTPException(404, f"프로젝트 ID {pid}를 찾을 수 없습니다.")
return proj
async def _get_deliverable(db: AsyncSession, pid: int, did: int) -> Deliverable:
d = await db.get(Deliverable, did)
if not d or d.project_id != pid:
raise HTTPException(404, f"산출물 ID {did}를 찾을 수 없습니다.")
return d
# ── 목록 ─────────────────────────────────────────────────────────────────────
@router.get("/{pid}/deliverables", response_model=List[DeliverableOut])
async def list_deliverables(
pid: int,
status: Optional[str] = None,
type_: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""프로젝트 산출물 목록 조회."""
await _get_project(db, pid)
q = select(Deliverable).where(Deliverable.project_id == pid)
if status:
q = q.where(Deliverable.status == status)
if type_:
q = q.where(Deliverable.deliverable_type == type_)
q = q.order_by(Deliverable.due_date.asc().nullslast())
rows = (await db.execute(q)).scalars().all()
return rows
# ── 생성 ─────────────────────────────────────────────────────────────────────
@router.post("/{pid}/deliverables", response_model=DeliverableOut, status_code=201)
async def create_deliverable(
pid: int,
body: DeliverableCreate,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""산출물 등록."""
await _get_project(db, pid)
body.project_id = pid
d = Deliverable(
project_id = pid,
wbs_item_id = body.wbs_item_id,
milestone_id = body.milestone_id,
name = body.name,
description = body.description,
deliverable_type= body.deliverable_type,
version = body.version,
due_date = body.due_date,
reviewer = body.reviewer,
created_by = cu.username,
)
db.add(d)
await db.commit()
await db.refresh(d)
return d
# ── 상세 ─────────────────────────────────────────────────────────────────────
@router.get("/{pid}/deliverables/{did}", response_model=DeliverableOut)
async def get_deliverable(
pid: int, did: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
return await _get_deliverable(db, pid, did)
# ── 수정 ─────────────────────────────────────────────────────────────────────
@router.patch("/{pid}/deliverables/{did}", response_model=DeliverableOut)
async def update_deliverable(
pid: int, did: int,
body: DeliverableUpdate,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
d = await _get_deliverable(db, pid, did)
for field, val in body.model_dump(exclude_none=True).items():
setattr(d, field, val)
d.updated_at = datetime.now()
await db.commit()
await db.refresh(d)
return d
# ── 삭제 ─────────────────────────────────────────────────────────────────────
@router.delete("/{pid}/deliverables/{did}", status_code=204)
async def delete_deliverable(
pid: int, did: int,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
if cu.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "ADMIN 또는 PM만 산출물을 삭제할 수 있습니다.")
d = await _get_deliverable(db, pid, did)
await db.delete(d)
await db.commit()
# ── 제출 처리 ────────────────────────────────────────────────────────────────
@router.post("/{pid}/deliverables/{did}/submit")
async def submit_deliverable(
pid: int,
did: int,
file: Optional[UploadFile] = File(None),
comment: str = Form(""),
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""산출물 제출 (파일 첨부 선택)."""
d = await _get_deliverable(db, pid, did)
if d.status in (DeliverableStatus.APPROVED,):
raise HTTPException(400, "이미 승인된 산출물입니다.")
# 파일 저장
if file:
import aiofiles
from pathlib import Path
upload_dir = Path(__file__).parent.parent / "uploads" / "deliverables" / str(pid)
upload_dir.mkdir(parents=True, exist_ok=True)
safe_name = f"{did}_{file.filename}"
out_path = upload_dir / safe_name
async with aiofiles.open(out_path, "wb") as f:
content = await file.read()
await f.write(content)
d.file_path = str(out_path)
d.file_name = file.filename
d.status = DeliverableStatus.SUBMITTED
d.submitted_at = datetime.now()
d.submitted_by = cu.username
d.updated_at = datetime.now()
await db.commit()
return {
"message": f"산출물 '{d.name}' 제출 완료",
"status": d.status,
"submitted_at": d.submitted_at.isoformat(),
}
# ── 검토 결과 등록 ────────────────────────────────────────────────────────────
@router.post("/{pid}/deliverables/{did}/review")
async def review_deliverable(
pid: int,
did: int,
body: ReviewRequest,
db: AsyncSession = Depends(get_db),
cu: User = Depends(get_current_user),
):
"""검토 결과 등록 (APPROVED / REJECTED)."""
if cu.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN만 검토 결과를 등록할 수 있습니다.")
d = await _get_deliverable(db, pid, did)
if d.status not in (DeliverableStatus.SUBMITTED, DeliverableStatus.REVIEWING):
raise HTTPException(400, "제출된 산출물에만 검토 결과를 등록할 수 있습니다.")
d.status = body.result # APPROVED or REJECTED
d.reviewer = cu.username
d.reviewed_at = datetime.now()
d.review_comment = body.comment
d.updated_at = datetime.now()
await db.commit()
msg = "승인" if body.result == DeliverableStatus.APPROVED else "반려"
return {"message": f"산출물 '{d.name}' {msg} 처리 완료", "status": d.status}
# ── 제출 현황 요약 ────────────────────────────────────────────────────────────
@router.get("/{pid}/deliverables/summary")
async def deliverable_summary(
pid: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""산출물 제출 현황 요약."""
await _get_project(db, pid)
rows = (await db.execute(
select(Deliverable).where(Deliverable.project_id == pid)
)).scalars().all()
total = len(rows)
by_status= {}
overdue = []
today = date.today()
for d in rows:
by_status[d.status] = by_status.get(d.status, 0) + 1
if d.status == DeliverableStatus.PENDING and d.due_date and d.due_date < today:
overdue.append({
"id": d.id,
"name": d.name,
"due_date": d.due_date.isoformat(),
"days_overdue": (today - d.due_date).days,
})
approved = by_status.get(DeliverableStatus.APPROVED, 0)
submit_rate = round(
(total - by_status.get(DeliverableStatus.PENDING, 0)) / total * 100, 1
) if total else 0.0
return {
"total": total,
"by_status": by_status,
"approved": approved,
"submit_rate": submit_rate,
"approval_rate": round(approved / total * 100, 1) if total else 0.0,
"overdue": overdue,
}