0b4bd5680d
Add a 'Copy from existing' option to the New Canvas modal. It lists every
canvas with node/group/text counts; picking one deep-copies its nodes, edges,
parent/child links and canvas state (viewport, custom style, floor plan) into
a fresh design.
Backend: POST /designs/{source_id}/copy remaps node ids, re-points edges and
parent links, and clones canvas state; GET /designs now returns per-design
counts for the picker. Standalone mode clones the localStorage canvas.
Closes #216
ha-relevant: maybe
180 lines
6.5 KiB
Python
180 lines
6.5 KiB
Python
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import func, 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 CanvasState, Design, Edge, Node
|
|
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
|
|
|
|
router = APIRouter()
|
|
|
|
# Node.type values that are canvas annotations rather than real devices. Kept in
|
|
# sync with the frontend (Sidebar counts, canvasSerializer types).
|
|
_GROUP_TYPE = "groupRect"
|
|
_TEXT_TYPE = "text"
|
|
|
|
|
|
@router.get("", response_model=list[DesignResponse])
|
|
async def list_designs(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> list[DesignResponse]:
|
|
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
|
# One grouped query for all designs → node/group/text counts per design.
|
|
rows = (
|
|
await db.execute(select(Node.design_id, Node.type, func.count()).group_by(Node.design_id, Node.type))
|
|
).all()
|
|
counts: dict[str, dict[str, int]] = {}
|
|
for design_id, node_type, count in rows:
|
|
if design_id is None:
|
|
continue
|
|
bucket = counts.setdefault(design_id, {"node": 0, "group": 0, "text": 0})
|
|
if node_type == _GROUP_TYPE:
|
|
bucket["group"] += count
|
|
elif node_type == _TEXT_TYPE:
|
|
bucket["text"] += count
|
|
else:
|
|
bucket["node"] += count
|
|
|
|
result = []
|
|
for d in designs:
|
|
resp = DesignResponse.model_validate(d)
|
|
c = counts.get(d.id, {"node": 0, "group": 0, "text": 0})
|
|
resp.node_count = c["node"]
|
|
resp.group_count = c["group"]
|
|
resp.text_count = c["text"]
|
|
result.append(resp)
|
|
return result
|
|
|
|
|
|
@router.post("", response_model=DesignResponse, status_code=201)
|
|
async def create_design(
|
|
body: DesignCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> DesignResponse:
|
|
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
|
|
db.add(design)
|
|
await db.flush()
|
|
# Create empty canvas state for the new design
|
|
db.add(CanvasState(design_id=design.id))
|
|
await db.commit()
|
|
await db.refresh(design)
|
|
return DesignResponse.model_validate(design)
|
|
|
|
|
|
@router.post("/{source_id}/copy", response_model=DesignResponse, status_code=201)
|
|
async def copy_design(
|
|
source_id: str,
|
|
body: DesignCopy,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> DesignResponse:
|
|
"""Create a new design that deep-copies the source's nodes, edges and canvas state."""
|
|
source = await db.get(Design, source_id)
|
|
if not source:
|
|
raise HTTPException(404, "Source design not found")
|
|
|
|
new_design = Design(name=body.name, icon=body.icon, design_type=source.design_type)
|
|
db.add(new_design)
|
|
await db.flush()
|
|
|
|
src_nodes = (await db.execute(select(Node).where(Node.design_id == source_id))).scalars().all()
|
|
src_edges = (await db.execute(select(Edge).where(Edge.design_id == source_id))).scalars().all()
|
|
|
|
# New id per source node so edges and parent links can be re-pointed at the copy.
|
|
id_map = {n.id: str(uuid.uuid4()) for n in src_nodes}
|
|
|
|
# Columns we set explicitly or let the DB default — never copy verbatim.
|
|
node_skip = {"id", "design_id", "parent_id", "created_at", "updated_at"}
|
|
for n in src_nodes:
|
|
cols = {c.name: getattr(n, c.name) for c in Node.__table__.columns if c.name not in node_skip}
|
|
db.add(Node(id=id_map[n.id], design_id=new_design.id, parent_id=None, **cols))
|
|
await db.flush() # nodes must exist before we wire self-referential parent_id
|
|
|
|
# Second pass: re-point parent links inside the copy.
|
|
for n in src_nodes:
|
|
if n.parent_id and n.parent_id in id_map:
|
|
child = await db.get(Node, id_map[n.id])
|
|
if child:
|
|
child.parent_id = id_map[n.parent_id]
|
|
|
|
edge_skip = {"id", "design_id", "source", "target", "created_at"}
|
|
for e in src_edges:
|
|
# Skip edges whose endpoints aren't part of this design (dangling FKs).
|
|
if e.source not in id_map or e.target not in id_map:
|
|
continue
|
|
cols = {c.name: getattr(e, c.name) for c in Edge.__table__.columns if c.name not in edge_skip}
|
|
db.add(
|
|
Edge(
|
|
id=str(uuid.uuid4()),
|
|
design_id=new_design.id,
|
|
source=id_map[e.source],
|
|
target=id_map[e.target],
|
|
**cols,
|
|
)
|
|
)
|
|
|
|
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
|
|
src_state = await db.get(CanvasState, source_id)
|
|
db.add(
|
|
CanvasState(
|
|
design_id=new_design.id,
|
|
viewport=src_state.viewport if src_state else {},
|
|
custom_style=src_state.custom_style if src_state else None,
|
|
)
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(new_design)
|
|
return DesignResponse.model_validate(new_design)
|
|
|
|
|
|
@router.put("/{design_id}", response_model=DesignResponse)
|
|
async def update_design(
|
|
design_id: str,
|
|
body: DesignUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> DesignResponse:
|
|
design = await db.get(Design, design_id)
|
|
if not design:
|
|
raise HTTPException(404, "Design not found")
|
|
if body.name is not None:
|
|
design.name = body.name
|
|
if body.icon is not None:
|
|
design.icon = body.icon
|
|
await db.commit()
|
|
await db.refresh(design)
|
|
return DesignResponse.model_validate(design)
|
|
|
|
|
|
@router.delete("/{design_id}", status_code=204)
|
|
async def delete_design(
|
|
design_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: str = Depends(get_current_user),
|
|
) -> None:
|
|
design = await db.get(Design, design_id)
|
|
if not design:
|
|
raise HTTPException(404, "Design not found")
|
|
# Count remaining designs — prevent deleting the last one
|
|
count = (await db.execute(select(Design))).scalars().all()
|
|
if len(count) <= 1:
|
|
raise HTTPException(400, "Cannot delete the only design")
|
|
# Delete associated canvas state, edges, nodes
|
|
cs = await db.get(CanvasState, design_id)
|
|
if cs:
|
|
await db.delete(cs)
|
|
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
|
for e in edges:
|
|
await db.delete(e)
|
|
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
|
for n in nodes:
|
|
await db.delete(n)
|
|
await db.delete(design)
|
|
await db.commit()
|