264 lines
9.9 KiB
Python
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,
|
|
}
|