mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 16:38:04 +08:00
Return the form expired error code in get form definition in WEbAPp Api (vibe-kanban 758765b0)
This commit is contained in:
@ -76,7 +76,7 @@ class HumanInputFormApi(Resource):
|
||||
if form is None:
|
||||
raise NotFoundError("Form not found")
|
||||
|
||||
service._ensure_not_submitted(form)
|
||||
service.ensure_form_active(form)
|
||||
app_model, site = _get_app_site_from_form(form)
|
||||
|
||||
return _jsonify_form_definition(form, site_payload=serialize_app_site_payload(app_model, site, None))
|
||||
|
||||
@ -143,6 +143,7 @@ class HumanInputFormRecord:
|
||||
form_kind: HumanInputFormKind
|
||||
definition: FormDefinition
|
||||
rendered_content: str
|
||||
created_at: datetime
|
||||
expiration_time: datetime
|
||||
status: HumanInputFormStatus
|
||||
selected_action_id: str | None
|
||||
@ -172,6 +173,7 @@ class HumanInputFormRecord:
|
||||
form_kind=form_model.form_kind,
|
||||
definition=FormDefinition.model_validate_json(form_model.form_definition),
|
||||
rendered_content=form_model.rendered_content,
|
||||
created_at=form_model.created_at,
|
||||
expiration_time=form_model.expiration_time,
|
||||
status=form_model.status,
|
||||
selected_action_id=form_model.selected_action_id,
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.repositories.human_input_reposotiry import (
|
||||
HumanInputFormRecord,
|
||||
HumanInputFormSubmissionRepository,
|
||||
@ -15,7 +17,7 @@ from core.workflow.nodes.human_input.entities import (
|
||||
validate_human_input_submission,
|
||||
)
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
|
||||
from libs.exception import BaseHTTPException
|
||||
from models.human_input import RecipientType
|
||||
from models.model import App, AppMode
|
||||
@ -68,7 +70,11 @@ class Form:
|
||||
return self._record.form_kind
|
||||
|
||||
@property
|
||||
def expiration_time(self):
|
||||
def created_at(self) -> "datetime":
|
||||
return self._record.created_at
|
||||
|
||||
@property
|
||||
def expiration_time(self) -> "datetime":
|
||||
return self._record.expiration_time
|
||||
|
||||
|
||||
@ -159,7 +165,7 @@ class HumanInputService:
|
||||
if form is None or form.recipient_type != recipient_type:
|
||||
raise WebAppDeliveryNotEnabledError()
|
||||
|
||||
self._ensure_form_active(form)
|
||||
self.ensure_form_active(form)
|
||||
self._validate_submission(form=form, selected_action_id=selected_action_id, form_data=form_data)
|
||||
|
||||
result = self._form_repository.mark_submitted(
|
||||
@ -177,13 +183,15 @@ class HumanInputService:
|
||||
return
|
||||
self._enqueue_resume(result.workflow_run_id)
|
||||
|
||||
def _ensure_form_active(self, form: Form) -> None:
|
||||
def ensure_form_active(self, form: Form) -> None:
|
||||
if form.submitted:
|
||||
raise FormSubmittedError(form.id)
|
||||
if form.status == HumanInputFormStatus.TIMEOUT:
|
||||
raise FormExpiredError(form.id)
|
||||
now = naive_utc_now()
|
||||
if form.expiration_time <= now:
|
||||
if ensure_naive_utc(form.expiration_time) <= now:
|
||||
raise FormExpiredError(form.id)
|
||||
if self._is_globally_expired(form, now=now):
|
||||
raise FormExpiredError(form.id)
|
||||
|
||||
def _ensure_not_submitted(self, form: Form) -> None:
|
||||
@ -229,3 +237,14 @@ class HumanInputService:
|
||||
return
|
||||
|
||||
logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id)
|
||||
|
||||
def _is_globally_expired(self, form: Form, *, now: datetime | None = None) -> bool:
|
||||
global_timeout_seconds = dify_config.HITL_GLOBAL_TIMEOUT_SECONDS
|
||||
if global_timeout_seconds <= 0:
|
||||
return False
|
||||
if form.workflow_run_id is None:
|
||||
return False
|
||||
current = now or naive_utc_now()
|
||||
created_at = ensure_naive_utc(form.created_at)
|
||||
global_deadline = created_at + timedelta(seconds=global_timeout_seconds)
|
||||
return global_deadline <= current
|
||||
|
||||
@ -15,6 +15,7 @@ from werkzeug.exceptions import Forbidden
|
||||
import controllers.web.human_input_form as human_input_module
|
||||
import controllers.web.site as site_module
|
||||
from models.human_input import RecipientType
|
||||
from services.human_input_service import FormExpiredError
|
||||
|
||||
HumanInputFormApi = human_input_module.HumanInputFormApi
|
||||
TenantStatus = human_input_module.TenantStatus
|
||||
@ -59,7 +60,7 @@ class _FakeDB:
|
||||
def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET returns form definition merged with site payload."""
|
||||
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self):
|
||||
@ -176,7 +177,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET returns form payload for backstage token."""
|
||||
|
||||
expiration_time = datetime(2024, 1, 2, tzinfo=UTC)
|
||||
expiration_time = datetime(2099, 1, 2, tzinfo=UTC)
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self):
|
||||
@ -289,7 +290,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
|
||||
def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET raises Forbidden if site cannot be resolved."""
|
||||
|
||||
expiration_time = datetime(2024, 1, 3, tzinfo=UTC)
|
||||
expiration_time = datetime(2099, 1, 3, tzinfo=UTC)
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self):
|
||||
@ -356,3 +357,21 @@ def test_submit_form_accepts_backstage_token(monkeypatch: pytest.MonkeyPatch, ap
|
||||
form_data={"content": "ok"},
|
||||
submission_end_user_id=None,
|
||||
)
|
||||
|
||||
|
||||
def test_get_form_raises_expired(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
class _FakeForm:
|
||||
pass
|
||||
|
||||
form = _FakeForm()
|
||||
service_mock = MagicMock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
service_mock.ensure_form_active.side_effect = FormExpiredError("form-id")
|
||||
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="GET"):
|
||||
with pytest.raises(FormExpiredError):
|
||||
HumanInputFormApi().get("token-1")
|
||||
|
||||
service_mock.ensure_form_active.assert_called_once_with(form)
|
||||
|
||||
@ -4,6 +4,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import services.human_input_service as human_input_service_module
|
||||
from core.repositories.human_input_reposotiry import (
|
||||
HumanInputFormRecord,
|
||||
HumanInputFormSubmissionRepository,
|
||||
@ -20,7 +21,7 @@ from core.workflow.nodes.human_input.enums import (
|
||||
TimeoutUnit,
|
||||
)
|
||||
from models.human_input import RecipientType
|
||||
from services.human_input_service import HumanInputService, InvalidFormDataError
|
||||
from services.human_input_service import Form, FormExpiredError, HumanInputService, InvalidFormDataError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -53,6 +54,7 @@ def sample_form_record():
|
||||
timeout_unit=TimeoutUnit.HOUR,
|
||||
),
|
||||
rendered_content="<p>hello</p>",
|
||||
created_at=datetime.utcnow(),
|
||||
expiration_time=datetime.utcnow() + timedelta(hours=1),
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
selected_action_id=None,
|
||||
@ -95,6 +97,20 @@ def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factor
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
def test_ensure_form_active_respects_global_timeout(monkeypatch, sample_form_record, mock_session_factory):
|
||||
session_factory, _ = mock_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
expired_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
created_at=datetime.utcnow() - timedelta(hours=2),
|
||||
expiration_time=datetime.utcnow() + timedelta(hours=2),
|
||||
)
|
||||
monkeypatch.setattr(human_input_service_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 3600)
|
||||
|
||||
with pytest.raises(FormExpiredError):
|
||||
service.ensure_form_active(Form(expired_record))
|
||||
|
||||
|
||||
def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_factory):
|
||||
session_factory, session = mock_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
Reference in New Issue
Block a user