fix: compatibility with multi-design schema for scan/zigbee/liveview/CustomStyleModal

- liveview.py: replace hardcoded CanvasState PK lookup (get(1)) with
  design_id-aware query; filter nodes/edges by design_id
- scan.py: add design_id to bulk approve, single approve, and edge
  resolution Node/Edge constructors (fallback to first design)
- zigbee.py: add design_id to coordinator auto-approval Node constructor
- schemas/nodes.py: add design_id to NodeCreate and NodeResponse
- schemas/edges.py: add design_id to EdgeCreate
- CustomStyleModal.tsx: add 'electrical' to EDITABLE_EDGE_TYPES
This commit is contained in:
Pranjal Joshi
2026-05-31 14:44:17 +05:30
parent 46435605eb
commit 3a57d809a4
6 changed files with 41 additions and 9 deletions
+11 -4
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.db.database import get_db from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasStateResponse from app.schemas.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
@@ -18,6 +18,7 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse) @router.get("", response_model=CanvasStateResponse)
async def liveview_canvas( async def liveview_canvas(
key: str | None = Query(default=None), key: str | None = Query(default=None),
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> CanvasStateResponse: ) -> CanvasStateResponse:
"""Read-only public canvas endpoint. """Read-only public canvas endpoint.
@@ -30,9 +31,15 @@ async def liveview_canvas(
if not key or not hmac.compare_digest(key, settings.liveview_key): if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key") raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all() if design_id is None:
edges = (await db.execute(select(Edge))).scalars().all() first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
state = await db.get(CanvasState, 1) design_id = first.id if first else None
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
custom_style: dict[str, Any] | None = state.custom_style if state else None custom_style: dict[str, Any] | None = state.custom_style if state else None
return CanvasStateResponse( return CanvasStateResponse(
+19 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.core.config import settings from app.core.config import settings
from app.db.database import AsyncSessionLocal, get_db from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan from app.services.scanner import request_cancel, run_scan
@@ -118,6 +118,10 @@ async def bulk_approve_devices(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Determine target design (use first design as fallback)
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None
result = await db.execute( result = await db.execute(
select(PendingDevice).where( select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids), PendingDevice.id.in_(payload.device_ids),
@@ -144,6 +148,7 @@ async def bulk_approve_devices(
# Default to ping so the status checker actually polls the new node. # Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check). # Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_zigbee else ("ping" if device.ip else None), check_method="none" if is_zigbee else ("ping" if device.ip else None),
design_id=default_design_id,
) )
db.add(node) db.add(node)
created_nodes.append(node) created_nodes.append(node)
@@ -227,6 +232,12 @@ async def approve_device(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Determine target design
node_design_id = node_data.design_id
if node_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
node_design_id = first.id if first else None
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail="Device not found") raise HTTPException(status_code=404, detail="Device not found")
@@ -247,6 +258,7 @@ async def approve_device(
) if _is_zigbee else (node_data.properties or []), ) if _is_zigbee else (node_data.properties or []),
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)), check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target, check_target=None if _is_zigbee else node_data.check_target,
design_id=node_design_id,
) )
db.add(node) db.add(node)
await db.flush() await db.flush()
@@ -328,12 +340,18 @@ async def _resolve_pending_links_for_ieee(
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs: if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
await db.delete(link) await db.delete(link)
continue continue
# Use the source node's design_id for the edge
edge_design_id = self_node.design_id if self_node else None
if edge_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
edge_design_id = first.id if first else None
edge = Edge( edge = Edge(
source=src_id, source=src_id,
target=tgt_id, target=tgt_id,
type="iot", type="iot",
source_handle="bottom", source_handle="bottom",
target_handle="top-t", target_handle="top-t",
design_id=edge_design_id,
) )
db.add(edge) db.add(edge)
await db.flush() await db.flush()
+6 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.database import AsyncSessionLocal, get_db from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import ( from app.schemas.zigbee import (
ZigbeeCoordinatorOut, ZigbeeCoordinatorOut,
@@ -138,6 +138,10 @@ async def _persist_pending_import(
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE. Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
All zigbee-source links are wiped and re-inserted from the new map. All zigbee-source links are wiped and re-inserted from the new map.
""" """
# Determine target design (use first design as fallback)
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None
coordinator_out: ZigbeeCoordinatorOut | None = None coordinator_out: ZigbeeCoordinatorOut | None = None
coordinator_existed = False coordinator_existed = False
pending_created = 0 pending_created = 0
@@ -174,6 +178,7 @@ async def _persist_pending_import(
ieee_address=ieee, ieee_address=ieee,
services=[], services=[],
properties=props, properties=props,
design_id=default_design_id,
) )
db.add(node) db.add(node)
await db.flush() await db.flush()
+1 -1
View File
@@ -26,7 +26,7 @@ class EdgeBase(BaseModel):
class EdgeCreate(EdgeBase): class EdgeCreate(EdgeBase):
pass design_id: str | None = None
class EdgeUpdate(BaseModel): class EdgeUpdate(BaseModel):
+3 -1
View File
@@ -34,7 +34,7 @@ class NodeBase(BaseModel):
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
pass design_id: str | None = None
class NodeUpdate(BaseModel): class NodeUpdate(BaseModel):
@@ -68,6 +68,8 @@ class NodeUpdate(BaseModel):
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
id: str id: str
design_id: str | None = None
ieee_address: str | None = None
last_seen: datetime | None = None last_seen: datetime | None = None
response_time_ms: int | None = None response_time_ms: int | None = None
created_at: datetime created_at: datetime
@@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
'generic', 'generic',
] ]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre'] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
const NODE_ICONS: Record<string, LucideIcon> = { const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,