""" 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, }