2d3b646e45
Node.ip stores several comma-separated addresses once a user adds an IPv6 (e.g. "fe80::1, 192.168.1.5"). All placement/inventory matching did exact string equality on that field, so a device scanned as the plain IPv4 looked absent from the canvas: canvas_count stayed 0, the "In N canvas" badge and hide-on-canvas button vanished, and bulk-approve re-placed a duplicate node. Match per-address instead, and add MAC (a stable identifier immune to IP edits) as a cumulative match key alongside ieee/ip: - _canvas_correlation indexes ip per token + by_mac - bulk_approve skip detection tokenizes ip + tracks mac - find_duplicate_node narrows with Node.ip.contains then confirms per token in Python (guards the 10.0.0.4 / 10.0.0.40 substring false match) ha-relevant: maybe
242 lines
8.3 KiB
Python
242 lines
8.3 KiB
Python
"""Collapse *true* duplicate canvas nodes: same ``ieee_address`` **and** same
|
|
``design_id`` (i.e. the same device placed twice on the *same* canvas).
|
|
|
|
The same device legitimately appears on multiple canvases — placing a device on
|
|
another design creates a second :class:`Node` for that IEEE by design (see the
|
|
per-design guard in ``bulk_approve_devices``). Those cross-design rows are NOT
|
|
duplicates and must be preserved. Only two rows sharing both the IEEE *and* the
|
|
design are corrupt, and only those are collapsed here.
|
|
|
|
This module provides an idempotent, **loss-free** repair: per ``(ieee,
|
|
design_id)`` group with >1 node it keeps the oldest as canonical, merges the
|
|
extras' data into it (properties, missing scalar fields, services), re-points
|
|
every edge and ``parent_id`` reference onto the canonical node, then deletes the
|
|
extras. Edges are de-duplicated and self-loops dropped after re-pointing so no
|
|
dangling or redundant links remain.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import Edge, Node
|
|
from app.services.zigbee_service import merge_zigbee_properties
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _ip_tokens(ip: str | None) -> list[str]:
|
|
"""Split a Node ``ip`` field into individual, whitespace-trimmed addresses.
|
|
|
|
The canvas stores several addresses in one comma-separated string once a
|
|
user adds e.g. an IPv6 address, so identity matching must compare per token.
|
|
"""
|
|
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
|
|
|
|
|
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.)
|
|
"""
|
|
ip_toks = _ip_tokens(ip)
|
|
conds = []
|
|
if ieee:
|
|
conds.append(Node.ieee_address == ieee)
|
|
# A node's ip may hold several comma-separated addresses (e.g. an IPv6 added
|
|
# before the IPv4), so narrow with a substring match then confirm per-token
|
|
# in Python — exact ``Node.ip == ip`` would miss those rows (issue #258).
|
|
for tok in ip_toks:
|
|
conds.append(Node.ip.contains(tok))
|
|
if mac:
|
|
conds.append(Node.mac == mac)
|
|
if not conds:
|
|
return None
|
|
candidates = (
|
|
await db.execute(
|
|
select(Node).where(Node.design_id == design_id, or_(*conds))
|
|
)
|
|
).scalars().all()
|
|
|
|
# Confirm a real match (the ip ``contains`` above can false-positive, e.g.
|
|
# "1.2.3.4" inside "1.2.3.40"), preferring ieee > ip > mac.
|
|
def _matches(node: Node) -> tuple[str, str | None] | None:
|
|
if ieee and node.ieee_address == ieee:
|
|
return "ieee", node.ieee_address
|
|
node_toks = set(_ip_tokens(node.ip))
|
|
hit = next((t for t in ip_toks if t in node_toks), None)
|
|
if hit is not None:
|
|
return "ip", hit
|
|
if mac and node.mac == mac:
|
|
return "mac", mac
|
|
return None
|
|
|
|
existing = None
|
|
matched: tuple[str, str | None] | None = None
|
|
for node in candidates:
|
|
m = _matches(node)
|
|
if m is not None:
|
|
existing, matched = node, m
|
|
break
|
|
if existing is None or matched is None:
|
|
return None
|
|
match, value = matched
|
|
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).
|
|
_FILLABLE_FIELDS = (
|
|
"hostname",
|
|
"ip",
|
|
"mac",
|
|
"os",
|
|
"check_method",
|
|
"check_target",
|
|
"notes",
|
|
"cpu_count",
|
|
"cpu_model",
|
|
"ram_gb",
|
|
"disk_gb",
|
|
"custom_icon",
|
|
"last_seen",
|
|
"last_scan",
|
|
)
|
|
|
|
|
|
def _merge_services(
|
|
a: list[Any] | None, b: list[Any] | None
|
|
) -> list[Any]:
|
|
"""Union two service lists, de-duplicated, order-stable."""
|
|
out: list[Any] = list(a or [])
|
|
seen = {repr(s) for s in out}
|
|
for s in b or []:
|
|
if repr(s) not in seen:
|
|
out.append(s)
|
|
seen.add(repr(s))
|
|
return out
|
|
|
|
|
|
def _merge_into_canonical(canonical: Node, dup: Node) -> None:
|
|
"""Fold ``dup``'s data into ``canonical`` in place (no field lost)."""
|
|
canonical.properties = merge_zigbee_properties(
|
|
canonical.properties, dup.properties or []
|
|
)
|
|
canonical.services = _merge_services(canonical.services, dup.services)
|
|
for field in _FILLABLE_FIELDS:
|
|
if getattr(canonical, field, None) in (None, "") and getattr(dup, field, None) not in (None, ""):
|
|
setattr(canonical, field, getattr(dup, field))
|
|
# Prefer a human label over a bare IEEE/hex fallback.
|
|
if (not canonical.label or canonical.label == canonical.ieee_address) and dup.label:
|
|
canonical.label = dup.label
|
|
|
|
|
|
async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
|
"""Merge duplicate nodes sharing an ``ieee_address`` AND ``design_id``.
|
|
|
|
Returns the number of nodes removed. Idempotent: a no-op when every
|
|
``(ieee, design)`` pair maps to at most one node. Nodes with the same IEEE
|
|
on *different* designs are left untouched (valid cross-canvas placement).
|
|
Does not commit — the caller owns the transaction.
|
|
"""
|
|
rows = (
|
|
await db.execute(
|
|
select(Node)
|
|
.where(Node.ieee_address.is_not(None))
|
|
.order_by(Node.ieee_address, Node.created_at, Node.id)
|
|
)
|
|
).scalars().all()
|
|
|
|
groups: dict[tuple[str, str | None], list[Node]] = {}
|
|
for node in rows:
|
|
groups.setdefault((node.ieee_address, node.design_id), []).append(node) # type: ignore[arg-type]
|
|
|
|
removed = 0
|
|
for (ieee, _design), nodes in groups.items():
|
|
if len(nodes) < 2:
|
|
continue
|
|
canonical, *dups = nodes # oldest first (ordered above)
|
|
dup_ids = {d.id for d in dups}
|
|
|
|
for dup in dups:
|
|
_merge_into_canonical(canonical, dup)
|
|
|
|
# Re-point edges + parents, then drop self-loops / duplicates.
|
|
edges = (
|
|
await db.execute(
|
|
select(Edge).where(
|
|
Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
for edge in edges:
|
|
if edge.source in dup_ids:
|
|
edge.source = canonical.id
|
|
if edge.target in dup_ids:
|
|
edge.target = canonical.id
|
|
|
|
# Re-point children whose parent was a duplicate.
|
|
children = (
|
|
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
|
|
).scalars().all()
|
|
for child in children:
|
|
child.parent_id = canonical.id
|
|
|
|
# Collapse self-loops and now-redundant parallel edges.
|
|
all_edges = (
|
|
await db.execute(
|
|
select(Edge).where(
|
|
(Edge.source == canonical.id) | (Edge.target == canonical.id)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
seen_pairs: set[tuple[str, str, str]] = set()
|
|
for edge in all_edges:
|
|
if edge.source == edge.target:
|
|
await db.delete(edge)
|
|
continue
|
|
key = (edge.source, edge.target, edge.type)
|
|
if key in seen_pairs:
|
|
await db.delete(edge)
|
|
continue
|
|
seen_pairs.add(key)
|
|
|
|
await db.flush()
|
|
for dup in dups:
|
|
await db.delete(dup)
|
|
removed += 1
|
|
|
|
logger.info(
|
|
"Deduped IEEE %s: merged %d duplicate node(s) into %s",
|
|
ieee, len(dups), canonical.id,
|
|
)
|
|
|
|
if removed:
|
|
await db.flush()
|
|
return removed
|