Compare commits

...

8 Commits

32 changed files with 1417 additions and 650 deletions

View File

@@ -1,6 +1,5 @@
from uuid import UUID
from flask import request
from flask_restx import Resource
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
@@ -14,6 +13,7 @@ from controllers.console.wraps import (
RBACResourceScope,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
@@ -64,8 +64,16 @@ class WorkflowAgentComposerApi(Resource):
@with_current_tenant_id
@with_session
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, session: Session, tenant_id: str, account_id: str, app_model: App, node_id: str):
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(WorkflowAgentComposerQuery)
def get(
self,
req_data: WorkflowAgentComposerQuery,
session: Session,
tenant_id: str,
account_id: str,
app_model: App,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
@@ -74,7 +82,7 @@ class WorkflowAgentComposerApi(Resource):
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
snapshot_id=query.snapshot_id,
snapshot_id=req_data.snapshot_id,
),
)
@@ -91,8 +99,16 @@ class WorkflowAgentComposerApi(Resource):
@with_current_tenant_id
@with_session
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def put(self, session: Session, tenant_id: str, account_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
@model_validate(ComposerSavePayload)
def put(
self,
req_data: ComposerSavePayload,
session: Session,
tenant_id: str,
account_id: str,
app_model: App,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
@@ -101,7 +117,7 @@ class WorkflowAgentComposerApi(Resource):
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
payload=payload,
payload=req_data,
),
)
@@ -123,8 +139,16 @@ class WorkflowAgentComposerCopyFromRosterApi(Resource):
@with_current_tenant_id
@with_session
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, session: Session, tenant_id: str, account_id: str, app_model: App, node_id: str):
payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {})
@model_validate(WorkflowComposerCopyFromRosterPayload)
def post(
self,
req_data: WorkflowComposerCopyFromRosterPayload,
session: Session,
tenant_id: str,
account_id: str,
app_model: App,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.copy_workflow_composer_from_roster(
@@ -133,9 +157,9 @@ class WorkflowAgentComposerCopyFromRosterApi(Resource):
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
source_agent_id=payload.source_agent_id,
source_snapshot_id=payload.source_snapshot_id,
idempotency_key=payload.idempotency_key,
source_agent_id=req_data.source_agent_id,
source_snapshot_id=req_data.source_snapshot_id,
idempotency_key=req_data.idempotency_key,
),
)
@@ -152,16 +176,16 @@ class WorkflowAgentComposerValidateApi(Resource):
@with_current_tenant_id
@with_session(write=False)
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, session: Session, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, app_model: App, node_id: str):
ComposerConfigValidator.validate_publish_payload(req_data)
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=payload.agent_soul
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=payload,
payload=req_data,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
),
@@ -204,9 +228,9 @@ class WorkflowAgentComposerImpactApi(Resource):
@with_current_tenant_id
@with_session(write=False)
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, session: Session, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, app_model: App, node_id: str):
current_snapshot_id = req_data.binding.current_snapshot_id if req_data.binding else None
if not current_snapshot_id:
return dump_response(
AgentComposerImpactResponse, {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
@@ -235,8 +259,16 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
@with_current_tenant_id
@with_session
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, session: Session, tenant_id: str, account_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
@model_validate(ComposerSavePayload)
def post(
self,
req_data: ComposerSavePayload,
session: Session,
tenant_id: str,
account_id: str,
app_model: App,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
@@ -245,7 +277,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
payload=payload,
payload=req_data,
),
)
@@ -270,8 +302,16 @@ class SnippetAgentComposerApi(Resource):
@with_current_user_id
@with_current_tenant_id
@with_session
def get(self, session: Session, tenant_id: str, account_id: str, snippet_id: UUID, node_id: str):
query = WorkflowAgentComposerQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(WorkflowAgentComposerQuery)
def get(
self,
req_data: WorkflowAgentComposerQuery,
session: Session,
tenant_id: str,
account_id: str,
snippet_id: UUID,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
@@ -280,7 +320,7 @@ class SnippetAgentComposerApi(Resource):
app_id=_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id),
node_id=node_id,
account_id=account_id,
snapshot_id=query.snapshot_id,
snapshot_id=req_data.snapshot_id,
),
)
@@ -296,8 +336,16 @@ class SnippetAgentComposerApi(Resource):
@with_current_user_id
@with_current_tenant_id
@with_session
def put(self, session: Session, tenant_id: str, account_id: str, snippet_id: UUID, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
@model_validate(ComposerSavePayload)
def put(
self,
req_data: ComposerSavePayload,
session: Session,
tenant_id: str,
account_id: str,
snippet_id: UUID,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
@@ -306,7 +354,7 @@ class SnippetAgentComposerApi(Resource):
app_id=_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id),
node_id=node_id,
account_id=account_id,
payload=payload,
payload=req_data,
),
)
@@ -327,8 +375,16 @@ class SnippetAgentComposerCopyFromRosterApi(Resource):
@with_current_user_id
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, account_id: str, snippet_id: UUID, node_id: str):
payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {})
@model_validate(WorkflowComposerCopyFromRosterPayload)
def post(
self,
req_data: WorkflowComposerCopyFromRosterPayload,
session: Session,
tenant_id: str,
account_id: str,
snippet_id: UUID,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.copy_workflow_composer_from_roster(
@@ -337,9 +393,9 @@ class SnippetAgentComposerCopyFromRosterApi(Resource):
app_id=_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id),
node_id=node_id,
account_id=account_id,
source_agent_id=payload.source_agent_id,
source_snapshot_id=payload.source_snapshot_id,
idempotency_key=payload.idempotency_key,
source_agent_id=req_data.source_agent_id,
source_snapshot_id=req_data.source_snapshot_id,
idempotency_key=req_data.idempotency_key,
),
)
@@ -355,17 +411,17 @@ class SnippetAgentComposerValidateApi(Resource):
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def post(self, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
ComposerConfigValidator.validate_publish_payload(req_data)
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=payload.agent_soul
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=payload,
payload=req_data,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
session=session,
tenant_id=tenant_id,
@@ -409,10 +465,10 @@ class SnippetAgentComposerImpactApi(Resource):
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def post(self, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
current_snapshot_id = req_data.binding.current_snapshot_id if req_data.binding else None
if not current_snapshot_id:
return dump_response(
AgentComposerImpactResponse, {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
@@ -444,8 +500,16 @@ class SnippetAgentComposerSaveToRosterApi(Resource):
@with_current_user_id
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, account_id: str, snippet_id: UUID, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
@model_validate(ComposerSavePayload)
def post(
self,
req_data: ComposerSavePayload,
session: Session,
tenant_id: str,
account_id: str,
snippet_id: UUID,
node_id: str,
):
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
@@ -454,7 +518,7 @@ class SnippetAgentComposerSaveToRosterApi(Resource):
app_id=_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id),
node_id=node_id,
account_id=account_id,
payload=payload,
payload=req_data,
),
)
@@ -484,8 +548,8 @@ class AgentComposerApi(Resource):
@with_current_user_id
@with_current_tenant_id
@with_session
def put(self, session: Session, tenant_id: str, account_id: str, agent_id: UUID):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
@model_validate(ComposerSavePayload)
def put(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, account_id: str, agent_id: UUID):
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_composer(
@@ -493,7 +557,7 @@ class AgentComposerApi(Resource):
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=account_id,
payload=payload,
payload=req_data,
),
)
@@ -509,17 +573,17 @@ class AgentComposerValidateApi(Resource):
@account_initialization_required
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, agent_id: UUID):
@model_validate(ComposerSavePayload)
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, agent_id: UUID):
AgentComposerService.load_agent_composer(session=session, tenant_id=tenant_id, agent_id=str(agent_id))
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
ComposerConfigValidator.validate_publish_payload(req_data)
AgentComposerService.validate_knowledge_datasets(
session=session, tenant_id=tenant_id, agent_soul=payload.agent_soul
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
)
findings = AgentComposerService.collect_validation_findings(
session=session,
tenant_id=tenant_id,
payload=payload,
payload=req_data,
agent_id=str(agent_id),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})

View File

