Files
homelable/backend/app/api/routes/edges.py
T
2026-07-10 00:02:11 +02:00

113 lines
4.4 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Edge, Node
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
router = APIRouter()
# ---------------------------------------------------------------------------
# Auto-handle helpers
# ---------------------------------------------------------------------------
async def _abs_y(db: AsyncSession, node_id: str) -> float | None:
"""Resolve the approximate absolute canvas Y of a node.
Walks up the parent chain (up to 8 levels) and accumulates pos_y offsets so
that children inside containers are compared correctly against top-level nodes.
Returns None when the node is not found.
"""
node = await db.get(Node, node_id)
if node is None:
return None
y = node.pos_y
current = node
for _ in range(8):
if current.parent_id is None:
break
parent = await db.get(Node, current.parent_id)
if parent is None:
break
y += parent.pos_y
current = parent
return y
async def _auto_handles(
db: AsyncSession, source_id: str, target_id: str
) -> tuple[str, str]:
"""Return (source_handle, target_handle) that reflect the upstream/downstream
relationship between two nodes.
- Source above target (lower Y value) → downstream flow: exit bottom, enter top
- Source below target → upstream flow: exit top, enter bottom
- Equal or unknown → default to bottom/top-t (most common topology direction)
"""
src_y = await _abs_y(db, source_id)
tgt_y = await _abs_y(db, target_id)
if src_y is None or tgt_y is None or src_y <= tgt_y:
return "bottom", "top-t"
return "top", "bottom-t"
@router.get("", response_model=list[EdgeResponse])
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
result = await db.execute(select(Edge))
return list(result.scalars().all())
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
data = body.model_dump()
# Same reconciliation as nodes: clients omitting design_id (MCP write tools)
# would create design_id=null edges that never render until a restart.
# Fall back to the first design so the edge attaches to a canvas.
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
# Auto-assign source/target handles when the caller omits them.
# Compares the canvas Y positions of both nodes so that the edge always exits
# the upstream node's bottom and enters the downstream node's top (or vice versa
# for reverse flows), matching the UI convention for top-to-bottom topologies.
if data.get("source_handle") is None or data.get("target_handle") is None:
auto_src, auto_tgt = await _auto_handles(db, data["source"], data["target"])
if data.get("source_handle") is None:
data["source_handle"] = auto_src
if data.get("target_handle") is None:
data["target_handle"] = auto_tgt
edge = Edge(**data)
db.add(edge)
await db.commit()
await db.refresh(edge)
return edge
@router.delete("/{edge_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> None:
edge = await db.get(Edge, edge_id)
if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
await db.delete(edge)
await db.commit()
@router.patch("/{edge_id}", response_model=EdgeResponse)
async def update_edge(
edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> Edge:
edge = await db.get(Edge, edge_id)
if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(edge, field, value)
await db.commit()
await db.refresh(edge)
return edge