feat: 信标设备绑定 + 蓝牙模式管理 + 系统管理增强 + 数据导出
- 新增 DeviceBeaconBinding 模型,信标-设备多对多绑定 CRUD - 蓝牙打卡模式批量配置/恢复正常模式 API - 反向同步: 查询设备 BTMACSET 配置更新数据库绑定 (独立 session 解决事务隔离) - 设备列表快捷操作弹窗修复 (fire-and-forget IIFE 替代阻塞轮询) - 保存按钮防抖: 围栏/信标绑定保存点击后 disabled + 转圈防重复提交 - 审计日志中间件 + 系统配置/备份/固件 API - 设备分组管理 + 告警规则配置 - 5个数据导出 API (CSV UTF-8 BOM) - 位置热力图 + 告警条件删除 + 位置清理 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
210
app/routers/device_groups.py
Normal file
210
app/routers/device_groups.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Device Groups Router - 设备分组管理接口
|
||||
API endpoints for device group CRUD and membership management.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_write
|
||||
from app.models import Device, DeviceGroup, DeviceGroupMember
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
DeviceGroupCreate,
|
||||
DeviceGroupResponse,
|
||||
DeviceGroupUpdate,
|
||||
DeviceGroupWithCount,
|
||||
DeviceResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/groups", tags=["Device Groups / 设备分组"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[list[DeviceGroupWithCount]],
|
||||
summary="获取分组列表 / List device groups",
|
||||
)
|
||||
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有设备分组及各组设备数。"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
DeviceGroup,
|
||||
func.count(DeviceGroupMember.id).label("cnt"),
|
||||
)
|
||||
.outerjoin(DeviceGroupMember, DeviceGroup.id == DeviceGroupMember.group_id)
|
||||
.group_by(DeviceGroup.id)
|
||||
.order_by(DeviceGroup.name)
|
||||
)
|
||||
groups = []
|
||||
for row in result.all():
|
||||
g = DeviceGroupResponse.model_validate(row[0])
|
||||
groups.append(DeviceGroupWithCount(**g.model_dump(), device_count=row[1]))
|
||||
return APIResponse(data=groups)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=APIResponse[DeviceGroupResponse],
|
||||
status_code=201,
|
||||
summary="创建分组 / Create group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def create_group(body: DeviceGroupCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""创建新设备分组。"""
|
||||
existing = await db.execute(
|
||||
select(DeviceGroup).where(DeviceGroup.name == body.name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"分组名 '{body.name}' 已存在")
|
||||
group = DeviceGroup(name=body.name, description=body.description, color=body.color)
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{group_id}",
|
||||
response_model=APIResponse[DeviceGroupResponse],
|
||||
summary="更新分组 / Update group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def update_group(
|
||||
group_id: int, body: DeviceGroupUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新分组信息。"""
|
||||
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(group, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{group_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除分组 / Delete group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除分组(级联删除成员关系,不删除设备)。"""
|
||||
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
# Clear group_id on devices
|
||||
devices_result = await db.execute(select(Device).where(Device.group_id == group_id))
|
||||
for d in devices_result.scalars().all():
|
||||
d.group_id = None
|
||||
await db.delete(group)
|
||||
await db.flush()
|
||||
return APIResponse(message="分组已删除")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[list[DeviceResponse]],
|
||||
summary="获取分组设备 / Get group devices",
|
||||
)
|
||||
async def get_group_devices(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取分组内的设备列表。"""
|
||||
result = await db.execute(
|
||||
select(Device)
|
||||
.join(DeviceGroupMember, Device.id == DeviceGroupMember.device_id)
|
||||
.where(DeviceGroupMember.group_id == group_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
return APIResponse(data=[DeviceResponse.model_validate(d) for d in devices])
|
||||
|
||||
|
||||
class GroupMemberRequest(BaseModel):
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="设备ID列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[dict],
|
||||
summary="添加设备到分组 / Add devices to group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def add_devices_to_group(
|
||||
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加设备到分组(幂等,已存在的跳过)。"""
|
||||
# Verify group exists
|
||||
group = (await db.execute(
|
||||
select(DeviceGroup).where(DeviceGroup.id == group_id)
|
||||
)).scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
|
||||
# Get existing members
|
||||
existing = await db.execute(
|
||||
select(DeviceGroupMember.device_id).where(
|
||||
DeviceGroupMember.group_id == group_id,
|
||||
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing.all()}
|
||||
|
||||
added = 0
|
||||
for did in body.device_ids:
|
||||
if did not in existing_ids:
|
||||
db.add(DeviceGroupMember(device_id=did, group_id=group_id))
|
||||
added += 1
|
||||
# Also update device.group_id
|
||||
if body.device_ids:
|
||||
devices = await db.execute(
|
||||
select(Device).where(Device.id.in_(body.device_ids))
|
||||
)
|
||||
for d in devices.scalars().all():
|
||||
d.group_id = group_id
|
||||
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已添加 {added} 台设备到分组",
|
||||
data={"added": added, "already_in_group": len(existing_ids)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[dict],
|
||||
summary="从分组移除设备 / Remove devices from group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def remove_devices_from_group(
|
||||
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""从分组移除设备。"""
|
||||
result = await db.execute(
|
||||
delete(DeviceGroupMember).where(
|
||||
DeviceGroupMember.group_id == group_id,
|
||||
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||
)
|
||||
)
|
||||
# Clear group_id on removed devices
|
||||
devices = await db.execute(
|
||||
select(Device).where(
|
||||
Device.id.in_(body.device_ids),
|
||||
Device.group_id == group_id,
|
||||
)
|
||||
)
|
||||
for d in devices.scalars().all():
|
||||
d.group_id = None
|
||||
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已移除 {result.rowcount} 台设备",
|
||||
data={"removed": result.rowcount},
|
||||
)
|
||||
Reference in New Issue
Block a user