feat: 高德IoT v5 API升级、电子围栏管理、设备绑定自动考勤
- 前向地理编码升级为高德IoT v5 API (POST restapi.amap.com/v5/position/IoT) - 修复LBS定位偏差: 添加network=LTE参数区分4G/2G, bts格式补充cage字段 - 新增电子围栏管理模块 (circle/polygon/rectangle), 支持地图绘制和POI搜索 - 新增设备-围栏多对多绑定 (DeviceFenceBinding/DeviceFenceState) - 围栏自动考勤引擎 (fence_checker.py): haversine距离、ray-casting多边形判定、容差机制、防抖 - TCP位置上报自动检测围栏进出, 生成考勤记录并WebSocket广播 - 前端围栏页面: 绑定设备弹窗、POI搜索定位、左侧围栏面板 - 新增fence_attendance WebSocket topic via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -78,7 +78,8 @@ async def update_beacon(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(beacon, key, value)
|
||||
beacon.updated_at = datetime.now(timezone.utc)
|
||||
from app.config import now_cst
|
||||
beacon.updated_at = now_cst()
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(beacon)
|
||||
|
||||
@@ -3,7 +3,8 @@ Device Service - 设备管理服务
|
||||
Provides CRUD operations and statistics for badge devices.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from app.config import now_cst
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -158,7 +159,7 @@ async def update_device(
|
||||
for field, value in update_fields.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
device.updated_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(device)
|
||||
return device
|
||||
@@ -245,7 +246,7 @@ async def batch_update_devices(
|
||||
devices = await get_devices_by_ids(db, device_ids)
|
||||
found_map = {d.id: d for d in devices}
|
||||
update_fields = update_data.model_dump(exclude_unset=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = now_cst()
|
||||
|
||||
results = []
|
||||
for device_id in device_ids:
|
||||
|
||||
360
app/services/fence_checker.py
Normal file
360
app/services/fence_checker.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Fence checker service - geofence judgment engine with auto-attendance.
|
||||
|
||||
Checks whether a device's reported coordinates fall inside its bound fences.
|
||||
Creates automatic attendance records (clock_in/clock_out) on state transitions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import now_cst, settings
|
||||
from app.models import (
|
||||
AttendanceRecord,
|
||||
DeviceFenceBinding,
|
||||
DeviceFenceState,
|
||||
FenceConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EARTH_RADIUS_M = 6_371_000.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geometry helpers (WGS-84)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return distance in meters between two WGS-84 points."""
|
||||
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
return _EARTH_RADIUS_M * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def is_inside_circle(
|
||||
lat: float, lon: float,
|
||||
center_lat: float, center_lng: float,
|
||||
radius_m: float,
|
||||
) -> bool:
|
||||
"""Check if point is inside a circle (haversine)."""
|
||||
return haversine_distance(lat, lon, center_lat, center_lng) <= radius_m
|
||||
|
||||
|
||||
def is_inside_polygon(
|
||||
lat: float, lon: float,
|
||||
vertices: list[list[float]],
|
||||
) -> bool:
|
||||
"""Ray-casting algorithm. vertices = [[lng, lat], ...] in WGS-84."""
|
||||
n = len(vertices)
|
||||
if n < 3:
|
||||
return False
|
||||
inside = False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = vertices[i][0], vertices[i][1] # lng, lat
|
||||
xj, yj = vertices[j][0], vertices[j][1]
|
||||
if ((yi > lat) != (yj > lat)) and (
|
||||
lon < (xj - xi) * (lat - yi) / (yj - yi) + xi
|
||||
):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def is_inside_fence(
|
||||
lat: float, lon: float,
|
||||
fence: FenceConfig,
|
||||
tolerance_m: float = 0,
|
||||
) -> bool:
|
||||
"""Check if a point is inside a fence, with optional tolerance buffer."""
|
||||
if fence.fence_type == "circle":
|
||||
if fence.center_lat is None or fence.center_lng is None or fence.radius is None:
|
||||
return False
|
||||
return is_inside_circle(
|
||||
lat, lon,
|
||||
fence.center_lat, fence.center_lng,
|
||||
fence.radius + tolerance_m,
|
||||
)
|
||||
|
||||
# polygon / rectangle: parse points JSON
|
||||
if not fence.points:
|
||||
return False
|
||||
try:
|
||||
vertices = json.loads(fence.points)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Fence %d has invalid points JSON", fence.id)
|
||||
return False
|
||||
|
||||
if not isinstance(vertices, list) or len(vertices) < 3:
|
||||
return False
|
||||
|
||||
# For polygon with tolerance, check point-in-polygon first
|
||||
if is_inside_polygon(lat, lon, vertices):
|
||||
return True
|
||||
|
||||
# If not inside but tolerance > 0, check distance to nearest edge
|
||||
if tolerance_m > 0:
|
||||
return _min_distance_to_polygon(lat, lon, vertices) <= tolerance_m
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _min_distance_to_polygon(
|
||||
lat: float, lon: float,
|
||||
vertices: list[list[float]],
|
||||
) -> float:
|
||||
"""Approximate minimum distance from point to polygon edges (meters)."""
|
||||
min_dist = float("inf")
|
||||
n = len(vertices)
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
# Each vertex is [lng, lat]
|
||||
dist = _point_to_segment_distance(
|
||||
lat, lon,
|
||||
vertices[i][1], vertices[i][0],
|
||||
vertices[j][1], vertices[j][0],
|
||||
)
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
return min_dist
|
||||
|
||||
|
||||
def _point_to_segment_distance(
|
||||
plat: float, plon: float,
|
||||
alat: float, alon: float,
|
||||
blat: float, blon: float,
|
||||
) -> float:
|
||||
"""Approximate distance from point P to line segment AB (meters)."""
|
||||
# Project P onto AB using flat-earth approximation (good for short segments)
|
||||
dx = blon - alon
|
||||
dy = blat - alat
|
||||
if dx == 0 and dy == 0:
|
||||
return haversine_distance(plat, plon, alat, alon)
|
||||
|
||||
t = max(0, min(1, ((plon - alon) * dx + (plat - alat) * dy) / (dx * dx + dy * dy)))
|
||||
proj_lat = alat + t * dy
|
||||
proj_lon = alon + t * dx
|
||||
return haversine_distance(plat, plon, proj_lat, proj_lon)
|
||||
|
||||
|
||||
def _get_tolerance_for_location_type(location_type: str) -> float:
|
||||
"""Return tolerance in meters based on location type accuracy."""
|
||||
if location_type in ("lbs", "lbs_4g"):
|
||||
return float(settings.FENCE_LBS_TOLERANCE_METERS)
|
||||
if location_type in ("wifi", "wifi_4g"):
|
||||
return float(settings.FENCE_WIFI_TOLERANCE_METERS)
|
||||
# GPS: no extra tolerance
|
||||
return 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main fence check entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def check_device_fences(
|
||||
session: AsyncSession,
|
||||
device_id: int,
|
||||
imei: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
location_type: str,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
) -> list[dict]:
|
||||
"""Check all bound active fences for a device. Returns attendance events.
|
||||
|
||||
Called after each location report is stored. Creates automatic
|
||||
AttendanceRecords for fence entry/exit transitions.
|
||||
"""
|
||||
# 1. Query active fences bound to this device
|
||||
result = await session.execute(
|
||||
select(FenceConfig)
|
||||
.join(DeviceFenceBinding, DeviceFenceBinding.fence_id == FenceConfig.id)
|
||||
.where(
|
||||
DeviceFenceBinding.device_id == device_id,
|
||||
FenceConfig.is_active == 1,
|
||||
)
|
||||
)
|
||||
fences = list(result.scalars().all())
|
||||
if not fences:
|
||||
return []
|
||||
|
||||
tolerance = _get_tolerance_for_location_type(location_type)
|
||||
events: list[dict] = []
|
||||
now = now_cst()
|
||||
min_interval = settings.FENCE_MIN_INSIDE_SECONDS
|
||||
|
||||
for fence in fences:
|
||||
currently_inside = is_inside_fence(latitude, longitude, fence, tolerance)
|
||||
|
||||
# 2. Get or create state record
|
||||
state_result = await session.execute(
|
||||
select(DeviceFenceState).where(
|
||||
DeviceFenceState.device_id == device_id,
|
||||
DeviceFenceState.fence_id == fence.id,
|
||||
)
|
||||
)
|
||||
state = state_result.scalar_one_or_none()
|
||||
|
||||
was_inside = bool(state and state.is_inside)
|
||||
|
||||
# 3. Detect transition
|
||||
if currently_inside and not was_inside:
|
||||
# ENTRY: outside -> inside = clock_in
|
||||
if state and state.last_transition_at:
|
||||
elapsed = (now - state.last_transition_at).total_seconds()
|
||||
if elapsed < min_interval:
|
||||
logger.debug(
|
||||
"Fence %d debounce: %ds < %ds, skip clock_in for device %d",
|
||||
fence.id, int(elapsed), min_interval, device_id,
|
||||
)
|
||||
_update_state(state, currently_inside, now, latitude, longitude)
|
||||
continue
|
||||
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_in", latitude, longitude,
|
||||
address, recorded_at, fence,
|
||||
)
|
||||
session.add(attendance)
|
||||
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_in",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_in: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
elif not currently_inside and was_inside:
|
||||
# EXIT: inside -> outside = clock_out
|
||||
if state and state.last_transition_at:
|
||||
elapsed = (now - state.last_transition_at).total_seconds()
|
||||
if elapsed < min_interval:
|
||||
logger.debug(
|
||||
"Fence %d debounce: %ds < %ds, skip clock_out for device %d",
|
||||
fence.id, int(elapsed), min_interval, device_id,
|
||||
)
|
||||
_update_state(state, currently_inside, now, latitude, longitude)
|
||||
continue
|
||||
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_out", latitude, longitude,
|
||||
address, recorded_at, fence,
|
||||
)
|
||||
session.add(attendance)
|
||||
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_out",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_out: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
# 4. Update state
|
||||
if state is None:
|
||||
state = DeviceFenceState(
|
||||
device_id=device_id,
|
||||
fence_id=fence.id,
|
||||
is_inside=currently_inside,
|
||||
last_transition_at=now if (currently_inside != was_inside) else None,
|
||||
last_check_at=now,
|
||||
last_latitude=latitude,
|
||||
last_longitude=longitude,
|
||||
)
|
||||
session.add(state)
|
||||
else:
|
||||
if currently_inside != was_inside:
|
||||
state.last_transition_at = now
|
||||
state.is_inside = currently_inside
|
||||
state.last_check_at = now
|
||||
state.last_latitude = latitude
|
||||
state.last_longitude = longitude
|
||||
|
||||
await session.flush()
|
||||
return events
|
||||
|
||||
|
||||
def _update_state(
|
||||
state: DeviceFenceState,
|
||||
is_inside: bool,
|
||||
now: datetime,
|
||||
lat: float,
|
||||
lon: float,
|
||||
) -> None:
|
||||
"""Update state fields without creating a transition."""
|
||||
state.last_check_at = now
|
||||
state.last_latitude = lat
|
||||
state.last_longitude = lon
|
||||
# Don't update is_inside or last_transition_at during debounce
|
||||
|
||||
|
||||
def _create_attendance(
|
||||
device_id: int,
|
||||
imei: str,
|
||||
attendance_type: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
fence: FenceConfig,
|
||||
) -> AttendanceRecord:
|
||||
"""Create an auto-generated fence attendance record."""
|
||||
return AttendanceRecord(
|
||||
device_id=device_id,
|
||||
imei=imei,
|
||||
attendance_type=attendance_type,
|
||||
protocol_number=0, # synthetic, not from device protocol
|
||||
gps_positioned=True,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
address=address,
|
||||
recorded_at=recorded_at,
|
||||
lbs_data={
|
||||
"source": "fence_auto",
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_event(
|
||||
device_id: int,
|
||||
imei: str,
|
||||
fence: FenceConfig,
|
||||
attendance_type: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
) -> dict:
|
||||
"""Build a WebSocket broadcast event dict."""
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"imei": imei,
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
"attendance_type": attendance_type,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"address": address,
|
||||
"recorded_at": recorded_at.isoformat() if recorded_at else None,
|
||||
"source": "fence_auto",
|
||||
}
|
||||
208
app/services/fence_service.py
Normal file
208
app/services/fence_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Fence Service - CRUD operations for geofence configuration and device bindings."""
|
||||
|
||||
from app.config import now_cst
|
||||
|
||||
from sqlalchemy import delete as sa_delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Device, DeviceFenceBinding, DeviceFenceState, FenceConfig
|
||||
from app.schemas import FenceConfigCreate, FenceConfigUpdate
|
||||
|
||||
|
||||
async def get_fences(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[FenceConfig], int]:
|
||||
query = select(FenceConfig)
|
||||
count_query = select(func.count(FenceConfig.id))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(FenceConfig.is_active == is_active)
|
||||
count_query = count_query.where(FenceConfig.is_active == is_active)
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
cond = FenceConfig.name.ilike(like) | FenceConfig.description.ilike(like)
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
query.order_by(FenceConfig.created_at.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def get_all_active_fences(db: AsyncSession) -> list[FenceConfig]:
|
||||
result = await db.execute(
|
||||
select(FenceConfig).where(FenceConfig.is_active == 1).order_by(FenceConfig.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_fence(db: AsyncSession, fence_id: int) -> FenceConfig | None:
|
||||
result = await db.execute(select(FenceConfig).where(FenceConfig.id == fence_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_fence(db: AsyncSession, data: FenceConfigCreate) -> FenceConfig:
|
||||
fence = FenceConfig(**data.model_dump())
|
||||
db.add(fence)
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def update_fence(db: AsyncSession, fence_id: int, data: FenceConfigUpdate) -> FenceConfig | None:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return None
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(fence, key, value)
|
||||
fence.updated_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def delete_fence(db: AsyncSession, fence_id: int) -> bool:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return False
|
||||
# CASCADE FK handles bindings/states, but explicit delete for safety
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(DeviceFenceState.fence_id == fence_id)
|
||||
)
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(DeviceFenceBinding.fence_id == fence_id)
|
||||
)
|
||||
await db.delete(fence)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_fence_devices(
|
||||
db: AsyncSession, fence_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get devices bound to a fence, with their current fence state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, Device, DeviceFenceState)
|
||||
.join(Device, Device.id == DeviceFenceBinding.device_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.fence_id == fence_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
items = []
|
||||
for binding, device, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"imei": device.imei,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def get_device_fences(
|
||||
db: AsyncSession, device_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get fences bound to a device, with current state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, FenceConfig, DeviceFenceState)
|
||||
.join(FenceConfig, FenceConfig.id == DeviceFenceBinding.fence_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.device_id == device_id)
|
||||
.order_by(FenceConfig.name)
|
||||
)
|
||||
items = []
|
||||
for binding, fence, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
"fence_type": fence.fence_type,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def bind_devices_to_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> dict:
|
||||
"""Bind multiple devices to a fence. Idempotent (skips existing bindings)."""
|
||||
# Verify fence exists
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Fence not found"}
|
||||
|
||||
# Verify devices exist
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.id.in_(device_ids))
|
||||
)
|
||||
existing_device_ids = set(row[0] for row in result.all())
|
||||
|
||||
# Check existing bindings
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding.device_id).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
already_bound_ids = set(row[0] for row in result.all())
|
||||
|
||||
created = 0
|
||||
for did in device_ids:
|
||||
if did not in existing_device_ids:
|
||||
continue
|
||||
if did in already_bound_ids:
|
||||
continue
|
||||
db.add(DeviceFenceBinding(device_id=did, fence_id=fence_id))
|
||||
created += 1
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"created": created,
|
||||
"already_bound": len(already_bound_ids & existing_device_ids),
|
||||
"not_found": len(set(device_ids) - existing_device_ids),
|
||||
}
|
||||
|
||||
|
||||
async def unbind_devices_from_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> int:
|
||||
"""Unbind devices from a fence. Also cleans up state records."""
|
||||
result = await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
# Clean up state records
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(
|
||||
DeviceFenceState.fence_id == fence_id,
|
||||
DeviceFenceState.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return result.rowcount
|
||||
Reference in New Issue
Block a user