guardia-itsm/routers/alert_rules.py
2026-06-06 18:13:48 +09:00

199 lines
6.3 KiB
Python

"""
알림 규칙 CRUD API (모바일 기능 #45).
엔드포인트:
GET /api/alert-rules/ — 내 알림 규칙 목록 (tenant 필터)
POST /api/alert-rules/ — 알림 규칙 생성
PUT /api/alert-rules/{id} — 알림 규칙 수정
DELETE /api/alert-rules/{id} — 알림 규칙 삭제
PATCH /api/alert-rules/{id}/toggle — 활성/비활성 토글
AlertRule: target_type(server/service/sr), metric(cpu/memory/disk/sla),
threshold, operator(>/</=), channel(push/inapp/sms), enabled
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import AlertRule, User
router = APIRouter(prefix="/api/alert-rules", tags=["Alert Rules"])
_VALID_TARGET = {"server", "service", "sr"}
_VALID_METRIC = {"cpu", "memory", "disk", "sla"}
_VALID_OPERATOR = {">", "<", "="}
_VALID_CHANNEL = {"push", "inapp", "sms"}
def _tenant_of(user: User) -> str:
"""사용자의 테넌트 식별자 — inst_code 우선, 없으면 username 단위 격리."""
return user.inst_code or f"user:{user.username}"
class AlertRuleCreate(BaseModel):
target_type: str
target_id: Optional[str] = None
metric: str
threshold: float
operator: str = ">"
channel: str = "inapp"
enabled: bool = True
@field_validator("target_type")
@classmethod
def _v_target(cls, v: str) -> str:
if v not in _VALID_TARGET:
raise ValueError(f"target_type은 {_VALID_TARGET} 중 하나여야 합니다.")
return v
@field_validator("metric")
@classmethod
def _v_metric(cls, v: str) -> str:
if v not in _VALID_METRIC:
raise ValueError(f"metric은 {_VALID_METRIC} 중 하나여야 합니다.")
return v
@field_validator("operator")
@classmethod
def _v_op(cls, v: str) -> str:
if v not in _VALID_OPERATOR:
raise ValueError(f"operator는 {_VALID_OPERATOR} 중 하나여야 합니다.")
return v
@field_validator("channel")
@classmethod
def _v_ch(cls, v: str) -> str:
if v not in _VALID_CHANNEL:
raise ValueError(f"channel은 {_VALID_CHANNEL} 중 하나여야 합니다.")
return v
class AlertRuleUpdate(BaseModel):
target_type: Optional[str] = None
target_id: Optional[str] = None
metric: Optional[str] = None
threshold: Optional[float] = None
operator: Optional[str] = None
channel: Optional[str] = None
enabled: Optional[bool] = None
class AlertRuleOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
target_type: str
target_id: Optional[str]
metric: str
threshold: float
operator: str
channel: str
enabled: bool
created_by: Optional[str]
created_at: Optional[datetime]
@router.get("/", response_model=List[AlertRuleOut])
async def list_alert_rules(
enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""내 테넌트의 알림 규칙 목록."""
q = select(AlertRule).where(AlertRule.tenant_id == _tenant_of(current_user))
if enabled is not None:
q = q.where(AlertRule.enabled == enabled)
q = q.order_by(AlertRule.created_at.desc())
rows = (await db.execute(q)).scalars().all()
return rows
@router.post("/", response_model=AlertRuleOut, status_code=201)
async def create_alert_rule(
payload: AlertRuleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rule = AlertRule(
tenant_id=_tenant_of(current_user),
target_type=payload.target_type,
target_id=payload.target_id,
metric=payload.metric,
threshold=payload.threshold,
operator=payload.operator,
channel=payload.channel,
enabled=payload.enabled,
created_by=current_user.username,
)
db.add(rule)
await db.commit()
await db.refresh(rule)
return rule
async def _get_owned_rule(rule_id: int, db: AsyncSession, user: User) -> AlertRule:
rule = await db.get(AlertRule, rule_id)
if not rule or rule.tenant_id != _tenant_of(user):
raise HTTPException(404, "알림 규칙을 찾을 수 없습니다.")
return rule
@router.put("/{rule_id}", response_model=AlertRuleOut)
async def update_alert_rule(
rule_id: int,
payload: AlertRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rule = await _get_owned_rule(rule_id, db, current_user)
data = payload.model_dump(exclude_unset=True)
# 유효성 검증
if "target_type" in data and data["target_type"] not in _VALID_TARGET:
raise HTTPException(422, f"target_type은 {_VALID_TARGET} 중 하나여야 합니다.")
if "metric" in data and data["metric"] not in _VALID_METRIC:
raise HTTPException(422, f"metric은 {_VALID_METRIC} 중 하나여야 합니다.")
if "operator" in data and data["operator"] not in _VALID_OPERATOR:
raise HTTPException(422, f"operator는 {_VALID_OPERATOR} 중 하나여야 합니다.")
if "channel" in data and data["channel"] not in _VALID_CHANNEL:
raise HTTPException(422, f"channel은 {_VALID_CHANNEL} 중 하나여야 합니다.")
for k, v in data.items():
setattr(rule, k, v)
rule.updated_at = datetime.now()
await db.commit()
await db.refresh(rule)
return rule
@router.delete("/{rule_id}", status_code=204)
async def delete_alert_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rule = await _get_owned_rule(rule_id, db, current_user)
await db.delete(rule)
await db.commit()
@router.patch("/{rule_id}/toggle", response_model=AlertRuleOut)
async def toggle_alert_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""알림 규칙 활성/비활성 토글."""
rule = await _get_owned_rule(rule_id, db, current_user)
rule.enabled = not rule.enabled
rule.updated_at = datetime.now()
await db.commit()
await db.refresh(rule)
return rule