Files
desungongpai/app/routers/alarms.py

289 lines
10 KiB
Python
Raw Normal View History

"""
Alarms Router - 报警管理接口
API endpoints for alarm record queries, acknowledgement, and statistics.
"""
import math
from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, select, case, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.models import AlarmRecord
from app.schemas import (
AlarmAcknowledge,
AlarmRecordResponse,
APIResponse,
PaginatedList,
)
router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[AlarmRecordResponse]],
summary="获取报警记录列表 / List alarms",
)
async def list_alarms(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"),
alarm_source: str | None = Query(default=None, description="报警来源 / Alarm source (single_fence/multi_fence/lbs/wifi)"),
acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取报警记录列表支持按设备报警类型来源确认状态时间范围过滤
List alarm records with filters for device, alarm type, source, acknowledged status, and time range.
"""
query = select(AlarmRecord)
count_query = select(func.count(AlarmRecord.id))
if device_id is not None:
query = query.where(AlarmRecord.device_id == device_id)
count_query = count_query.where(AlarmRecord.device_id == device_id)
if alarm_type:
query = query.where(AlarmRecord.alarm_type == alarm_type)
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
if alarm_source:
query = query.where(AlarmRecord.alarm_source == alarm_source)
count_query = count_query.where(AlarmRecord.alarm_source == alarm_source)
if acknowledged is not None:
query = query.where(AlarmRecord.acknowledged == acknowledged)
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
if start_time:
query = query.where(AlarmRecord.recorded_at >= start_time)
count_query = count_query.where(AlarmRecord.recorded_at >= start_time)
if end_time:
query = query.where(AlarmRecord.recorded_at <= end_time)
count_query = count_query.where(AlarmRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
order = AlarmRecord.recorded_at.asc() if sort_order == "asc" else AlarmRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
alarms = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AlarmRecordResponse.model_validate(a) for a in alarms],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="获取报警统计(增强版)/ Get enhanced alarm statistics",
)
async def alarm_stats(
days: int = Query(default=7, ge=1, le=90, description="趋势天数 / Trend days"),
db: AsyncSession = Depends(get_db),
):
"""
增强版报警统计总数未确认数按类型分组按天趋势平均响应时间TOP10设备
Enhanced alarm stats: totals, by type, daily trend, avg response time, top 10 devices.
"""
from app.config import now_cst
now = now_cst()
# Total alarms
total_result = await db.execute(select(func.count(AlarmRecord.id)))
total = total_result.scalar() or 0
# Unacknowledged alarms
unack_result = await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.acknowledged == False) # noqa: E712
)
unacknowledged = unack_result.scalar() or 0
# By type
type_result = await db.execute(
select(AlarmRecord.alarm_type, func.count(AlarmRecord.id))
.group_by(AlarmRecord.alarm_type)
.order_by(func.count(AlarmRecord.id).desc())
)
by_type = {row[0]: row[1] for row in type_result.all()}
# By source
source_result = await db.execute(
select(AlarmRecord.alarm_source, func.count(AlarmRecord.id))
.group_by(AlarmRecord.alarm_source)
)
by_source = {(row[0] or "unknown"): row[1] for row in source_result.all()}
# Daily trend (last N days)
cutoff = now - timedelta(days=days)
trend_result = await db.execute(
select(
func.date(AlarmRecord.recorded_at).label("day"),
func.count(AlarmRecord.id),
)
.where(AlarmRecord.recorded_at >= cutoff)
.group_by("day")
.order_by("day")
)
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
# Today count
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_result = await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.recorded_at >= today_start)
)
today_count = today_result.scalar() or 0
# Top 10 devices by alarm count
top_result = await db.execute(
select(AlarmRecord.device_id, AlarmRecord.imei, func.count(AlarmRecord.id).label("cnt"))
.group_by(AlarmRecord.device_id, AlarmRecord.imei)
.order_by(func.count(AlarmRecord.id).desc())
.limit(10)
)
top_devices = [{"device_id": row[0], "imei": row[1], "count": row[2]} for row in top_result.all()]
return APIResponse(
data={
"total": total,
"unacknowledged": unacknowledged,
"acknowledged": total - unacknowledged,
"today": today_count,
"by_type": by_type,
"by_source": by_source,
"daily_trend": daily_trend,
"top_devices": top_devices,
}
)
class BatchAcknowledgeRequest(BaseModel):
alarm_ids: list[int] = Field(..., min_length=1, max_length=500, description="告警ID列表")
acknowledged: bool = Field(default=True, description="确认状态")
@router.post(
"/batch-acknowledge",
response_model=APIResponse[dict],
summary="批量确认告警 / Batch acknowledge alarms",
dependencies=[Depends(require_write)],
)
async def batch_acknowledge_alarms(
body: BatchAcknowledgeRequest,
db: AsyncSession = Depends(get_db),
):
"""
批量确认或取消确认告警记录最多500条
Batch acknowledge (or un-acknowledge) alarm records (max 500).
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids))
)
records = list(result.scalars().all())
for r in records:
r.acknowledged = body.acknowledged
await db.flush()
return APIResponse(
message=f"{'确认' if body.acknowledged else '取消确认'} {len(records)} 条告警",
data={"updated": len(records), "requested": len(body.alarm_ids)},
)
@router.post(
"/batch-delete",
response_model=APIResponse[dict],
summary="批量删除告警记录 / Batch delete alarms",
dependencies=[Depends(require_write)],
)
async def batch_delete_alarms(
body: dict,
db: AsyncSession = Depends(get_db),
):
"""批量删除告警记录最多500条。 / Batch delete alarm records (max 500)."""
alarm_ids = body.get("alarm_ids", [])
if not alarm_ids:
raise HTTPException(status_code=400, detail="alarm_ids is required")
if len(alarm_ids) > 500:
raise HTTPException(status_code=400, detail="Max 500 records per request")
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id.in_(alarm_ids))
)
records = list(result.scalars().all())
for r in records:
await db.delete(r)
await db.flush()
return APIResponse(data={"deleted": len(records)})
@router.get(
"/{alarm_id}",
response_model=APIResponse[AlarmRecordResponse],
summary="获取报警详情 / Get alarm details",
)
async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取报警记录详情
Get alarm record details by ID.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
return APIResponse(data=AlarmRecordResponse.model_validate(alarm))
@router.put(
"/{alarm_id}/acknowledge",
response_model=APIResponse[AlarmRecordResponse],
summary="确认报警 / Acknowledge alarm",
dependencies=[Depends(require_write)],
)
async def acknowledge_alarm(
alarm_id: int,
body: AlarmAcknowledge | None = None,
db: AsyncSession = Depends(get_db),
):
"""
确认或取消确认报警记录
Acknowledge (or un-acknowledge) an alarm record.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
acknowledged = body.acknowledged if body else True
alarm.acknowledged = acknowledged
await db.flush()
await db.refresh(alarm)
return APIResponse(
message="Alarm acknowledged / 报警已确认" if acknowledged else "Alarm un-acknowledged / 已取消确认",
data=AlarmRecordResponse.model_validate(alarm),
)