Duplicate flows and dashboards: POST /flows/{name}/duplicate and /dashboards/{name}/duplicate copy the working copy into {name}_copy (then _copy_2, ...) as an unpublished draft with " (copy)" on the title. A flow copy takes its node sources as drafts and leaves origin and runtime.json behind; copying code nodes is a superuser's (the save check, now taking the replaced copy). Client regenerated
This commit is contained in:
@@ -201,6 +201,18 @@ async def delete_dashboard(
|
||||
return Message(message=f"Deleted dashboard '{name}'")
|
||||
|
||||
|
||||
@router.post("/{name}/duplicate", response_model=DashboardDef)
|
||||
async def duplicate_dashboard(name: str, store: DashboardStoreDep) -> Any:
|
||||
"""Copy a dashboard into a new one that is only a draft, named after it.
|
||||
|
||||
No history limits to apply, as for a new dashboard: nothing published moved.
|
||||
"""
|
||||
try:
|
||||
return await run_in_threadpool(store.duplicate, name)
|
||||
except DashboardNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
|
||||
|
||||
|
||||
@router.post("/{name}/rename", response_model=DashboardDef)
|
||||
async def rename_dashboard(
|
||||
name: str, body: RenameRequest, store: DashboardStoreDep
|
||||
|
||||
@@ -480,15 +480,12 @@ def _changes_code(node: NodeDef, old: NodeDef | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _refuse_code_changes(
|
||||
controller: FlowController, name: str, definition: FlowDef
|
||||
) -> None:
|
||||
def _refuse_code_changes(definition: FlowDef, before: FlowDef | None) -> None:
|
||||
"""Refuse a non-superuser a change to code this flow would run.
|
||||
|
||||
Wiring, titles, a function node's settings and every other node stay
|
||||
anyone's.
|
||||
*before* is the working copy being replaced, None for a new flow. Wiring,
|
||||
titles, a function node's settings and every other node stay anyone's.
|
||||
"""
|
||||
before = _read_flow(controller, name) if controller.store.exists(name) else None
|
||||
previous = {node.id: node for node in before.nodes} if before else {}
|
||||
for node in definition.nodes:
|
||||
if _changes_code(node, previous.get(node.id)):
|
||||
@@ -524,7 +521,8 @@ async def save_flow(
|
||||
_check_flavors(definition)
|
||||
_check_origin(definition)
|
||||
if not user.is_superuser:
|
||||
_refuse_code_changes(controller, name, definition)
|
||||
before = _read_flow(controller, name) if controller.store.exists(name) else None
|
||||
_refuse_code_changes(definition, before)
|
||||
|
||||
try:
|
||||
stored = await run_in_threadpool(
|
||||
@@ -648,6 +646,24 @@ async def rename_flow(
|
||||
return _detail(controller, renamed)
|
||||
|
||||
|
||||
@router.post("/{name}/duplicate", response_model=FlowDetail)
|
||||
async def duplicate_flow(
|
||||
name: str, controller: FlowControllerDep, user: CurrentUser
|
||||
) -> Any:
|
||||
"""Copy a flow into a new one that is only a draft, named after it.
|
||||
|
||||
Nothing to reload: a draft does not run. Its code nodes are new code in
|
||||
the copy, so copying them is a superuser's, like writing them.
|
||||
"""
|
||||
if not user.is_superuser:
|
||||
_refuse_code_changes(_read_flow(controller, name), None)
|
||||
try:
|
||||
copy = await run_in_threadpool(controller.store.duplicate_flow, name)
|
||||
except FlowNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
|
||||
return _detail(controller, copy)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Node source
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -824,6 +824,32 @@ class DashboardStore:
|
||||
self.flows._commit(f"Rename dashboard '{name}' to '{new_name}'")
|
||||
return renamed
|
||||
|
||||
def duplicate(self, name: str) -> DashboardDef:
|
||||
"""Copy the working copy into a new dashboard, as a draft only.
|
||||
|
||||
Named ``{name}_copy``, then ``_copy_2`` and on. Unpublished like a new
|
||||
dashboard, so no panel shows it until someone publishes it.
|
||||
"""
|
||||
with self._lock, self.flows._write_lock:
|
||||
source = self.read(name, draft=True)
|
||||
new_name, count = f"{name}_copy", 1
|
||||
while self.exists(new_name):
|
||||
count += 1
|
||||
new_name = f"{name}_copy_{count}"
|
||||
copy = source.model_copy(
|
||||
update={
|
||||
"name": new_name,
|
||||
"title": f"{source.title} (copy)" if source.title else "",
|
||||
"version": 1,
|
||||
"has_draft": True,
|
||||
}
|
||||
)
|
||||
path = self._draft_file(new_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(self._dump(copy))
|
||||
self.flows._commit(f"Duplicate dashboard '{name}' as '{new_name}'")
|
||||
return copy
|
||||
|
||||
def bindings_for(self, flow: str) -> Bindings:
|
||||
"""Every widget bound to a message of ``flow``.
|
||||
|
||||
|
||||
@@ -674,6 +674,46 @@ class FlowStore:
|
||||
self._commit(f"Rename flow '{name}' to '{new_name}'")
|
||||
return self.read_flow(new_name, draft=True)
|
||||
|
||||
def duplicate_flow(self, name: str) -> FlowDef:
|
||||
"""Copy a flow's working copy into a new flow that is only a draft.
|
||||
|
||||
Like a new flow, the copy runs nothing until someone publishes it. It is
|
||||
named ``{name}_copy``, then ``_copy_2`` and on. ``origin`` is dropped,
|
||||
since the copy is a local flow ``fluksio sync`` must not claim, and
|
||||
``runtime.json`` stays behind with everything else about running.
|
||||
"""
|
||||
with self._write_lock:
|
||||
source = self.read_flow(name, draft=True)
|
||||
new_name, count = f"{name}_copy", 1
|
||||
# The directory rather than `exists`: anything left in it would
|
||||
# otherwise end up in the copy.
|
||||
while self._flow_dir(new_name).exists():
|
||||
count += 1
|
||||
new_name = f"{name}_copy_{count}"
|
||||
|
||||
copy = source.model_copy(
|
||||
update={
|
||||
"name": new_name,
|
||||
"title": f"{source.title} (copy)" if source.title else "",
|
||||
"version": 1,
|
||||
"origin": None,
|
||||
}
|
||||
)
|
||||
path = self._draft_file(new_name)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(copy.model_dump_json(indent=2) + "\n")
|
||||
|
||||
# The bodies the source's editor shows: published, edits over them.
|
||||
# As drafts, so publishing the copy promotes them like a new flow's.
|
||||
target = self._draft_nodes_dir(new_name)
|
||||
for folder in (self._flow_dir(name) / "nodes", self._draft_nodes_dir(name)):
|
||||
for body in sorted(folder.glob("*.py")):
|
||||
target.mkdir(exist_ok=True)
|
||||
shutil.copy2(body, target / body.name)
|
||||
|
||||
self._commit(f"Duplicate flow '{name}' as '{new_name}'")
|
||||
return copy
|
||||
|
||||
@staticmethod
|
||||
def _retarget(flow: FlowDef, old_prefix: str, new_prefix: str) -> bool:
|
||||
"""Point this flow's cross-flow message names at a renamed flow."""
|
||||
|
||||
@@ -90,6 +90,24 @@ def test_a_user_keeps_everything_but_the_code(
|
||||
assert saved.status_code == 200, saved.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("save")
|
||||
def test_duplicating_code_nodes_is_a_superusers(
|
||||
client: TestClient,
|
||||
normal_user_token_headers: dict[str, str],
|
||||
superuser_token_headers: dict[str, str],
|
||||
) -> None:
|
||||
url = f"{PREFIX}/{FLOW}/duplicate"
|
||||
|
||||
refused = client.post(url, headers=normal_user_token_headers)
|
||||
assert refused.status_code == 403
|
||||
assert refused.json()["detail"].startswith(CODE_IS_SUPERUSERS)
|
||||
|
||||
copied = client.post(url, headers=superuser_token_headers)
|
||||
assert copied.status_code == 200, copied.text
|
||||
name = copied.json()["definition"]["name"]
|
||||
client.delete(f"{PREFIX}/{name}", headers=superuser_token_headers)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("save")
|
||||
def test_node_source_is_a_superusers_to_write(
|
||||
client: TestClient,
|
||||
|
||||
@@ -123,6 +123,22 @@ def test_an_unpublished_dashboard_can_be_renamed_and_deleted(store: DashboardSto
|
||||
assert not store.exists("home")
|
||||
|
||||
|
||||
def test_a_duplicate_is_an_unpublished_copy_of_the_working_copy(
|
||||
store: DashboardStore,
|
||||
):
|
||||
store.write(default_dashboard("house"))
|
||||
edited = store.write_draft(
|
||||
store.read("house", draft=True).model_copy(update={"title": "Edited"})
|
||||
)
|
||||
|
||||
copy = store.duplicate("house")
|
||||
|
||||
assert (copy.name, copy.title, copy.version) == ("house_copy", "Edited (copy)", 1)
|
||||
assert store.has_draft("house_copy") and not store.is_published("house_copy")
|
||||
assert store.read("house", draft=True).version == edited.version
|
||||
assert store.duplicate("house").name == "house_copy_2"
|
||||
|
||||
|
||||
def test_deleting_and_renaming(store: DashboardStore):
|
||||
store.write(default_dashboard("house"))
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.messages import MessageSpec
|
||||
from fluksio.flow.schemas import FlowDef, NodeDef
|
||||
from fluksio.flow.schemas import FlowDef, FlowOrigin, NodeDef
|
||||
from fluksio.flow.store import FlowStore, StaleVersion
|
||||
|
||||
SOURCE = "def process():\n return {}\n"
|
||||
@@ -181,3 +181,22 @@ def test_renaming_carries_the_draft_and_repoints_other_drafts(store: FlowStore):
|
||||
assert store.read_flow("display", draft=True).nodes[0].requires[0].name == (
|
||||
"warmth.temp"
|
||||
)
|
||||
|
||||
|
||||
def test_a_duplicate_is_a_local_draft_of_what_the_editor_shows(store: FlowStore):
|
||||
store.write_flow(a_flow(title="Warm").model_copy(update={"origin": FlowOrigin()}))
|
||||
store.write_node_source("heating", "sensor", SOURCE)
|
||||
store.write_node_source("heating", "sensor", EDITED, draft=True)
|
||||
store.write_enabled("heating", False)
|
||||
|
||||
copy = store.duplicate_flow("heating")
|
||||
|
||||
assert (copy.name, copy.title, copy.version) == ("heating_copy", "Warm (copy)", 1)
|
||||
assert copy.origin is None
|
||||
assert store.has_draft("heating_copy") and not store.is_published("heating_copy")
|
||||
assert store.read_node_source("heating_copy", "sensor", draft=True) == EDITED
|
||||
assert not (store.root / "heating_copy" / "runtime.json").exists()
|
||||
assert store.duplicate_flow("heating").name == "heating_copy_2"
|
||||
|
||||
store.publish_flow("heating_copy", copy.version)
|
||||
assert store.read_node_source("heating_copy", "sensor") == EDITED
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1765,6 +1765,12 @@ export type DashboardsDiscardDashboardDraftData = {
|
||||
|
||||
export type DashboardsDiscardDashboardDraftResponse = (DashboardDef_Output);
|
||||
|
||||
export type DashboardsDuplicateDashboardData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DashboardsDuplicateDashboardResponse = (DashboardDef_Output);
|
||||
|
||||
export type DashboardsRenameDashboardData = {
|
||||
name: string;
|
||||
requestBody: fluksio__api__routes__dashboards__RenameRequest;
|
||||
@@ -1847,6 +1853,12 @@ export type FlowsRenameFlowData = {
|
||||
|
||||
export type FlowsRenameFlowResponse = (FlowDetail);
|
||||
|
||||
export type FlowsDuplicateFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsDuplicateFlowResponse = (FlowDetail);
|
||||
|
||||
export type FlowsReadNodeSourceData = {
|
||||
name: string;
|
||||
nodeId: string;
|
||||
|
||||
Reference in New Issue
Block a user