""" 프로젝트 이슈 관리 API — 일정/자원/기술/요구사항변경 등 장애 요인 추적. 엔드포인트: GET /api/si/projects/{pid}/issues — 이슈 목록 POST /api/si/projects/{pid}/issues — 이슈 등록 GET /api/si/projects/{pid}/issues/{id} — 이슈 상세 PATCH /api/si/projects/{pid}/issues/{id} — 이슈 수정 PATCH /api/si/projects/{pid}/issues/{id}/resolve — 이슈 해결 처리 DELETE /api/si/projects/{pid}/issues/{id} — 이슈 삭제 GET /api/si/projects/{pid}/issues/stats — 유형별·상태별 통계 """ from __future__ import annotations import logging from datetime import datetime from typing import List, Optional from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( SiProject, ProjectIssue, ProjectIssueCreate, ProjectIssueOut, ProjectIssueUpdate, IssueStatus, IssueType, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/si/projects", tags=["si-issues"]) def _new_issue_id() -> str: return f"ISS-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}" # ── 목록 ────────────────────────────────────────────────────────────────────── @router.get("/{project_id}/issues", response_model=List[ProjectIssueOut]) async def list_issues( project_id: int, issue_type: Optional[str] = Query(None), status: Optional[str] = Query(None), priority: Optional[str] = Query(None), assigned_to: Optional[str] = Query(None), keyword: Optional[str] = Query(None), skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) q = select(ProjectIssue).where(ProjectIssue.project_id == project_id) if issue_type: q = q.where(ProjectIssue.issue_type == issue_type) if status: q = q.where(ProjectIssue.status == status) if priority: q = q.where(ProjectIssue.priority == priority) if assigned_to: q = q.where(ProjectIssue.assigned_to == assigned_to) if keyword: q = q.where(ProjectIssue.title.contains(keyword)) q = q.order_by(desc(ProjectIssue.created_at)).offset(skip).limit(limit) return (await db.execute(q)).scalars().all() # ── 생성 ────────────────────────────────────────────────────────────────────── @router.post("/{project_id}/issues", response_model=ProjectIssueOut, status_code=201) async def create_issue( project_id: int, body: ProjectIssueCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) issue = ProjectIssue( issue_id = _new_issue_id(), project_id = project_id, wbs_item_id = body.wbs_item_id, issue_type = body.issue_type, title = body.title, description = body.description, priority = body.priority, raised_by = body.raised_by or current_user.username, assigned_to = body.assigned_to, due_date = body.due_date, impact = body.impact, ) db.add(issue) await db.commit() await db.refresh(issue) return issue # ── 상세 ────────────────────────────────────────────────────────────────────── @router.get("/{project_id}/issues/{issue_id}", response_model=ProjectIssueOut) async def get_issue( project_id: int, issue_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): return await _get_issue_or_404(project_id, issue_id, db) # ── 수정 ────────────────────────────────────────────────────────────────────── @router.patch("/{project_id}/issues/{issue_id}", response_model=ProjectIssueOut) async def update_issue( project_id: int, issue_id: int, body: ProjectIssueUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): issue = await _get_issue_or_404(project_id, issue_id, db) for field, val in body.model_dump(exclude_none=True).items(): setattr(issue, field, val) await db.commit() await db.refresh(issue) return issue # ── 해결 처리 ───────────────────────────────────────────────────────────────── @router.patch("/{project_id}/issues/{issue_id}/resolve", response_model=ProjectIssueOut) async def resolve_issue( project_id: int, issue_id: int, resolution: str = Query(..., description="해결 내용"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): issue = await _get_issue_or_404(project_id, issue_id, db) issue.status = IssueStatus.RESOLVED issue.resolution = resolution issue.resolved_at = datetime.now() await db.commit() await db.refresh(issue) return issue # ── 삭제 ────────────────────────────────────────────────────────────────────── @router.delete("/{project_id}/issues/{issue_id}", status_code=204) async def delete_issue( project_id: int, issue_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 이상 권한 필요") issue = await _get_issue_or_404(project_id, issue_id, db) await db.delete(issue) await db.commit() # ── 통계 ────────────────────────────────────────────────────────────────────── @router.get("/{project_id}/issues/stats") async def get_issue_stats( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _assert_project(project_id, db) issues = (await db.execute( select(ProjectIssue).where(ProjectIssue.project_id == project_id) )).scalars().all() by_type = {} by_status = {} overdue = 0 from datetime import date today = date.today() for i in issues: by_type[i.issue_type] = by_type.get(i.issue_type, 0) + 1 by_status[i.status] = by_status.get(i.status, 0) + 1 if i.due_date and i.due_date < today and i.status not in ("RESOLVED", "CLOSED"): overdue += 1 return { "project_id": project_id, "total": len(issues), "by_type": by_type, "by_status": by_status, "overdue": overdue, } # ── 내부 헬퍼 ───────────────────────────────────────────────────────────────── 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_issue_or_404(project_id: int, issue_id: int, db: AsyncSession) -> ProjectIssue: issue = (await db.execute( select(ProjectIssue).where( ProjectIssue.id == issue_id, ProjectIssue.project_id == project_id, ) )).scalars().first() if not issue: raise HTTPException(404, "이슈를 찾을 수 없습니다") return issue