mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
feat(api): adjust /pause-details api, add backstage form token
This commit is contained in:
@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.console.app import workflow_run as workflow_run_module
|
||||
from core.workflow.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.enums import WorkflowExecutionStatus
|
||||
from core.workflow.nodes.human_input.entities import FormInput, UserAction
|
||||
from core.workflow.nodes.human_input.enums import FormInputType
|
||||
from libs import login as login_lib
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.workflow import WorkflowRun
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.role = TenantAccountRole.OWNER
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account) -> None:
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(
|
||||
workflow_run_module, "current_account_with_tenant", lambda: (account, account.current_tenant_id)
|
||||
)
|
||||
monkeypatch.setattr(workflow_run_module, "current_user", account)
|
||||
monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
|
||||
|
||||
|
||||
class _PauseEntity:
|
||||
def __init__(self, paused_at: datetime, reasons: list[HumanInputRequired]):
|
||||
self.paused_at = paused_at
|
||||
self._reasons = reasons
|
||||
|
||||
def get_pause_reasons(self):
|
||||
return self._reasons
|
||||
|
||||
|
||||
def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = _make_account()
|
||||
_patch_console_guards(monkeypatch, account)
|
||||
monkeypatch.setattr(workflow_run_module.dify_config, "CONSOLE_WEB_URL", "https://console.example.com")
|
||||
|
||||
workflow_run = Mock(spec=WorkflowRun)
|
||||
workflow_run.status = WorkflowExecutionStatus.PAUSED
|
||||
workflow_run.created_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
monkeypatch.setattr(workflow_run_module.db.session, "get", lambda *_: workflow_run)
|
||||
|
||||
reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
|
||||
actions=[UserAction(id="approve", title="Approve")],
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
form_token="backstage-token",
|
||||
)
|
||||
pause_entity = _PauseEntity(paused_at=datetime(2024, 1, 1, 12, 0, 0), reasons=[reason])
|
||||
|
||||
repo = Mock()
|
||||
repo.get_workflow_pause.return_value = pause_entity
|
||||
monkeypatch.setattr(
|
||||
workflow_run_module.DifyAPIRepositoryFactory,
|
||||
"create_api_workflow_run_repository",
|
||||
lambda *_, **__: repo,
|
||||
)
|
||||
|
||||
with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"):
|
||||
response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["paused_at"] == "2024-01-01T12:00:00Z"
|
||||
assert response["paused_nodes"][0]["node_id"] == "node-1"
|
||||
assert response["paused_nodes"][0]["pause_type"]["type"] == "human_input"
|
||||
assert (
|
||||
response["paused_nodes"][0]["pause_type"]["backstage_input_url"]
|
||||
== "https://console.example.com/form/backstage-token"
|
||||
)
|
||||
assert "pending_human_inputs" not in response
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@ -115,6 +115,63 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
)
|
||||
|
||||
|
||||
def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET falls back to backstage token lookup."""
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self):
|
||||
return {"form_content": "hello"}
|
||||
|
||||
class _FakeForm:
|
||||
workflow_run_id = "workflow-1"
|
||||
app_id = "app-1"
|
||||
tenant_id = "tenant-1"
|
||||
|
||||
def get_definition(self):
|
||||
return _FakeDefinition()
|
||||
|
||||
form = _FakeForm()
|
||||
tenant = SimpleNamespace(status=TenantStatus.NORMAL)
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant)
|
||||
workflow_run = SimpleNamespace(app_id="app-1")
|
||||
site_model = SimpleNamespace(
|
||||
title="My Site",
|
||||
icon_type="emoji",
|
||||
icon=None,
|
||||
icon_background="#fff",
|
||||
description="desc",
|
||||
default_language="en",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
copyright=None,
|
||||
privacy_policy=None,
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
use_icon_as_answer_icon=False,
|
||||
)
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.get_form_definition_by_token.side_effect = [None, form]
|
||||
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
|
||||
|
||||
db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": site_model}))
|
||||
monkeypatch.setattr(human_input_module, "db", db_stub)
|
||||
|
||||
monkeypatch.setattr(human_input_module, "serialize_site", lambda site: {"title": site.title})
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
|
||||
response = HumanInputFormApi().get("token-1")
|
||||
|
||||
body = json.loads(response.get_data(as_text=True))
|
||||
assert body["form_content"] == "hello"
|
||||
assert body["site"] == {"title": "My Site"}
|
||||
assert service_mock.get_form_definition_by_token.call_args_list == [
|
||||
call(RecipientType.STANDALONE_WEB_APP, "token-1"),
|
||||
call(RecipientType.BACKSTAGE, "token-1"),
|
||||
]
|
||||
|
||||
|
||||
def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET raises Forbidden if site cannot be resolved."""
|
||||
|
||||
@ -145,3 +202,33 @@ def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyP
|
||||
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
|
||||
with pytest.raises(Forbidden):
|
||||
HumanInputFormApi().get("token-1")
|
||||
|
||||
|
||||
def test_submit_form_accepts_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""POST forwards backstage submissions to the service."""
|
||||
|
||||
class _FakeForm:
|
||||
recipient_type = RecipientType.BACKSTAGE
|
||||
|
||||
form = _FakeForm()
|
||||
service_mock = MagicMock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
|
||||
monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({})))
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/form/human_input/token-1",
|
||||
method="POST",
|
||||
json={"inputs": {"content": "ok"}, "action": "approve"},
|
||||
):
|
||||
response, status = HumanInputFormApi().post("token-1")
|
||||
|
||||
assert status == 200
|
||||
assert response == {}
|
||||
service_mock.submit_form_by_token.assert_called_once_with(
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
form_token="token-1",
|
||||
selected_action_id="approve",
|
||||
form_data={"content": "ok"},
|
||||
submission_end_user_id=None,
|
||||
)
|
||||
|
||||
@ -70,4 +70,3 @@ def test_dispatcher_drains_events_when_paused() -> None:
|
||||
|
||||
assert handler.events == [event]
|
||||
assert coordinator.mark_complete_called is True
|
||||
|
||||
|
||||
@ -6,12 +6,17 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.workflow.entities.pause_reason import HumanInputRequired, PauseReasonType
|
||||
from core.workflow.enums import WorkflowExecutionStatus
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition, FormInput, UserAction
|
||||
from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormStatus, TimeoutUnit
|
||||
from models.human_input import BackstageRecipientPayload, HumanInputForm, HumanInputFormRecipient, RecipientType
|
||||
from models.workflow import WorkflowPause as WorkflowPauseModel
|
||||
from models.workflow import WorkflowRun
|
||||
from models.workflow import WorkflowPauseReason, WorkflowRun
|
||||
from repositories.entities.workflow_pause import WorkflowPauseEntity
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import (
|
||||
DifyAPISQLAlchemyWorkflowRunRepository,
|
||||
_build_human_input_required_reason,
|
||||
_PrivateWorkflowPauseEntity,
|
||||
_WorkflowRunError,
|
||||
)
|
||||
@ -363,3 +368,52 @@ class TestPrivateWorkflowPauseEntity(TestDifyAPISQLAlchemyWorkflowRunRepository)
|
||||
assert result1 == expected_state
|
||||
assert result2 == expected_state
|
||||
mock_storage.load.assert_called_once() # Only called once due to caching
|
||||
|
||||
|
||||
class TestBuildHumanInputRequiredReason:
|
||||
def test_prefers_backstage_token_when_available(self):
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
timeout=1,
|
||||
timeout_unit=TimeoutUnit.HOUR,
|
||||
placeholder_values={"name": "Alice"},
|
||||
node_title="Ask Name",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form_model = HumanInputForm(
|
||||
id="form-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_run_id="run-1",
|
||||
node_id="node-1",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="rendered",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=datetime.now(UTC),
|
||||
)
|
||||
reason_model = WorkflowPauseReason(
|
||||
pause_id="pause-1",
|
||||
type_=PauseReasonType.HUMAN_INPUT_REQUIRED,
|
||||
form_id="form-1",
|
||||
node_id="node-1",
|
||||
message="",
|
||||
)
|
||||
backstage_recipient = HumanInputFormRecipient(
|
||||
form_id="form-1",
|
||||
delivery_id="delivery-1",
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
recipient_payload=BackstageRecipientPayload().model_dump_json(),
|
||||
access_token="backstage-token",
|
||||
)
|
||||
|
||||
reason = _build_human_input_required_reason(reason_model, form_model, [backstage_recipient])
|
||||
|
||||
assert isinstance(reason, HumanInputRequired)
|
||||
assert reason.form_token == "backstage-token"
|
||||
assert reason.node_title == "Ask Name"
|
||||
assert reason.form_content == "content"
|
||||
assert reason.inputs[0].output_variable_name == "name"
|
||||
assert reason.actions[0].id == "approve"
|
||||
|
||||
Reference in New Issue
Block a user