"""Fences Router - geofence management API endpoints.""" import math from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import require_write from app.database import get_db from app.models import AttendanceRecord, DeviceFenceBinding, DeviceFenceState, FenceConfig from app.schemas import ( APIResponse, DeviceFenceBindRequest, FenceConfigCreate, FenceConfigResponse, FenceConfigUpdate, FenceDeviceDetail, PaginatedList, ) from app.services import fence_service router = APIRouter(prefix="/api/fences", tags=["Fences"]) @router.get("", response_model=APIResponse[PaginatedList[FenceConfigResponse]]) async def list_fences( is_active: bool | None = Query(default=None), search: str | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: AsyncSession = Depends(get_db), ): records, total = await fence_service.get_fences(db, page, page_size, is_active, search) return APIResponse( data=PaginatedList( items=[FenceConfigResponse.model_validate(r) for r in records], 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="围栏统计 / Fence statistics", ) async def fence_stats(db: AsyncSession = Depends(get_db)): """ 围栏统计:总数、活跃数、绑定设备总数、各围栏当前在内设备数、今日进出事件数。 Fence stats: totals, bindings, devices currently inside, today's events. """ from app.config import now_cst now = now_cst() total = (await db.execute(select(func.count(FenceConfig.id)))).scalar() or 0 active = (await db.execute( select(func.count(FenceConfig.id)).where(FenceConfig.is_active == True) # noqa: E712 )).scalar() or 0 total_bindings = (await db.execute(select(func.count(DeviceFenceBinding.id)))).scalar() or 0 # Devices currently inside fences inside_result = await db.execute( select( DeviceFenceState.fence_id, func.count(DeviceFenceState.id), ) .where(DeviceFenceState.is_inside == True) # noqa: E712 .group_by(DeviceFenceState.fence_id) ) devices_inside = {row[0]: row[1] for row in inside_result.all()} total_inside = sum(devices_inside.values()) # Today's fence attendance events today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_events = (await db.execute( select(func.count(AttendanceRecord.id)) .where( AttendanceRecord.attendance_source == "fence", AttendanceRecord.recorded_at >= today_start, ) )).scalar() or 0 # Per-fence summary fence_result = await db.execute( select(FenceConfig.id, FenceConfig.name, FenceConfig.fence_type, FenceConfig.is_active) ) fence_summary = [] for row in fence_result.all(): fid = row[0] fence_summary.append({ "fence_id": fid, "name": row[1], "type": row[2], "is_active": bool(row[3]), "devices_inside": devices_inside.get(fid, 0), }) return APIResponse(data={ "total": total, "active": active, "inactive": total - active, "total_bindings": total_bindings, "total_devices_inside": total_inside, "today_events": today_events, "fences": fence_summary, }) @router.get("/all-active", response_model=APIResponse[list[FenceConfigResponse]]) async def get_all_active(db: AsyncSession = Depends(get_db)): fences = await fence_service.get_all_active_fences(db) return APIResponse(data=[FenceConfigResponse.model_validate(f) for f in fences]) @router.get("/{fence_id}", response_model=APIResponse[FenceConfigResponse]) async def get_fence(fence_id: int, db: AsyncSession = Depends(get_db)): fence = await fence_service.get_fence(db, fence_id) if fence is None: raise HTTPException(status_code=404, detail="Fence not found") return APIResponse(data=FenceConfigResponse.model_validate(fence)) @router.post("", response_model=APIResponse[FenceConfigResponse], status_code=201, dependencies=[Depends(require_write)]) async def create_fence(body: FenceConfigCreate, db: AsyncSession = Depends(get_db)): fence = await fence_service.create_fence(db, body) return APIResponse(message="Fence created", data=FenceConfigResponse.model_validate(fence)) @router.put("/{fence_id}", response_model=APIResponse[FenceConfigResponse], dependencies=[Depends(require_write)]) async def update_fence(fence_id: int, body: FenceConfigUpdate, db: AsyncSession = Depends(get_db)): fence = await fence_service.update_fence(db, fence_id, body) if fence is None: raise HTTPException(status_code=404, detail="Fence not found") return APIResponse(message="Fence updated", data=FenceConfigResponse.model_validate(fence)) @router.delete("/{fence_id}", response_model=APIResponse, dependencies=[Depends(require_write)]) async def delete_fence(fence_id: int, db: AsyncSession = Depends(get_db)): if not await fence_service.delete_fence(db, fence_id): raise HTTPException(status_code=404, detail="Fence not found") return APIResponse(message="Fence deleted") @router.get( "/{fence_id}/events", response_model=APIResponse[PaginatedList[dict]], summary="围栏进出事件历史 / Fence events history", ) async def fence_events( fence_id: int, start_time: datetime | None = Query(default=None, description="开始时间"), end_time: datetime | None = Query(default=None, description="结束时间"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: AsyncSession = Depends(get_db), ): """ 获取围栏的进出事件历史(来源为 fence 的考勤记录)。 Get fence entry/exit events (attendance records with source=fence). """ fence = await fence_service.get_fence(db, fence_id) if fence is None: raise HTTPException(status_code=404, detail="Fence not found") from sqlalchemy.dialects.sqlite import JSON as _JSON filters = [ AttendanceRecord.attendance_source == "fence", ] if start_time: filters.append(AttendanceRecord.recorded_at >= start_time) if end_time: filters.append(AttendanceRecord.recorded_at <= end_time) # Filter by fence_id in lbs_data JSON (fence_auto records store fence_id there) # Since SQLite JSON support is limited, we use LIKE for the fence_id match filters.append(AttendanceRecord.lbs_data.like(f'%"fence_id": {fence_id}%')) count_q = select(func.count(AttendanceRecord.id)).where(*filters) total = (await db.execute(count_q)).scalar() or 0 offset = (page - 1) * page_size result = await db.execute( select(AttendanceRecord) .where(*filters) .order_by(AttendanceRecord.recorded_at.desc()) .offset(offset).limit(page_size) ) records = result.scalars().all() items = [] for r in records: items.append({ "id": r.id, "device_id": r.device_id, "imei": r.imei, "event_type": r.attendance_type, "latitude": r.latitude, "longitude": r.longitude, "address": r.address, "recorded_at": str(r.recorded_at), }) return APIResponse(data=PaginatedList( items=items, total=total, page=page, page_size=page_size, total_pages=math.ceil(total / page_size) if total else 0, )) # --------------------------------------------------------------------------- # Device-Fence Binding endpoints # --------------------------------------------------------------------------- @router.get( "/{fence_id}/devices", response_model=APIResponse[list[FenceDeviceDetail]], summary="获取围栏绑定的设备列表", ) async def get_fence_devices(fence_id: int, db: AsyncSession = Depends(get_db)): fence = await fence_service.get_fence(db, fence_id) if fence is None: raise HTTPException(status_code=404, detail="Fence not found") items = await fence_service.get_fence_devices(db, fence_id) return APIResponse(data=[FenceDeviceDetail(**item) for item in items]) @router.post( "/{fence_id}/devices", response_model=APIResponse, dependencies=[Depends(require_write)], summary="绑定设备到围栏", ) async def bind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)): result = await fence_service.bind_devices_to_fence(db, fence_id, body.device_ids) if result.get("error"): raise HTTPException(status_code=404, detail=result["error"]) return APIResponse( message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}", data=result, ) @router.delete( "/{fence_id}/devices", response_model=APIResponse, dependencies=[Depends(require_write)], summary="解绑设备与围栏", ) async def unbind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)): count = await fence_service.unbind_devices_from_fence(db, fence_id, body.device_ids) return APIResponse(message=f"已解绑 {count} 个设备")