guardia-itsm/routers/change.py
2026-05-30 23:02:43 +09:00

675 lines
23 KiB
Python

"""
C-2: 변경 관리 CAB (Change Advisory Board) API 라우터
엔드포인트:
POST /api/change/rfc — RFC 생성
GET /api/change/rfc — RFC 목록
GET /api/change/rfc/{rfc_id} — RFC 상세
PATCH /api/change/rfc/{rfc_id} — RFC 수정
POST /api/change/rfc/{rfc_id}/submit — CAB 검토 제출
POST /api/change/rfc/{rfc_id}/vote — CAB 투표
POST /api/change/rfc/{rfc_id}/decide — CAB 최종 결정 (승인/거부)
POST /api/change/rfc/{rfc_id}/schedule — 일정 확정
POST /api/change/rfc/{rfc_id}/start — 변경 시작
POST /api/change/rfc/{rfc_id}/complete — 변경 완료
POST /api/change/rfc/{rfc_id}/fail — 변경 실패/롤백
GET /api/change/rfc/{rfc_id}/votes — CAB 투표 현황
POST /api/change/freeze — 동결 기간 등록
GET /api/change/freeze — 동결 기간 목록
DELETE /api/change/freeze/{id} — 동결 기간 삭제
GET /api/change/freeze/check — 특정 일시 동결 여부 확인
GET /api/change/calendar — 변경 일정 캘린더
GET /api/change/stats — 변경 관리 통계
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Body
from pydantic import BaseModel
from sqlalchemy import select, desc, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
User, UserRole,
RFChange, RFChangeOut, RFChangeCreate, RFChangeUpdate,
CABVote, CABVoteOut, CABVoteCreate,
FreezeWindow, FreezeWindowOut, FreezeWindowCreate,
RFCStatus, ChangeType, ChangeRisk, CABVoteResult,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/change", tags=["change"])
# ── 유틸리티 ─────────────────────────────────────────────────────────────────
async def _next_rfc_id(db: AsyncSession) -> str:
"""RFC-YYYYMMDD-NNNN 형식 RFC ID 생성."""
today = datetime.utcnow().strftime("%Y%m%d")
prefix = f"RFC-{today}-"
last = (await db.execute(
select(RFChange.rfc_id)
.where(RFChange.rfc_id.like(f"{prefix}%"))
.order_by(desc(RFChange.rfc_id))
.limit(1)
)).scalar()
seq = 1
if last:
try:
seq = int(last.split("-")[-1]) + 1
except ValueError:
seq = 1
return f"{prefix}{seq:04d}"
async def _check_freeze(db: AsyncSession, start_dt: datetime, end_dt: datetime) -> Optional[FreezeWindow]:
"""변경 일정이 동결 기간과 겹치는지 확인."""
freezes = (await db.execute(
select(FreezeWindow).where(
and_(
FreezeWindow.is_active == True,
FreezeWindow.start_dt <= end_dt,
FreezeWindow.end_dt >= start_dt,
)
)
)).scalars().all()
return freezes[0] if freezes else None
async def _get_rfc(db: AsyncSession, rfc_id: str) -> RFChange:
"""RFC 조회 + 404 처리."""
rfc = (await db.execute(
select(RFChange).where(RFChange.rfc_id == rfc_id)
)).scalars().first()
if not rfc:
raise HTTPException(404, f"RFC {rfc_id}를 찾을 수 없습니다.")
return rfc
def _count_votes(votes: List[CABVote]) -> dict:
"""투표 집계."""
result = {"APPROVE": 0, "REJECT": 0, "ABSTAIN": 0, "DEFER": 0}
for v in votes:
result[v.vote] = result.get(v.vote, 0) + 1
return result
# ── RFC CRUD ────────────────────────────────────────────────────────────────
@router.post("/rfc", response_model=RFChangeOut, status_code=201)
async def create_rfc(
body: RFChangeCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""변경 요청서(RFC) 생성."""
rfc_id = await _next_rfc_id(db)
rfc = RFChange(
rfc_id = rfc_id,
title = body.title,
description = body.description,
change_type = body.change_type.upper(),
risk_level = body.risk_level.upper(),
status = RFCStatus.DRAFT.value,
priority = body.priority.upper(),
planned_start = body.planned_start,
planned_end = body.planned_end,
freeze_exempt = body.freeze_exempt,
change_plan = body.change_plan,
rollback_plan = body.rollback_plan,
test_plan = body.test_plan,
impact_analysis = body.impact_analysis,
sr_id = body.sr_id,
ci_ids_json = json.dumps(body.ci_ids or [], ensure_ascii=False),
requester_id = current_user.id,
created_by = current_user.username,
)
db.add(rfc)
await db.commit()
await db.refresh(rfc)
return rfc
@router.get("/rfc", response_model=List[RFChangeOut])
async def list_rfc(
status: Optional[str] = Query(None),
change_type: Optional[str] = Query(None),
risk_level: Optional[str] = Query(None),
created_by: Optional[str] = Query(None),
hours: int = Query(720, ge=1, le=8760),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""RFC 목록 조회."""
since = datetime.utcnow() - timedelta(hours=hours)
conditions = [RFChange.created_at >= since]
if status:
conditions.append(RFChange.status == status.upper())
if change_type:
conditions.append(RFChange.change_type == change_type.upper())
if risk_level:
conditions.append(RFChange.risk_level == risk_level.upper())
if created_by:
conditions.append(RFChange.created_by == created_by)
q = (
select(RFChange)
.where(and_(*conditions))
.order_by(desc(RFChange.created_at))
.limit(limit).offset(offset)
)
return (await db.execute(q)).scalars().all()
@router.get("/rfc/{rfc_id}", response_model=RFChangeOut)
async def get_rfc(
rfc_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""RFC 상세 조회."""
return await _get_rfc(db, rfc_id)
@router.patch("/rfc/{rfc_id}", response_model=RFChangeOut)
async def update_rfc(
rfc_id: str,
body: RFChangeUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""RFC 수정 (DRAFT / REJECTED 상태에서만 가능)."""
rfc = await _get_rfc(db, rfc_id)
if rfc.status not in (RFCStatus.DRAFT.value, RFCStatus.REJECTED.value):
raise HTTPException(400, f"수정 불가 상태: {rfc.status}")
for field, val in body.model_dump(exclude_unset=True, exclude_none=True).items():
if field == "ci_ids":
rfc.ci_ids_json = json.dumps(val, ensure_ascii=False)
else:
if hasattr(rfc, field):
setattr(rfc, field, val)
await db.commit()
await db.refresh(rfc)
return rfc
# ── RFC 상태 전환 ─────────────────────────────────────────────────────────────
@router.post("/rfc/{rfc_id}/submit")
async def submit_rfc(
rfc_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""RFC를 CAB 검토에 제출 (DRAFT → SUBMITTED)."""
rfc = await _get_rfc(db, rfc_id)
if rfc.status != RFCStatus.DRAFT.value:
raise HTTPException(400, f"DRAFT 상태가 아닙니다: {rfc.status}")
# 필수 항목 체크
if not rfc.change_plan:
raise HTTPException(400, "변경 계획(change_plan)이 필요합니다.")
if not rfc.rollback_plan:
raise HTTPException(400, "롤백 계획(rollback_plan)이 필요합니다.")
rfc.status = RFCStatus.SUBMITTED.value
rfc.submitted_at = datetime.utcnow()
await db.commit()
return {
"rfc_id": rfc_id,
"status": rfc.status,
"message": "CAB 검토 대기열에 등록되었습니다.",
}
@router.post("/rfc/{rfc_id}/vote")
async def cast_vote(
rfc_id: str,
body: CABVoteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""CAB 위원 투표."""
rfc = await _get_rfc(db, rfc_id)
if rfc.status not in (RFCStatus.SUBMITTED.value, RFCStatus.IN_REVIEW.value):
raise HTTPException(400, f"투표 불가 상태: {rfc.status}")
vote_val = body.vote.upper()
if vote_val not in [v.value for v in CABVoteResult]:
raise HTTPException(400, f"유효하지 않은 투표: {vote_val}")
# 동일 투표자 중복 투표 검사
existing = (await db.execute(
select(CABVote).where(
and_(
CABVote.rfc_id_fk == rfc.id,
CABVote.voter_id == current_user.id,
)
)
)).scalars().first()
if existing:
# 투표 변경 허용
existing.vote = vote_val
existing.comment = body.comment
existing.voted_at = datetime.utcnow()
await db.commit()
return {"message": "투표가 변경되었습니다.", "vote": vote_val}
# 신규 투표
vote = CABVote(
rfc_id_fk = rfc.id,
voter_id = current_user.id,
voter_name = current_user.username,
vote = vote_val,
comment = body.comment,
is_final = body.is_final,
)
db.add(vote)
# SUBMITTED → IN_REVIEW 전환 (첫 투표 시)
if rfc.status == RFCStatus.SUBMITTED.value:
rfc.status = RFCStatus.IN_REVIEW.value
await db.commit()
return {"message": "투표가 등록되었습니다.", "vote": vote_val}
@router.post("/rfc/{rfc_id}/decide")
async def decide_rfc(
rfc_id: str,
approve: bool = Body(..., embed=True),
note: Optional[str] = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""CAB 최종 결정 (승인/거부). PM 또는 ADMIN 권한 필요."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "CAB 최종 결정은 PM/ADMIN 권한이 필요합니다.")
rfc = await _get_rfc(db, rfc_id)
if rfc.status not in (RFCStatus.IN_REVIEW.value, RFCStatus.SUBMITTED.value):
raise HTTPException(400, f"결정 불가 상태: {rfc.status}")
# 최종 투표 기록
vote = CABVote(
rfc_id_fk = rfc.id,
voter_id = current_user.id,
voter_name = current_user.username,
vote = CABVoteResult.APPROVE.value if approve else CABVoteResult.REJECT.value,
comment = note or ("최종 승인" if approve else "최종 거부"),
is_final = True,
)
db.add(vote)
if approve:
rfc.status = RFCStatus.APPROVED.value
rfc.approved_at = datetime.utcnow()
msg = f"RFC {rfc_id} 승인 완료"
else:
rfc.status = RFCStatus.REJECTED.value
msg = f"RFC {rfc_id} 거부됨"
await db.commit()
return {"rfc_id": rfc_id, "approved": approve, "status": rfc.status, "message": msg}
@router.post("/rfc/{rfc_id}/schedule")
async def schedule_rfc(
rfc_id: str,
planned_start: datetime = Body(..., embed=True),
planned_end: datetime = Body(..., embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""RFC 일정 확정 (APPROVED → SCHEDULED). 동결 기간 충돌 검사."""
rfc = await _get_rfc(db, rfc_id)
if rfc.status != RFCStatus.APPROVED.value:
raise HTTPException(400, f"APPROVED 상태가 아닙니다: {rfc.status}")
# 동결 기간 충돌 검사
if not rfc.freeze_exempt:
freeze = await _check_freeze(db, planned_start, planned_end)
if freeze:
raise HTTPException(409, {
"message": f"변경 동결 기간과 충돌합니다: {freeze.name}",
"freeze_id": freeze.id,
"freeze_name": freeze.name,
"freeze_start": freeze.start_dt.isoformat(),
"freeze_end": freeze.end_dt.isoformat(),
})
rfc.planned_start = planned_start
rfc.planned_end = planned_end
rfc.status = RFCStatus.SCHEDULED.value
await db.commit()
return {
"rfc_id": rfc_id,
"status": rfc.status,
"planned_start": planned_start.isoformat(),
"planned_end": planned_end.isoformat(),
}
@router.post("/rfc/{rfc_id}/start")
async def start_rfc(
rfc_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""변경 실행 시작 (SCHEDULED → IN_PROGRESS)."""
rfc = await _get_rfc(db, rfc_id)
if rfc.status not in (RFCStatus.SCHEDULED.value, RFCStatus.APPROVED.value):
raise HTTPException(400, f"시작 불가 상태: {rfc.status}")
rfc.status = RFCStatus.IN_PROGRESS.value
rfc.actual_start = datetime.utcnow()
await db.commit()
return {"rfc_id": rfc_id, "status": rfc.status}
@router.post("/rfc/{rfc_id}/complete")
async def complete_rfc(
rfc_id: str,
result_summary: Optional[str] = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""변경 완료 (IN_PROGRESS → COMPLETED)."""
rfc = await _get_rfc(db, rfc_id)
if rfc.status != RFCStatus.IN_PROGRESS.value:
raise HTTPException(400, f"IN_PROGRESS 상태가 아닙니다: {rfc.status}")
rfc.status = RFCStatus.COMPLETED.value
rfc.actual_end = datetime.utcnow()
rfc.completed_at = datetime.utcnow()
if result_summary:
rfc.result_summary = result_summary
await db.commit()
return {"rfc_id": rfc_id, "status": rfc.status}
@router.post("/rfc/{rfc_id}/fail")
async def fail_rfc(
rfc_id: str,
error_msg: Optional[str] = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""변경 실패/롤백 처리 (IN_PROGRESS → FAILED)."""
rfc = await _get_rfc(db, rfc_id)
if rfc.status != RFCStatus.IN_PROGRESS.value:
raise HTTPException(400, f"IN_PROGRESS 상태가 아닙니다: {rfc.status}")
rfc.status = RFCStatus.FAILED.value
rfc.actual_end = datetime.utcnow()
if error_msg:
rfc.error_msg = error_msg
await db.commit()
return {"rfc_id": rfc_id, "status": rfc.status}
# ── CAB 투표 현황 ─────────────────────────────────────────────────────────────
@router.get("/rfc/{rfc_id}/votes")
async def get_votes(
rfc_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""RFC CAB 투표 현황."""
rfc = await _get_rfc(db, rfc_id)
votes = (await db.execute(
select(CABVote)
.where(CABVote.rfc_id_fk == rfc.id)
.order_by(CABVote.voted_at)
)).scalars().all()
tally = _count_votes(votes)
total = sum(tally.values())
return {
"rfc_id": rfc_id,
"status": rfc.status,
"tally": tally,
"total": total,
"approved": tally["APPROVE"] > (total / 2) if total > 0 else False,
"votes": [
{
"id": v.id,
"voter": v.voter_name,
"vote": v.vote,
"comment": v.comment,
"is_final": v.is_final,
"voted_at": v.voted_at.isoformat(),
}
for v in votes
],
}
# ── 동결 기간 관리 ────────────────────────────────────────────────────────────
@router.post("/freeze", response_model=FreezeWindowOut, status_code=201)
async def create_freeze(
body: FreezeWindowCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""변경 동결 기간 등록. PM/ADMIN 권한 필요."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
if body.start_dt >= body.end_dt:
raise HTTPException(400, "종료 일시가 시작 일시보다 늦어야 합니다.")
freeze = FreezeWindow(
name = body.name,
start_dt = body.start_dt,
end_dt = body.end_dt,
scope = body.scope.upper(),
reason = body.reason,
created_by = current_user.username,
)
db.add(freeze)
await db.commit()
await db.refresh(freeze)
return freeze
@router.get("/freeze", response_model=List[FreezeWindowOut])
async def list_freeze(
active_only: bool = Query(True),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""동결 기간 목록."""
q = select(FreezeWindow)
if active_only:
q = q.where(FreezeWindow.is_active == True)
q = q.order_by(FreezeWindow.start_dt)
return (await db.execute(q)).scalars().all()
@router.delete("/freeze/{freeze_id}", status_code=204)
async def delete_freeze(
freeze_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""동결 기간 비활성화 (삭제 대신 is_active=False)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
freeze = (await db.execute(select(FreezeWindow).where(FreezeWindow.id == freeze_id))).scalars().first()
if not freeze:
raise HTTPException(404, "동결 기간을 찾을 수 없습니다.")
freeze.is_active = False
await db.commit()
@router.get("/freeze/check")
async def check_freeze(
start_dt: datetime = Query(...),
end_dt: datetime = Query(...),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""특정 일시 범위가 동결 기간에 해당하는지 확인."""
freeze = await _check_freeze(db, start_dt, end_dt)
if freeze:
return {
"frozen": True,
"freeze_id": freeze.id,
"freeze_name": freeze.name,
"freeze_start": freeze.start_dt.isoformat(),
"freeze_end": freeze.end_dt.isoformat(),
"scope": freeze.scope,
}
return {"frozen": False}
# ── 변경 일정 캘린더 ──────────────────────────────────────────────────────────
@router.get("/calendar")
async def change_calendar(
year: int = Query(..., ge=2020, le=2099),
month: int = Query(..., ge=1, le=12),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""해당 월의 변경 일정 + 동결 기간 캘린더."""
import calendar
_, days_in_month = calendar.monthrange(year, month)
start = datetime(year, month, 1)
end = datetime(year, month, days_in_month, 23, 59, 59)
# 해당 기간 RFC
rfcs = (await db.execute(
select(RFChange).where(
and_(
or_(
and_(RFChange.planned_start >= start, RFChange.planned_start <= end),
and_(RFChange.planned_end >= start, RFChange.planned_end <= end),
),
RFChange.status.notin_([RFCStatus.DRAFT.value, RFCStatus.WITHDRAWN.value]),
)
)
)).scalars().all()
# 해당 기간 동결
freezes = (await db.execute(
select(FreezeWindow).where(
and_(
FreezeWindow.is_active == True,
FreezeWindow.start_dt <= end,
FreezeWindow.end_dt >= start,
)
)
)).scalars().all()
return {
"year": year,
"month": month,
"changes": [
{
"rfc_id": r.rfc_id,
"title": r.title,
"status": r.status,
"change_type": r.change_type,
"risk_level": r.risk_level,
"planned_start": r.planned_start.isoformat() if r.planned_start else None,
"planned_end": r.planned_end.isoformat() if r.planned_end else None,
}
for r in rfcs
],
"freeze_windows": [
{
"id": f.id,
"name": f.name,
"start": f.start_dt.isoformat(),
"end": f.end_dt.isoformat(),
"scope": f.scope,
}
for f in freezes
],
}
# ── 통계 ─────────────────────────────────────────────────────────────────────
@router.get("/stats")
async def change_stats(
days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""변경 관리 통계."""
since = datetime.utcnow() - timedelta(days=days)
total = (await db.execute(
select(func.count()).select_from(RFChange).where(RFChange.created_at >= since)
)).scalar() or 0
by_status = {}
for st, cnt in (await db.execute(
select(RFChange.status, func.count()).where(RFChange.created_at >= since).group_by(RFChange.status)
)).all():
by_status[st] = cnt
by_type = {}
for ct, cnt in (await db.execute(
select(RFChange.change_type, func.count()).where(RFChange.created_at >= since).group_by(RFChange.change_type)
)).all():
by_type[ct] = cnt
by_risk = {}
for rl, cnt in (await db.execute(
select(RFChange.risk_level, func.count()).where(RFChange.created_at >= since).group_by(RFChange.risk_level)
)).all():
by_risk[rl] = cnt
# 승인율
approved = by_status.get("APPROVED", 0) + by_status.get("SCHEDULED", 0) + by_status.get("IN_PROGRESS", 0) + by_status.get("COMPLETED", 0)
rejected = by_status.get("REJECTED", 0)
decided = approved + rejected
approval_rate = round(approved / decided * 100, 1) if decided > 0 else 0.0
# 성공률
completed = by_status.get("COMPLETED", 0)
failed = by_status.get("FAILED", 0)
executed = completed + failed
success_rate = round(completed / executed * 100, 1) if executed > 0 else 0.0
# 현재 동결 기간
now = datetime.utcnow()
current_freeze = (await db.execute(
select(FreezeWindow).where(
and_(
FreezeWindow.is_active == True,
FreezeWindow.start_dt <= now,
FreezeWindow.end_dt >= now,
)
)
)).scalars().first()
return {
"period_days": days,
"total_rfc": total,
"by_status": by_status,
"by_type": by_type,
"by_risk": by_risk,
"approval_rate": approval_rate,
"success_rate": success_rate,
"currently_frozen": current_freeze is not None,
"freeze_name": current_freeze.name if current_freeze else None,
}