"""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