- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
264 lines
8.9 KiB
Python
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,
|
|
}
|