""" 온콜/당직 일정 관리 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(), }