""" 알림 규칙 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(>/", "<", "="} _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