Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>tags/1.8.1
| @@ -237,9 +237,14 @@ class AppExportApi(Resource): | |||
| # Add include_secret params | |||
| parser = reqparse.RequestParser() | |||
| parser.add_argument("include_secret", type=inputs.boolean, default=False, location="args") | |||
| parser.add_argument("workflow_id", type=str, location="args") | |||
| args = parser.parse_args() | |||
| return {"data": AppDslService.export_dsl(app_model=app_model, include_secret=args["include_secret"])} | |||
| return { | |||
| "data": AppDslService.export_dsl( | |||
| app_model=app_model, include_secret=args["include_secret"], workflow_id=args.get("workflow_id") | |||
| ) | |||
| } | |||
| class AppNameApi(Resource): | |||
| @@ -532,7 +532,7 @@ class AppDslService: | |||
| return app | |||
| @classmethod | |||
| def export_dsl(cls, app_model: App, include_secret: bool = False) -> str: | |||
| def export_dsl(cls, app_model: App, include_secret: bool = False, workflow_id: Optional[str] = None) -> str: | |||
| """ | |||
| Export app | |||
| :param app_model: App instance | |||
| @@ -556,7 +556,7 @@ class AppDslService: | |||
| if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: | |||
| cls._append_workflow_export_data( | |||
| export_data=export_data, app_model=app_model, include_secret=include_secret | |||
| export_data=export_data, app_model=app_model, include_secret=include_secret, workflow_id=workflow_id | |||
| ) | |||
| else: | |||
| cls._append_model_config_export_data(export_data, app_model) | |||
| @@ -564,14 +564,16 @@ class AppDslService: | |||
| return yaml.dump(export_data, allow_unicode=True) # type: ignore | |||
| @classmethod | |||
| def _append_workflow_export_data(cls, *, export_data: dict, app_model: App, include_secret: bool) -> None: | |||
| def _append_workflow_export_data( | |||
| cls, *, export_data: dict, app_model: App, include_secret: bool, workflow_id: Optional[str] = None | |||
| ) -> None: | |||
| """ | |||
| Append workflow export data | |||
| :param export_data: export data | |||
| :param app_model: App instance | |||
| """ | |||
| workflow_service = WorkflowService() | |||
| workflow = workflow_service.get_draft_workflow(app_model) | |||
| workflow = workflow_service.get_draft_workflow(app_model, workflow_id) | |||
| if not workflow: | |||
| raise ValueError("Missing draft workflow configuration, please check.") | |||
| @@ -96,10 +96,12 @@ class WorkflowService: | |||
| ) | |||
| return db.session.execute(stmt).scalar_one() | |||
| def get_draft_workflow(self, app_model: App) -> Optional[Workflow]: | |||
| def get_draft_workflow(self, app_model: App, workflow_id: Optional[str] = None) -> Optional[Workflow]: | |||
| """ | |||
| Get draft workflow | |||
| """ | |||
| if workflow_id: | |||
| return self.get_published_workflow_by_id(app_model, workflow_id) | |||
| # fetch draft workflow by app_model | |||
| workflow = ( | |||
| db.session.query(Workflow) | |||
| @@ -115,7 +117,9 @@ class WorkflowService: | |||
| return workflow | |||
| def get_published_workflow_by_id(self, app_model: App, workflow_id: str) -> Optional[Workflow]: | |||
| # fetch published workflow by workflow_id | |||
| """ | |||
| fetch published workflow by workflow_id | |||
| """ | |||
| workflow = ( | |||
| db.session.query(Workflow) | |||
| .where( | |||
| @@ -322,7 +322,87 @@ class TestAppDslService: | |||
| # Verify workflow service was called | |||
| mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( | |||
| app | |||
| app, None | |||
| ) | |||
| def test_export_dsl_with_workflow_id_success(self, db_session_with_containers, mock_external_service_dependencies): | |||
| """ | |||
| Test successful DSL export with specific workflow ID. | |||
| """ | |||
| fake = Faker() | |||
| app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) | |||
| # Update app to workflow mode | |||
| app.mode = "workflow" | |||
| db_session_with_containers.commit() | |||
| # Mock workflow service to return a workflow when specific workflow_id is provided | |||
| mock_workflow = MagicMock() | |||
| mock_workflow.to_dict.return_value = { | |||
| "graph": {"nodes": [{"id": "start", "type": "start", "data": {"type": "start"}}], "edges": []}, | |||
| "features": {}, | |||
| "environment_variables": [], | |||
| "conversation_variables": [], | |||
| } | |||
| # Mock the get_draft_workflow method to return different workflows based on workflow_id | |||
| def mock_get_draft_workflow(app_model, workflow_id=None): | |||
| if workflow_id == "specific-workflow-id": | |||
| return mock_workflow | |||
| return None | |||
| mock_external_service_dependencies[ | |||
| "workflow_service" | |||
| ].return_value.get_draft_workflow.side_effect = mock_get_draft_workflow | |||
| # Export DSL with specific workflow ID | |||
| exported_dsl = AppDslService.export_dsl(app, include_secret=False, workflow_id="specific-workflow-id") | |||
| # Parse exported YAML | |||
| exported_data = yaml.safe_load(exported_dsl) | |||
| # Verify exported data structure | |||
| assert exported_data["kind"] == "app" | |||
| assert exported_data["app"]["name"] == app.name | |||
| assert exported_data["app"]["mode"] == "workflow" | |||
| # Verify workflow was exported | |||
| assert "workflow" in exported_data | |||
| assert "graph" in exported_data["workflow"] | |||
| assert "nodes" in exported_data["workflow"]["graph"] | |||
| # Verify dependencies were exported | |||
| assert "dependencies" in exported_data | |||
| assert isinstance(exported_data["dependencies"], list) | |||
| # Verify workflow service was called with specific workflow ID | |||
| mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( | |||
| app, "specific-workflow-id" | |||
| ) | |||
| def test_export_dsl_with_invalid_workflow_id_raises_error( | |||
| self, db_session_with_containers, mock_external_service_dependencies | |||
| ): | |||
| """ | |||
| Test that export_dsl raises error when invalid workflow ID is provided. | |||
| """ | |||
| fake = Faker() | |||
| app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) | |||
| # Update app to workflow mode | |||
| app.mode = "workflow" | |||
| db_session_with_containers.commit() | |||
| # Mock workflow service to return None when invalid workflow ID is provided | |||
| mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.return_value = None | |||
| # Export DSL with invalid workflow ID should raise ValueError | |||
| with pytest.raises(ValueError, match="Missing draft workflow configuration, please check."): | |||
| AppDslService.export_dsl(app, include_secret=False, workflow_id="invalid-workflow-id") | |||
| # Verify workflow service was called with the invalid workflow ID | |||
| mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( | |||
| app, "invalid-workflow-id" | |||
| ) | |||
| def test_check_dependencies_success(self, db_session_with_containers, mock_external_service_dependencies): | |||
| @@ -346,7 +346,7 @@ export const useDSL = () => { | |||
| const appDetail = useAppStore(s => s.appDetail) | |||
| const handleExportDSL = useCallback(async (include = false) => { | |||
| const handleExportDSL = useCallback(async (include = false, workflowId?: string) => { | |||
| if (!appDetail) | |||
| return | |||
| @@ -358,6 +358,7 @@ export const useDSL = () => { | |||
| await doSyncWorkflowDraft() | |||
| const { data } = await exportAppConfig({ | |||
| appID: appDetail.id, | |||
| workflowID: workflowId, | |||
| include, | |||
| }) | |||
| const a = document.createElement('a') | |||
| @@ -29,6 +29,10 @@ const useContextMenu = (props: ContextMenuProps) => { | |||
| key: VersionHistoryContextMenuOptions.edit, | |||
| name: t('workflow.versionHistory.nameThisVersion'), | |||
| }, | |||
| { | |||
| key: VersionHistoryContextMenuOptions.exportDSL, | |||
| name: t('app.export'), | |||
| }, | |||
| { | |||
| key: VersionHistoryContextMenuOptions.copyId, | |||
| name: t('workflow.versionHistory.copyId'), | |||
| @@ -3,7 +3,7 @@ import React, { useCallback, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react' | |||
| import copy from 'copy-to-clipboard' | |||
| import { useNodesSyncDraft, useWorkflowRun } from '../../hooks' | |||
| import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks' | |||
| import { useStore, useWorkflowStore } from '../../store' | |||
| import { VersionHistoryContextMenuOptions, WorkflowVersionFilterOptions } from '../../types' | |||
| import VersionHistoryItem from './version-history-item' | |||
| @@ -33,6 +33,7 @@ const VersionHistoryPanel = () => { | |||
| const workflowStore = useWorkflowStore() | |||
| const { handleSyncWorkflowDraft } = useNodesSyncDraft() | |||
| const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() | |||
| const { handleExportDSL } = useDSL() | |||
| const appDetail = useAppStore.getState().appDetail | |||
| const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) | |||
| const currentVersion = useStore(s => s.currentVersion) | |||
| @@ -107,8 +108,11 @@ const VersionHistoryPanel = () => { | |||
| message: t('workflow.versionHistory.action.copyIdSuccess'), | |||
| }) | |||
| break | |||
| case VersionHistoryContextMenuOptions.exportDSL: | |||
| handleExportDSL(false, item.id) | |||
| break | |||
| } | |||
| }, [t]) | |||
| }, [t, handleExportDSL]) | |||
| const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => { | |||
| switch (operation) { | |||
| @@ -452,6 +452,7 @@ export enum VersionHistoryContextMenuOptions { | |||
| restore = 'restore', | |||
| edit = 'edit', | |||
| delete = 'delete', | |||
| exportDSL = 'exportDSL', | |||
| copyId = 'copyId', | |||
| } | |||
| @@ -35,8 +35,13 @@ export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; | |||
| return post<AppDetailResponse>(`apps/${appID}/copy`, { body: { name, icon_type, icon, icon_background, mode, description } }) | |||
| } | |||
| export const exportAppConfig: Fetcher<{ data: string }, { appID: string; include?: boolean }> = ({ appID, include = false }) => { | |||
| return get<{ data: string }>(`apps/${appID}/export?include_secret=${include}`) | |||
| export const exportAppConfig: Fetcher<{ data: string }, { appID: string; include?: boolean; workflowID?: string }> = ({ appID, include = false, workflowID }) => { | |||
| const params = new URLSearchParams({ | |||
| include_secret: include.toString(), | |||
| }) | |||
| if (workflowID) | |||
| params.append('workflow_id', workflowID) | |||
| return get<{ data: string }>(`apps/${appID}/export?${params.toString()}`) | |||
| } | |||
| // TODO: delete | |||