guardia-itsm/routers/oncall.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
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>
2026-05-29 18:18:52 +09:00

440 lines
16 KiB
Python

"""
온콜/당직 일정 관리 API.
엔드포인트:
GET /api/oncall — 당직 일정 목록 (기간 필터)
POST /api/oncall — 당직 등록
GET /api/oncall/today — 오늘의 당직자 (장애 시 즉시 조회용)
GET /api/oncall/week — 이번 주 당직 일정
PATCH /api/oncall/{id} — 당직 수정
DELETE /api/oncall/{id} — 당직 삭제
POST /api/oncall/bulk — 일괄 등록 (주간/월간 당직표)
"""
from __future__ import annotations
import json
import logging
from datetime import date, datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
OncallSchedule, OncallScheduleCreate, OncallScheduleOut,
OncallRotateConfig, OncallRotateConfigOut, OncallRotateConfigUpdate,
User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/oncall", tags=["oncall"])
# ── 목록 ──────────────────────────────────────────────────────────────────────
@router.get("", response_model=List[OncallScheduleOut])
async def list_oncall(
date_from: Optional[date] = Query(None),
date_to: Optional[date] = Query(None),
engineer: Optional[str] = Query(None),
shift: Optional[str] = Query(None),
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
q = select(OncallSchedule)
if date_from:
q = q.where(OncallSchedule.duty_date >= date_from)
if date_to:
q = q.where(OncallSchedule.duty_date <= date_to)
if engineer:
q = q.where(OncallSchedule.engineer.contains(engineer))
if shift:
q = q.where(OncallSchedule.shift == shift)
q = q.order_by(OncallSchedule.duty_date, OncallSchedule.shift).offset(skip).limit(limit)
result = await db.execute(q)
return result.scalars().all()
# ── 오늘 당직 ─────────────────────────────────────────────────────────────────
@router.get("/today")
async def today_oncall(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""오늘의 당직자 조회 — 장애 발생 시 즉시 연락처 확인용."""
today = date.today()
result = await db.execute(
select(OncallSchedule)
.where(OncallSchedule.duty_date == today)
.order_by(OncallSchedule.shift)
)
schedules = result.scalars().all()
if not schedules:
return {
"date": today.isoformat(),
"schedules": [],
"message": "오늘 등록된 당직자가 없습니다.",
}
return {
"date": today.isoformat(),
"schedules": [
{
"id": s.id,
"shift": s.shift,
"engineer": s.engineer,
"backup_engineer": s.backup_engineer,
"escalation_to": s.escalation_to,
"note": s.note,
}
for s in schedules
],
}
# ── 이번 주 ───────────────────────────────────────────────────────────────────
@router.get("/week")
async def week_oncall(
week_offset: int = Query(0, description="0=이번주, 1=다음주, -1=지난주"),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""이번 주 (월~일) 당직 일정 캘린더 형식."""
today = date.today()
monday = today - timedelta(days=today.weekday()) + timedelta(weeks=week_offset)
sunday = monday + timedelta(days=6)
result = await db.execute(
select(OncallSchedule)
.where(
OncallSchedule.duty_date >= monday,
OncallSchedule.duty_date <= sunday,
)
.order_by(OncallSchedule.duty_date, OncallSchedule.shift)
)
schedules = result.scalars().all()
# 날짜별 그룹핑
by_date: dict[str, list] = {}
for d_offset in range(7):
d = monday + timedelta(days=d_offset)
by_date[d.isoformat()] = []
for s in schedules:
key = s.duty_date.isoformat()
if key in by_date:
by_date[key].append({
"id": s.id,
"shift": s.shift,
"engineer": s.engineer,
"backup_engineer": s.backup_engineer,
"escalation_to": s.escalation_to,
})
return {
"week_start": monday.isoformat(),
"week_end": sunday.isoformat(),
"schedule": by_date,
}
# ── 등록 ──────────────────────────────────────────────────────────────────────
@router.post("", response_model=OncallScheduleOut, status_code=201)
async def create_oncall(
payload: OncallScheduleCreate,
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, "ADMIN 또는 PM 권한이 필요합니다.")
# 중복 확인 (같은 날 + 같은 shift)
dup = (await db.execute(
select(OncallSchedule).where(
OncallSchedule.duty_date == payload.duty_date,
OncallSchedule.shift == payload.shift,
)
)).scalars().first()
if dup:
raise HTTPException(
409,
f"{payload.duty_date.isoformat()} {payload.shift} 시프트가 이미 등록되어 있습니다. "
f"수정이 필요하면 PATCH /{dup.id}를 사용하세요.",
)
schedule = OncallSchedule(
**payload.model_dump(),
created_by=current_user.username,
)
db.add(schedule)
await db.commit()
await db.refresh(schedule)
return schedule
# ── 일괄 등록 ─────────────────────────────────────────────────────────────────
@router.post("/bulk", status_code=201)
async def bulk_create_oncall(
items: List[OncallScheduleCreate],
overwrite: bool = Query(False, description="기존 일정 덮어쓰기 여부"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""주간/월간 당직표 일괄 등록 (최대 62건)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.")
if len(items) > 62:
raise HTTPException(422, "한 번에 최대 62건까지 등록 가능합니다.")
created = 0
skipped = 0
for payload in items:
existing = (await db.execute(
select(OncallSchedule).where(
OncallSchedule.duty_date == payload.duty_date,
OncallSchedule.shift == payload.shift,
)
)).scalars().first()
if existing:
if overwrite:
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(existing, k, v)
existing.updated_at = datetime.now()
created += 1
else:
skipped += 1
continue
schedule = OncallSchedule(
**payload.model_dump(),
created_by=current_user.username,
)
db.add(schedule)
created += 1
await db.commit()
return {
"created": created,
"skipped": skipped,
"message": f"{created}건 등록, {skipped}건 건너뜀",
}
# ── 수정 ──────────────────────────────────────────────────────────────────────
@router.patch("/{oncall_id}", response_model=OncallScheduleOut)
async def update_oncall(
oncall_id: int,
payload: OncallScheduleCreate,
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, "ADMIN 또는 PM 권한이 필요합니다.")
r = await db.execute(select(OncallSchedule).where(OncallSchedule.id == oncall_id))
sched = r.scalars().first()
if not sched:
raise HTTPException(404, "당직 일정을 찾을 수 없습니다.")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(sched, k, v)
sched.updated_at = datetime.now()
await db.commit()
await db.refresh(sched)
return sched
# ── 삭제 ──────────────────────────────────────────────────────────────────────
@router.delete("/{oncall_id}", status_code=204)
async def delete_oncall(
oncall_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, "ADMIN 또는 PM 권한이 필요합니다.")
r = await db.execute(select(OncallSchedule).where(OncallSchedule.id == oncall_id))
sched = r.scalars().first()
if not sched:
raise HTTPException(404, "당직 일정을 찾을 수 없습니다.")
await db.delete(sched)
await db.commit()
# ═══════════════════════════════════════════════════════════════════════════════
# ── A-5: On-Call 자동 로테이션 엔드포인트 ────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════════
@router.get("/rotate/config", response_model=OncallRotateConfigOut)
async def get_rotate_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
On-Call 자동 로테이션 설정 조회.
설정이 없으면 기본값으로 생성 후 반환.
"""
from core.oncall_rotate import get_or_create_rotate_config
cfg = await get_or_create_rotate_config(db)
return cfg
@router.put("/rotate/config", response_model=OncallRotateConfigOut)
async def update_rotate_config(
payload: OncallRotateConfigUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
On-Call 자동 로테이션 설정 수정.
ADMIN / PM 권한 필요.
"""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.")
from core.oncall_rotate import get_or_create_rotate_config
cfg = await get_or_create_rotate_config(db)
if payload.is_active is not None:
cfg.is_active = payload.is_active
if payload.engineer_list is not None:
if len(payload.engineer_list) > 50:
raise HTTPException(422, "엔지니어 목록은 최대 50명까지 등록 가능합니다.")
cfg.engineer_list = json.dumps(payload.engineer_list, ensure_ascii=False)
# current_index 범위 조정
if cfg.current_index >= len(payload.engineer_list):
cfg.current_index = 0
if payload.current_index is not None:
eng_count = len(json.loads(cfg.engineer_list or "[]"))
if eng_count > 0 and payload.current_index >= eng_count:
raise HTTPException(422, f"current_index는 0~{eng_count-1} 범위여야 합니다.")
cfg.current_index = payload.current_index
if payload.rotate_days is not None:
cfg.rotate_days = max(1, payload.rotate_days)
if payload.default_shift is not None:
allowed_shifts = {"ALL_DAY", "DAYTIME", "NIGHTTIME"}
if payload.default_shift not in allowed_shifts:
raise HTTPException(422, f"shift는 {allowed_shifts} 중 하나여야 합니다.")
cfg.default_shift = payload.default_shift
if payload.escalation_chain is not None:
cfg.escalation_chain = json.dumps(payload.escalation_chain, ensure_ascii=False)
if payload.notify_on_assign is not None:
cfg.notify_on_assign = payload.notify_on_assign
if payload.advance_days is not None:
cfg.advance_days = max(0, payload.advance_days)
cfg.updated_at = datetime.now()
cfg.updated_by = current_user.username
await db.commit()
await db.refresh(cfg)
return cfg
@router.get("/on-duty")
async def get_on_duty(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""
현재 당직자 + 에스컬레이션 체인 전체 조회.
장애 발생 시 연락해야 할 순서를 즉시 파악하기 위한 엔드포인트.
"""
from core.oncall_rotate import get_current_oncall, _get_rotate_config
from models import OncallRotateConfig
today = date.today()
schedule = await get_current_oncall(db)
cfg = await _get_rotate_config(db)
# 에스컬레이션 체인 구성
chain: List[str] = []
if schedule:
if schedule.engineer:
chain.append(schedule.engineer)
if schedule.backup_engineer:
chain.append(schedule.backup_engineer)
if schedule.escalation_to:
chain.append(schedule.escalation_to)
# 로테이션 설정의 escalation_chain 추가
if cfg and cfg.escalation_chain:
extra = json.loads(cfg.escalation_chain)
for e in extra:
if e not in chain:
chain.append(e)
return {
"date": today.isoformat(),
"has_schedule": schedule is not None,
"primary_engineer": schedule.engineer if schedule else None,
"backup_engineer": schedule.backup_engineer if schedule else None,
"shift": schedule.shift if schedule else None,
"escalation_chain": chain,
"rotate_active": cfg.is_active if cfg else False,
"note": schedule.note if schedule else None,
}
@router.post("/escalate")
async def trigger_escalation(
incident_title: str = Query(..., description="인시던트/장애 제목"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
수동 에스컬레이션 트리거.
ADMIN / PM / ENGINEER 가 즉시 당직 에스컬레이션 체인 실행.
당직자 → 백업 → escalation_to → ADMIN 순으로 메신저 알림 발송.
"""
if current_user.role == UserRole.CUSTOMER:
raise HTTPException(403, "권한이 없습니다.")
from core.oncall_rotate import escalate_oncall
target = await escalate_oncall(incident_title, db)
return {
"escalated_to": target,
"incident_title": incident_title,
"triggered_by": current_user.username,
"triggered_at": datetime.now().isoformat(),
"message": f"에스컬레이션 알림을 '{target}'에게 발송했습니다." if target
else "에스컬레이션 대상을 찾을 수 없습니다.",
}
@router.post("/rotate/trigger", status_code=201)
async def manual_rotate_trigger(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
수동 로테이션 트리거 (테스트/긴급 재배정용).
ADMIN 전용 — 자동 로테이션 로직을 즉시 한 번 실행.
"""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
from core.oncall_rotate import auto_rotate_oncall
await auto_rotate_oncall()
return {
"message": "수동 로테이션 실행 완료",
"triggered_by": current_user.username,
"triggered_at": datetime.now().isoformat(),
}