""" 마일스톤 & 산출물 관리 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