@@ -38,6 +38,7 @@ from controllers.console.wraps import (
edit_permission_required,
enterprise_license_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
@@ -607,16 +608,16 @@ class AgentAppListApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, current_tenant_id: str, current_user: Account):
args = AgentAppCreatePayload.model_validate(console_ns.payload)
@model_validate(AgentAppCreatePayload)
def post(self, req_data: AgentAppCreatePayload, session: Session, current_tenant_id: str, current_user: Account):
params = CreateAppParams(
name=args.name,
description=args.description,
name=req_data.name,
description=req_data.description,
mode="agent",
agent_role=args.role or "",
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
agent_role=req_data.role or "",
icon_type=req_data.icon_type,
icon=req_data.icon,
icon_background=req_data.icon_background,
)
app = AppService().create_app(current_tenant_id, params, current_user, session=session)
@@ -649,18 +650,25 @@ class AgentAppApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session
def put(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
@model_validate(AgentAppUpdatePayload)
def put(
self,
req_data: AgentAppUpdatePayload,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
args = AgentAppUpdatePayload.model_validate(console_ns.payload)
args_dict: AppService.ArgsDict = {
"name": args.name,
"description": args.description or "",
"icon_type": args.icon_type,
"icon": args.icon or "",
"icon_background": args.icon_background or "",
"use_icon_as_answer_icon": args.use_icon_as_answer_icon or False,
"max_active_requests": args.max_active_requests or 0,
"role": args.role,
"name": req_data.name,
"description": req_data.description or "",
"icon_type": req_data.icon_type,
"icon": req_data.icon or "",
"icon_background": req_data.icon_background or "",
"use_icon_as_answer_icon": req_data.use_icon_as_answer_icon or False,
"max_active_requests": req_data.max_active_requests or 0,
"role": req_data.role,
}
updated = AppService().update_app(app_model, args_dict, session=session)
return _serialize_agent_app_detail(session, updated, current_user=current_user)
@@ -721,14 +729,21 @@ class AgentPublishApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentPublishPayload.model_validate(console_ns.payload or {})
@model_validate(AgentPublishPayload)
def post(
self,
req_data: AgentPublishPayload,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
return AgentComposerService.publish_agent_app_draft(
session=session,
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
version_note=args.version_note,
version_note=req_data.version_note,
)
@@ -744,14 +759,21 @@ class AgentBuildDraftCheckoutApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
@model_validate(AgentBuildDraftCheckoutPayload)
def post(
self,
req_data: AgentBuildDraftCheckoutPayload,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
return AgentComposerService.checkout_agent_app_build_draft(
session=session,
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
force=args.force,
force=req_data.force,
)
@@ -783,14 +805,21 @@ class AgentBuildDraftApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session
def put(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
@model_validate(ComposerSavePayload)
def put(
self,
req_data: ComposerSavePayload,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
return AgentComposerService.save_agent_app_build_draft(
session=session,
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
payload=payload,
payload=req_data,
)
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
@@ -844,18 +873,25 @@ class AgentAppCopyApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentAppCopyPayload.model_validate(console_ns.payload or {})
@model_validate(AgentAppCopyPayload)
def post(
self,
req_data: AgentAppCopyPayload,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
copied_app = _agent_roster_service(session).duplicate_agent_app(
tenant_id=tenant_id,
agent_id=str(agent_id),
account=current_user,
name=args.name,
description=args.description,
role=args.role,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
name=req_data.name,
description=req_data.description,
role=req_data.role,
icon_type=req_data.icon_type,
icon=req_data.icon,
icon_background=req_data.icon_background,
)
return _serialize_agent_app_detail(session, copied_app, current_user=current_user), 201
@@ -887,10 +923,10 @@ class AgentApiStatusApi(Resource):
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, agent_id: UUID):
@model_validate(AgentApiStatusPayload)
def post(self, req_data: AgentApiStatusPayload, session: Session, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
args = AgentApiStatusPayload.model_validate(console_ns.payload)
app_model = AppService().update_app_api_status(app_model, args.enable_api, session=session)
app_model = AppService().update_app_api_status(app_model, req_data.enable_api, session=session)
return _serialize_agent_api_access(session, app_model)
@@ -958,16 +994,16 @@ class AgentInviteOptionsApi(Resource):
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str):
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(AgentInviteOptionsQuery)
def get(self, req_data: AgentInviteOptionsQuery, session: Session, tenant_id: str):
return dump_response(
AgentInviteOptionsResponse,
_agent_roster_service(session).list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
page=req_data.page,
limit=req_data.limit,
keyword=req_data.keyword,
app_id=req_data.app_id,
),
)
@@ -1082,16 +1118,23 @@ class AgentStatisticsSummaryApi(Resource):
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
@model_validate(AgentStatisticsQuery)
def get(
self,
req_data: AgentStatisticsQuery,
session: Session,
tenant_id: str,
current_user: Account,
agent_id: UUID,
):
app_model = _resolve_agent_runtime_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
timezone = current_user.timezone or "UTC"
start, end = _parse_observability_time_range(query.start, query.end, current_user)
start, end = _parse_observability_time_range(req_data.start, req_data.end, current_user)
try:
payload = _agent_observability_service(session).get_statistics_summary(
app=app_model,
agent_id=str(agent_id),
params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone),
params=AgentStatisticsQueryParams(source=req_data.source, start=start, end=end, timezone=timezone),
)
except ValueError as exc:
abort(400, description=str(exc))

View File

@@ -9,6 +9,7 @@ from controllers.common.schema import query_params_from_model, register_schema_m
from controllers.console import console_ns
from controllers.console.auth.error import InvitationAccountMismatchError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.wraps import model_validate
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
@@ -86,14 +87,14 @@ class ActivateCheckApi(Resource):
"Success",
console_ns.models[ActivationCheckResponse.__name__],
)
def get(self):
args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(ActivateCheckQuery)
def get(self, req_data: ActivateCheckQuery):
workspaceId = args.workspace_id
token = args.token
workspaceId = req_data.workspace_id
token = req_data.token
invitation = RegisterService.get_invitation_with_case_fallback(
workspaceId, args.email, token, session=db.session()
workspaceId, req_data.email, token, session=db.session()
)
if invitation:
data = invitation.get("data", {})
@@ -138,18 +139,18 @@ class ActivateApi(Resource):
console_ns.models[ActivationResponse.__name__],
)
@console_ns.response(400, "Already activated or invalid token")
def post(self):
@model_validate(ActivatePayload)
def post(self, req_data: ActivatePayload):
"""Accept an invitation without letting an existing session act for another account.
Token-only activation remains available for legacy clients. When the request already
carries a console session, that session must belong to the account encoded in the
invitation before the token is consumed or tenant membership is changed.
"""
args = ActivatePayload.model_validate(console_ns.payload)
normalized_request_email = args.email.lower() if args.email else None
normalized_request_email = req_data.email.lower() if req_data.email else None
invitation = RegisterService.get_invitation_with_case_fallback(
args.workspace_id, args.email, args.token, session=db.session()
req_data.workspace_id, req_data.email, req_data.token, session=db.session()
)
if invitation is None:
raise AlreadyActivateError()
@@ -185,11 +186,11 @@ class ActivateApi(Resource):
setup_fields: tuple[str, str, str] | None = None
if requires_setup:
if not args.name or not args.interface_language or not args.timezone:
if not req_data.name or not req_data.interface_language or not req_data.timezone:
raise AlreadyActivateError()
setup_fields = (args.name, args.interface_language, args.timezone)
setup_fields = (req_data.name, req_data.interface_language, req_data.timezone)
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
RegisterService.revoke_token(req_data.workspace_id, normalized_request_email, req_data.token)
if membership_id is None:
TenantService.create_tenant_member(tenant, account, db.session(), role=role)

View File

@@ -26,7 +26,7 @@ from services.billing_service import BillingService
from services.errors.account import AccountRegisterError, SeatsLimitExceededError
from ..error import AccountInFreezeError, EmailSendIpLimitError, SeatsLimitExceeded
from ..wraps import email_password_login_enabled, email_register_enabled, setup_required
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
class EmailRegisterSendPayload(BaseModel):
@@ -87,21 +87,21 @@ class EmailRegisterSendEmailApi(Resource):
@email_register_enabled
@console_ns.expect(console_ns.models[EmailRegisterSendPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def post(self):
args = EmailRegisterSendPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
@model_validate(EmailRegisterSendPayload)
def post(self, req_data: EmailRegisterSendPayload):
normalized_email = req_data.email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
language = "en-US"
if args.language is not None and args.language in languages:
language = args.language
if req_data.language is not None and req_data.language in languages:
language = req_data.language
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(args.email, session=db.session())
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
return {"result": "success", "data": token}
@@ -113,16 +113,16 @@ class EmailRegisterCheckApi(Resource):
@email_register_enabled
@console_ns.expect(console_ns.models[EmailRegisterValidityPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
def post(self):
args = EmailRegisterValidityPayload.model_validate(console_ns.payload)
@model_validate(EmailRegisterValidityPayload)
def post(self, req_data: EmailRegisterValidityPayload):
user_email = args.email.lower()
user_email = req_data.email.lower()
is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(user_email)
if is_email_register_error_rate_limit:
raise EmailRegisterLimitError()
token_data = AccountService.get_email_register_data(args.token)
token_data = AccountService.get_email_register_data(req_data.token)
if token_data is None:
raise InvalidTokenError()
@@ -132,16 +132,16 @@ class EmailRegisterCheckApi(Resource):
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.get("code"):
if req_data.code != token_data.get("code"):
AccountService.add_email_register_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
AccountService.revoke_email_register_token(args.token)
AccountService.revoke_email_register_token(req_data.token)
# Refresh token data by generating a new token
_, new_token = AccountService.generate_email_register_token(
user_email, code=args.code, additional_data={"phase": "register"}
user_email, code=req_data.code, additional_data={"phase": "register"}
)
AccountService.reset_email_register_error_rate_limit(user_email)
@@ -155,15 +155,15 @@ class EmailRegisterResetApi(Resource):
@email_register_enabled
@console_ns.expect(console_ns.models[EmailRegisterResetPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[EmailRegisterResetResponse.__name__])
def post(self):
args = EmailRegisterResetPayload.model_validate(console_ns.payload)
@model_validate(EmailRegisterResetPayload)
def post(self, req_data: EmailRegisterResetPayload):
# Validate passwords match
if args.new_password != args.password_confirm:
if req_data.new_password != req_data.password_confirm:
raise PasswordMismatchError()
# Validate token and get register data
register_data = AccountService.get_email_register_data(args.token)
register_data = AccountService.get_email_register_data(req_data.token)
if not register_data:
raise InvalidTokenError()
# Must use token in reset phase
@@ -171,7 +171,7 @@ class EmailRegisterResetApi(Resource):
raise InvalidTokenError()
# Revoke token to prevent reuse
AccountService.revoke_email_register_token(args.token)
AccountService.revoke_email_register_token(req_data.token)
email = register_data.get("email", "")
normalized_email = email.lower()
@@ -183,9 +183,9 @@ class EmailRegisterResetApi(Resource):
account = self._create_new_account(
email=normalized_email,
password=args.password_confirm,
timezone=args.timezone,
language=args.language,
password=req_data.password_confirm,
timezone=req_data.timezone,
language=req_data.language,
)
token_pair = AccountService.login(account=account, session=db.session(), ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)

View File

@@ -15,7 +15,7 @@ from controllers.console.auth.error import (
PasswordMismatchError,
)
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
from controllers.console.wraps import email_password_login_enabled, setup_required
from controllers.console.wraps import email_password_login_enabled, model_validate, setup_required
from extensions.ext_database import db
from libs.helper import EmailStr, extract_remote_ip
from libs.password import hash_password
@@ -68,20 +68,20 @@ class ForgotPasswordSendEmailApi(Resource):
@console_ns.response(400, "Invalid email or rate limit exceeded")
@setup_required
@email_password_login_enabled
def post(self):
args = ForgotPasswordSendPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
@model_validate(ForgotPasswordSendPayload)
def post(self, req_data: ForgotPasswordSendPayload):
normalized_email = req_data.email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
if args.language is not None and args.language == "zh-Hans":
if req_data.language is not None and req_data.language == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
account = AccountService.get_account_by_email_with_case_fallback(args.email, session=db.session())
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
token = AccountService.send_reset_password_email(
account=account,
@@ -106,16 +106,16 @@ class ForgotPasswordCheckApi(Resource):
@console_ns.response(400, "Invalid code or token")
@setup_required
@email_password_login_enabled
def post(self):
args = ForgotPasswordCheckPayload.model_validate(console_ns.payload)
@model_validate(ForgotPasswordCheckPayload)
def post(self, req_data: ForgotPasswordCheckPayload):
user_email = args.email.lower()
user_email = req_data.email.lower()
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(user_email)
if is_forgot_password_error_rate_limit:
raise EmailPasswordResetLimitError()
token_data = AccountService.get_reset_password_data(args.token)
token_data = AccountService.get_reset_password_data(req_data.token)
if token_data is None:
raise InvalidTokenError()
@@ -127,16 +127,16 @@ class ForgotPasswordCheckApi(Resource):
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.get("code"):
if req_data.code != token_data.get("code"):
AccountService.add_forgot_password_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
AccountService.revoke_reset_password_token(args.token)
AccountService.revoke_reset_password_token(req_data.token)
# Refresh token data by generating a new token
_, new_token = AccountService.generate_reset_password_token(
token_email, code=args.code, additional_data={"phase": "reset"}
token_email, code=req_data.code, additional_data={"phase": "reset"}
)
AccountService.reset_forgot_password_error_rate_limit(user_email)
@@ -156,15 +156,15 @@ class ForgotPasswordResetApi(Resource):
@console_ns.response(400, "Invalid token or password mismatch")
@setup_required
@email_password_login_enabled
def post(self):
args = ForgotPasswordResetPayload.model_validate(console_ns.payload)
@model_validate(ForgotPasswordResetPayload)
def post(self, req_data: ForgotPasswordResetPayload):
# Validate passwords match
if args.new_password != args.password_confirm:
if req_data.new_password != req_data.password_confirm:
raise PasswordMismatchError()
# Validate token and get reset data
reset_data = AccountService.get_reset_password_data(args.token)
reset_data = AccountService.get_reset_password_data(req_data.token)
if not reset_data:
raise InvalidTokenError()
# Must use token in reset phase
@@ -172,11 +172,11 @@ class ForgotPasswordResetApi(Resource):
raise InvalidTokenError()
# Revoke token to prevent reuse
AccountService.revoke_reset_password_token(args.token)
AccountService.revoke_reset_password_token(req_data.token)
# Generate secure salt and hash password
salt = secrets.token_bytes(16)
password_hashed = hash_password(args.new_password, salt)
password_hashed = hash_password(req_data.new_password, salt)
email = reset_data.get("email", "")
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())

View File

@@ -39,6 +39,7 @@ from controllers.console.wraps import (
decrypt_code_field,
decrypt_password_field,
email_password_login_enabled,
model_validate,
setup_required,
with_current_user,
)
@@ -114,10 +115,10 @@ class LoginApi(Resource):
@console_ns.expect(console_ns.models[LoginPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultOptionalDataResponse.__name__])
@decrypt_password_field
def post(self):
@model_validate(LoginPayload)
def post(self, req_data: LoginPayload):
"""Authenticate user and login."""
args = LoginPayload.model_validate(console_ns.payload)
request_email = args.email
request_email = req_data.email
normalized_email = request_email.lower()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
@@ -129,7 +130,7 @@ class LoginApi(Resource):
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.LOGIN_RATE_LIMITED)
raise EmailPasswordLoginLimitError()
invite_token = args.invite_token
invite_token = req_data.invite_token
invitation_data: InvitationDetailDict | None = None
if invite_token:
invitation_data = RegisterService.get_invitation_with_case_fallback(
@@ -150,7 +151,7 @@ class LoginApi(Resource):
)
raise InvalidEmailError()
account = _authenticate_account_with_case_fallback(
request_email, normalized_email, args.password, invite_token
request_email, normalized_email, req_data.password, invite_token
)
except services.errors.account.AccountLoginError:
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_BANNED)
@@ -215,16 +216,16 @@ class ResetPasswordSendEmailApi(Resource):
@email_password_login_enabled
@console_ns.expect(console_ns.models[EmailPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def post(self):
args = EmailPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
@model_validate(EmailPayload)
def post(self, req_data: EmailPayload):
normalized_email = req_data.email.lower()
if args.language is not None and args.language == "zh-Hans":
if req_data.language is not None and req_data.language == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
try:
account = _get_account_with_case_fallback(args.email)
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
@@ -243,20 +244,20 @@ class EmailCodeLoginSendEmailApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[EmailPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
def post(self):
args = EmailPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
@model_validate(EmailPayload)
def post(self, req_data: EmailPayload):
normalized_email = req_data.email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
if args.language is not None and args.language == "zh-Hans":
if req_data.language is not None and req_data.language == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
try:
account = _get_account_with_case_fallback(args.email)
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
@@ -277,14 +278,14 @@ class EmailCodeLoginApi(Resource):
@console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@decrypt_code_field
def post(self):
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
@model_validate(EmailCodeLoginPayload)
def post(self, req_data: EmailCodeLoginPayload):
original_email = args.email
original_email = req_data.email
user_email = original_email.lower()
language = args.language
language = req_data.language
token_data = AccountService.get_email_code_login_data(args.token)
token_data = AccountService.get_email_code_login_data(req_data.token)
if token_data is None:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
raise InvalidTokenError()
@@ -295,11 +296,11 @@ class EmailCodeLoginApi(Resource):
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
raise InvalidEmailError()
if token_data["code"] != args.code:
if token_data["code"] != req_data.code:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
raise EmailCodeError()
AccountService.revoke_email_code_login_token(args.token)
AccountService.revoke_email_code_login_token(req_data.token)
try:
account = _get_account_with_case_fallback(original_email)
except Unauthorized as exc:
@@ -325,7 +326,7 @@ class EmailCodeLoginApi(Resource):
email=user_email,
name=user_email,
interface_language=get_valid_language(language),
timezone=args.timezone,
timezone=req_data.timezone,
session=db.session(),
)
except WorkSpaceNotAllowedCreateError:

View File

@@ -1,7 +1,6 @@
import base64
from typing import Any, Literal
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from werkzeug.exceptions import BadRequest
@@ -10,6 +9,7 @@ from controllers.common.schema import query_params_from_model, register_response
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
model_validate,
only_edition_cloud,
setup_required,
with_current_tenant_id,
@@ -54,10 +54,10 @@ class Subscription(Resource):
@only_edition_cloud
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(SubscriptionQuery)
def get(self, req_data: SubscriptionQuery, current_tenant_id: str, current_user: Account):
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
return BillingService.get_subscription(req_data.plan, req_data.interval, current_user.email, current_tenant_id)
@console_ns.route("/billing/invoices")
@@ -87,10 +87,10 @@ class PartnerTenants(Resource):
@account_initialization_required
@only_edition_cloud
@with_current_user
def put(self, current_user: Account, partner_key: str):
@model_validate(PartnerTenantsPayload)
def put(self, req_data: PartnerTenantsPayload, current_user: Account, partner_key: str):
try:
args = PartnerTenantsPayload.model_validate(console_ns.payload or {})
click_id = args.click_id
click_id = req_data.click_id
decoded_partner_key = base64.b64decode(partner_key).decode("utf-8")
except Exception:
raise BadRequest("Invalid partner_key")

View File

@@ -14,6 +14,7 @@ from ...common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0
from .. import console_ns
from ..wraps import (
account_initialization_required,
model_validate,
only_edition_cloud,
setup_required,
with_current_tenant_id,
@@ -48,13 +49,13 @@ class ComplianceApi(Resource):
@only_edition_cloud
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
args = ComplianceDownloadQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(ComplianceDownloadQuery)
def get(self, req_data: ComplianceDownloadQuery, current_tenant_id: str, current_user: Account):
ip_address = extract_remote_ip(request)
device_info = request.headers.get("User-Agent", "Unknown device")
return BillingService.get_compliance_download_link(
doc_name=args.doc_name,
doc_name=req_data.doc_name,
account_id=current_user.id,
tenant_id=current_tenant_id,
ip=ip_address,

View File

@@ -1,13 +1,13 @@
from datetime import datetime
from typing import cast
from flask import request
from flask_restx import Namespace, Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
from controllers.common.schema import query_params_from_model, register_response_schema_models
from controllers.console import api
from controllers.console.wraps import model_validate
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
@@ -64,23 +64,22 @@ class BannerApi(Resource):
@api.doc(params=query_params_from_model(BannerListQuery))
@api.response(200, "Success", api.models[BannerListResponse.__name__])
def get(self):
@model_validate(BannerListQuery)
def get(self, req_data: BannerListQuery):
"""Get banner list."""
if not FeatureService.is_explore_banner_enabled():
return dump_response(BannerListResponse, [])
query = BannerListQuery.model_validate(request.args.to_dict(flat=True))
# Build base query for enabled banners
base_query = select(ExporleBanner).where(ExporleBanner.status == BannerStatus.ENABLED)
# Try to get banners in the requested language
banners = db.session.scalars(
base_query.where(ExporleBanner.language == query.language).order_by(ExporleBanner.sort)
base_query.where(ExporleBanner.language == req_data.language).order_by(ExporleBanner.sort)
).all()
# Fallback to en-US if no banners found and language is not en-US
if not banners and query.language != "en-US":
if not banners and req_data.language != "en-US":
banners = db.session.scalars(
base_query.where(ExporleBanner.language == "en-US").order_by(ExporleBanner.sort)
).all()

View File

@@ -1,14 +1,13 @@
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, computed_field, field_validator
from constants.languages import languages
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, with_current_user
from controllers.console.wraps import account_initialization_required, model_validate, with_current_user
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import build_icon_url, dump_response
@@ -114,10 +113,10 @@ class RecommendedAppListApi(Resource):
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account):
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
# language args
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
language_prefix = _resolve_language(req_data.language, current_user)
return dump_response(
RecommendedAppListResponse,
@@ -132,9 +131,9 @@ class LearnDifyAppListApi(Resource):
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
language_prefix = _resolve_language(req_data.language, current_user)
return dump_response(
LearnDifyAppListResponse,

View File

@@ -49,7 +49,7 @@ from controllers.console.explore.error import (
from controllers.console.explore.wraps import TrialAppResource
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request
from controllers.console.wraps import cloud_edition_billing_resource_check, with_current_user
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate, with_current_user
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.base_app_queue_manager import AppQueueManager
@@ -487,7 +487,8 @@ class TrialAppWorkflowRunApi(TrialAppResource):
@console_ns.response(200, "Success")
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@model_validate(WorkflowRunRequest)
def post(self, req_data: WorkflowRunRequest, session: Session, current_user: Account, trial_app):
"""
Run workflow
"""
@@ -498,8 +499,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
request_data = WorkflowRunRequest.model_validate(console_ns.payload)
args = request_data.model_dump()
args = req_data.model_dump()
try:
app_id = app_model.id
user_id = current_user.id
@@ -559,14 +559,14 @@ class TrialChatApi(TrialAppResource):
@console_ns.response(200, "Success")
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@model_validate(ChatRequest)
def post(self, req_data: ChatRequest, session: Session, current_user: Account, trial_app):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
request_data = ChatRequest.model_validate(console_ns.payload)
args = request_data.model_dump()
args = req_data.model_dump()
# Validate UUID values if provided
if args.get("conversation_id"):
@@ -709,14 +709,13 @@ class TrialChatTextApi(TrialAppResource):
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@with_current_user
def post(self, current_user: Account, trial_app):
@model_validate(TextToSpeechRequest)
def post(self, req_data: TextToSpeechRequest, current_user: Account, trial_app):
app_model = trial_app
try:
request_data = TextToSpeechRequest.model_validate(console_ns.payload)
message_id = request_data.message_id
text = request_data.text
voice = request_data.voice
message_id = req_data.message_id
text = req_data.text
voice = req_data.voice
message_ref = None
if message_id:
app_ref = AppRefService.create_app_ref(app_model)
@@ -770,13 +769,13 @@ class TrialCompletionApi(TrialAppResource):
@console_ns.response(200, "Success")
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@model_validate(CompletionRequest)
def post(self, req_data: CompletionRequest, session: Session, current_user: Account, trial_app):
app_model = trial_app
if app_model.mode != "completion":
raise NotCompletionAppError()
request_data = CompletionRequest.model_validate(console_ns.payload)
args = request_data.model_dump()
args = req_data.model_dump()
streaming = args["response_mode"] == "streaming"
args["auto_generate_name"] = False

View File

@@ -51,8 +51,8 @@ def upload_remote_file_from_request(
current_user: Account,
resource_tenant_id: str | None = None,
) -> FileWithSignedUrl:
"""Validate the JSON request, fetch its remote file, and persist it under the requested tenant."""
payload = RemoteFileUploadPayload.model_validate(console_ns.payload)
"""Validate the JSON request, fetch its remote file, and persist it under the requested tenant."""
url = payload.url
# Try to fetch remote file metadata/content first

View File

@@ -36,6 +36,7 @@ from controllers.console.wraps import (
RBACResourceScope,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_user,
@@ -204,18 +205,18 @@ class SnippetDraftWorkflowApi(Resource):
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet):
@model_validate(SnippetDraftSyncPayload)
def post(self, req_data: SnippetDraftSyncPayload, current_user: Account, snippet: CustomizedSnippet):
"""Sync draft workflow for snippet."""
payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {})
try:
snippet_service = _snippet_service()
workflow = snippet_service.sync_draft_workflow(
snippet=snippet,
graph=payload.graph,
unique_hash=payload.hash,
graph=req_data.graph,
unique_hash=req_data.hash,
account=current_user,
input_fields=payload.input_fields,
input_fields=req_data.input_fields,
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
@@ -363,24 +364,24 @@ class SnippetPublishedAllWorkflowApi(Resource):
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def get(self, snippet: CustomizedSnippet):
@model_validate(SnippetWorkflowListQuery)
def get(self, req_data: SnippetWorkflowListQuery, snippet: CustomizedSnippet):
"""Get all published workflow versions for snippet."""
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
snippet_service = _snippet_service()
with Session(db.engine) as session:
workflows, has_more = snippet_service.get_all_published_workflows(
session=session,
snippet=snippet,
page=args.page,
limit=args.limit,
page=req_data.page,
limit=req_data.limit,
)
response = SnippetWorkflowPaginationResponse.model_validate(
{
"items": workflows,
"page": args.page,
"limit": args.limit,
"page": req_data.page,
"limit": req_data.limit,
"has_more": has_more,
},
from_attributes=True,
@@ -449,10 +450,16 @@ class SnippetWorkflowByIdApi(Resource):
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def patch(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str):
@model_validate(WorkflowUpdatePayload)
def patch(
self,
req_data: WorkflowUpdatePayload,
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)
update_data = req_data.model_dump(exclude_unset=True)
if not update_data:
return {"message": "No valid fields to update"}, 400
@@ -575,16 +582,22 @@ class SnippetDraftNodeRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
@model_validate(SnippetDraftNodeRunPayload)
def post(
self,
req_data: SnippetDraftNodeRunPayload,
current_user: Account,
snippet: CustomizedSnippet,
node_id: str,
):
"""
Run a single node in snippet draft workflow.
Executes a specific node with provided inputs for single-step debugging.
Returns the node execution result including status, outputs, and timing.
"""
payload = SnippetDraftNodeRunPayload.model_validate(console_ns.payload or {})
user_inputs = payload.inputs
user_inputs = req_data.inputs
# Get draft workflow for file parsing
snippet_service = _snippet_service()
@@ -592,14 +605,14 @@ class SnippetDraftNodeRunApi(Resource):
if not draft_workflow:
raise NotFound("Draft workflow not found")
files = SnippetGenerateService.parse_files(draft_workflow, payload.files)
files = SnippetGenerateService.parse_files(draft_workflow, req_data.files)
workflow_node_execution = SnippetGenerateService.run_draft_node(
snippet=snippet,
node_id=node_id,
user_inputs=user_inputs,
account=current_user,
query=payload.query,
query=req_data.query,
files=files,
session_maker=_snippet_session_maker(),
)
@@ -663,14 +676,21 @@ class SnippetDraftRunIterationNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
@model_validate(SnippetIterationNodeRunPayload)
def post(
self,
req_data: SnippetIterationNodeRunPayload,
current_user: Account,
snippet: CustomizedSnippet,
node_id: str,
):
"""
Run a draft workflow iteration node for snippet.
Iteration nodes execute their internal sub-graph multiple times over an input list.
Returns an SSE event stream with iteration progress and results.
"""
args = SnippetIterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
args = req_data.model_dump(exclude_none=True)
try:
response = SnippetGenerateService.generate_single_iteration(
@@ -708,21 +728,27 @@ class SnippetDraftRunLoopNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
@model_validate(SnippetLoopNodeRunPayload)
def post(
self,
req_data: SnippetLoopNodeRunPayload,
current_user: Account,
snippet: CustomizedSnippet,
node_id: str,
):
"""
Run a draft workflow loop node for snippet.
Loop nodes execute their internal sub-graph repeatedly until a condition is met.
Returns an SSE event stream with loop progress and results.
"""
args = SnippetLoopNodeRunPayload.model_validate(console_ns.payload or {})
try:
response = SnippetGenerateService.generate_single_loop(
snippet=snippet,
user=current_user,
node_id=node_id,
args=args,
args=req_data,
streaming=True,
session_maker=_snippet_session_maker(),
)
@@ -751,15 +777,15 @@ class SnippetDraftWorkflowRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
def post(self, current_user: Account, snippet: CustomizedSnippet):
@model_validate(SnippetDraftRunPayload)
def post(self, req_data: SnippetDraftRunPayload, current_user: Account, snippet: CustomizedSnippet):
"""
Run draft workflow for snippet.
Executes the snippet's draft workflow with the provided inputs
and returns an SSE event stream with execution progress and results.
"""
payload = SnippetDraftRunPayload.model_validate(console_ns.payload or {})
args = payload.model_dump(exclude_none=True)
args = req_data.model_dump(exclude_none=True)
try:
response = SnippetGenerateService.generate(

View File

@@ -36,6 +36,7 @@ from controllers.console.snippets.snippet_workflow import get_snippet
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
model_validate,
setup_required,
with_current_user,
)
@@ -186,9 +187,15 @@ class SnippetVariableApi(Resource):
@console_ns.response(404, "Variable not found")
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_model)
def patch(self, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable:
@model_validate(WorkflowDraftVariableUpdatePayload)
def patch(
self,
req_data: WorkflowDraftVariableUpdatePayload,
current_user: Account,
snippet: CustomizedSnippet,
variable_id: str,
) -> WorkflowDraftVariable:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {})
variable = ensure_variable_access(
variable=draft_var_srv.get_variable(variable_id=variable_id),
@@ -198,8 +205,8 @@ class SnippetVariableApi(Resource):
)
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
new_name = args_model.name
raw_value = args_model.value
new_name = req_data.name
raw_value = req_data.value
if new_name is None and raw_value is None:
return variable

View File

@@ -1,7 +1,6 @@
from typing import Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
@@ -135,10 +134,9 @@ class TagListApi(Resource):
@console_ns.doc(params=query_params_from_model(TagListQueryParam))
@console_ns.response(200, "Success", console_ns.models[TagListResponse.__name__])
@with_current_tenant_id
def get(self, current_tenant_id: str):
raw_args = request.args.to_dict()
param = TagListQueryParam.model_validate(raw_args)
tags = TagService.get_tags(param.type, current_tenant_id, param.keyword, session=db.session())
@model_validate(TagListQueryParam)
def get(self, req_data: TagListQueryParam, current_tenant_id: str):
tags = TagService.get_tags(req_data.type, current_tenant_id, req_data.keyword, session=db.session())
return dump_response(TagListResponse, tags), 200
@@ -213,10 +211,9 @@ def _require_tag_binding_edit_permission(current_user: Account) -> None:
raise Forbidden()
def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
def _create_tag_bindings(current_user: Account, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.save_tag_binding(
TagBindingCreatePayload(
@@ -229,10 +226,9 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
return {"result": "success"}, 200
def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
def _remove_tag_bindings(current_user: Account, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.delete_tag_binding(
TagBindingDeletePayload(
@@ -256,8 +252,9 @@ class TagBindingCollectionApi(Resource):
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account):
return _create_tag_bindings(current_user)
@model_validate(TagBindingPayload)
def post(self, req_data: TagBindingPayload, current_user: Account):
return _create_tag_bindings(current_user, req_data)
@console_ns.route("/tag-bindings/remove")
@@ -272,5 +269,6 @@ class TagBindingRemoveApi(Resource):
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account):
return _remove_tag_bindings(current_user)
@model_validate(TagBindingRemovePayload)
def post(self, req_data: TagBindingRemovePayload, current_user: Account):
return _remove_tag_bindings(current_user, req_data)

View File

@@ -24,6 +24,7 @@ from controllers.console.wraps import (
RBACResourceScope,
account_initialization_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
@@ -592,10 +593,10 @@ class PluginListApi(Resource):
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, user_id: str):
args = ParserList.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserList)
def get(self, req_data: ParserList, tenant_id: str, user_id: str):
try:
plugins_with_total = PluginService.list_with_total(tenant_id, user_id, args.page, args.page_size)
plugins_with_total = PluginService.list_with_total(tenant_id, user_id, req_data.page, req_data.page_size)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -660,10 +661,10 @@ class PluginInstalledIdsApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
args = PluginInstalledIdsQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(PluginInstalledIdsQuery)
def get(self, req_data: PluginInstalledIdsQuery, tenant_id: str):
try:
plugin_ids = PluginService.list_installed_plugin_ids(tenant_id, args.category)
plugin_ids = PluginService.list_installed_plugin_ids(tenant_id, req_data.category)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -677,11 +678,11 @@ class PluginListLatestVersionsApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
args = ParserLatest.model_validate(console_ns.payload)
@model_validate(ParserLatest)
def post(self, req_data: ParserLatest):
try:
versions = PluginService.list_latest_versions(args.plugin_ids)
versions = PluginService.list_latest_versions(req_data.plugin_ids)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -696,11 +697,11 @@ class PluginListInstallationsFromIdsApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserLatest.model_validate(console_ns.payload)
@model_validate(ParserLatest)
def post(self, req_data: ParserLatest, tenant_id: str):
try:
plugins = PluginService.list_installations_from_ids(tenant_id, args.plugin_ids)
plugins = PluginService.list_installations_from_ids(tenant_id, req_data.plugin_ids)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -712,11 +713,11 @@ class PluginIconApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserIcon))
@console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__])
@setup_required
def get(self):
args = ParserIcon.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserIcon)
def get(self, req_data: ParserIcon):
try:
icon_bytes, mimetype = PluginService.get_asset(args.tenant_id, args.filename)
icon_bytes, mimetype = PluginService.get_asset(req_data.tenant_id, req_data.filename)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -732,11 +733,11 @@ class PluginAssetApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserAsset.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserAsset)
def get(self, req_data: ParserAsset, tenant_id: str):
try:
binary = PluginService.extract_asset(tenant_id, args.plugin_unique_identifier, args.file_name)
binary = PluginService.extract_asset(tenant_id, req_data.plugin_unique_identifier, req_data.file_name)
return send_file(io.BytesIO(binary), mimetype="application/octet-stream")
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -773,11 +774,13 @@ class PluginUploadFromGithubApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserGithubUpload.model_validate(console_ns.payload)
@model_validate(ParserGithubUpload)
def post(self, req_data: ParserGithubUpload, tenant_id: str):
try:
response = PluginService.upload_pkg_from_github(tenant_id, args.repo, args.version, args.package)
response = PluginService.upload_pkg_from_github(
tenant_id, req_data.repo, req_data.version, req_data.package
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -814,11 +817,11 @@ class PluginInstallFromPkgApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserPluginIdentifiers.model_validate(console_ns.payload)
@model_validate(ParserPluginIdentifiers)
def post(self, req_data: ParserPluginIdentifiers, tenant_id: str):
try:
response = PluginService.install_from_local_pkg(tenant_id, args.plugin_unique_identifiers)
response = PluginService.install_from_local_pkg(tenant_id, req_data.plugin_unique_identifiers)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -835,16 +838,16 @@ class PluginInstallFromGithubApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserGithubInstall.model_validate(console_ns.payload)
@model_validate(ParserGithubInstall)
def post(self, req_data: ParserGithubInstall, tenant_id: str):
try:
response = PluginService.install_from_github(
tenant_id,
args.plugin_unique_identifier,
args.repo,
args.version,
args.package,
req_data.plugin_unique_identifier,
req_data.repo,
req_data.version,
req_data.package,
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -862,11 +865,11 @@ class PluginInstallFromMarketplaceApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserPluginIdentifiers.model_validate(console_ns.payload)
@model_validate(ParserPluginIdentifiers)
def post(self, req_data: ParserPluginIdentifiers, tenant_id: str):
try:
response = PluginService.install_from_marketplace_pkg(tenant_id, args.plugin_unique_identifiers)
response = PluginService.install_from_marketplace_pkg(tenant_id, req_data.plugin_unique_identifiers)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -883,15 +886,15 @@ class PluginFetchMarketplacePkgApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserPluginIdentifierQuery)
def get(self, req_data: ParserPluginIdentifierQuery, tenant_id: str):
try:
return jsonable_encoder(
{
"manifest": PluginService.fetch_marketplace_pkg(
tenant_id,
args.plugin_unique_identifier,
req_data.plugin_unique_identifier,
)
}
)
@@ -909,12 +912,16 @@ class PluginFetchManifestApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserPluginIdentifierQuery)
def get(self, req_data: ParserPluginIdentifierQuery, tenant_id: str):
try:
return jsonable_encoder(
{"manifest": PluginService.fetch_plugin_manifest(tenant_id, args.plugin_unique_identifier).model_dump()}
{
"manifest": PluginService.fetch_plugin_manifest(
tenant_id, req_data.plugin_unique_identifier
).model_dump()
},
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -929,11 +936,13 @@ class PluginFetchInstallTasksApi(Resource):
@account_initialization_required
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserTasks.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserTasks)
def get(self, req_data: ParserTasks, tenant_id: str):
try:
return jsonable_encoder({"tasks": PluginService.fetch_install_tasks(tenant_id, args.page, args.page_size)})
return jsonable_encoder(
{"tasks": PluginService.fetch_install_tasks(tenant_id, req_data.page, req_data.page_size)}
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -1008,13 +1017,13 @@ class PluginUpgradeFromMarketplaceApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserMarketplaceUpgrade.model_validate(console_ns.payload)
@model_validate(ParserMarketplaceUpgrade)
def post(self, req_data: ParserMarketplaceUpgrade, tenant_id: str):
try:
return jsonable_encoder(
PluginService.upgrade_plugin_with_marketplace(
tenant_id, args.original_plugin_unique_identifier, args.new_plugin_unique_identifier
tenant_id, req_data.original_plugin_unique_identifier, req_data.new_plugin_unique_identifier
)
)
except PluginDaemonClientSideError as e:
@@ -1031,18 +1040,18 @@ class PluginUpgradeFromGithubApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserGithubUpgrade.model_validate(console_ns.payload)
@model_validate(ParserGithubUpgrade)
def post(self, req_data: ParserGithubUpgrade, tenant_id: str):
try:
return jsonable_encoder(
PluginService.upgrade_plugin_with_github(
tenant_id,
args.original_plugin_unique_identifier,
args.new_plugin_unique_identifier,
args.repo,
args.version,
args.package,
req_data.original_plugin_unique_identifier,
req_data.new_plugin_unique_identifier,
req_data.repo,
req_data.version,
req_data.package,
)
)
except PluginDaemonClientSideError as e:
@@ -1059,15 +1068,15 @@ class PluginUninstallApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_DELETE, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserUninstall.model_validate(console_ns.payload)
@model_validate(ParserUninstall)
def post(self, req_data: ParserUninstall, tenant_id: str):
try:
return {
"success": PluginService.uninstall(
tenant_id,
args.plugin_installation_id,
preserve_credentials=args.preserve_credentials,
req_data.plugin_installation_id,
preserve_credentials=req_data.preserve_credentials,
)
}
except PluginDaemonClientSideError as e:
@@ -1083,14 +1092,13 @@ class PluginChangePermissionApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
@model_validate(ParserPermissionChange)
def post(self, req_data: ParserPermissionChange, tenant_id: str, user: Account):
if not user.is_admin_or_owner:
raise Forbidden()
args = ParserPermissionChange.model_validate(console_ns.payload)
set_permission_result = PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission, session=db.session()
tenant_id, req_data.install_permission, req_data.debug_permission, session=db.session()
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
@@ -1134,19 +1142,19 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account):
args = ParserDynamicOptions.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserDynamicOptions)
def get(self, req_data: ParserDynamicOptions, tenant_id: str, current_user: Account):
try:
options = PluginParameterService.get_dynamic_select_options(
tenant_id=tenant_id,
user_id=current_user.id,
plugin_id=args.plugin_id,
provider=args.provider,
action=args.action,
parameter=args.parameter,
credential_id=args.credential_id,
provider_type=args.provider_type,
plugin_id=req_data.plugin_id,
provider=req_data.provider,
action=req_data.action,
parameter=req_data.parameter,
credential_id=req_data.credential_id,
provider_type=req_data.provider_type,
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -1165,20 +1173,20 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account):
@model_validate(ParserDynamicOptionsWithCredentials)
def post(self, req_data: ParserDynamicOptionsWithCredentials, tenant_id: str, current_user: Account):
"""Fetch dynamic options using credentials directly (for edit mode)."""
args = ParserDynamicOptionsWithCredentials.model_validate(console_ns.payload)
try:
options = PluginParameterService.get_dynamic_select_options_with_credentials(
tenant_id=tenant_id,
user_id=current_user.id,
plugin_id=args.plugin_id,
provider=args.provider,
action=args.action,
parameter=args.parameter,
credential_id=args.credential_id,
credentials=args.credentials,
plugin_id=req_data.plugin_id,
provider=req_data.provider,
action=req_data.action,
parameter=req_data.parameter,
credential_id=req_data.credential_id,
credentials=req_data.credentials,
)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
@@ -1196,13 +1204,12 @@ class PluginChangeAutoUpgradeApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
@model_validate(ParserAutoUpgradeChange)
def post(self, req_data: ParserAutoUpgradeChange, tenant_id: str, user: Account):
if not dify_config.RBAC_ENABLED and not user.is_admin_or_owner:
raise Forbidden()
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
auto_upgrade = args.auto_upgrade
auto_upgrade = req_data.auto_upgrade
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
tenant_id,
auto_upgrade.strategy_setting,
@@ -1210,7 +1217,7 @@ class PluginChangeAutoUpgradeApi(Resource):
auto_upgrade.upgrade_mode,
auto_upgrade.exclude_plugins,
auto_upgrade.include_plugins,
category=args.category,
category=req_data.category,
session=db.session(),
)
if not set_auto_upgrade_strategy_result:
@@ -1227,16 +1234,16 @@ class PluginFetchAutoUpgradeApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category, session=db.session())
@model_validate(ParserAutoUpgradeFetch)
def get(self, req_data: ParserAutoUpgradeFetch, tenant_id: str):
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, req_data.category, session=db.session())
auto_upgrade_dict = (
_auto_upgrade_settings_to_dict(auto_upgrade) if auto_upgrade else _missing_auto_upgrade_settings(tenant_id)
)
return jsonable_encoder(
{
"category": args.category,
"category": req_data.category,
"auto_upgrade": auto_upgrade_dict,
}
)
@@ -1251,14 +1258,14 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
@with_current_tenant_id
def post(self, tenant_id: str):
@model_validate(ParserExcludePlugin)
def post(self, req_data: ParserExcludePlugin, tenant_id: str):
# exclude one single plugin
args = ParserExcludePlugin.model_validate(console_ns.payload)
return jsonable_encoder(
{
"success": PluginAutoUpgradeService.exclude_plugin(
tenant_id, args.plugin_id, args.category, session=db.session()
tenant_id, req_data.plugin_id, req_data.category, session=db.session()
)
}
)
@@ -1272,8 +1279,12 @@ class PluginReadmeApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserReadme.model_validate(request.args.to_dict(flat=True))
@model_validate(ParserReadme)
def get(self, req_data: ParserReadme, tenant_id: str):
return jsonable_encoder(
{"readme": PluginService.fetch_plugin_readme(tenant_id, args.plugin_unique_identifier, args.language)}
{
"readme": PluginService.fetch_plugin_readme(
tenant_id, req_data.plugin_unique_identifier, req_data.language
)
},
)

View File

@@ -33,6 +33,7 @@ from controllers.console.wraps import (
account_initialization_required,
enterprise_license_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
@@ -555,16 +556,15 @@ class ToolBuiltinProviderDeleteApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
payload = BuiltinToolCredentialDeletePayload.model_validate(console_ns.payload or {})
@model_validate(BuiltinToolCredentialDeletePayload)
def post(self, req_data: BuiltinToolCredentialDeletePayload, tenant_id: str, provider: str):
return dump_response(
SimpleResultResponse,
BuiltinToolManageService.delete_builtin_tool_provider(
tenant_id,
provider,
payload.credential_id,
req_data.credential_id,
),
)
@@ -582,19 +582,18 @@ class ToolBuiltinProviderAddApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account, provider: str):
payload = BuiltinToolAddPayload.model_validate(console_ns.payload or {})
@model_validate(BuiltinToolAddPayload)
def post(self, req_data: BuiltinToolAddPayload, tenant_id: str, user: Account, provider: str):
return dump_response(
SimpleResultResponse,
BuiltinToolManageService.add_builtin_tool_provider(
user_id=user.id,
tenant_id=tenant_id,
provider=provider,
credentials=payload.credentials,
name=payload.name,
api_type=CredentialType.of(payload.type),
visibility=payload.visibility,
credentials=req_data.credentials,
name=req_data.name,
api_type=CredentialType.of(req_data.type),
visibility=req_data.visibility,
),
)
@@ -614,16 +613,15 @@ class ToolBuiltinProviderUpdateApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account, provider: str):
payload = BuiltinToolUpdatePayload.model_validate(console_ns.payload or {})
@model_validate(BuiltinToolUpdatePayload)
def post(self, req_data: BuiltinToolUpdatePayload, tenant_id: str, user: Account, provider: str):
result = BuiltinToolManageService.update_builtin_tool_provider(
user_id=user.id,
tenant_id=tenant_id,
provider=provider,
credential_id=payload.credential_id,
credentials=payload.credentials,
name=payload.name or "",
credential_id=req_data.credential_id,
credentials=req_data.credentials,
name=req_data.name or "",
)
return dump_response(SimpleResultResponse, result)
@@ -683,22 +681,21 @@ class ToolApiProviderAddApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
payload = ApiToolProviderAddPayload.model_validate(console_ns.payload or {})
@model_validate(ApiToolProviderAddPayload)
def post(self, req_data: ApiToolProviderAddPayload, tenant_id: str, user: Account):
return dump_response(
SimpleResultResponse,
ApiToolManageService.create_api_tool_provider(
user.id,
tenant_id,
payload.provider,
payload.icon.model_dump(mode="json"),
payload.credentials,
payload.schema_type,
payload.schema_,
payload.privacy_policy or "",
payload.custom_disclaimer or "",
payload.labels or [],
req_data.provider,
req_data.icon.model_dump(mode="json"),
req_data.credentials,
req_data.schema_type,
req_data.schema_,
req_data.privacy_policy or "",
req_data.custom_disclaimer or "",
req_data.labels or [],
),
)
@@ -764,23 +761,22 @@ class ToolApiProviderUpdateApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
payload = ApiToolProviderUpdatePayload.model_validate(console_ns.payload or {})
@model_validate(ApiToolProviderUpdatePayload)
def post(self, req_data: ApiToolProviderUpdatePayload, tenant_id: str, user: Account):
return dump_response(
SimpleResultResponse,
ApiToolManageService.update_api_tool_provider(
user.id,
tenant_id,
payload.provider,
payload.original_provider,
payload.icon.model_dump(mode="json"),
payload.credentials,
payload.schema_type,
payload.schema_,
payload.privacy_policy,
payload.custom_disclaimer,
payload.labels or [],
req_data.provider,
req_data.original_provider,
req_data.icon.model_dump(mode="json"),
req_data.credentials,
req_data.schema_type,
req_data.schema_,
req_data.privacy_policy,
req_data.custom_disclaimer,
req_data.labels or [],
),
)
@@ -796,15 +792,14 @@ class ToolApiProviderDeleteApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
payload = ApiToolProviderDeletePayload.model_validate(console_ns.payload or {})
@model_validate(ApiToolProviderDeletePayload)
def post(self, req_data: ApiToolProviderDeletePayload, tenant_id: str, user: Account):
return dump_response(
SimpleResultResponse,
ApiToolManageService.delete_api_tool_provider(
user.id,
tenant_id,
payload.provider,
req_data.provider,
),
)
@@ -861,10 +856,9 @@ class ToolApiProviderSchemaApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
payload = ApiToolSchemaPayload.model_validate(console_ns.payload or {})
return dump_response(ApiSchemaParseResponse, ApiToolManageService.parser_api_schema(schema=payload.schema_))
@model_validate(ApiToolSchemaPayload)
def post(self, req_data: ApiToolSchemaPayload):
return dump_response(ApiSchemaParseResponse, ApiToolManageService.parser_api_schema(schema=req_data.schema_))
@console_ns.route("/workspaces/current/tool-provider/api/test/pre")
@@ -879,18 +873,18 @@ class ToolApiProviderPreviousTestApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
payload = ApiToolTestPayload.model_validate(console_ns.payload or {})
@model_validate(ApiToolTestPayload)
def post(self, req_data: ApiToolTestPayload, current_tenant_id: str):
return dump_response(
ApiToolPreviewResponse,
ApiToolManageService.test_api_tool_preview(
current_tenant_id,
payload.provider_name or "",
payload.tool_name,
payload.credentials,
payload.parameters,
payload.schema_type,
payload.schema_,
req_data.provider_name or "",
req_data.tool_name,
req_data.credentials,
req_data.parameters,
req_data.schema_type,
req_data.schema_,
),
)
@@ -906,22 +900,21 @@ class ToolWorkflowProviderCreateApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
payload = WorkflowToolCreatePayload.model_validate(console_ns.payload or {})
@model_validate(WorkflowToolCreatePayload)
def post(self, req_data: WorkflowToolCreatePayload, tenant_id: str, user: Account):
return dump_response(
SimpleResultResponse,
WorkflowToolManageService.create_workflow_tool(
user_id=user.id,
tenant_id=tenant_id,
workflow_app_id=payload.workflow_app_id,
name=payload.name,
label=payload.label,
icon=payload.icon.model_dump(mode="json"),
description=payload.description,
parameters=payload.parameters,
privacy_policy=payload.privacy_policy or "",
labels=payload.labels or [],
workflow_app_id=req_data.workflow_app_id,
name=req_data.name,
label=req_data.label,
icon=req_data.icon.model_dump(mode="json"),
description=req_data.description,
parameters=req_data.parameters,
privacy_policy=req_data.privacy_policy or "",
labels=req_data.labels or [],
),
)
@@ -937,22 +930,21 @@ class ToolWorkflowProviderUpdateApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
payload = WorkflowToolUpdatePayload.model_validate(console_ns.payload or {})
@model_validate(WorkflowToolUpdatePayload)
def post(self, req_data: WorkflowToolUpdatePayload, tenant_id: str, user: Account):
return dump_response(
SimpleResultResponse,
WorkflowToolManageService.update_workflow_tool(
user.id,
tenant_id,
payload.workflow_tool_id,
payload.name,
payload.label,
payload.icon.model_dump(mode="json"),
payload.description,
payload.parameters,
payload.privacy_policy or "",
payload.labels or [],
req_data.workflow_tool_id,
req_data.name,
req_data.label,
req_data.icon.model_dump(mode="json"),
req_data.description,
req_data.parameters,
req_data.privacy_policy or "",
req_data.labels or [],
),
)
@@ -968,15 +960,14 @@ class ToolWorkflowProviderDeleteApi(Resource):
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
payload = WorkflowToolDeletePayload.model_validate(console_ns.payload or {})
@model_validate(WorkflowToolDeletePayload)
def post(self, req_data: WorkflowToolDeletePayload, tenant_id: str, user: Account):
return dump_response(
SimpleResultResponse,
WorkflowToolManageService.delete_workflow_tool(
user.id,
tenant_id,
payload.workflow_tool_id,
req_data.workflow_tool_id,
),
)
@@ -1224,12 +1215,12 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
payload = BuiltinProviderDefaultCredentialPayload.model_validate(console_ns.payload or {})
@model_validate(BuiltinProviderDefaultCredentialPayload)
def post(self, req_data: BuiltinProviderDefaultCredentialPayload, current_tenant_id: str, provider: str):
return dump_response(
SimpleResultResponse,
BuiltinToolManageService.set_default_provider(
tenant_id=current_tenant_id, provider=provider, id=payload.id
tenant_id=current_tenant_id, provider=provider, id=req_data.id
),
)
@@ -1246,17 +1237,16 @@ class ToolOAuthCustomClient(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
payload = ToolOAuthCustomClientPayload.model_validate(console_ns.payload or {})
@model_validate(ToolOAuthCustomClientPayload)
def post(self, req_data: ToolOAuthCustomClientPayload, tenant_id: str, provider: str):
return dump_response(
SimpleResultResponse,
BuiltinToolManageService.save_custom_oauth_client_params(
tenant_id=tenant_id,
provider=provider,
client_params=payload.client_params or {},
enable_oauth_custom_client=payload.enable_oauth_custom_client
if payload.enable_oauth_custom_client is not None
client_params=req_data.client_params or {},
enable_oauth_custom_client=req_data.enable_oauth_custom_client
if req_data.enable_oauth_custom_client is not None
else True,
),
)
@@ -1349,11 +1339,10 @@ class ToolProviderMCPApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
payload = MCPProviderCreatePayload.model_validate(console_ns.payload or {})
configuration = payload.configuration or MCPConfiguration()
authentication = payload.authentication
@model_validate(MCPProviderCreatePayload)
def post(self, req_data: MCPProviderCreatePayload, tenant_id: str, user: Account):
configuration = req_data.configuration or MCPConfiguration()
authentication = req_data.authentication
# 1) Create provider in a short transaction (no network I/O inside)
with session_factory.create_session() as session, session.begin():
@@ -1361,24 +1350,24 @@ class ToolProviderMCPApi(Resource):
result = service.create_provider(
tenant_id=tenant_id,
user_id=user.id,
server_url=payload.server_url,
name=payload.name,
icon=payload.icon,
icon_type=payload.icon_type,
icon_background=payload.icon_background,
server_identifier=payload.server_identifier,
headers=payload.headers or {},
server_url=req_data.server_url,
name=req_data.name,
icon=req_data.icon,
icon_type=req_data.icon_type,
icon_background=req_data.icon_background,
server_identifier=req_data.server_identifier,
headers=req_data.headers or {},
configuration=configuration,
authentication=authentication,
identity_mode=_resolve_identity_mode(payload.identity_mode, current=IdentityMode.OFF),
identity_mode=_resolve_identity_mode(req_data.identity_mode, current=IdentityMode.OFF),
)
# 2) Try to fetch tools immediately after creation so they appear without a second save.
# Perform network I/O outside any DB session to avoid holding locks.
try:
reconnect = MCPToolManageService.reconnect_with_url(
server_url=payload.server_url,
headers=payload.headers or {},
server_url=req_data.server_url,
headers=req_data.headers or {},
timeout=configuration.timeout,
sse_read_timeout=configuration.sse_read_timeout,
)
@@ -1403,24 +1392,24 @@ class ToolProviderMCPApi(Resource):
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_tenant_id
def put(self, current_tenant_id: str):
payload = MCPProviderUpdatePayload.model_validate(console_ns.payload or {})
configuration = payload.configuration or MCPConfiguration()
authentication = payload.authentication
@model_validate(MCPProviderUpdatePayload)
def put(self, req_data: MCPProviderUpdatePayload, current_tenant_id: str):
configuration = req_data.configuration or MCPConfiguration()
authentication = req_data.authentication
# Step 1: Get provider data for URL validation (short-lived session, no network I/O)
validation_data = None
with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session)
validation_data = service.get_provider_for_url_validation(
tenant_id=current_tenant_id, provider_id=payload.provider_id
tenant_id=current_tenant_id, provider_id=req_data.provider_id
)
# Step 2: Perform URL validation with network I/O OUTSIDE of any database session
# This prevents holding database locks during potentially slow network operations
validation_result = MCPToolManageService.validate_server_url_standalone(
tenant_id=current_tenant_id,
new_server_url=payload.server_url,
new_server_url=req_data.server_url,
validation_data=validation_data,
)
@@ -1430,18 +1419,18 @@ class ToolProviderMCPApi(Resource):
# Resolve "leave unchanged" (None) against the stored value, and gate
# the result on ENTERPRISE_ENABLED — both are API-layer concerns, so
# the service receives a concrete IdentityMode.
existing = service.get_provider(provider_id=payload.provider_id, tenant_id=current_tenant_id)
identity_mode = _resolve_identity_mode(payload.identity_mode, current=IdentityMode(existing.identity_mode))
existing = service.get_provider(provider_id=req_data.provider_id, tenant_id=current_tenant_id)
identity_mode = _resolve_identity_mode(req_data.identity_mode, current=IdentityMode(existing.identity_mode))
service.update_provider(
tenant_id=current_tenant_id,
provider_id=payload.provider_id,
server_url=payload.server_url,
name=payload.name,
icon=payload.icon,
icon_type=payload.icon_type,
icon_background=payload.icon_background,
server_identifier=payload.server_identifier,
headers=payload.headers or {},
provider_id=req_data.provider_id,
server_url=req_data.server_url,
name=req_data.name,
icon=req_data.icon,
icon_type=req_data.icon_type,
icon_background=req_data.icon_background,
server_identifier=req_data.server_identifier,
headers=req_data.headers or {},
configuration=configuration,
authentication=authentication,
validation_result=validation_result,
@@ -1457,12 +1446,11 @@ class ToolProviderMCPApi(Resource):
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_tenant_id
def delete(self, current_tenant_id: str):
payload = MCPProviderDeletePayload.model_validate(console_ns.payload or {})
@model_validate(MCPProviderDeletePayload)
def delete(self, req_data: MCPProviderDeletePayload, current_tenant_id: str):
with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session)
service.delete_provider(tenant_id=current_tenant_id, provider_id=payload.provider_id)
service.delete_provider(tenant_id=current_tenant_id, provider_id=req_data.provider_id)
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -1476,9 +1464,9 @@ class ToolMCPAuthApi(Resource):
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_tenant_id
def post(self, tenant_id: str):
payload = MCPAuthPayload.model_validate(console_ns.payload or {})
provider_id = payload.provider_id
@model_validate(MCPAuthPayload)
def post(self, req_data: MCPAuthPayload, tenant_id: str):
provider_id = req_data.provider_id
with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session)
@@ -1515,7 +1503,7 @@ class ToolMCPAuthApi(Resource):
# Pass the extracted OAuth metadata hints to auth()
auth_result = auth(
provider_entity,
payload.authorization_code,
req_data.authorization_code,
resource_metadata_url=e.resource_metadata_url,
scope_hint=e.scope_hint,
)

View File

@@ -28,21 +28,29 @@ from controllers.console.agent.roster import (
AgentApiKeyApi,
AgentApiKeyListApi,
AgentApiStatusApi,
AgentApiStatusPayload,
AgentAppApi,
AgentAppCopyApi,
AgentAppCopyPayload,
AgentAppCreatePayload,
AgentAppListApi,
AgentAppUpdatePayload,
AgentBuildDraftApi,
AgentBuildDraftApplyApi,
AgentBuildDraftCheckoutApi,
AgentBuildDraftCheckoutPayload,
AgentDebugConversationRefreshApi,
AgentInviteOptionsApi,
AgentInviteOptionsQuery,
AgentLogMessagesApi,
AgentLogsApi,
AgentLogSourcesApi,
AgentPublishApi,
AgentPublishPayload,
AgentRosterVersionDetailApi,
AgentRosterVersionRestoreApi,
AgentRosterVersionsApi,
AgentStatisticsQuery,
AgentStatisticsSummaryApi,
)
from controllers.console.app import completion as completion_controller
@@ -59,7 +67,13 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from models.agent import Agent, AgentConfigDraftType, AgentScope, AgentSource, AgentStatus
from models.enums import ConversationFromSource
from models.model import AppMode, Conversation, Message
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
from services.entities.agent_entities import (
ComposerSavePayload,
ComposerSaveStrategy,
ComposerVariant,
WorkflowAgentComposerQuery,
WorkflowComposerCopyFromRosterPayload,
)
def _persist_conversation_message(
@@ -430,7 +444,13 @@ def test_agent_app_list_and_create_use_agent_route(
json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"},
):
created, status = unwrap(AgentAppListApi.post)(
AgentAppListApi(), MagicMock(), "tenant-1", SimpleNamespace(id=account_id)
AgentAppListApi(),
AgentAppCreatePayload(
name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot"
),
MagicMock(),
"tenant-1",
SimpleNamespace(id=account_id),
)
assert status == 201
assert created["id"] == "agent-created"
@@ -484,7 +504,13 @@ def test_agent_app_create_omits_optional_role_as_empty_string(
"/console/api/agent",
json={"name": "No-role Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"},
):
created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), MagicMock(), "tenant-1", current_user)
created, status = unwrap(AgentAppListApi.post)(
AgentAppListApi(),
AgentAppCreatePayload(name="No-role Iris", description="Agent app", icon_type="emoji", icon="robot"),
MagicMock(),
"tenant-1",
current_user,
)
assert status == 201
assert created == {"id": "agent-created", "app_id": "app-created"}
create_call = cast(dict[str, object], captured["create"])
@@ -566,7 +592,14 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
"/console/api/agent/00000000-0000-0000-0000-000000000001",
json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"},
):
updated = unwrap(AgentAppApi.put)(AgentAppApi(), session, tenant_id, SimpleNamespace(id=account_id), agent_id)
updated = unwrap(AgentAppApi.put)(
AgentAppApi(),
AgentAppUpdatePayload(name="Renamed", description="", role="Reviewer", icon_type="emoji", icon="R"),
session,
tenant_id,
SimpleNamespace(id=account_id),
agent_id,
)
assert updated["name"] == "Renamed"
assert updated["id"] == agent_id
assert updated["app_id"] == app_id
@@ -615,7 +648,19 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
},
):
copied, status = unwrap(AgentAppCopyApi.post)(
AgentAppCopyApi(), MagicMock(), "tenant-1", current_user, agent_id
AgentAppCopyApi(),
AgentAppCopyPayload(
name="Iris copy",
description="Copied",
role="Copied role",
icon_type="emoji",
icon="sparkles",
icon_background="#fff",
),
MagicMock(),
"tenant-1",
current_user,
agent_id,
)
assert status == 201
assert copied == {"id": "copied-agent", "app_id": "copied-app", "name": "Iris"}
@@ -715,7 +760,14 @@ def test_agent_publish_and_build_draft_routes_call_composer_service(
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/publish", json={"version_note": "publish v1"}
):
published = unwrap(AgentPublishApi.post)(AgentPublishApi(), MagicMock(), "tenant-1", current_user, agent_id)
published = unwrap(AgentPublishApi.post)(
AgentPublishApi(),
AgentPublishPayload(version_note="publish v1"),
MagicMock(),
"tenant-1",
current_user,
agent_id,
)
assert published["active_config_snapshot_id"] == "version-1"
captured["publish"].pop("session", None)
assert captured["publish"] == {
@@ -728,7 +780,12 @@ def test_agent_publish_and_build_draft_routes_call_composer_service(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout", json={"force": True}
):
checked_out = unwrap(AgentBuildDraftCheckoutApi.post)(
AgentBuildDraftCheckoutApi(), MagicMock(), "tenant-1", current_user, agent_id
AgentBuildDraftCheckoutApi(),
AgentBuildDraftCheckoutPayload(force=True),
MagicMock(),
"tenant-1",
current_user,
agent_id,
)
assert checked_out["draft"]["id"] == "build-draft-1"
captured["checkout"].pop("session", None)
@@ -747,7 +804,17 @@ def test_agent_publish_and_build_draft_routes_call_composer_service(
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}},
):
saved = unwrap(AgentBuildDraftApi.put)(AgentBuildDraftApi(), MagicMock(), "tenant-1", current_user, agent_id)
saved = unwrap(AgentBuildDraftApi.put)(
AgentBuildDraftApi(),
ComposerSavePayload(
variant=ComposerVariant.AGENT_APP,
save_strategy=ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
),
MagicMock(),
"tenant-1",
current_user,
agent_id,
)
assert saved["draft"]["id"] == "build-draft-1"
assert captured["save"]["tenant_id"] == "tenant-1"
assert captured["save"]["agent_id"] == agent_id
@@ -864,7 +931,9 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable", json={"enable_api": True}
):
enabled = unwrap(AgentApiStatusApi.post)(AgentApiStatusApi(), unbound_session, "tenant-1", agent_id)
enabled = unwrap(AgentApiStatusApi.post)(
AgentApiStatusApi(), AgentApiStatusPayload(enable_api=True), unbound_session, "tenant-1", agent_id
)
assert enabled["enabled"] is True
assert captured["enable"] == {"app": app_model, "enable_api": True}
keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), unbound_session, "tenant-1", agent_id)
@@ -948,7 +1017,12 @@ def test_agent_app_update_allows_empty_role(app: Flask, monkeypatch: pytest.Monk
json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"},
):
updated = unwrap(AgentAppApi.put)(
AgentAppApi(), MagicMock(), "tenant-1", SimpleNamespace(id="account-1"), agent_id
AgentAppApi(),
AgentAppUpdatePayload(name="Renamed", description="", role="", icon_type="emoji", icon="R"),
MagicMock(),
"tenant-1",
SimpleNamespace(id="account-1"),
agent_id,
)
assert updated["role"] == ""
update_call = cast(dict[str, object], captured["update"])
@@ -964,7 +1038,9 @@ def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.Monkey
monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options)
with app.test_request_context("/console/api/agent/invite-options?page=1&limit=10&app_id=app-1"):
result = unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi(), MagicMock(), "tenant-1")
result = unwrap(AgentInviteOptionsApi.get)(
AgentInviteOptionsApi(), AgentInviteOptionsQuery(page=1, limit=10, app_id="app-1"), MagicMock(), "tenant-1"
)
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
@@ -1201,7 +1277,7 @@ def test_agent_observability_routes_resolve_app_from_agent_id(
"/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api"
):
statistics = unwrap(AgentStatisticsSummaryApi.get)(
AgentStatisticsSummaryApi(), MagicMock(), "tenant-1", account, agent_id
AgentStatisticsSummaryApi(), AgentStatisticsQuery(source="api"), MagicMock(), "tenant-1", account, agent_id
)
assert statistics["summary"]["total_messages"] == 1
stats_call = cast(dict[str, object], captured["statistics"])
@@ -1253,18 +1329,40 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
)
with app.test_request_context("?snapshot_id=preview-version"):
workflow_state = unwrap(WorkflowAgentComposerApi.get)(
WorkflowAgentComposerApi(), MagicMock(), "tenant-1", account_id, app_model, "node-1"
WorkflowAgentComposerApi(),
WorkflowAgentComposerQuery(snapshot_id="preview-version"),
MagicMock(),
"tenant-1",
account_id,
app_model,
"node-1",
)
assert workflow_state["node_id"] == "node-1"
assert captured_load["account_id"] == account_id
assert captured_load["snapshot_id"] == "preview-version"
composer_save_payload = ComposerSavePayload(
variant=ComposerVariant.WORKFLOW,
save_strategy=ComposerSaveStrategy.NODE_JOB_ONLY,
binding={"binding_type": "roster_agent", "current_snapshot_id": "version-1"},
)
with app.test_request_context(json=payload):
saved_state = unwrap(WorkflowAgentComposerApi.put)(
WorkflowAgentComposerApi(), MagicMock(), "tenant-1", account_id, app_model, "node-1"
WorkflowAgentComposerApi(),
composer_save_payload,
MagicMock(),
"tenant-1",
account_id,
app_model,
"node-1",
)
assert saved_state["save_options"] == ["node_job_only"]
assert unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), MagicMock(), "tenant-1", app_model, "node-1"
WorkflowAgentComposerValidateApi(),
composer_save_payload,
MagicMock(),
"tenant-1",
app_model,
"node-1",
) == {"result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": []}
assert (
unwrap(WorkflowAgentComposerCandidatesApi.get)(
@@ -1274,10 +1372,21 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
)
with app.test_request_context(json=payload):
assert unwrap(WorkflowAgentComposerImpactApi.post)(
WorkflowAgentComposerImpactApi(), MagicMock(), "tenant-1", app_model, "node-1"
WorkflowAgentComposerImpactApi(),
composer_save_payload,
MagicMock(),
"tenant-1",
app_model,
"node-1",
) == {"current_snapshot_id": "version-1", "workflow_node_count": 1, "bindings": []}
assert unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), MagicMock(), "tenant-1", account_id, app_model, "node-1"
WorkflowAgentComposerSaveToRosterApi(),
composer_save_payload,
MagicMock(),
"tenant-1",
account_id,
app_model,
"node-1",
)["save_options"] == ["node_job_only"]
@@ -1325,7 +1434,17 @@ def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.Monk
}
):
result = unwrap(WorkflowAgentComposerCopyFromRosterApi.post)(
WorkflowAgentComposerCopyFromRosterApi(), MagicMock(), "tenant-1", account_id, app_model, "node-1"
WorkflowAgentComposerCopyFromRosterApi(),
WorkflowComposerCopyFromRosterPayload(
source_agent_id="roster-agent-1",
source_snapshot_id="roster-version-1",
idempotency_key="copy-1",
),
MagicMock(),
"tenant-1",
account_id,
app_model,
"node-1",
)
assert result["binding"]["binding_type"] == "inline_agent"
captured.pop("session", None)
@@ -1344,7 +1463,15 @@ def test_workflow_impact_returns_empty_without_version(app: Flask) -> None:
payload = {"variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value}
with app.test_request_context(json=payload):
result = unwrap(WorkflowAgentComposerImpactApi.post)(
WorkflowAgentComposerImpactApi(), MagicMock(), "tenant-1", SimpleNamespace(id="app-1"), "node-1"
WorkflowAgentComposerImpactApi(),
ComposerSavePayload(
variant=ComposerVariant.WORKFLOW,
save_strategy=ComposerSaveStrategy.NODE_JOB_ONLY,
),
MagicMock(),
"tenant-1",
SimpleNamespace(id="app-1"),
"node-1",
)
assert result == {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
@@ -1387,12 +1514,21 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
assert composer["variant"] == "agent_app"
assert composer["active_config_is_published"] is True
assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id
composer_save_payload = ComposerSavePayload(
variant=ComposerVariant.AGENT_APP,
save_strategy=ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
agent_soul={"prompt": {"system_prompt": "x"}},
)
with app.test_request_context(json=payload):
saved_composer = unwrap(AgentComposerApi.put)(AgentComposerApi(), MagicMock(), "tenant-1", account_id, agent_id)
saved_composer = unwrap(AgentComposerApi.put)(
AgentComposerApi(), composer_save_payload, MagicMock(), "tenant-1", account_id, agent_id
)
assert saved_composer["variant"] == "agent_app"
assert saved_composer["active_config_is_published"] is True
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), MagicMock(), "tenant-1", agent_id) == {
assert unwrap(AgentComposerValidateApi.post)(
AgentComposerValidateApi(), composer_save_payload, MagicMock(), "tenant-1", agent_id
) == {
"result": "success",
"errors": [],
"warnings": [],

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest
from werkzeug.exceptions import BadRequest, UnprocessableEntity
from controllers.console import wraps as console_wraps
from controllers.console.billing.billing import PartnerTenants
@@ -129,7 +129,7 @@ class TestPartnerTenants:
assert "Invalid partner_key" in str(exc_info.value)
def test_put_missing_click_id(self, app: Flask, mock_account, mock_billing_service, mock_decorators):
"""Test that missing click_id raises BadRequest."""
"""Test that missing click_id raises UnprocessableEntity (422)."""
# Arrange
partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8")
@@ -148,8 +148,8 @@ class TestPartnerTenants:
resource = PartnerTenants()
# Act & Assert
# Validation should raise BadRequest for missing required field
with pytest.raises(BadRequest):
# Validation should raise UnprocessableEntity (422) for missing required field
with pytest.raises(UnprocessableEntity):
resource.put(partner_key_encoded)
def test_put_billing_service_json_decode_error(

View File

@@ -6,6 +6,7 @@ from flask import Flask
from pydantic import ValidationError
import controllers.console.explore.recommended_app as module
from controllers.console.explore.recommended_app import RecommendedAppsQuery
from models import Account
from models.model import AppMode, IconType
@@ -32,7 +33,7 @@ class TestRecommendedAppListApi:
return_value=result_data,
) as service_mock,
):
result = method(api, make_account("fr-FR"))
result = method(api, RecommendedAppsQuery(language="en-US"), make_account("fr-FR"))
service_mock.assert_called_once_with("en-US", session=ANY)
assert result == result_data
@@ -51,7 +52,7 @@ class TestRecommendedAppListApi:
return_value=result_data,
) as service_mock,
):
result = method(api, make_account("fr-FR"))
result = method(api, RecommendedAppsQuery(), make_account("fr-FR"))
service_mock.assert_called_once_with("fr-FR", session=ANY)
assert result == result_data
@@ -70,7 +71,7 @@ class TestRecommendedAppListApi:
return_value=result_data,
) as service_mock,
):
result = method(api, make_account(None))
result = method(api, RecommendedAppsQuery(), make_account(None))
service_mock.assert_called_once_with(module.languages[0], session=ANY)
assert result == result_data
@@ -91,7 +92,7 @@ class TestLearnDifyAppListApi:
return_value=result_data,
) as service_mock,
):
result = method(api, make_account("fr-FR"))
result = method(api, RecommendedAppsQuery(language="en-US"), make_account("fr-FR"))
service_mock.assert_called_once_with("en-US", session=ANY)
assert result == result_data
@@ -110,7 +111,7 @@ class TestLearnDifyAppListApi:
return_value=result_data,
) as service_mock,
):
result = method(api, make_account("fr-FR"))
result = method(api, RecommendedAppsQuery(), make_account("fr-FR"))
service_mock.assert_called_once_with("fr-FR", session=ANY)
assert result == result_data

