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>
440 lines
16 KiB
Python
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(),
|
|
}
|