guardia-itsm/routers/deliverables.py
DESKTOP-TKLFCPRython 1f8b926066 feat(itsm): PMS/준수성/JMeter/공공기관 기능 + Nifty UI + 로고 Copyright
[PMS 완성]
- core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX)
- routers/si_report.py: daily|weekly|monthly + 메신저 발송
- routers/deliverables.py: 산출물 CRUD + 제출/검토
- si_issues.py: 이슈→SR 자동 연결
- scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서
- models.py: Deliverable 모델

[준수성 자동 점검]
- core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙
- routers/compliance.py: 스캔 + HTML/Excel 보고서

[JMeter 성능 테스트]
- routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서

[공공기관 필수 기능]
- routers/public_checklist.py: 행안부 기준 19개 항목

[UI/브랜드]
- 로고(ziologo.png) + Copyright 2026 All Rights Reserved
- Nifty 계층형 사이드바 (PMS 서브메뉴)
- X-Powered-By + X-Copyright 응답 헤더
- manual/15_UI_Nifty_가이드.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:50:29 +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,
}