15cfb821d3
A secret-safe equivalent of blueprint import: the graph editor's overflow menu gains "Duplicate selection", which deep-clones the selected value and colour-strip sources server-side (full config preserved, never crossing the wire) and rewires references that point within the selection — shared dependencies (devices, HA sources, …) stay shared. - graph_schema.remap_refs: write-twin of extract_refs (same dot/list/bindable grammar) that rewrites only in-selection ids; 8 unit tests. - BaseSqliteStore.clone(): faithful deep-copy clone (no schema round-trip, so no field is lost), prefix-preserving fresh id; reusable by any store. - POST /api/v1/graph/duplicate: two-pass clone-then-rewire restricted to value / colour-strip sources (no inline secrets), with a safety net flagging any unremapped reference; 7 integration tests vs real stores. - Frontend: duplicateSubgraph (+cache invalidation), graphDuplicateSelection (reload + reselect the new cluster), overflow-menu item, i18n (en/ru/zh).
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""Wiring-graph endpoints: schema registry, full topology, and dependents.
|
|
|
|
These power the visual graph editor (and any other client) with a single
|
|
authoritative view of how entities are wired together:
|
|
|
|
* ``GET /api/v1/graph/schema`` — the connectable-field registry.
|
|
* ``GET /api/v1/graph`` — nodes + edges + validation.
|
|
* ``GET /api/v1/graph/dependents/{kind}/{id}`` — what references an entity.
|
|
|
|
All heavy logic lives in :mod:`ledgrab.api.graph_schema` (pure, unit-tested);
|
|
this layer only gathers serialized entities from the stores and delegates.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Callable
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ledgrab.api import dependencies as deps
|
|
from ledgrab.api.auth import AuthRequired
|
|
from ledgrab.api.graph_schema import (
|
|
ENTITY_KINDS,
|
|
NODE_TYPE_FIELD,
|
|
build_topology,
|
|
extract_refs,
|
|
find_dependents,
|
|
remap_refs,
|
|
schema_as_dicts,
|
|
schema_for_kind,
|
|
serialize_entity,
|
|
serialize_entity_for_graph,
|
|
validate_connection,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConnectionValidationRequest(BaseModel):
|
|
"""A proposed wiring edit: set ``target_kind.field`` to ``source_id``."""
|
|
|
|
target_kind: str
|
|
target_id: str
|
|
field: str
|
|
source_id: str = Field(default="", description="Empty string detaches the slot.")
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
# kind → dependency getter for the store that owns that entity kind.
|
|
_KIND_STORES: dict[str, Callable[[], Any]] = {
|
|
"device": deps.get_device_store,
|
|
"capture_template": deps.get_template_store,
|
|
"pp_template": deps.get_pp_template_store,
|
|
"audio_template": deps.get_audio_template_store,
|
|
"pattern_template": deps.get_pattern_template_store,
|
|
"picture_source": deps.get_picture_source_store,
|
|
"audio_source": deps.get_audio_source_store,
|
|
"value_source": deps.get_value_source_store,
|
|
"color_strip_source": deps.get_color_strip_store,
|
|
"sync_clock": deps.get_sync_clock_store,
|
|
"output_target": deps.get_output_target_store,
|
|
"scene_preset": deps.get_scene_preset_store,
|
|
"automation": deps.get_automation_store,
|
|
"cspt": deps.get_cspt_store,
|
|
}
|
|
|
|
|
|
def _gather_entities() -> dict[str, list[dict[str, Any]]]:
|
|
"""Serialize every entity, keyed by kind. Missing stores yield ``[]``."""
|
|
out: dict[str, list[dict[str, Any]]] = {}
|
|
for kind, getter in _KIND_STORES.items():
|
|
try:
|
|
store = getter()
|
|
models = store.get_all()
|
|
except (
|
|
Exception
|
|
) as exc: # noqa: BLE001 — an uninitialized/failing store must not 500 the graph
|
|
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
|
out[kind] = []
|
|
continue
|
|
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
|
|
return out
|
|
|
|
|
|
@router.get("/api/v1/graph/schema", tags=["Graph"])
|
|
async def get_graph_schema(_auth: AuthRequired) -> dict[str, Any]:
|
|
"""Return the authoritative registry of connectable reference fields."""
|
|
return {
|
|
"kinds": list(ENTITY_KINDS),
|
|
"node_type_field": NODE_TYPE_FIELD,
|
|
"connections": schema_as_dicts(),
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/graph", tags=["Graph"])
|
|
async def get_graph(_auth: AuthRequired) -> dict[str, Any]:
|
|
"""Return the full wiring topology (nodes + edges) and a validation report."""
|
|
entities = await run_in_threadpool(_gather_entities)
|
|
return build_topology(entities)
|
|
|
|
|
|
@router.get("/api/v1/graph/dependents/{kind}/{entity_id}", tags=["Graph"])
|
|
async def get_graph_dependents(kind: str, entity_id: str, _auth: AuthRequired) -> dict[str, Any]:
|
|
"""Return every entity that references ``(kind, entity_id)``."""
|
|
if kind not in ENTITY_KINDS:
|
|
raise HTTPException(status_code=404, detail=f"Unknown entity kind: {kind}")
|
|
entities = await run_in_threadpool(_gather_entities)
|
|
return {"dependents": find_dependents(entities, kind, entity_id)}
|
|
|
|
|
|
@router.post("/api/v1/graph/validate-connection", tags=["Graph"])
|
|
async def validate_graph_connection(
|
|
body: ConnectionValidationRequest, _auth: AuthRequired
|
|
) -> dict[str, Any]:
|
|
"""Validate a proposed wiring edit (existence + source kind + no cycle).
|
|
|
|
The graph editor calls this before persisting a drag-connect so it can
|
|
refuse edits that would dangle a reference or create a dependency loop.
|
|
"""
|
|
entities = await run_in_threadpool(_gather_entities)
|
|
ok, error = validate_connection(
|
|
entities, body.target_kind, body.target_id, body.field, body.source_id
|
|
)
|
|
return {"ok": ok, "error": error}
|
|
|
|
|
|
# ── Subgraph duplication (server-side blueprint instantiate) ─────────────────
|
|
# Only these kinds are cloned. They carry no inline secrets — they *reference*
|
|
# shared secret-bearing entities (devices, HA sources, HTTP endpoints) by id,
|
|
# and those are NOT cloned — and they have no hardware identity to conflict
|
|
# over. Output targets, automations, devices and integrations are out of scope.
|
|
_DUPLICABLE_KINDS: tuple[str, ...] = ("value_source", "color_strip_source")
|
|
_MAX_DUPLICATE = 200
|
|
|
|
|
|
class DuplicateRequest(BaseModel):
|
|
"""Duplicate a selected subgraph of value / colour-strip sources."""
|
|
|
|
node_ids: list[str] = Field(..., min_length=1, max_length=_MAX_DUPLICATE)
|
|
name_suffix: str = Field(default=" (copy)", max_length=40)
|
|
|
|
|
|
def _unique_name(existing: set[str], desired: str) -> str:
|
|
"""A name not already in ``existing`` (appends ' 2', ' 3', … on collision)."""
|
|
if desired not in existing:
|
|
return desired
|
|
i = 2
|
|
while f"{desired} {i}" in existing:
|
|
i += 1
|
|
return f"{desired} {i}"
|
|
|
|
|
|
def _duplicate_subgraph(node_ids: list[str], name_suffix: str) -> dict[str, Any]:
|
|
"""Deep-clone selected value/colour-strip sources with new ids, rewiring
|
|
references that point *within* the selection (shared deps are left alone)."""
|
|
# Index every duplicable entity by id → (kind, store, model); track names.
|
|
index: dict[str, tuple[str, Any, Any]] = {}
|
|
existing_names: dict[str, set[str]] = {}
|
|
for kind in _DUPLICABLE_KINDS:
|
|
try:
|
|
store = _KIND_STORES[kind]()
|
|
models = store.get_all()
|
|
except Exception as exc: # noqa: BLE001 — a failing store must not 500 the request
|
|
logger.warning("graph.duplicate: store for %s unavailable: %s", kind, exc)
|
|
continue
|
|
names = existing_names.setdefault(kind, set())
|
|
for m in models:
|
|
mid = getattr(m, "id", None)
|
|
mname = getattr(m, "name", None)
|
|
if isinstance(mname, str):
|
|
names.add(mname)
|
|
if isinstance(mid, str) and mid:
|
|
index[mid] = (kind, store, m)
|
|
|
|
selected: list[str] = []
|
|
skipped: list[dict[str, str]] = []
|
|
for nid in dict.fromkeys(node_ids): # de-dupe, preserve order
|
|
if nid in index:
|
|
selected.append(nid)
|
|
else:
|
|
skipped.append(
|
|
{"id": nid, "reason": "only value and colour-strip sources can be duplicated"}
|
|
)
|
|
|
|
# Pass 1 — create clones; their refs still point at the originals (valid).
|
|
id_map: dict[str, str] = {}
|
|
created: list[dict[str, str]] = []
|
|
clones: list[tuple[str, Any, str]] = []
|
|
for old_id in selected:
|
|
kind, store, model = index[old_id]
|
|
base = (getattr(model, "name", None) or old_id) + name_suffix
|
|
name = _unique_name(existing_names[kind], base)
|
|
existing_names[kind].add(name)
|
|
try:
|
|
new = store.clone(old_id, name)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("graph.duplicate: clone of %s %s failed: %s", kind, old_id, exc)
|
|
skipped.append({"id": old_id, "reason": f"clone failed: {exc}"})
|
|
continue
|
|
id_map[old_id] = new.id
|
|
created.append({"id": new.id, "kind": kind, "name": new.name})
|
|
clones.append((kind, store, new.id))
|
|
|
|
# Pass 2 — rewrite references that point within the cloned set.
|
|
warnings: list[dict[str, str]] = []
|
|
for kind, store, new_id in clones:
|
|
clone = serialize_entity(store.get(new_id))
|
|
changed_roots: set[str] = set()
|
|
for cf in schema_for_kind(kind):
|
|
if remap_refs(clone, cf.field, id_map):
|
|
changed_roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
|
|
if not changed_roots:
|
|
continue
|
|
# `clone` is the FULL serialized entity, so each changed root carries a
|
|
# complete, structurally-intact value (the whole `layers` list / bindable
|
|
# dict) that ``update_source`` replaces or merges wholesale. (Within the
|
|
# duplicable set the only roots that change are scalar ids, `layers` and
|
|
# bindable slots — never a partially-built nested object.)
|
|
updates = {root: clone[root] for root in changed_roots if root in clone}
|
|
try:
|
|
store.update_source(new_id, **updates)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("graph.duplicate: ref remap of %s failed: %s", new_id, exc)
|
|
warnings.append({"id": new_id, "reason": f"reference remap failed: {exc}"})
|
|
|
|
# Safety net — a clone must never still reference an OLD (in-selection) id.
|
|
for kind, store, new_id in clones:
|
|
clone = serialize_entity(store.get(new_id))
|
|
for cf in schema_for_kind(kind):
|
|
if any(ref in id_map for ref in extract_refs(clone, cf.field)):
|
|
warnings.append({"id": new_id, "reason": f"unremapped reference at {cf.field}"})
|
|
|
|
return {"id_map": id_map, "created": created, "skipped": skipped, "warnings": warnings}
|
|
|
|
|
|
@router.post("/api/v1/graph/duplicate", tags=["Graph"])
|
|
async def duplicate_subgraph(body: DuplicateRequest, _auth: AuthRequired) -> dict[str, Any]:
|
|
"""Deep-clone the selected value/colour-strip sources (new ids, wiring remapped).
|
|
|
|
References that point *within* the selection are rewired to the new clones;
|
|
references to entities outside it (devices, HA sources, …) stay shared with
|
|
the originals. Only value and colour-strip sources are cloned — they carry no
|
|
inline secrets — so any other kind in the selection is reported in ``skipped``.
|
|
"""
|
|
return await run_in_threadpool(_duplicate_subgraph, body.node_ids, body.name_suffix)
|