- 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>
245 lines
9.5 KiB
Python
245 lines
9.5 KiB
Python
"""
|
|
변경 요청 (Change Request) 관리 API.
|
|
|
|
엔드포인트:
|
|
GET /api/si/projects/{pid}/crs — CR 목록
|
|
POST /api/si/projects/{pid}/crs — CR 등록
|
|
GET /api/si/projects/{pid}/crs/{id} — CR 상세
|
|
PATCH /api/si/projects/{pid}/crs/{id} — CR 수정
|
|
PATCH /api/si/projects/{pid}/crs/{id}/approve — CR 승인
|
|
PATCH /api/si/projects/{pid}/crs/{id}/reject — CR 반려
|
|
PATCH /api/si/projects/{pid}/crs/{id}/implement — CR 구현 완료
|
|
DELETE /api/si/projects/{pid}/crs/{id} — CR 삭제
|
|
|
|
CR 상태 흐름: DRAFT → REVIEW → APPROVED/REJECTED → IMPLEMENTED
|
|
"""
|
|
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, ChangeRequest,
|
|
ChangeRequestCreate, ChangeRequestOut, ChangeRequestUpdate,
|
|
CrStatus,
|
|
User, UserRole,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/si/projects", tags=["si-change-requests"])
|
|
|
|
|
|
def _new_cr_id() -> str:
|
|
return f"CR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}"
|
|
|
|
|
|
# ── 목록 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/crs", response_model=List[ChangeRequestOut])
|
|
async def list_crs(
|
|
project_id: int,
|
|
cr_type: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _assert_project(project_id, db)
|
|
q = select(ChangeRequest).where(ChangeRequest.project_id == project_id)
|
|
if cr_type:
|
|
q = q.where(ChangeRequest.cr_type == cr_type)
|
|
if status:
|
|
q = q.where(ChangeRequest.status == status)
|
|
q = q.order_by(desc(ChangeRequest.created_at))
|
|
return (await db.execute(q)).scalars().all()
|
|
|
|
|
|
# ── 생성 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{project_id}/crs", response_model=ChangeRequestOut, status_code=201)
|
|
async def create_cr(
|
|
project_id: int,
|
|
body: ChangeRequestCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _assert_project(project_id, db)
|
|
|
|
cr = ChangeRequest(
|
|
cr_id = _new_cr_id(),
|
|
project_id = project_id,
|
|
cr_type = body.cr_type,
|
|
title = body.title,
|
|
description = body.description,
|
|
reason = body.reason,
|
|
impact_scope = body.impact_scope,
|
|
impact_schedule = body.impact_schedule,
|
|
impact_budget = body.impact_budget,
|
|
requested_by = body.requested_by or current_user.username,
|
|
)
|
|
db.add(cr)
|
|
await db.commit()
|
|
await db.refresh(cr)
|
|
return cr
|
|
|
|
|
|
# ── 상세 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/crs/{cr_id}", response_model=ChangeRequestOut)
|
|
async def get_cr(
|
|
project_id: int, cr_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await _get_cr_or_404(project_id, cr_id, db)
|
|
|
|
|
|
# ── 수정 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/crs/{cr_id}", response_model=ChangeRequestOut)
|
|
async def update_cr(
|
|
project_id: int, cr_id: int,
|
|
body: ChangeRequestUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
cr = await _get_cr_or_404(project_id, cr_id, db)
|
|
|
|
if cr.status not in (CrStatus.DRAFT, CrStatus.REVIEW):
|
|
raise HTTPException(400, f"'{cr.status}' 상태에서는 수정할 수 없습니다")
|
|
|
|
for field, val in body.model_dump(exclude_none=True).items():
|
|
setattr(cr, field, val)
|
|
|
|
await db.commit()
|
|
await db.refresh(cr)
|
|
return cr
|
|
|
|
|
|
# ── 검토 요청 ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/crs/{cr_id}/submit-review", response_model=ChangeRequestOut)
|
|
async def submit_cr_for_review(
|
|
project_id: int, cr_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
cr = await _get_cr_or_404(project_id, cr_id, db)
|
|
if cr.status != CrStatus.DRAFT:
|
|
raise HTTPException(400, "초안 상태에서만 검토 요청할 수 있습니다")
|
|
cr.status = CrStatus.REVIEW
|
|
await db.commit()
|
|
await db.refresh(cr)
|
|
return cr
|
|
|
|
|
|
# ── 승인 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/crs/{cr_id}/approve", response_model=ChangeRequestOut)
|
|
async def approve_cr(
|
|
project_id: int, cr_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 이상 권한 필요")
|
|
cr = await _get_cr_or_404(project_id, cr_id, db)
|
|
if cr.status != CrStatus.REVIEW:
|
|
raise HTTPException(400, "검토 중인 CR만 승인할 수 있습니다")
|
|
|
|
cr.status = CrStatus.APPROVED
|
|
cr.approved_by = current_user.username
|
|
cr.approved_at = datetime.now()
|
|
|
|
await db.commit()
|
|
await db.refresh(cr)
|
|
return cr
|
|
|
|
|
|
# ── 반려 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/crs/{cr_id}/reject", response_model=ChangeRequestOut)
|
|
async def reject_cr(
|
|
project_id: int, cr_id: int,
|
|
reason: Optional[str] = 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 이상 권한 필요")
|
|
cr = await _get_cr_or_404(project_id, cr_id, db)
|
|
if cr.status not in (CrStatus.REVIEW, CrStatus.DRAFT):
|
|
raise HTTPException(400, "검토 중이거나 초안 상태 CR만 반려할 수 있습니다")
|
|
|
|
cr.status = CrStatus.REJECTED
|
|
cr.reviewed_by = current_user.username
|
|
if reason:
|
|
cr.reason = (cr.reason or "") + f"\n[반려 사유] {reason}"
|
|
|
|
await db.commit()
|
|
await db.refresh(cr)
|
|
return cr
|
|
|
|
|
|
# ── 구현 완료 ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/crs/{cr_id}/implement", response_model=ChangeRequestOut)
|
|
async def implement_cr(
|
|
project_id: int, cr_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
cr = await _get_cr_or_404(project_id, cr_id, db)
|
|
if cr.status != CrStatus.APPROVED:
|
|
raise HTTPException(400, "승인된 CR만 구현 완료 처리할 수 있습니다")
|
|
|
|
cr.status = CrStatus.IMPLEMENTED
|
|
cr.implemented_at = datetime.now()
|
|
|
|
await db.commit()
|
|
await db.refresh(cr)
|
|
return cr
|
|
|
|
|
|
# ── 삭제 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.delete("/{project_id}/crs/{cr_id}", status_code=204)
|
|
async def delete_cr(
|
|
project_id: int, cr_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 이상 권한 필요")
|
|
cr = await _get_cr_or_404(project_id, cr_id, db)
|
|
await db.delete(cr)
|
|
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_cr_or_404(project_id: int, cr_id: int, db: AsyncSession) -> ChangeRequest:
|
|
cr = (await db.execute(
|
|
select(ChangeRequest).where(
|
|
ChangeRequest.id == cr_id,
|
|
ChangeRequest.project_id == project_id,
|
|
)
|
|
)).scalars().first()
|
|
if not cr:
|
|
raise HTTPException(404, "변경 요청을 찾을 수 없습니다")
|
|
return cr
|