View File

@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from flask import Flask, request
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
@@ -28,6 +28,7 @@ from controllers.console.explore.error import (
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.trial import ChatRequest, CompletionRequest, TextToSpeechRequest, WorkflowRunRequest
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -258,9 +259,15 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with app.test_request_context("/"):
with app.test_request_context("/", json={"inputs": {}}):
with pytest.raises(NotWorkflowAppError):
method(api, self.sqlite_session, account, MagicMock(mode=AppMode.CHAT))
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
MagicMock(mode=AppMode.CHAT),
)
def test_success(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
@@ -271,7 +278,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
patch.object(module.AppGenerateService, "generate", return_value=MagicMock()),
patch.object(module.RecommendedAppService, "add_trial_app_record"),
):
result = method(api, self.sqlite_session, account, trial_app_workflow)
result = method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
assert result is not None
@@ -288,7 +301,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderNotInitializeError):
method(api, self.sqlite_session, account, trial_app_workflow)
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
@@ -303,7 +322,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderQuotaExceededError):
method(api, self.sqlite_session, account, trial_app_workflow)
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_model_not_support(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
@@ -318,7 +343,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method(api, self.sqlite_session, account, trial_app_workflow)
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_invoke_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
@@ -333,7 +364,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
),
):
with pytest.raises(CompletionRequestError):
method(api, self.sqlite_session, account, trial_app_workflow)
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
@@ -348,7 +385,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
),
):
with pytest.raises(InvokeRateLimitHttpError):
method(api, self.sqlite_session, account, trial_app_workflow)
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_value_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
@@ -363,7 +406,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
),
):
with pytest.raises(ValueError):
method(api, self.sqlite_session, account, trial_app_workflow)
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_generic_exception(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
@@ -378,7 +427,13 @@ class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
),
):
with pytest.raises(InternalServerError):
method(api, self.sqlite_session, account, trial_app_workflow)
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
class TestTrialChatApi(_UsesSQLiteSession):
@@ -388,7 +443,13 @@ class TestTrialChatApi(_UsesSQLiteSession):
with app.test_request_context("/", json={"inputs": {}, "query": "hi"}):
with pytest.raises(NotChatAppError):
method(api, self.sqlite_session, account, MagicMock(mode="completion"))
method(
api,
ChatRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
MagicMock(mode="completion"),
)
def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -399,7 +460,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
patch.object(module.AppGenerateService, "generate", return_value=MagicMock()),
patch.object(module.RecommendedAppService, "add_trial_app_record"),
):
result = method(api, self.sqlite_session, account, trial_app_chat)
result = method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
assert result is not None
@@ -416,7 +479,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(NotFound):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_conversation_completed(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -431,7 +496,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(ConversationCompletedError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -446,7 +513,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(AppUnavailableError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -461,7 +530,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderNotInitializeError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -476,7 +547,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderQuotaExceededError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_model_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -491,7 +564,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -506,7 +581,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(CompletionRequestError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_rate_limit_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -521,7 +598,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(InvokeRateLimitHttpError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_value_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -536,7 +615,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(ValueError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_generic_exception(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatApi()
@@ -551,7 +632,9 @@ class TestTrialChatApi(_UsesSQLiteSession):
),
):
with pytest.raises(InternalServerError):
method(api, self.sqlite_session, account, trial_app_chat)
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
class TestTrialCompletionApi(_UsesSQLiteSession):
@@ -561,7 +644,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
with app.test_request_context("/", json={"inputs": {}, "query": ""}):
with pytest.raises(NotCompletionAppError):
method(api, self.sqlite_session, account, MagicMock(mode=AppMode.CHAT))
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
MagicMock(mode=AppMode.CHAT),
)
def test_success(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -572,7 +661,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
patch.object(module.AppGenerateService, "generate", return_value=MagicMock()),
patch.object(module.RecommendedAppService, "add_trial_app_record"),
):
result = method(api, self.sqlite_session, account, trial_app_completion)
result = method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
assert result is not None
@@ -589,7 +684,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(AppUnavailableError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_provider_not_init(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -604,7 +705,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderNotInitializeError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_quota_exceeded(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -619,7 +726,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderQuotaExceededError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_model_not_support(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -634,7 +747,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_invoke_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -649,7 +768,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(CompletionRequestError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_rate_limit_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -664,7 +789,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(InternalServerError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_value_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -679,7 +810,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(ValueError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_generic_exception(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None:
api = module.TrialCompletionApi()
@@ -694,7 +831,13 @@ class TestTrialCompletionApi(_UsesSQLiteSession):
),
):
with pytest.raises(InternalServerError):
method(api, self.sqlite_session, account, trial_app_completion)
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
class TestTrialMessageSuggestedQuestionApi:
@@ -843,7 +986,11 @@ class TestTrialChatAudioApi:
),
):
with pytest.raises(module.AppUnavailableError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -862,7 +1009,11 @@ class TestTrialChatAudioApi:
),
):
with pytest.raises(module.NoAudioUploadedError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_missing_file_field_returns_400(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
"""A multipart POST with no `file` field must surface as 400, not 500.
@@ -883,7 +1034,11 @@ class TestTrialChatAudioApi:
patch.object(module.AudioService, "transcript_asr", side_effect=fake_asr),
):
with pytest.raises(module.NoAudioUploadedError) as exc_info:
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
assert exc_info.value.code == 400
@@ -904,7 +1059,11 @@ class TestTrialChatAudioApi:
),
):
with pytest.raises(module.AudioTooLargeError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_unsupported_audio_type(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -923,7 +1082,11 @@ class TestTrialChatAudioApi:
),
):
with pytest.raises(module.UnsupportedAudioTypeError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_provider_not_support_tts(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -942,7 +1105,11 @@ class TestTrialChatAudioApi:
),
):
with pytest.raises(module.ProviderNotSupportSpeechToTextError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_speech_to_text_disabled(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -960,7 +1127,11 @@ class TestTrialChatAudioApi:
),
):
with pytest.raises(SpeechToTextDisabledError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -975,7 +1146,11 @@ class TestTrialChatAudioApi:
patch.object(module.AudioService, "transcript_asr", side_effect=ProviderTokenNotInitError("test")),
):
with pytest.raises(ProviderNotInitializeError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -990,7 +1165,11 @@ class TestTrialChatAudioApi:
patch.object(module.AudioService, "transcript_asr", side_effect=QuotaExceededError()),
):
with pytest.raises(ProviderQuotaExceededError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
class TestTrialChatTextApi:
@@ -1003,7 +1182,9 @@ class TestTrialChatTextApi:
patch.object(module.AudioService, "transcript_tts", return_value={"audio": "base64_data"}),
patch.object(module.RecommendedAppService, "add_trial_app_record"),
):
result = method(api, account, trial_app_chat)
result = method(
api, TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}), account, trial_app_chat
)
assert result == {"audio": "base64_data"}
@@ -1018,7 +1199,9 @@ class TestTrialChatTextApi:
patch.object(module.AudioService, "transcript_tts", transcript_tts),
patch.object(module.RecommendedAppService, "add_trial_app_record"),
):
result = method(api, account, trial_app_chat)
result = method(
api, TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}), account, trial_app_chat
)
assert result == {"audio": "base64_data"}
assert transcript_tts.call_args.kwargs["message_ref"] == MessageRef(
@@ -1040,7 +1223,12 @@ class TestTrialChatTextApi:
),
):
with pytest.raises(module.AppUnavailableError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_provider_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1055,7 +1243,12 @@ class TestTrialChatTextApi:
),
):
with pytest.raises(module.ProviderNotSupportSpeechToTextError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1070,7 +1263,12 @@ class TestTrialChatTextApi:
),
):
with pytest.raises(module.AudioTooLargeError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1085,7 +1283,12 @@ class TestTrialChatTextApi:
),
):
with pytest.raises(module.NoAudioUploadedError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1096,7 +1299,12 @@ class TestTrialChatTextApi:
patch.object(module.AudioService, "transcript_tts", side_effect=ProviderTokenNotInitError("test")),
):
with pytest.raises(ProviderNotInitializeError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1107,7 +1315,12 @@ class TestTrialChatTextApi:
patch.object(module.AudioService, "transcript_tts", side_effect=QuotaExceededError()),
):
with pytest.raises(ProviderQuotaExceededError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_model_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1118,7 +1331,12 @@ class TestTrialChatTextApi:
patch.object(module.AudioService, "transcript_tts", side_effect=ModelCurrentlyNotSupportError()),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1129,14 +1347,19 @@ class TestTrialChatTextApi:
patch.object(module.AudioService, "transcript_tts", side_effect=InvokeError("test error")),
):
with pytest.raises(CompletionRequestError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
class TestTrialAppWorkflowTaskStopApi:
def test_not_workflow_app(self, app: Flask, trial_app_chat: MagicMock) -> None:
api = module.TrialAppWorkflowTaskStopApi()
with app.test_request_context("/"):
with app.test_request_context("/", json={"inputs": {}}):
with pytest.raises(NotWorkflowAppError):
api.post(trial_app_chat, str(uuid4()))
@@ -1336,7 +1559,11 @@ class TestTrialChatAudioApiExceptionHandlers:
),
):
with pytest.raises(ProviderNotInitializeError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -1355,7 +1582,11 @@ class TestTrialChatAudioApiExceptionHandlers:
),
):
with pytest.raises(ProviderQuotaExceededError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
@@ -1374,7 +1605,11 @@ class TestTrialChatAudioApiExceptionHandlers:
),
):
with pytest.raises(CompletionRequestError):
method(api, account, trial_app_chat)
method(
api,
account,
trial_app_chat,
)
class TestTrialChatTextApiExceptionHandlers:
@@ -1391,7 +1626,12 @@ class TestTrialChatTextApiExceptionHandlers:
),
):
with pytest.raises(module.AppUnavailableError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)
def test_unsupported_audio_type(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
@@ -1406,4 +1646,9 @@ class TestTrialChatTextApiExceptionHandlers:
),
):
with pytest.raises(module.UnsupportedAudioTypeError):
method(api, account, trial_app_chat)
method(
api,
TextToSpeechRequest.model_validate(request.get_json(silent=True) or {}),
account,
trial_app_chat,
)

View File

@@ -132,7 +132,14 @@ def test_draft_workflow_post_returns_400_for_invalid_graph(app: Flask, monkeypat
method="POST",
json={"graph": {"nodes": [], "edges": []}, "hash": "hash-1"},
):
response, status_code = handler(api, user, snippet)
response, status_code = handler(
api,
snippet_workflow_module.SnippetDraftSyncPayload.model_validate(
{"graph": {"nodes": [], "edges": []}, "hash": "hash-1"}
),
user,
snippet,
)
assert status_code == 400
assert response == {"message": "invalid graph"}
@@ -244,7 +251,11 @@ def test_list_published_snippet_workflows_includes_input_fields(
handler = unwrap(api.get)
with app.test_request_context("/snippets/snippet-1/workflows?page=1&limit=20"):
response = handler(api, snippet=snippet)
response = handler(
api,
snippet_workflow_module.SnippetWorkflowListQuery.model_validate({"page": 1, "limit": 20}),
snippet=snippet,
)
assert response["items"][0]["input_fields"] == input_fields
@@ -411,7 +422,15 @@ def test_update_published_snippet_workflow_returns_updated_workflow(
method="PATCH",
json={"marked_name": "v1", "marked_comment": "first version"},
):
response = handler(api, user, snippet, workflow_id="workflow-1")
response = handler(
api,
snippet_workflow_module.WorkflowUpdatePayload.model_validate(
{"marked_name": "v1", "marked_comment": "first version"}
),
user,
snippet,
workflow_id="workflow-1",
)
update_workflow.assert_called_once()
update_call = update_workflow.call_args.kwargs
@@ -432,7 +451,13 @@ def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask
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")
response, status_code = handler(
api,
snippet_workflow_module.WorkflowUpdatePayload(),
_account("account-1"),
_snippet(),
workflow_id="workflow-1",
)
assert status_code == 400
assert response == {"message": "No valid fields to update"}
@@ -468,7 +493,13 @@ def test_update_published_snippet_workflow_raises_not_found(
json={"marked_name": "v1"},
):
with pytest.raises(NotFound, match="Workflow not found"):
handler(api, user, snippet, workflow_id="missing-workflow")
handler(
api,
snippet_workflow_module.WorkflowUpdatePayload.model_validate({"marked_name": "v1"}),
user,
snippet,
workflow_id="missing-workflow",
)
sqlite_session.refresh(snippet)
assert snippet.name == "Snippet"

View File

@@ -255,6 +255,7 @@ def test_variable_patch_returns_persisted_variable_without_committing_when_no_ch
with app.test_request_context("/", method="PATCH", json={}):
result = handler(
api,
module.WorkflowDraftVariableUpdatePayload(),
_make_account(),
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
variable_id="var-1",

View File

@@ -13,8 +13,11 @@ from controllers.console import console_ns
from controllers.console.tag.tags import (
TagBasePayload,
TagBindingCollectionApi,
TagBindingPayload,
TagBindingRemoveApi,
TagBindingRemovePayload,
TagListApi,
TagListQueryParam,
TagUpdateDeleteApi,
TagUpdateRequestPayload,
)
@@ -132,7 +135,7 @@ class TestTagListApi:
],
),
):
result, status = method(api, "tenant-1")
result, status = method(api, TagListQueryParam(type="knowledge"), "tenant-1")
assert status == 200
assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}]
@@ -155,7 +158,7 @@ class TestTagListApi:
],
) as get_tags_mock,
):
result, status = method(api, "tenant-1")
result, status = method(api, TagListQueryParam(type="snippet"), "tenant-1")
get_tags_mock.assert_called_once()
assert get_tags_mock.call_args.args == ("snippet", "tenant-1", None)
@@ -219,7 +222,7 @@ class TestTagListApi:
with app.test_request_context("/"):
with pytest.raises(Forbidden):
method(api, None, readonly_user)
method(api, TagBasePayload(name="test", type=TagType.KNOWLEDGE), readonly_user)
class TestTagUpdateDeleteApi:
@@ -256,7 +259,7 @@ class TestTagUpdateDeleteApi:
with app.test_request_context("/"):
with pytest.raises(Forbidden):
method(api, None, readonly_user, "tag-1")
method(api, TagUpdateRequestPayload(name="test"), readonly_user, "tag-1")
def test_delete_success(self, app: Flask, admin_user, sqlite_engine: Engine):
api = TagUpdateDeleteApi()
@@ -375,7 +378,7 @@ class TestTagBindingCollectionApi:
payload_patch(payload),
patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock,
):
result, status = method(api, admin_user)
result, status = method(api, TagBindingPayload.model_validate(payload), admin_user)
save_mock.assert_called_once()
assert status == 200
@@ -396,7 +399,7 @@ class TestTagBindingCollectionApi:
payload_patch(payload),
patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock,
):
result, status = method(api, admin_user)
result, status = method(api, TagBindingPayload.model_validate(payload), admin_user)
save_mock.assert_called_once()
binding_payload = save_mock.call_args.args[0]
@@ -414,7 +417,11 @@ class TestTagBindingCollectionApi:
payload_patch({}),
):
with pytest.raises(Forbidden):
method(api, readonly_user)
method(
api,
TagBindingPayload(tag_ids=["tag-1"], target_id="target-1", type=TagType.KNOWLEDGE),
readonly_user,
)
class TestTagBindingRemoveApi:
@@ -433,7 +440,7 @@ class TestTagBindingRemoveApi:
payload_patch(payload),
patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock,
):
result, status = method(api, admin_user)
result, status = method(api, TagBindingRemovePayload.model_validate(payload), admin_user)
delete_mock.assert_called_once()
delete_payload = delete_mock.call_args.args[0]
@@ -450,7 +457,11 @@ class TestTagBindingRemoveApi:
payload_patch({}),
):
with pytest.raises(Forbidden):
method(api, readonly_user)
method(
api,
TagBindingRemovePayload(tag_ids=["tag-1"], target_id="target-1", type=TagType.KNOWLEDGE),
readonly_user,
)
class TestTagResponseModel:

View File

@@ -10,6 +10,25 @@ from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
from controllers.console.workspace.plugin import (
ParserAsset,
ParserAutoUpgradeChange,
ParserAutoUpgradeFetch,
ParserDynamicOptions,
ParserDynamicOptionsWithCredentials,
ParserExcludePlugin,
ParserGithubInstall,
ParserGithubUpgrade,
ParserGithubUpload,
ParserIcon,
ParserLatest,
ParserList,
ParserMarketplaceUpgrade,
ParserPermissionChange,
ParserPluginIdentifierQuery,
ParserPluginIdentifiers,
ParserReadme,
ParserTasks,
ParserUninstall,
PluginAssetApi,
PluginAutoUpgradeExcludePluginApi,
PluginCategoryListApi,
@@ -29,6 +48,7 @@ from controllers.console.workspace.plugin import (
PluginFetchPermissionApi,
PluginIconApi,
PluginInstalledIdsApi,
PluginInstalledIdsQuery,
PluginInstallFromGithubApi,
PluginInstallFromMarketplaceApi,
PluginInstallFromPkgApi,
@@ -367,7 +387,7 @@ class TestPluginListLatestVersionsApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginService.list_latest_versions", return_value=versions),
):
result = method(api)
result = method(api, ParserLatest.model_validate(payload))
assert result == {
"versions": {
@@ -389,7 +409,7 @@ class TestPluginListLatestVersionsApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api)
result = method(api, ParserLatest.model_validate(payload))
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -435,7 +455,7 @@ class TestPluginListApi:
return_value=plugins_with_total,
) as mock_list_with_total,
):
result = method(api, "t1", "u1")
result = method(api, ParserList(page=1, page_size=10), "t1", "u1")
assert result == {"plugins": [_expected_plugin_entity_dump()], "total": 1}
mock_list_with_total.assert_called_once_with("t1", "u1", 1, 10)
@@ -584,7 +604,7 @@ class TestPluginIconApi:
app.test_request_context("/?tenant_id=t1&filename=a.png"),
patch("controllers.console.workspace.plugin.PluginService.get_asset", return_value=(b"x", "image/png")),
):
response = method(api)
response = method(api, ParserIcon.model_validate({"tenant_id": "t1", "filename": "a.png"}))
assert response.mimetype == "image/png"
@@ -598,7 +618,9 @@ class TestPluginAssetApi:
app.test_request_context("/?plugin_unique_identifier=p&file_name=a.bin"),
patch("controllers.console.workspace.plugin.PluginService.extract_asset", return_value=b"x"),
):
response = method(api, "t1")
response = method(
api, ParserAsset.model_validate({"plugin_unique_identifier": "p", "file_name": "a.bin"}), "t1"
)
assert response.mimetype == "application/octet-stream"
@@ -653,7 +675,7 @@ class TestPluginInstallFromPkgApi:
"controllers.console.workspace.plugin.PluginService.install_from_local_pkg", return_value={"ok": True}
),
):
result = method(api, "t1")
result = method(api, ParserPluginIdentifiers.model_validate(payload), "t1")
assert result["ok"] is True
@@ -673,7 +695,7 @@ class TestPluginUninstallApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginService.uninstall", return_value=True) as uninstall_mock,
):
result = method(api, "t1")
result = method(api, ParserUninstall.model_validate(payload), "t1")
assert result["success"] is True
uninstall_mock.assert_called_once_with(
@@ -699,7 +721,7 @@ class TestPluginChangePermissionApi:
app.test_request_context("/", json=payload),
):
with pytest.raises(Forbidden):
method(api, "t1", user)
method(api, ParserPermissionChange(), "t1", user)
def test_change_permission_success(self, app: Flask):
api = PluginChangePermissionApi()
@@ -716,7 +738,7 @@ class TestPluginChangePermissionApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=True),
):
result = method(api, "t1", user)
result = method(api, ParserPermissionChange(), "t1", user)
assert result["success"] is True
@@ -747,7 +769,14 @@ class TestPluginFetchDynamicSelectOptionsApi:
return_value=[_dynamic_option()],
),
):
result = method(api, "t1", user)
result = method(
api,
ParserDynamicOptions.model_validate(
{"plugin_id": "p", "provider": "x", "action": "y", "parameter": "z", "provider_type": "tool"}
),
"t1",
user,
)
assert result == {"options": [_expected_dynamic_option_dump()]}
@@ -761,7 +790,7 @@ class TestPluginReadmeApi:
app.test_request_context("/?plugin_unique_identifier=p"),
patch("controllers.console.workspace.plugin.PluginService.fetch_plugin_readme", return_value="readme"),
):
result = method(api, "t1")
result = method(api, ParserReadme.model_validate({"plugin_unique_identifier": "p"}), "t1")
assert result["readme"] == "readme"
@@ -780,7 +809,7 @@ class TestPluginListInstallationsFromIdsApi:
return_value=[_plugin_installation()],
),
):
result = method(api, "t1")
result = method(api, ParserLatest.model_validate(payload), "t1")
assert result == {"plugins": [_expected_plugin_installation_dump()]}
@@ -797,7 +826,7 @@ class TestPluginListInstallationsFromIdsApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserLatest.model_validate(payload), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -813,7 +842,7 @@ class TestPluginInstalledIdsApi:
return_value=["langgenius/openai", "langgenius/anthropic"],
) as list_installed_plugin_ids,
):
result = method(api, "t1")
result = method(api, PluginInstalledIdsQuery.model_validate({"category": "tool"}), "t1")
assert result == {"plugin_ids": ["langgenius/openai", "langgenius/anthropic"]}
list_installed_plugin_ids.assert_called_once_with("t1", PluginCategory.Tool)
@@ -829,7 +858,7 @@ class TestPluginInstalledIdsApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, PluginInstalledIdsQuery.model_validate({"category": "tool"}), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -847,7 +876,7 @@ class TestPluginUploadFromGithubApi:
"controllers.console.workspace.plugin.PluginService.upload_pkg_from_github", return_value={"ok": True}
),
):
result = method(api, "t1")
result = method(api, ParserGithubUpload.model_validate(payload), "t1")
assert result["ok"] is True
@@ -864,7 +893,7 @@ class TestPluginUploadFromGithubApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserGithubUpload.model_validate(payload), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -933,7 +962,7 @@ class TestPluginInstallFromGithubApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginService.install_from_github", return_value={"ok": True}),
):
result = method(api, "t1")
result = method(api, ParserGithubInstall.model_validate(payload), "t1")
assert result["ok"] is True
@@ -955,7 +984,7 @@ class TestPluginInstallFromGithubApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserGithubInstall.model_validate(payload), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -973,7 +1002,7 @@ class TestPluginInstallFromMarketplaceApi:
return_value={"ok": True},
),
):
result = method(api, "t1")
result = method(api, ParserPluginIdentifiers.model_validate(payload), "t1")
assert result["ok"] is True
@@ -990,7 +1019,7 @@ class TestPluginInstallFromMarketplaceApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserPluginIdentifiers.model_validate(payload), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -1006,7 +1035,7 @@ class TestPluginFetchMarketplacePkgApi:
return_value=_plugin_declaration(),
),
):
result = method(api, "t1")
result = method(api, ParserPluginIdentifierQuery.model_validate({"plugin_unique_identifier": "p"}), "t1")
assert result == {"manifest": _expected_plugin_declaration_dump()}
@@ -1021,7 +1050,7 @@ class TestPluginFetchMarketplacePkgApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserPluginIdentifierQuery.model_validate({"plugin_unique_identifier": "p"}), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -1036,7 +1065,7 @@ class TestPluginFetchManifestApi:
app.test_request_context("/?plugin_unique_identifier=p"),
patch("controllers.console.workspace.plugin.PluginService.fetch_plugin_manifest", return_value=manifest),
):
result = method(api, "t1")
result = method(api, ParserPluginIdentifierQuery.model_validate({"plugin_unique_identifier": "p"}), "t1")
assert result == {"manifest": _expected_plugin_declaration_dump()}
@@ -1051,7 +1080,7 @@ class TestPluginFetchManifestApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserPluginIdentifierQuery.model_validate({"plugin_unique_identifier": "p"}), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -1067,7 +1096,7 @@ class TestPluginFetchInstallTasksApi:
return_value=[_plugin_task()],
),
):
result = method(api, "t1")
result = method(api, ParserTasks(), "t1")
assert result == {"tasks": [_expected_plugin_task_dump()]}
@@ -1082,7 +1111,7 @@ class TestPluginFetchInstallTasksApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserTasks(), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -1217,7 +1246,7 @@ class TestPluginUpgradeFromMarketplaceApi:
return_value={"ok": True},
),
):
result = method(api, "t1")
result = method(api, ParserMarketplaceUpgrade.model_validate(payload), "t1")
assert result["ok"] is True
@@ -1237,7 +1266,7 @@ class TestPluginUpgradeFromMarketplaceApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserMarketplaceUpgrade.model_validate(payload), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -1261,7 +1290,7 @@ class TestPluginUpgradeFromGithubApi:
return_value={"ok": True},
),
):
result = method(api, "t1")
result = method(api, ParserGithubUpgrade.model_validate(payload), "t1")
assert result["ok"] is True
@@ -1284,7 +1313,7 @@ class TestPluginUpgradeFromGithubApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1")
result = method(api, ParserGithubUpgrade.model_validate(payload), "t1")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -1309,7 +1338,7 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi:
return_value=[_dynamic_option()],
),
):
result = method(api, "t1", user)
result = method(api, ParserDynamicOptionsWithCredentials.model_validate(payload), "t1", user)
assert result == {"options": [_expected_dynamic_option_dump()]}
@@ -1333,7 +1362,7 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi:
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1", user)
result = method(api, ParserDynamicOptionsWithCredentials.model_validate(payload), "t1", user)
assert result == ({"code": "plugin_error", "message": "error"}, 400)
@@ -1361,7 +1390,7 @@ class TestPluginChangeAutoUpgradeApi:
"controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True
) as change,
):
result = method(api, "t1", user)
result = method(api, ParserAutoUpgradeChange.model_validate(payload), "t1", user)
assert result["success"] is True
change.assert_called_once()
@@ -1389,7 +1418,7 @@ class TestPluginChangeAutoUpgradeApi:
"controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True
) as change,
):
result = method(api, "t1", user)
result = method(api, ParserAutoUpgradeChange.model_validate(payload), "t1", user)
assert result["success"] is True
change.assert_called_once()
@@ -1416,7 +1445,7 @@ class TestPluginChangeAutoUpgradeApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=False),
):
result = method(api, "t1", user)
result = method(api, ParserAutoUpgradeChange.model_validate(payload), "t1", user)
assert result["success"] is False
@@ -1442,7 +1471,11 @@ class TestPluginFetchAutoUpgradeApi:
return_value=auto_upgrade,
),
):
result = method(api, "t1")
result = method(
api,
ParserAutoUpgradeFetch.model_validate({"category": TenantPluginAutoUpgradeCategory.TOOL.value}),
"t1",
)
assert result["category"] == TenantPluginAutoUpgradeCategory.TOOL
assert result["auto_upgrade"]["upgrade_time_of_day"] == 1
@@ -1462,7 +1495,11 @@ class TestPluginFetchAutoUpgradeApi:
return_value=78300,
),
):
result = method(api, "t1")
result = method(
api,
ParserAutoUpgradeFetch.model_validate({"category": TenantPluginAutoUpgradeCategory.MODEL.value}),
"t1",
)
assert result == {
"category": TenantPluginAutoUpgradeCategory.MODEL,
@@ -1487,7 +1524,7 @@ class TestPluginAutoUpgradeExcludePluginApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.exclude_plugin", return_value=True),
):
result = method(api, "t1")
result = method(api, ParserExcludePlugin.model_validate(payload), "t1")
assert result["success"] is True
@@ -1501,6 +1538,6 @@ class TestPluginAutoUpgradeExcludePluginApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.exclude_plugin", return_value=False),
):
result = method(api, "t1")
result = method(api, ParserExcludePlugin.model_validate(payload), "t1")
assert result["success"] is False

View File

@@ -10,6 +10,13 @@ from flask import Flask
from werkzeug.exceptions import Forbidden
from controllers.console.workspace.tool_providers import (
ApiToolProviderAddPayload,
ApiToolProviderDeletePayload,
ApiToolProviderUpdatePayload,
BuiltinProviderDefaultCredentialPayload,
BuiltinToolAddPayload,
BuiltinToolCredentialDeletePayload,
BuiltinToolUpdatePayload,
ToolApiListApi,
ToolApiProviderAddApi,
ToolApiProviderDeleteApi,
@@ -32,6 +39,7 @@ from controllers.console.workspace.tool_providers import (
ToolLabelsApi,
ToolOAuthCallback,
ToolOAuthCustomClient,
ToolOAuthCustomClientPayload,
ToolPluginOAuthApi,
ToolProviderListApi,
ToolWorkflowListApi,
@@ -39,6 +47,9 @@ from controllers.console.workspace.tool_providers import (
ToolWorkflowProviderDeleteApi,
ToolWorkflowProviderGetApi,
ToolWorkflowProviderUpdateApi,
WorkflowToolCreatePayload,
WorkflowToolDeletePayload,
WorkflowToolUpdatePayload,
is_valid_url,
)
from core.tools.entities.api_entities import ToolProviderApiEntity as CoreToolProviderApiEntity
@@ -276,7 +287,8 @@ class TestBuiltinProviderApis:
return_value={"result": "success"},
),
):
assert method(api, "t1", "provider")["result"] == "success"
req = BuiltinToolCredentialDeletePayload(credential_id="cid")
assert method(api, req, "t1", "provider")["result"] == "success"
def test_add_invalid_type(self, app: Flask) -> None:
api = ToolBuiltinProviderAddApi()
@@ -286,7 +298,13 @@ class TestBuiltinProviderApis:
app.test_request_context("/", json={"credentials": empty_mapping(), "type": "invalid"}),
):
with pytest.raises(ValueError):
method(api, "t", make_account(), "provider")
method(
api,
BuiltinToolAddPayload(credentials=empty_mapping(), type="invalid"),
"t",
make_account(),
"provider",
)
def test_add_success(self, app: Flask) -> None:
api = ToolBuiltinProviderAddApi()
@@ -301,7 +319,7 @@ class TestBuiltinProviderApis:
return_value={"result": "success"},
),
):
assert method(api, "t", make_account(), "provider")["result"] == "success"
assert method(api, BuiltinToolAddPayload(**payload), "t", make_account(), "provider")["result"] == "success"
def test_update(self, app: Flask) -> None:
api = ToolBuiltinProviderUpdateApi()
@@ -316,7 +334,8 @@ class TestBuiltinProviderApis:
return_value={"result": "success"},
),
):
assert method(api, "t", make_account(), "provider")["result"] == "success"
req = BuiltinToolUpdatePayload(**payload)
assert method(api, req, "t", make_account(), "provider")["result"] == "success"
def test_get_credentials(self, app: Flask) -> None:
api = ToolBuiltinProviderGetCredentialsApi()
@@ -369,7 +388,8 @@ class TestBuiltinProviderApis:
return_value={"result": "success"},
),
):
assert method(api, "t", "provider")["result"] == "success"
req = BuiltinProviderDefaultCredentialPayload(id="c1")
assert method(api, req, "t", "provider")["result"] == "success"
def test_get_credential_info(self, app: Flask) -> None:
api = ToolBuiltinProviderGetCredentialInfoApi()
@@ -418,7 +438,7 @@ class TestApiProviderApis:
return_value={"result": "success"},
) as create_api_tool_provider,
):
assert method(api, "t", make_account()) == {"result": "success"}
assert method(api, ApiToolProviderAddPayload(**payload), "t", make_account()) == {"result": "success"}
create_api_tool_provider.assert_called_once()
assert create_api_tool_provider.call_args.args[3] == emoji_icon()
@@ -472,7 +492,7 @@ class TestApiProviderApis:
return_value={"result": "success"},
) as update_api_tool_provider,
):
assert method(api, "t", make_account()) == {"result": "success"}
assert method(api, ApiToolProviderUpdatePayload(**payload), "t", make_account()) == {"result": "success"}
update_api_tool_provider.assert_called_once()
assert update_api_tool_provider.call_args.args[4] == emoji_icon()
@@ -488,7 +508,8 @@ class TestApiProviderApis:
return_value={"result": "success"},
),
):
assert method(api, "t", make_account())["result"] == "success"
req = ApiToolProviderDeletePayload(provider="p")
assert method(api, req, "t", make_account())["result"] == "success"
def test_get(self, app: Flask) -> None:
api = ToolApiProviderGetApi()
@@ -525,7 +546,7 @@ class TestWorkflowApis:
return_value={"result": "success"},
) as create_workflow_tool,
):
assert method(api, "t", make_account()) == {"result": "success"}
assert method(api, WorkflowToolCreatePayload(**payload), "t", make_account()) == {"result": "success"}
create_workflow_tool.assert_called_once()
assert create_workflow_tool.call_args.kwargs["icon"] == emoji_icon()
@@ -549,7 +570,7 @@ class TestWorkflowApis:
return_value={"result": "success"},
) as update_workflow_tool,
):
result = method(api, "t", make_account())
result = method(api, WorkflowToolUpdatePayload(**payload), "t", make_account())
assert result == {"result": "success"}
update_workflow_tool.assert_called_once()
@@ -566,7 +587,8 @@ class TestWorkflowApis:
return_value={"result": "success"},
),
):
assert method(api, "t", make_account())["result"] == "success"
req = WorkflowToolDeletePayload(workflow_tool_id="123e4567-e89b-12d3-a456-426614174000")
assert method(api, req, "t", make_account())["result"] == "success"
def test_get_error(self, app: Flask) -> None:
api = ToolWorkflowProviderGetApi()
@@ -671,7 +693,8 @@ class TestOAuthCustomClient:
return_value={"result": "success"},
),
):
assert method(api, "t", "provider") == {"result": "success"}
req = ToolOAuthCustomClientPayload(client_params={"a": 1})
assert method(api, req, "t", "provider") == {"result": "success"}
def test_get_custom_client(self, app: Flask) -> None:
api = ToolOAuthCustomClient()

View File

@@ -12,9 +12,11 @@ import type { Edge, Node } from '@/app/components/workflow/types'
import { LoroDoc, LoroMap } from 'loro-crdt'
import { BlockEnum } from '@/app/components/workflow/types'
import { CollaborationManager } from '../collaboration-manager'
import { webSocketClient } from '../websocket-manager'
import * as websocketManager from '../websocket-manager'
import { attachCrdtRuntime } from './test-crdt-runtime'
const { webSocketClient } = websocketManager
type ReactFlowStore = {
getState: () => {
getNodes: () => Node[]
@@ -180,6 +182,108 @@ describe('CollaborationManager socket and subscription behavior', () => {
expect(manager.canUseLocalDraftFallback()).toBe(false)
})
it('switches to local editing and stops reconnecting when the initial connection fails', async () => {
vi.spyOn(websocketManager, 'isDefaultSocketUrl').mockReturnValueOnce(true)
const manager = new CollaborationManager()
attachCrdtRuntime(manager)
const socket = createMockSocket('socket-initial-failure')
socket.connected = false
const reactFlowStore: ReactFlowStore = {
getState: () => ({
getNodes: () => [],
setNodes: vi.fn(),
getEdges: () => [],
setEdges: vi.fn(),
}),
}
const connectSpy = vi
.spyOn(webSocketClient, 'connect')
.mockReturnValue(socket as unknown as Socket)
vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket)
vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false)
const disconnectSpy = vi
.spyOn(webSocketClient, 'disconnect')
.mockImplementation(() => undefined)
const connectionId = await manager.connect('app-initial-failure', reactFlowStore)
const graphReadyStates: boolean[] = []
manager.onGraphReadyChange((isReady) => graphReadyStates.push(isReady))
expect(manager.canPersistLocalGraph()).toBe(false)
expect(graphReadyStates.at(-1)).toBe(false)
socket.trigger('connect_error', new Error('connect failed'))
expect(manager.canApplyLocalGraphMutation()).toBe(true)
expect(manager.canPersistLocalGraph()).toBe(true)
expect(disconnectSpy).toHaveBeenCalledWith('app-initial-failure')
expect(graphReadyStates.at(-1)).toBe(true)
const secondConnectionId = await manager.connect('app-initial-failure', reactFlowStore)
expect(connectSpy).toHaveBeenCalledTimes(1)
manager.disconnect(connectionId)
manager.disconnect(secondConnectionId)
expect(manager.canPersistLocalGraph()).toBe(false)
})
it('keeps editing blocked when a configured socket URL fails to connect', () => {
vi.spyOn(websocketManager, 'isDefaultSocketUrl')
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
const manager = new CollaborationManager()
attachCrdtRuntime(manager)
const internals = getManagerInternals(manager)
const socket = createMockSocket('socket-configured-failure')
socket.connected = false
internals.currentAppId = 'app-configured-failure'
vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket)
vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false)
const disconnectSpy = vi.spyOn(webSocketClient, 'disconnect')
internals.setupSocketEventListeners(socket as unknown as Socket)
socket.trigger('connect_error', new Error('connect failed'))
expect(manager.canUseLocalDraftFallback()).toBe(false)
expect(manager.canApplyLocalGraphMutation()).toBe(false)
expect(manager.canPersistLocalGraph()).toBe(false)
expect(disconnectSpy).not.toHaveBeenCalled()
})
it('keeps editing blocked when a previously established connection fails', async () => {
const manager = new CollaborationManager()
attachCrdtRuntime(manager)
const socket = createMockSocket('socket-established-failure')
const reactFlowStore: ReactFlowStore = {
getState: () => ({
getNodes: () => [],
setNodes: vi.fn(),
getEdges: () => [],
setEdges: vi.fn(),
}),
}
vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket as unknown as Socket)
vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket)
vi.spyOn(webSocketClient, 'isConnected').mockImplementation(() => socket.connected)
const disconnectSpy = vi
.spyOn(webSocketClient, 'disconnect')
.mockImplementation(() => undefined)
const connectionId = await manager.connect('app-established-failure', reactFlowStore)
socket.trigger('status', { isLeader: true })
expect(manager.canApplyLocalGraphMutation()).toBe(true)
socket.connected = false
socket.trigger('disconnect', 'transport close')
socket.trigger('connect_error', new Error('reconnect failed'))
expect(manager.canApplyLocalGraphMutation()).toBe(false)
expect(manager.canPersistLocalGraph()).toBe(false)
expect(disconnectSpy).not.toHaveBeenCalled()
manager.disconnect(connectionId)
})
it('emits cursor/sync/workflow events via collaboration_event when connected', async () => {
const { manager, internals } = setupManagerWithDoc()
const socket = createMockSocket('socket-connected')

View File

@@ -47,6 +47,14 @@ describe('WebSocketClient', () => {
ioMock.mockReset()
})
it('identifies the built-in socket URLs', async () => {
const { isDefaultSocketUrl } = await import('../websocket-manager')
expect(isDefaultSocketUrl('ws://localhost')).toBe(true)
expect(isDefaultSocketUrl('ws://localhost:5001')).toBe(true)
expect(isDefaultSocketUrl('wss://collaboration.example.com')).toBe(false)
})
it('connects with default url and registers base listeners', async () => {
const mockSocket = createMockSocket('socket-fallback')
ioMock.mockImplementation(() => mockSocket)

View File

@@ -21,7 +21,7 @@ import type { CRDTProvider } from './crdt-provider'
import { cloneDeep } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { EventEmitter } from './event-emitter'
import { emitWithAuthGuard, webSocketClient } from './websocket-manager'
import { emitWithAuthGuard, isDefaultSocketUrl, webSocketClient } from './websocket-manager'
type CrdtRuntime = (typeof import('./crdt-runtime'))['crdtRuntime']
@@ -185,6 +185,7 @@ export class CollaborationManager {
private visibilityListenerAttached = false
private crdtTrusted = false
private hasEstablishedConnection = false
private localDraftFallbackActive = false
private rebuildCrdtOnNextConnect = false
private reconnectedWithFreshDoc = false
private awaitingSnapshotImport = false
@@ -729,6 +730,7 @@ export class CollaborationManager {
private forceDisconnect = ({
preserveConnectIntent = false,
}: { preserveConnectIntent?: boolean } = {}): void => {
this.localDraftFallbackActive = false
if (this.currentAppId) webSocketClient.disconnect(this.currentAppId)
this.clearInitialSyncRetry()
@@ -782,7 +784,10 @@ export class CollaborationManager {
canUseLocalDraftFallback(): boolean {
// A graph from a previously connected session must recover through collaboration before saving.
return !this.isConnected() && !this.hasEstablishedConnection
return (
this.localDraftFallbackActive ||
(isDefaultSocketUrl() && !this.isConnected() && !this.hasEstablishedConnection)
)
}
getNodes(): Node[] {
@@ -799,14 +804,30 @@ export class CollaborationManager {
}
canPersistLocalGraph(): boolean {
if (this.localDraftFallbackActive) return true
return this.crdtTrusted && !this.graphReloadRequired && this.graphViewActive !== false
}
canApplyLocalGraphMutation(): boolean {
if (!this.currentAppId) return true
if (this.localDraftFallbackActive || !this.currentAppId) return true
return this.canPersistLocalGraph() && this.getActiveSocket()?.connected === true
}
private activateLocalDraftFallback(socket: Socket): void {
// Before the first connection, ReactFlow still holds the HTTP draft baseline. Once a
// collaboration session has existed, that graph may be stale and must recover through CRDT.
if (!isDefaultSocketUrl() || this.localDraftFallbackActive || this.hasEstablishedConnection)
return
if (this.getActiveSocket() !== socket || !this.currentAppId) return
const appId = this.currentAppId
this.localDraftFallbackActive = true
this.clearInitialSyncRetry()
this.pendingInitialSync = false
webSocketClient.disconnect(appId)
this.emitGraphReadyState()
}
private emitGraphReadyState(): void {
this.eventEmitter.emit('graphReadyChange', this.canApplyLocalGraphMutation())
}
@@ -1932,6 +1953,7 @@ export class CollaborationManager {
socket.on('connect_error', (error: Error) => {
console.error('WebSocket connection error:', error)
this.activateLocalDraftFallback(socket)
this.eventEmitter.emit('stateChange', { isConnected: false, error: error.message })
})

View File

@@ -5,6 +5,9 @@ import { SOCKET_URL } from '@/config'
type AckArgs = unknown[]
export const isDefaultSocketUrl = (socketUrl = SOCKET_URL) =>
socketUrl === 'ws://localhost' || socketUrl === 'ws://localhost:5001'
const isUnauthorizedAck = (...ackArgs: AckArgs): boolean => {
const [first, second] = ackArgs

View File

@@ -131,9 +131,10 @@ describe('VariableInspect Panel', () => {
})
it('should render the listening state and stop the workflow on demand', () => {
renderPanel({
const { store } = renderPanel({
isListening: true,
listeningTriggerType: BlockEnum.TriggerWebhook,
showVariableInspectPanel: true,
})
fireEvent.click(
@@ -144,6 +145,9 @@ describe('VariableInspect Panel', () => {
expect(mockEmit).toHaveBeenCalledWith({
type: EVENT_WORKFLOW_STOP,
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(store.getState().showVariableInspectPanel).toBe(false)
})
it('should render the empty state and close the panel from the header action', () => {
@@ -151,7 +155,7 @@ describe('VariableInspect Panel', () => {
showVariableInspectPanel: true,
})
fireEvent.click(screen.getAllByRole('button')[0]!)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(screen.getByText('workflow.debug.variableInspect.emptyTip'))!.toBeInTheDocument()
expect(store.getState().showVariableInspectPanel).toBe(false)

View File

@@ -1,8 +1,6 @@
import type { FC } from 'react'
import type { NodeProps } from '../types'
import type { VarInInspect } from '@/types/workflow'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
@@ -26,7 +24,7 @@ export type currentVarType = {
nodeData: NodeProps['data']
}
const Panel: FC = () => {
const Panel = () => {
const { t } = useTranslation()
const bottomPanelWidth = useStore((s) => s.bottomPanelWidth)
@@ -175,8 +173,11 @@ const Panel: FC = () => {
<div className="system-sm-semibold-uppercase text-text-primary">
{t(($) => $['debug.variableInspect.title'], { ns: 'workflow' })}
</div>
<ActionButton onClick={() => setShowVariableInspectPanel(false)}>
<RiCloseLine className="size-4" />
<ActionButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={() => setShowVariableInspectPanel(false)}
>
<span aria-hidden className="i-ri-close-line size-4" />
</ActionButton>
</div>
<div className="grow p-2">
@@ -193,8 +194,11 @@ const Panel: FC = () => {
<div className="system-sm-semibold-uppercase text-text-primary">
{t(($) => $['debug.variableInspect.title'], { ns: 'workflow' })}
</div>
<ActionButton onClick={() => setShowVariableInspectPanel(false)}>
<RiCloseLine className="size-4" />
<ActionButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={() => setShowVariableInspectPanel(false)}
>
<span aria-hidden className="i-ri-close-line size-4" />
</ActionButton>
</div>
<div className="grow p-2">