guardia-itsm/routers/catalog.py
2026-05-30 23:02:43 +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,
}