27e18f1c96
Co-authored-by: Cursor <cursoragent@cursor.com>
113 lines
4.4 KiB
Python
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
|