guardia-itsm/routers/catalog.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

264 lines
8.9 KiB
Python

"""
C-5: 서비스 카탈로그 API 라우터
엔드포인트:
GET /api/catalog/ — 서비스 카탈로그 목록
POST /api/catalog/ — 서비스 항목 등록 (관리자)
GET /api/catalog/{service_id} — 서비스 항목 상세
PATCH /api/catalog/{service_id} — 서비스 항목 수정
DELETE /api/catalog/{service_id} — 서비스 항목 폐기
POST /api/catalog/{service_id}/request — 서비스 요청 (SR 자동 생성)
GET /api/catalog/categories — 카테고리 목록
GET /api/catalog/stats — 카탈로그 통계
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
User, UserRole,
ServiceItem, ServiceItemOut, ServiceItemCreate, ServiceItemUpdate,
ServiceStatus,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/catalog", tags=["catalog"])
async def _next_service_id(db: AsyncSession) -> str:
"""SVC-NNNN 형식 서비스 ID 생성."""
last = (await db.execute(
select(ServiceItem.service_id)
.where(ServiceItem.service_id.like("SVC-%"))
.order_by(desc(ServiceItem.service_id)).limit(1)
)).scalar()
seq = 1
if last:
try:
seq = int(last.split("-")[-1]) + 1
except ValueError:
seq = 1
return f"SVC-{seq:04d}"
async def _get_service(db: AsyncSession, service_id: str) -> ServiceItem:
svc = (await db.execute(
select(ServiceItem).where(ServiceItem.service_id == service_id)
)).scalars().first()
if not svc:
raise HTTPException(404, f"서비스 {service_id}를 찾을 수 없습니다.")
return svc
@router.get("/categories")
async def list_categories(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""카테고리 목록 조회."""
cats = (await db.execute(
select(ServiceItem.category, func.count())
.where(ServiceItem.status == ServiceStatus.ACTIVE.value)
.group_by(ServiceItem.category)
.order_by(ServiceItem.category)
)).all()
return [{"category": c, "count": cnt} for c, cnt in cats if c]
@router.get("/stats")
async def catalog_stats(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""서비스 카탈로그 통계."""
total = (await db.execute(select(func.count()).select_from(ServiceItem))).scalar() or 0
active = (await db.execute(
select(func.count()).select_from(ServiceItem).where(ServiceItem.status == "ACTIVE")
)).scalar() or 0
total_requests = (await db.execute(
select(func.sum(ServiceItem.request_count)).select_from(ServiceItem)
)).scalar() or 0
avg_satisfaction = (await db.execute(
select(func.avg(ServiceItem.satisfaction)).select_from(ServiceItem)
.where(ServiceItem.satisfaction != None)
)).scalar()
return {
"total_services": total,
"active_services": active,
"total_requests": total_requests,
"avg_satisfaction": round(float(avg_satisfaction), 2) if avg_satisfaction else None,
}
@router.get("/", response_model=List[ServiceItemOut])
async def list_catalog(
category: Optional[str] = Query(None),
status: Optional[str] = Query(None, enum=["ACTIVE", "DRAFT", "DEPRECATED", "RETIRED"]),
keyword: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""서비스 카탈로그 목록."""
conditions = []
if category: conditions.append(ServiceItem.category == category)
if status: conditions.append(ServiceItem.status == status.upper())
else: conditions.append(ServiceItem.status == ServiceStatus.ACTIVE.value)
if keyword: conditions.append(ServiceItem.name.ilike(f"%{keyword}%"))
q = (
select(ServiceItem)
.where(and_(*conditions))
.order_by(ServiceItem.category, ServiceItem.name)
.limit(limit).offset(offset)
)
return (await db.execute(q)).scalars().all()
@router.post("/", response_model=ServiceItemOut, status_code=201)
async def create_service(
body: ServiceItemCreate,
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, "서비스 등록은 PM/ADMIN 권한이 필요합니다.")
service_id = await _next_service_id(db)
svc = ServiceItem(
service_id = service_id,
name = body.name,
category = body.category,
description = body.description,
status = body.status.upper(),
sla_response_h = body.sla_response_h,
sla_resolve_h = body.sla_resolve_h,
sla_availability = body.sla_availability,
approval_required = body.approval_required,
auto_assignee = body.auto_assignee,
estimated_hours = body.estimated_hours,
charge_per_request = body.charge_per_request,
owner = body.owner,
tags = body.tags,
created_by = current_user.username,
)
db.add(svc)
await db.commit()
await db.refresh(svc)
return svc
@router.get("/{service_id}", response_model=ServiceItemOut)
async def get_service(
service_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
return await _get_service(db, service_id)
@router.patch("/{service_id}", response_model=ServiceItemOut)
async def update_service(
service_id: str,
body: ServiceItemUpdate,
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, "PM/ADMIN 권한이 필요합니다.")
svc = await _get_service(db, service_id)
for k, v in body.model_dump(exclude_unset=True, exclude_none=True).items():
setattr(svc, k, v)
await db.commit()
await db.refresh(svc)
return svc
@router.delete("/{service_id}", status_code=204)
async def retire_service(
service_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""서비스 항목 폐기 (RETIRED 상태)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
svc = await _get_service(db, service_id)
svc.status = ServiceStatus.RETIRED.value
await db.commit()
@router.post("/{service_id}/request")
async def request_service(
service_id: str,
description: Optional[str] = Body(None, embed=True),
priority: str = Body("MEDIUM", embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
서비스 카탈로그를 통한 SR 요청.
- 서비스 SLA를 SR에 자동 적용
- 승인 필요 서비스는 PENDING_APPROVAL 상태로 생성
"""
from models import SRRequest
svc = await _get_service(db, service_id)
if svc.status != ServiceStatus.ACTIVE.value:
raise HTTPException(400, f"활성 서비스가 아닙니다: {svc.status}")
# SR 생성
today = datetime.utcnow().strftime("%Y%m%d")
prefix = f"SR-{today}-"
last_sr = (await db.execute(
select(SRRequest.sr_id).where(SRRequest.sr_id.like(f"{prefix}%"))
.order_by(desc(SRRequest.sr_id)).limit(1)
)).scalar()
seq = 1
if last_sr:
try:
seq = int(last_sr.split("-")[-1]) + 1
except ValueError:
seq = 1
sr_id = f"{prefix}{seq:04d}"
status = "PENDING_APPROVAL" if svc.approval_required else "OPEN"
sr = SRRequest(
sr_id = sr_id,
title = f"[카탈로그] {svc.name}",
description = description or f"서비스 카탈로그 요청: {svc.name}",
status = status,
priority = priority.upper(),
sr_type = "OTHER",
created_at = datetime.utcnow(),
)
db.add(sr)
# 요청 카운트 증가
svc.request_count = (svc.request_count or 0) + 1
await db.commit()
return {
"sr_id": sr_id,
"service_id": service_id,
"service_name": svc.name,
"status": status,
"approval_required": svc.approval_required,
"sla_response_h": svc.sla_response_h,
"sla_resolve_h": svc.sla_resolve_h,
"estimated_hours": svc.estimated_hours,
}