feat: prompt on duplicate device instead of silently blocking/merging approve

Single-device approve now guards duplicates per-design the same way bulk
approve does, and asks the user instead of failing or silently merging:

- create_node and approve_device reject a same-design duplicate (ieee, ip
  or mac) with 409 + the existing node; a force flag creates it anyway.
- Frontend shows a confirm dialog: go to existing node, add duplicate
  anyway, or cancel.
- approve_device no longer rejects a device already on another canvas
  (status is global, canvas membership is per-design) — it can be placed
  on a new design, matching bulk approve.
- IEEE (Zigbee/Z-Wave) devices now use the same prompt as ip/mac instead
  of auto-merging into the existing node.
- bulk approve reports which devices it skipped as duplicates.

Closes #260

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-09 13:05:19 +02:00
parent 8ebc716de8
commit 0792105b96
9 changed files with 629 additions and 85 deletions
+12
View File
@@ -6,6 +6,7 @@ from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
from app.services.node_dedupe import find_duplicate_node
router = APIRouter()
@@ -19,6 +20,8 @@ async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
data = body.model_dump()
# `force` bypasses the duplicate guard below; it is not a Node column.
force = data.pop("force", False)
# Attach to a design so the node lands on a canvas. Clients that don't send a
# design_id (e.g. the MCP write tools) would otherwise create design_id=null
# nodes that exist in the DB but never render in the UI until a container
@@ -26,6 +29,15 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
if data.get("design_id") is None:
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
data["design_id"] = first_design.id if first_design else None
# Reject a silent duplicate: a node with the same ip OR mac already on the
# target design. Scripts/MCP clients get a clear 409 (with the existing id)
# instead of a second card for the same host. Pass force=True to override.
if not force:
dup = await find_duplicate_node(db, data["design_id"], data.get("ip"), data.get("mac"))
if dup is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=dup)
node = Node(**data)
db.add(node)
await db.commit()
+61 -45
View File
@@ -15,7 +15,7 @@ from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
from app.services.zigbee_service import (
build_zigbee_properties,
@@ -335,23 +335,37 @@ async def bulk_approve_devices(
devices = result.scalars().all()
# What already sits on the target canvas, so we skip devices already placed
# here (by ip or ieee_address) instead of creating duplicate nodes.
# here (by ip or ieee_address) instead of creating duplicate nodes. We map to
# the existing node id so the skip report can point the user at it. A value
# may be a Node still pending flush (in-batch duplicate) — resolved to its id
# after the flush below.
existing = (
await db.execute(
select(Node.ip, Node.ieee_address).where(Node.design_id == default_design_id)
select(Node.id, Node.ip, Node.ieee_address).where(Node.design_id == default_design_id)
)
).all()
placed_ips = {ip for ip, _ in existing if ip}
placed_ieee = {ieee for _, ieee in existing if ieee}
placed_ips: dict[str, Any] = {ip: nid for nid, ip, _ in existing if ip}
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, ieee in existing if ieee}
created_nodes: list[Node] = []
approved_devices: list[PendingDevice] = []
skipped_devices: list[dict[str, Any]] = []
for device in devices:
already_here = (
(device.ip is not None and device.ip in placed_ips)
or (device.ieee_address is not None and device.ieee_address in placed_ieee)
)
if already_here:
# Record which identifier collided so the caller can explain each skip
# (and, for existing on-canvas nodes, link to the node already there).
if device.ip is not None and device.ip in placed_ips:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.ip or "device",
"match": "ip", "value": device.ip, "_ref": placed_ips[device.ip],
})
continue
if device.ieee_address is not None and device.ieee_address in placed_ieee:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.ieee_address or "device",
"match": "ieee", "value": device.ieee_address, "_ref": placed_ieee[device.ieee_address],
})
continue
device.status = "approved"
node_type = device.suggested_type or "generic"
@@ -381,16 +395,23 @@ async def bulk_approve_devices(
created_nodes.append(node)
approved_devices.append(device)
# Track within this batch so a duplicate selection (same ip/ieee) is not
# placed twice on the same canvas.
# placed twice on the same canvas. Store the Node so a later in-batch
# skip can resolve to its id after flush.
if device.ip:
placed_ips.add(device.ip)
placed_ips[device.ip] = node
if device.ieee_address:
placed_ieee.add(device.ieee_address)
placed_ieee[device.ieee_address] = node
await db.flush() # populates node.id from Python-side default before reading
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in approved_devices]
# Resolve each skip's existing-node reference to a concrete id now that any
# in-batch nodes have been flushed, and expose it under a clean key.
for entry in skipped_devices:
ref = entry.pop("_ref")
entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref
all_edges: list[dict[str, str]] = []
for device in approved_devices:
all_edges.extend(
@@ -405,6 +426,7 @@ async def bulk_approve_devices(
"edges_created": len(all_edges),
"edges": all_edges,
"skipped": len(payload.device_ids) - len(node_ids),
"skipped_devices": skipped_devices,
}
@@ -478,41 +500,35 @@ async def approve_device(
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
# ANY canvas — but canvas membership is per-design. Approving onto a NEW
# design must therefore work even when the device already sits on another
# one (mirroring bulk_approve_devices, which deliberately does not filter on
# status == "pending"). Same-design duplicates are caught by the per-design
# IEEE/ip/mac guards below, not by this global flag. Only a user-hidden
# device is off-limits here.
if device.status == "hidden":
raise HTTPException(status_code=409, detail="Device is hidden")
wireless = _is_wireless(node_data.type)
# Guard against a true duplicate: same IEEE already placed on THIS design.
# (The same device on a *different* design is valid — one Node per canvas.)
if device.ieee_address is not None:
dup = (
await db.execute(
select(Node).where(
Node.ieee_address == device.ieee_address,
Node.design_id == node_design_id,
)
)
).scalars().first()
if dup is not None:
dup.properties = merge_zigbee_properties(
dup.properties,
_wireless_properties(
node_data.type, device.ieee_address,
device.vendor, device.model, device.lqi,
) if wireless else [],
)
edges = await _resolve_pending_links_for_ieee(
db, device.ieee_address, node_design_id
)
await db.commit()
return {
"approved": True,
"node_id": dup.id,
"edges_created": len(edges),
"edges": edges,
}
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
# again automatically: the user might genuinely want a second card, or might
# be re-approving by mistake. Reject with 409 + the existing node so the UI
# can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
# hosts. force=True (set after the user confirms) skips this and creates it.
# The same device on a *different* design is valid (one Node per canvas), so
# this is scoped to node_design_id.
if not node_data.force:
conflict = await find_duplicate_node(
db, node_design_id,
node_data.ip or device.ip,
node_data.mac or device.mac,
ieee=device.ieee_address,
)
if conflict is not None:
raise HTTPException(status_code=409, detail=conflict)
device.status = "approved"
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
+4
View File
@@ -39,6 +39,10 @@ class NodeBase(BaseModel):
class NodeCreate(NodeBase):
design_id: str | None = None
# When a node with the same ip/mac already exists on the target design, the
# create/approve endpoints reject with 409 so the UI can ask the user. Set
# force=True to bypass that guard and create the duplicate deliberately.
force: bool = False
class NodeUpdate(BaseModel):
+55 -1
View File
@@ -20,7 +20,7 @@ from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Edge, Node
@@ -28,6 +28,60 @@ from app.services.zigbee_service import merge_zigbee_properties
logger = logging.getLogger(__name__)
async def find_duplicate_node(
db: AsyncSession,
design_id: str | None,
ip: str | None,
mac: str | None,
ieee: str | None = None,
) -> dict[str, Any] | None:
"""Return conflict details if an equivalent node (same ieee, ip OR mac)
already sits on ``design_id``, else ``None``.
Scoped to a single design on purpose: the same device may legitimately
appear on several canvases (one :class:`Node` per design). Only a second
node for the same ieee/ip/mac on the *same* design is a duplicate — which
the create/approve endpoints turn into a 409 so the UI can offer "go to
existing" vs "add duplicate anyway", uniformly for IEEE (Zigbee/Z-Wave) and
plain IP/ARP hosts.
(:func:`dedupe_nodes_by_ieee` still repairs *pre-existing* same-canvas IEEE
duplicates; this guard prevents new ones unless the user forces them.)
"""
conds = []
if ieee:
conds.append(Node.ieee_address == ieee)
if ip:
conds.append(Node.ip == ip)
if mac:
conds.append(Node.mac == mac)
if not conds:
return None
existing = (
await db.execute(
select(Node).where(Node.design_id == design_id, or_(*conds))
)
).scalars().first()
if existing is None:
return None
# Report whichever field actually matched (ieee > ip > mac when several do).
match: str
value: str | None
if ieee and existing.ieee_address == ieee:
match, value = "ieee", existing.ieee_address
elif ip and existing.ip == ip:
match, value = "ip", existing.ip
else:
match, value = "mac", existing.mac
return {
"duplicate": True,
"existing_node_id": existing.id,
"existing_label": existing.label,
"match": match,
"value": value,
}
# Scalar Node fields worth carrying over from a duplicate when the canonical
# node has no value. Positions, design, parent and identity fields are left on
# the canonical node untouched (that's the row the user actually placed).