""" 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 fastapi.responses import Response 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.services.export_utils import build_csv_content, csv_filename 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( "/export", summary="导出告警记录 CSV / Export alarm records CSV", ) async def export_alarms( device_id: int | None = Query(default=None, description="设备ID"), alarm_type: str | None = Query(default=None, description="告警类型"), alarm_source: str | None = Query(default=None, description="告警来源"), acknowledged: bool | None = Query(default=None, description="是否已确认"), start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"), end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"), db: AsyncSession = Depends(get_db), ): """导出告警记录为 CSV,支持类型/状态/时间筛选。最多导出 50000 条。""" query = select(AlarmRecord) if device_id is not None: query = query.where(AlarmRecord.device_id == device_id) if alarm_type: query = query.where(AlarmRecord.alarm_type == alarm_type) if alarm_source: query = query.where(AlarmRecord.alarm_source == alarm_source) if acknowledged is not None: query = query.where(AlarmRecord.acknowledged == acknowledged) if start_time: query = query.where(AlarmRecord.recorded_at >= start_time) if end_time: query = query.where(AlarmRecord.recorded_at <= end_time) query = query.order_by(AlarmRecord.recorded_at.desc()).limit(50000) result = await db.execute(query) records = list(result.scalars().all()) headers = ["ID", "设备ID", "IMEI", "告警类型", "告警来源", "已确认", "纬度", "经度", "电量", "信号", "地址", "记录时间"] fields = ["id", "device_id", "imei", "alarm_type", "alarm_source", "acknowledged", "latitude", "longitude", "battery_level", "gsm_signal", "address", "recorded_at"] content = build_csv_content(headers, records, fields) return Response( content=content, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f"attachment; filename={csv_filename('alarms')}"}, ) @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)}, ) class BatchDeleteAlarmRequest(BaseModel): alarm_ids: list[int] | None = Field(default=None, max_length=500, description="告警ID列表 (与条件删除二选一)") device_id: int | None = Field(default=None, description="按设备ID删除") alarm_type: str | None = Field(default=None, description="按告警类型删除") acknowledged: bool | None = Field(default=None, description="按确认状态删除") start_time: datetime | None = Field(default=None, description="开始时间") end_time: datetime | None = Field(default=None, description="结束时间") @router.post( "/batch-delete", response_model=APIResponse[dict], summary="批量删除告警记录 / Batch delete alarms", dependencies=[Depends(require_write)], ) async def batch_delete_alarms( body: BatchDeleteAlarmRequest, db: AsyncSession = Depends(get_db), ): """ 批量删除告警记录。两种模式: 1. 按ID删除: 传 alarm_ids (最多500条) 2. 按条件删除: 传 device_id/alarm_type/acknowledged/start_time/end_time 组合 """ from sqlalchemy import delete as sql_delete if body.alarm_ids: # Mode 1: by IDs result = await db.execute( sql_delete(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids)) ) await db.flush() return APIResponse( message=f"已删除 {result.rowcount} 条告警", data={"deleted": result.rowcount, "requested": len(body.alarm_ids)}, ) # Mode 2: by filters (at least one filter required) conditions = [] if body.device_id is not None: conditions.append(AlarmRecord.device_id == body.device_id) if body.alarm_type: conditions.append(AlarmRecord.alarm_type == body.alarm_type) if body.acknowledged is not None: conditions.append(AlarmRecord.acknowledged == body.acknowledged) if body.start_time: conditions.append(AlarmRecord.recorded_at >= body.start_time) if body.end_time: conditions.append(AlarmRecord.recorded_at <= body.end_time) if not conditions: raise HTTPException(status_code=400, detail="需提供 alarm_ids 或至少一个筛选条件") # Count first count = (await db.execute( select(func.count(AlarmRecord.id)).where(*conditions) )).scalar() or 0 if count > 0: await db.execute(sql_delete(AlarmRecord).where(*conditions)) await db.flush() return APIResponse( message=f"已删除 {count} 条告警", data={"deleted": count}, ) @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), )