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

221 lines
8.6 KiB
Python

"""
요구사항 관리 API — RFP 요구사항 입력·추적.
엔드포인트:
GET /api/si/projects/{pid}/requirements — 요구사항 목록 (유형·상태 필터)
POST /api/si/projects/{pid}/requirements — 요구사항 등록
GET /api/si/projects/{pid}/requirements/{id} — 상세
PATCH /api/si/projects/{pid}/requirements/{id} — 수정
DELETE /api/si/projects/{pid}/requirements/{id} — 삭제
PATCH /api/si/projects/{pid}/requirements/{id}/confirm — 확정 처리
GET /api/si/projects/{pid}/requirements/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, SiRequirement,
SiRequirementCreate, SiRequirementOut, SiRequirementUpdate,
ReqStatus, ReqType,
User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/si/projects", tags=["si-requirements"])
def _new_req_id() -> str:
return f"REQ-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:4].upper()}"
# ── 목록 ──────────────────────────────────────────────────────────────────────
@router.get("/{project_id}/requirements", response_model=List[SiRequirementOut])
async def list_requirements(
project_id: int,
req_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
priority: Optional[str] = Query(None),
keyword: Optional[str] = Query(None),
skip: int = 0,
limit: int = 200,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _assert_project(project_id, db)
q = select(SiRequirement).where(SiRequirement.project_id == project_id)
if req_type:
q = q.where(SiRequirement.req_type == req_type)
if status:
q = q.where(SiRequirement.status == status)
if priority:
q = q.where(SiRequirement.priority == priority)
if keyword:
q = q.where(SiRequirement.title.contains(keyword))
q = q.order_by(SiRequirement.req_id).offset(skip).limit(limit)
return (await db.execute(q)).scalars().all()
# ── 생성 ──────────────────────────────────────────────────────────────────────
@router.post("/{project_id}/requirements", response_model=SiRequirementOut, status_code=201)
async def create_requirement(
project_id: int,
body: SiRequirementCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "엔지니어 이상 권한 필요")
await _assert_project(project_id, db)
req = SiRequirement(
req_id = _new_req_id(),
project_id = project_id,
req_type = body.req_type,
title = body.title,
description = body.description,
source = body.source,
priority = body.priority,
wbs_item_id = body.wbs_item_id,
acceptance_criteria = body.acceptance_criteria,
created_by = body.created_by or current_user.username,
)
db.add(req)
await db.commit()
await db.refresh(req)
return req
# ── 상세 ──────────────────────────────────────────────────────────────────────
@router.get("/{project_id}/requirements/{req_id}", response_model=SiRequirementOut)
async def get_requirement(
project_id: int, req_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await _get_req_or_404(project_id, req_id, db)
# ── 수정 ──────────────────────────────────────────────────────────────────────
@router.patch("/{project_id}/requirements/{req_id}", response_model=SiRequirementOut)
async def update_requirement(
project_id: int, req_id: int,
body: SiRequirementUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "엔지니어 이상 권한 필요")
req = await _get_req_or_404(project_id, req_id, db)
for field, val in body.model_dump(exclude_none=True).items():
setattr(req, field, val)
await db.commit()
await db.refresh(req)
return req
# ── 확정 ──────────────────────────────────────────────────────────────────────
@router.patch("/{project_id}/requirements/{req_id}/confirm", response_model=SiRequirementOut)
async def confirm_requirement(
project_id: int, req_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 이상 권한 필요")
req = await _get_req_or_404(project_id, req_id, db)
req.status = ReqStatus.CONFIRMED
req.confirmed_by = current_user.username
req.confirmed_at = datetime.now()
await db.commit()
await db.refresh(req)
return req
# ── 삭제 ──────────────────────────────────────────────────────────────────────
@router.delete("/{project_id}/requirements/{req_id}", status_code=204)
async def delete_requirement(
project_id: int, req_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 이상 권한 필요")
req = await _get_req_or_404(project_id, req_id, db)
await db.delete(req)
await db.commit()
# ── 통계 ──────────────────────────────────────────────────────────────────────
@router.get("/{project_id}/requirements/stats")
async def get_requirement_stats(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _assert_project(project_id, db)
reqs = (await db.execute(
select(SiRequirement).where(SiRequirement.project_id == project_id)
)).scalars().all()
by_type = {}
by_status = {}
by_priority = {}
for r in reqs:
by_type[r.req_type] = by_type.get(r.req_type, 0) + 1
by_status[r.status] = by_status.get(r.status, 0) + 1
by_priority[r.priority] = by_priority.get(r.priority, 0) + 1
return {
"project_id": project_id,
"total": len(reqs),
"by_type": by_type,
"by_status": by_status,
"by_priority": by_priority,
}
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
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_req_or_404(project_id: int, req_id: int, db: AsyncSession) -> SiRequirement:
req = (await db.execute(
select(SiRequirement).where(
SiRequirement.id == req_id,
SiRequirement.project_id == project_id,
)
)).scalars().first()
if not req:
raise HTTPException(404, "요구사항을 찾을 수 없습니다")
return req