G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
675 lines
23 KiB
Python
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,
|
|
}
|