From 81a07f3fad6f322cb292fc49554caf3b145d375b Mon Sep 17 00:00:00 2001 From: FFXN <31929997+FFXN@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:27:39 +0800 Subject: [PATCH] feat: refine snippet siderbar (#37925) --- .../console/snippets/snippet_workflow.py | 46 +++++++ api/services/snippet_service.py | 40 ++++++ .../console/snippets/test_snippet_workflow.py | 114 ++++++++++++++++++ .../services/test_snippet_service.py | 39 ++++++ 4 files changed, 239 insertions(+) diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index ee1d928a74f..3097c058dba 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound +from controllers.common.controller_schemas import WorkflowUpdatePayload from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns @@ -96,6 +97,7 @@ register_schema_models( SnippetLoopNodeRunPayload, SnippetWorkflowListQuery, WorkflowRunQuery, + WorkflowUpdatePayload, PublishWorkflowPayload, ) register_response_schema_models( @@ -411,6 +413,50 @@ class SnippetDraftWorkflowRestoreApi(Resource): } +@console_ns.route("/snippets//workflows/") +class SnippetWorkflowByIdApi(Resource): + @console_ns.doc("update_snippet_workflow_by_id") + @console_ns.doc(description="Update published snippet workflow attributes") + @console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"}) + @console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__]) + @console_ns.response(200, "Workflow updated successfully", console_ns.models[SnippetWorkflowResponse.__name__]) + @console_ns.response(400, "No valid fields to update") + @console_ns.response(404, "Workflow not found") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @get_snippet + @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) + def patch(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str): + """Update a published snippet workflow version's display metadata.""" + payload = WorkflowUpdatePayload.model_validate(console_ns.payload or {}) + update_data = payload.model_dump(exclude_unset=True) + + if not update_data: + return {"message": "No valid fields to update"}, 400 + + snippet_service = _snippet_service() + with Session(db.engine) as session: + workflow = snippet_service.update_workflow( + session=session, + snippet=snippet, + workflow_id=workflow_id, + account=current_user, + data=update_data, + ) + if not workflow: + raise NotFound("Workflow not found") + session.commit() + + response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response["input_fields"] = snippet.input_fields_list + return response + + @console_ns.route("/snippets//workflow-runs") class SnippetWorkflowRunsApi(Resource): @console_ns.doc("list_snippet_workflow_runs") diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py index 75282db9d4c..a54c9f6a069 100644 --- a/api/services/snippet_service.py +++ b/api/services/snippet_service.py @@ -680,6 +680,46 @@ class SnippetService: return workflows, has_more + def update_workflow( + self, + *, + session: Session, + snippet: CustomizedSnippet, + workflow_id: str, + account: Account, + data: dict[str, Any], + ) -> Workflow | None: + """ + Update a published snippet workflow version's display metadata. + + :param session: Database session + :param snippet: CustomizedSnippet instance + :param workflow_id: Workflow ID + :param account: Account making the change + :param data: Dictionary containing fields to update + :return: Updated workflow or None if not found + """ + stmt = select(Workflow).where( + Workflow.id == workflow_id, + Workflow.tenant_id == snippet.tenant_id, + Workflow.app_id == snippet.id, + self._snippet_kind_filter(), + Workflow.version != Workflow.VERSION_DRAFT, + ) + workflow = session.scalar(stmt) + if not workflow: + return None + + allowed_fields = {"marked_name", "marked_comment"} + for field, value in data.items(): + if field in allowed_fields: + setattr(workflow, field, value) + + workflow.updated_by = account.id + workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + session.add(workflow) + return workflow + # --- Default Block Configs --- def get_default_block_configs(self) -> list[dict]: diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index b20dd3e30a7..6f0eed4869c 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -361,6 +361,120 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra assert exc.value.description == "invalid snippet workflow graph" +def test_update_published_snippet_workflow_returns_updated_workflow( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + workflow = SimpleNamespace( + id="workflow-1", + graph_dict={"nodes": [], "edges": []}, + features_dict={}, + unique_hash="hash-1", + version="2024-01-01 00:00:00", + marked_name="v1", + marked_comment="first version", + created_by_account=None, + created_at=datetime(2024, 1, 1), + updated_by_account=None, + updated_at=datetime(2024, 1, 1), + tool_published=False, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + user = _account("account-1") + input_fields = [{"variable": "query", "type": "text"}] + snippet = _snippet(input_fields=json.dumps(input_fields)) + session = SimpleNamespace(commit=Mock()) + update_workflow = Mock(return_value=workflow) + + class SessionContext: + def __init__(self, engine): + self.engine = engine + + def __enter__(self): + return session + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(update_workflow=update_workflow), + ) + + api = snippet_workflow_module.SnippetWorkflowByIdApi() + handler = unwrap(api.patch) + + with app.test_request_context( + "/snippets/snippet-1/workflows/workflow-1", + method="PATCH", + json={"marked_name": "v1", "marked_comment": "first version"}, + ): + response = handler(api, user, snippet, workflow_id="workflow-1") + + update_workflow.assert_called_once_with( + session=session, + snippet=snippet, + workflow_id="workflow-1", + account=user, + data={"marked_name": "v1", "marked_comment": "first version"}, + ) + session.commit.assert_called_once() + assert response["marked_name"] == "v1" + assert response["marked_comment"] == "first version" + assert response["input_fields"] == input_fields + + +def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask) -> None: + api = snippet_workflow_module.SnippetWorkflowByIdApi() + handler = unwrap(api.patch) + + with app.test_request_context("/snippets/snippet-1/workflows/workflow-1", method="PATCH", json={}): + response, status_code = handler(api, _account("account-1"), _snippet(), workflow_id="workflow-1") + + assert status_code == 400 + assert response == {"message": "No valid fields to update"} + + +def test_update_published_snippet_workflow_raises_not_found( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + user = _account("account-1") + snippet = _snippet() + + class SessionContext: + def __init__(self, engine): + self.engine = engine + + def __enter__(self): + return SimpleNamespace(commit=Mock()) + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(update_workflow=Mock(return_value=None)), + ) + + api = snippet_workflow_module.SnippetWorkflowByIdApi() + handler = unwrap(api.patch) + + with app.test_request_context( + "/snippets/snippet-1/workflows/missing-workflow", + method="PATCH", + json={"marked_name": "v1"}, + ): + with pytest.raises(NotFound, match="Workflow not found"): + handler(api, user, snippet, workflow_id="missing-workflow") + + def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() monkeypatch.setattr( diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index 16e1e58baae..464f2313a6a 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -273,6 +273,45 @@ def test_sync_draft_workflow_updates_existing_draft_and_clears_variables(monkeyp session.commit.assert_called_once() +def test_update_workflow_updates_marked_fields() -> None: + service = SnippetService.__new__(SnippetService) + workflow = SimpleNamespace(marked_name="", marked_comment="", updated_by=None, updated_at=None) + session = SimpleNamespace(scalar=Mock(return_value=workflow), add=Mock()) + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + account = SimpleNamespace(id="account-1") + + result = service.update_workflow( + session=session, + snippet=snippet, + workflow_id="workflow-1", + account=account, + data={"marked_name": "v1", "marked_comment": "first version", "ignored": "value"}, + ) + + assert result is workflow + assert workflow.marked_name == "v1" + assert workflow.marked_comment == "first version" + assert workflow.updated_by == "account-1" + session.scalar.assert_called_once() + session.add.assert_called_once_with(workflow) + + +def test_update_workflow_returns_none_when_missing() -> None: + service = SnippetService.__new__(SnippetService) + session = SimpleNamespace(scalar=Mock(return_value=None), add=Mock()) + + result = service.update_workflow( + session=session, + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + workflow_id="missing-workflow", + account=SimpleNamespace(id="account-1"), + data={"marked_name": "v1"}, + ) + + assert result is None + session.add.assert_not_called() + + def test_get_default_block_configs_skips_empty_defaults(monkeypatch: pytest.MonkeyPatch) -> None: node_with_default = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"})) node_without_default = SimpleNamespace(get_default_config=Mock(return_value=None))