From 08a8802b65b055dafd1d3cfd466c5d363df03a03 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 7 Jan 2026 19:38:46 +0900 Subject: [PATCH 01/17] port app_fields.py --- api/controllers/console/app/app.py | 255 +------- api/controllers/console/app/app_import.py | 31 +- api/controllers/console/app/mcp_server.py | 33 +- api/controllers/console/app/site.py | 21 +- api/controllers/console/datasets/datasets.py | 17 +- api/fields/app_fields.py | 596 +++++++++++------- .../app/test_workflow_response_models.py | 198 ++++++ 7 files changed, 625 insertions(+), 526 deletions(-) create mode 100644 api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index d66bb7063f..ae7b3da263 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,11 +1,10 @@ import re import uuid -from datetime import datetime -from typing import Any, Literal, TypeAlias +from typing import Literal from flask import request from flask_restx import Resource -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator +from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest @@ -21,13 +20,24 @@ from controllers.console.wraps import ( is_admin_or_owner_required, setup_required, ) -from core.file import helpers as file_helpers from core.ops.ops_trace_manager import OpsTraceManager from core.workflow.enums import NodeType from extensions.ext_database import db +from fields.app_fields import ( + AppDetail, + AppDetailWithSite, + AppExportResponse, + AppPagination, + AppPartial, + DeletedTool, + ModelConfig, + ModelConfigPartial, + Site, + Tag, + WorkflowPartial, +) from libs.login import current_account_with_tenant, login_required from models import App, Workflow -from models.model import IconType from services.app_dsl_service import AppDslService, ImportMode from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService @@ -214,241 +224,6 @@ def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | return file_helpers.get_signed_file_url(icon) -class Tag(ResponseModel): - id: str - name: str - type: str - - -class WorkflowPartial(ResponseModel): - id: str - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class ModelConfigPartial(ResponseModel): - model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) - pre_prompt: str | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class ModelConfig(ResponseModel): - opening_statement: str | None = None - suggested_questions: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("suggested_questions_list", "suggested_questions") - ) - suggested_questions_after_answer: JSONValue | None = Field( - default=None, - validation_alias=AliasChoices("suggested_questions_after_answer_dict", "suggested_questions_after_answer"), - ) - speech_to_text: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("speech_to_text_dict", "speech_to_text") - ) - text_to_speech: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("text_to_speech_dict", "text_to_speech") - ) - retriever_resource: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("retriever_resource_dict", "retriever_resource") - ) - annotation_reply: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("annotation_reply_dict", "annotation_reply") - ) - more_like_this: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("more_like_this_dict", "more_like_this") - ) - sensitive_word_avoidance: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("sensitive_word_avoidance_dict", "sensitive_word_avoidance") - ) - external_data_tools: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("external_data_tools_list", "external_data_tools") - ) - model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) - user_input_form: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("user_input_form_list", "user_input_form") - ) - dataset_query_variable: str | None = None - pre_prompt: str | None = None - agent_mode: JSONValue | None = Field(default=None, validation_alias=AliasChoices("agent_mode_dict", "agent_mode")) - prompt_type: str | None = None - chat_prompt_config: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("chat_prompt_config_dict", "chat_prompt_config") - ) - completion_prompt_config: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("completion_prompt_config_dict", "completion_prompt_config") - ) - dataset_configs: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("dataset_configs_dict", "dataset_configs") - ) - file_upload: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("file_upload_dict", "file_upload") - ) - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class Site(ResponseModel): - access_token: str | None = Field(default=None, validation_alias="code") - code: str | None = None - title: str | None = None - icon_type: str | IconType | None = None - icon: str | None = None - icon_background: str | None = None - description: str | None = None - default_language: str | None = None - chat_color_theme: str | None = None - chat_color_theme_inverted: bool | None = None - customize_domain: str | None = None - copyright: str | None = None - privacy_policy: str | None = None - custom_disclaimer: str | None = None - customize_token_strategy: str | None = None - prompt_public: bool | None = None - app_base_url: str | None = None - show_workflow_steps: bool | None = None - use_icon_as_answer_icon: bool | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @computed_field(return_type=str | None) # type: ignore - @property - def icon_url(self) -> str | None: - return _build_icon_url(self.icon_type, self.icon) - - @field_validator("icon_type", mode="before") - @classmethod - def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: - if isinstance(value, IconType): - return value.value - return value - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class DeletedTool(ResponseModel): - type: str - tool_name: str - provider_id: str - - -class AppPartial(ResponseModel): - id: str - name: str - max_active_requests: int | None = None - description: str | None = Field(default=None, validation_alias=AliasChoices("desc_or_prompt", "description")) - mode: str = Field(validation_alias="mode_compatible_with_agent") - icon_type: str | None = None - icon: str | None = None - icon_background: str | None = None - model_config_: ModelConfigPartial | None = Field( - default=None, - validation_alias=AliasChoices("app_model_config", "model_config"), - alias="model_config", - ) - workflow: WorkflowPartial | None = None - use_icon_as_answer_icon: bool | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - tags: list[Tag] = Field(default_factory=list) - access_mode: str | None = None - create_user_name: str | None = None - author_name: str | None = None - has_draft_trigger: bool | None = None - - @computed_field(return_type=str | None) # type: ignore - @property - def icon_url(self) -> str | None: - return _build_icon_url(self.icon_type, self.icon) - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class AppDetail(ResponseModel): - id: str - name: str - description: str | None = None - mode: str = Field(validation_alias="mode_compatible_with_agent") - icon: str | None = None - icon_background: str | None = None - enable_site: bool - enable_api: bool - model_config_: ModelConfig | None = Field( - default=None, - validation_alias=AliasChoices("app_model_config", "model_config"), - alias="model_config", - ) - workflow: WorkflowPartial | None = None - tracing: JSONValue | None = None - use_icon_as_answer_icon: bool | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - access_mode: str | None = None - tags: list[Tag] = Field(default_factory=list) - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class AppDetailWithSite(AppDetail): - icon_type: str | None = None - api_base_url: str | None = None - max_active_requests: int | None = None - deleted_tools: list[DeletedTool] = Field(default_factory=list) - site: Site | None = None - - @computed_field(return_type=str | None) # type: ignore - @property - def icon_url(self) -> str | None: - return _build_icon_url(self.icon_type, self.icon) - - -class AppPagination(ResponseModel): - page: int - limit: int = Field(validation_alias=AliasChoices("per_page", "limit")) - total: int - has_more: bool = Field(validation_alias=AliasChoices("has_next", "has_more")) - data: list[AppPartial] = Field(validation_alias=AliasChoices("items", "data")) - - -class AppExportResponse(ResponseModel): - data: str - - register_schema_models( console_ns, AppListQuery, diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index 22e2aeb720..05ae6caf51 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -1,7 +1,8 @@ -from flask_restx import Resource, fields, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from controllers.common.schema import register_schema_models from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( account_initialization_required, @@ -10,11 +11,7 @@ from controllers.console.wraps import ( setup_required, ) from extensions.ext_database import db -from fields.app_fields import ( - app_import_check_dependencies_fields, - app_import_fields, - leaked_dependency_fields, -) +from fields.app_fields import AppImport, AppImportCheckDependencies, LeakedDependency from libs.login import current_account_with_tenant, login_required from models.model import App from services.app_dsl_service import AppDslService, ImportStatus @@ -23,19 +20,6 @@ from services.feature_service import FeatureService from .. import console_ns -# Register models for flask_restx to avoid dict type issues in Swagger -# Register base model first -leaked_dependency_model = console_ns.model("LeakedDependency", leaked_dependency_fields) - -app_import_model = console_ns.model("AppImport", app_import_fields) - -# For nested models, need to replace nested dict with registered model -app_import_check_dependencies_fields_copy = app_import_check_dependencies_fields.copy() -app_import_check_dependencies_fields_copy["leaked_dependencies"] = fields.List(fields.Nested(leaked_dependency_model)) -app_import_check_dependencies_model = console_ns.model( - "AppImportCheckDependencies", app_import_check_dependencies_fields_copy -) - DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" @@ -54,6 +38,12 @@ class AppImportPayload(BaseModel): console_ns.schema_model( AppImportPayload.__name__, AppImportPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) ) +register_schema_models( + console_ns, + AppImport, + AppImportCheckDependencies, + LeakedDependency, +) @console_ns.route("/apps/imports") @@ -62,7 +52,6 @@ class AppImportApi(Resource): @setup_required @login_required @account_initialization_required - @marshal_with(app_import_model) @cloud_edition_billing_resource_check("apps") @edit_permission_required def post(self): @@ -105,7 +94,6 @@ class AppImportConfirmApi(Resource): @setup_required @login_required @account_initialization_required - @marshal_with(app_import_model) @edit_permission_required def post(self, import_id): # Check user role first @@ -131,7 +119,6 @@ class AppImportCheckDependenciesApi(Resource): @login_required @get_app_model @account_initialization_required - @marshal_with(app_import_check_dependencies_model) @edit_permission_required def get(self, app_model: App): with Session(db.engine) as session: diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index dd982b6d7b..5a5ac7fcd4 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -1,7 +1,7 @@ import json from enum import StrEnum -from flask_restx import Resource, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field from werkzeug.exceptions import NotFound @@ -9,14 +9,19 @@ from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from extensions.ext_database import db -from fields.app_fields import app_server_fields +from fields.app_fields import AppServer from libs.login import current_account_with_tenant, login_required from models.model import AppMCPServer DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" -# Register model for flask_restx to avoid dict type issues in Swagger -app_server_model = console_ns.model("AppServer", app_server_fields) +console_ns.schema_model(AppServer.__name__, AppServer.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) + + +def _dump_server(server: AppMCPServer | None): + if server is None: + return None + return AppServer.model_validate(server, from_attributes=True).model_dump(mode="json") class AppMCPServerStatus(StrEnum): @@ -45,27 +50,25 @@ class AppMCPServerController(Resource): @console_ns.doc("get_app_mcp_server") @console_ns.doc(description="Get MCP server configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "MCP server configuration retrieved successfully", app_server_model) + @console_ns.response(200, "MCP server configuration retrieved successfully", console_ns.models[AppServer.__name__]) @login_required @account_initialization_required @setup_required @get_app_model - @marshal_with(app_server_model) def get(self, app_model): server = db.session.query(AppMCPServer).where(AppMCPServer.app_id == app_model.id).first() - return server + return AppServer.model_validate(server, from_attributes=True).model_dump(mode="json") if server else None @console_ns.doc("create_app_mcp_server") @console_ns.doc(description="Create MCP server configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[MCPServerCreatePayload.__name__]) - @console_ns.response(201, "MCP server configuration created successfully", app_server_model) + @console_ns.response(201, "MCP server configuration created successfully", console_ns.models[AppServer.__name__]) @console_ns.response(403, "Insufficient permissions") @account_initialization_required @get_app_model @login_required @setup_required - @marshal_with(app_server_model) @edit_permission_required def post(self, app_model): _, current_tenant_id = current_account_with_tenant() @@ -86,20 +89,19 @@ class AppMCPServerController(Resource): ) db.session.add(server) db.session.commit() - return server + return _dump_server(server) @console_ns.doc("update_app_mcp_server") @console_ns.doc(description="Update MCP server configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[MCPServerUpdatePayload.__name__]) - @console_ns.response(200, "MCP server configuration updated successfully", app_server_model) + @console_ns.response(200, "MCP server configuration updated successfully", console_ns.models[AppServer.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "Server not found") @get_app_model @login_required @setup_required @account_initialization_required - @marshal_with(app_server_model) @edit_permission_required def put(self, app_model): payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {}) @@ -121,7 +123,7 @@ class AppMCPServerController(Resource): raise ValueError("Invalid status") server.status = payload.status db.session.commit() - return server + return _dump_server(server) @console_ns.route("/apps//server/refresh") @@ -129,13 +131,12 @@ class AppMCPServerRefreshController(Resource): @console_ns.doc("refresh_app_mcp_server") @console_ns.doc(description="Refresh MCP server configuration and regenerate server code") @console_ns.doc(params={"server_id": "Server ID"}) - @console_ns.response(200, "MCP server refreshed successfully", app_server_model) + @console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppServer.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "Server not found") @setup_required @login_required @account_initialization_required - @marshal_with(app_server_model) @edit_permission_required def get(self, server_id): _, current_tenant_id = current_account_with_tenant() @@ -149,4 +150,4 @@ class AppMCPServerRefreshController(Resource): raise NotFound() server.server_code = AppMCPServer.generate_server_code(16) db.session.commit() - return server + return _dump_server(server) diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index db218d8b81..28a0b36e48 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -1,6 +1,6 @@ from typing import Literal -from flask_restx import Resource, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import NotFound @@ -14,7 +14,7 @@ from controllers.console.wraps import ( setup_required, ) from extensions.ext_database import db -from fields.app_fields import app_site_fields +from fields.app_fields import AppSite from libs.datetime_utils import naive_utc_now from libs.login import current_account_with_tenant, login_required from models import Site @@ -53,8 +53,11 @@ console_ns.schema_model( AppSiteUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), ) -# Register model for flask_restx to avoid dict type issues in Swagger -app_site_model = console_ns.model("AppSite", app_site_fields) +console_ns.schema_model(AppSite.__name__, AppSite.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) + + +def _dump_site(site: Site) -> dict: + return AppSite.model_validate(site, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps//site") @@ -63,7 +66,7 @@ class AppSite(Resource): @console_ns.doc(description="Update application site configuration") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[AppSiteUpdatePayload.__name__]) - @console_ns.response(200, "Site configuration updated successfully", app_site_model) + @console_ns.response(200, "Site configuration updated successfully", console_ns.models[AppSite.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "App not found") @setup_required @@ -71,7 +74,6 @@ class AppSite(Resource): @edit_permission_required @account_initialization_required @get_app_model - @marshal_with(app_site_model) def post(self, app_model): args = AppSiteUpdatePayload.model_validate(console_ns.payload or {}) current_user, _ = current_account_with_tenant() @@ -105,7 +107,7 @@ class AppSite(Resource): site.updated_at = naive_utc_now() db.session.commit() - return site + return _dump_site(site) @console_ns.route("/apps//site/access-token-reset") @@ -113,7 +115,7 @@ class AppSiteAccessTokenReset(Resource): @console_ns.doc("reset_app_site_access_token") @console_ns.doc(description="Reset access token for application site") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Access token reset successfully", app_site_model) + @console_ns.response(200, "Access token reset successfully", console_ns.models[AppSite.__name__]) @console_ns.response(403, "Insufficient permissions (admin/owner required)") @console_ns.response(404, "App or site not found") @setup_required @@ -121,7 +123,6 @@ class AppSiteAccessTokenReset(Resource): @is_admin_or_owner_required @account_initialization_required @get_app_model - @marshal_with(app_site_model) def post(self, app_model): current_user, _ = current_account_with_tenant() site = db.session.query(Site).where(Site.app_id == app_model.id).first() @@ -134,4 +135,4 @@ class AppSiteAccessTokenReset(Resource): site.updated_at = naive_utc_now() db.session.commit() - return site + return _dump_site(site) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 8ceb896d4f..11d24baada 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -32,7 +32,7 @@ from core.rag.extractor.entity.datasource_type import DatasourceType from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo from core.rag.retrieval.retrieval_methods import RetrievalMethod from extensions.ext_database import db -from fields.app_fields import app_detail_kernel_fields, related_app_list +from fields.app_fields import AppDetailKernel, RelatedAppList from fields.dataset_fields import ( dataset_detail_fields, dataset_fields, @@ -101,11 +101,7 @@ dataset_detail_fields_copy["icon_info"] = fields.Nested(icon_info_model) dataset_detail_model = _get_or_create_model("DatasetDetail", dataset_detail_fields_copy) dataset_query_detail_model = _get_or_create_model("DatasetQueryDetail", dataset_query_detail_fields) - -app_detail_kernel_model = _get_or_create_model("AppDetailKernel", app_detail_kernel_fields) -related_app_list_copy = related_app_list.copy() -related_app_list_copy["data"] = fields.List(fields.Nested(app_detail_kernel_model)) -related_app_list_model = _get_or_create_model("RelatedAppList", related_app_list_copy) +register_schema_models(console_ns, AppDetailKernel, RelatedAppList) def _validate_indexing_technique(value: str | None) -> str | None: @@ -634,11 +630,10 @@ class DatasetRelatedAppListApi(Resource): @console_ns.doc("get_dataset_related_apps") @console_ns.doc(description="Get applications related to dataset") @console_ns.doc(params={"dataset_id": "Dataset ID"}) - @console_ns.response(200, "Related apps retrieved successfully", related_app_list_model) + @console_ns.response(200, "Related apps retrieved successfully", console_ns.models[RelatedAppList.__name__]) @setup_required @login_required @account_initialization_required - @marshal_with(related_app_list_model) def get(self, dataset_id): current_user, _ = current_account_with_tenant() dataset_id_str = str(dataset_id) @@ -659,7 +654,11 @@ class DatasetRelatedAppListApi(Resource): if app_model: related_apps.append(app_model) - return {"data": related_apps, "total": len(related_apps)}, 200 + response = RelatedAppList( + data=[AppDetailKernel.model_validate(app, from_attributes=True) for app in related_apps], + total=len(related_apps), + ) + return response.model_dump(mode="json"), 200 @console_ns.route("/datasets//indexing-status") diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 7191933eed..013164b55d 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -1,248 +1,386 @@ +from __future__ import annotations + import json +from datetime import datetime +from typing import Any, TypeAlias -from flask_restx import fields +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator -from fields.workflow_fields import workflow_partial_fields -from libs.helper import AppIconUrlField, TimestampField +from core.file import helpers as file_helpers +from models.model import IconType + +JSONValue: TypeAlias = Any -class JsonStringField(fields.Raw): - def format(self, value): - if isinstance(value, str): - try: - return json.loads(value) - except (json.JSONDecodeError, TypeError): - return value +class ResponseModel(BaseModel): + model_config = ConfigDict( + from_attributes=True, + extra="ignore", + populate_by_name=True, + serialize_by_alias=True, + protected_namespaces=(), + ) + + +def _to_timestamp(value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return int(value.timestamp()) + return value + + +def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None: + if icon is None or icon_type is None: + return None + icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type) + if icon_type_value.lower() != IconType.IMAGE.value: + return None + return file_helpers.get_signed_file_url(icon) + + +class Tag(ResponseModel): + id: str + name: str + type: str + + +class WorkflowPartial(ResponseModel): + id: str + created_by: str | None = None + created_at: int | None = None + updated_by: str | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +class ModelConfigPartial(ResponseModel): + model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) + pre_prompt: str | None = None + created_by: str | None = None + created_at: int | None = None + updated_by: str | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +class ModelConfig(ResponseModel): + opening_statement: str | None = None + suggested_questions: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("suggested_questions_list", "suggested_questions") + ) + suggested_questions_after_answer: JSONValue | None = Field( + default=None, + validation_alias=AliasChoices("suggested_questions_after_answer_dict", "suggested_questions_after_answer"), + ) + speech_to_text: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("speech_to_text_dict", "speech_to_text") + ) + text_to_speech: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("text_to_speech_dict", "text_to_speech") + ) + retriever_resource: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("retriever_resource_dict", "retriever_resource") + ) + annotation_reply: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("annotation_reply_dict", "annotation_reply") + ) + more_like_this: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("more_like_this_dict", "more_like_this") + ) + sensitive_word_avoidance: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("sensitive_word_avoidance_dict", "sensitive_word_avoidance") + ) + external_data_tools: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("external_data_tools_list", "external_data_tools") + ) + model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) + user_input_form: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("user_input_form_list", "user_input_form") + ) + dataset_query_variable: str | None = None + pre_prompt: str | None = None + agent_mode: JSONValue | None = Field(default=None, validation_alias=AliasChoices("agent_mode_dict", "agent_mode")) + prompt_type: str | None = None + chat_prompt_config: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("chat_prompt_config_dict", "chat_prompt_config") + ) + completion_prompt_config: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("completion_prompt_config_dict", "completion_prompt_config") + ) + dataset_configs: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("dataset_configs_dict", "dataset_configs") + ) + file_upload: JSONValue | None = Field( + default=None, validation_alias=AliasChoices("file_upload_dict", "file_upload") + ) + created_by: str | None = None + created_at: int | None = None + updated_by: str | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +class AppDetailKernel(ResponseModel): + id: str + name: str + description: str | None = None + mode: str = Field(validation_alias="mode_compatible_with_agent") + icon_type: str | IconType | None = None + icon: str | None = None + icon_background: str | None = None + + @computed_field(return_type=str | None) # type: ignore[misc] + @property + def icon_url(self) -> str | None: + return _build_icon_url(self.icon_type, self.icon) + + @field_validator("icon_type", mode="before") + @classmethod + def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: + if isinstance(value, IconType): + return value.value return value -app_detail_kernel_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, -} - -related_app_list = { - "data": fields.List(fields.Nested(app_detail_kernel_fields)), - "total": fields.Integer, -} - -model_config_fields = { - "opening_statement": fields.String, - "suggested_questions": fields.Raw(attribute="suggested_questions_list"), - "suggested_questions_after_answer": fields.Raw(attribute="suggested_questions_after_answer_dict"), - "speech_to_text": fields.Raw(attribute="speech_to_text_dict"), - "text_to_speech": fields.Raw(attribute="text_to_speech_dict"), - "retriever_resource": fields.Raw(attribute="retriever_resource_dict"), - "annotation_reply": fields.Raw(attribute="annotation_reply_dict"), - "more_like_this": fields.Raw(attribute="more_like_this_dict"), - "sensitive_word_avoidance": fields.Raw(attribute="sensitive_word_avoidance_dict"), - "external_data_tools": fields.Raw(attribute="external_data_tools_list"), - "model": fields.Raw(attribute="model_dict"), - "user_input_form": fields.Raw(attribute="user_input_form_list"), - "dataset_query_variable": fields.String, - "pre_prompt": fields.String, - "agent_mode": fields.Raw(attribute="agent_mode_dict"), - "prompt_type": fields.String, - "chat_prompt_config": fields.Raw(attribute="chat_prompt_config_dict"), - "completion_prompt_config": fields.Raw(attribute="completion_prompt_config_dict"), - "dataset_configs": fields.Raw(attribute="dataset_configs_dict"), - "file_upload": fields.Raw(attribute="file_upload_dict"), - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String} - -app_detail_fields = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon": fields.String, - "icon_background": fields.String, - "enable_site": fields.Boolean, - "enable_api": fields.Boolean, - "model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True), - "workflow": fields.Nested(workflow_partial_fields, allow_null=True), - "tracing": fields.Raw, - "use_icon_as_answer_icon": fields.Boolean, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, - "access_mode": fields.String, - "tags": fields.List(fields.Nested(tag_fields)), -} - -prompt_config_fields = { - "prompt_template": fields.String, -} - -model_config_partial_fields = { - "model": fields.Raw(attribute="model_dict"), - "pre_prompt": fields.String, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -app_partial_fields = { - "id": fields.String, - "name": fields.String, - "max_active_requests": fields.Raw(), - "description": fields.String(attribute="desc_or_prompt"), - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "model_config": fields.Nested(model_config_partial_fields, attribute="app_model_config", allow_null=True), - "workflow": fields.Nested(workflow_partial_fields, allow_null=True), - "use_icon_as_answer_icon": fields.Boolean, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, - "tags": fields.List(fields.Nested(tag_fields)), - "access_mode": fields.String, - "create_user_name": fields.String, - "author_name": fields.String, - "has_draft_trigger": fields.Boolean, -} +class RelatedAppList(ResponseModel): + data: list[AppDetailKernel] + total: int -app_pagination_fields = { - "page": fields.Integer, - "limit": fields.Integer(attribute="per_page"), - "total": fields.Integer, - "has_more": fields.Boolean(attribute="has_next"), - "data": fields.List(fields.Nested(app_partial_fields), attribute="items"), -} +class Site(ResponseModel): + access_token: str | None = Field(default=None, validation_alias="code") + code: str | None = None + title: str | None = None + icon_type: str | IconType | None = None + icon: str | None = None + icon_background: str | None = None + description: str | None = None + default_language: str | None = None + chat_color_theme: str | None = None + chat_color_theme_inverted: bool | None = None + customize_domain: str | None = None + copyright: str | None = None + privacy_policy: str | None = None + custom_disclaimer: str | None = None + customize_token_strategy: str | None = None + prompt_public: bool | None = None + app_base_url: str | None = None + show_workflow_steps: bool | None = None + use_icon_as_answer_icon: bool | None = None + created_by: str | None = None + created_at: int | None = None + updated_by: str | None = None + updated_at: int | None = None -template_fields = { - "name": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "description": fields.String, - "mode": fields.String, - "model_config": fields.Nested(model_config_fields), -} + @computed_field(return_type=str | None) # type: ignore[misc] + @property + def icon_url(self) -> str | None: + return _build_icon_url(self.icon_type, self.icon) -template_list_fields = { - "data": fields.List(fields.Nested(template_fields)), -} + @field_validator("icon_type", mode="before") + @classmethod + def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: + if isinstance(value, IconType): + return value.value + return value -site_fields = { - "access_token": fields.String(attribute="code"), - "code": fields.String, - "title": fields.String, - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "description": fields.String, - "default_language": fields.String, - "chat_color_theme": fields.String, - "chat_color_theme_inverted": fields.Boolean, - "customize_domain": fields.String, - "copyright": fields.String, - "privacy_policy": fields.String, - "custom_disclaimer": fields.String, - "customize_token_strategy": fields.String, - "prompt_public": fields.Boolean, - "app_base_url": fields.String, - "show_workflow_steps": fields.Boolean, - "use_icon_as_answer_icon": fields.Boolean, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, -} - -deleted_tool_fields = { - "type": fields.String, - "tool_name": fields.String, - "provider_id": fields.String, -} - -app_detail_fields_with_site = { - "id": fields.String, - "name": fields.String, - "description": fields.String, - "mode": fields.String(attribute="mode_compatible_with_agent"), - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "enable_site": fields.Boolean, - "enable_api": fields.Boolean, - "model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True), - "workflow": fields.Nested(workflow_partial_fields, allow_null=True), - "api_base_url": fields.String, - "use_icon_as_answer_icon": fields.Boolean, - "max_active_requests": fields.Integer, - "created_by": fields.String, - "created_at": TimestampField, - "updated_by": fields.String, - "updated_at": TimestampField, - "deleted_tools": fields.List(fields.Nested(deleted_tool_fields)), - "access_mode": fields.String, - "tags": fields.List(fields.Nested(tag_fields)), - "site": fields.Nested(site_fields), -} + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) -app_site_fields = { - "app_id": fields.String, - "access_token": fields.String(attribute="code"), - "code": fields.String, - "title": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "description": fields.String, - "default_language": fields.String, - "customize_domain": fields.String, - "copyright": fields.String, - "privacy_policy": fields.String, - "custom_disclaimer": fields.String, - "customize_token_strategy": fields.String, - "prompt_public": fields.Boolean, - "show_workflow_steps": fields.Boolean, - "use_icon_as_answer_icon": fields.Boolean, -} +class AppSite(Site): + app_id: str | None = None -leaked_dependency_fields = {"type": fields.String, "value": fields.Raw, "current_identifier": fields.String} -app_import_fields = { - "id": fields.String, - "status": fields.String, - "app_id": fields.String, - "app_mode": fields.String, - "current_dsl_version": fields.String, - "imported_dsl_version": fields.String, - "error": fields.String, -} +class DeletedTool(ResponseModel): + type: str + tool_name: str + provider_id: str -app_import_check_dependencies_fields = { - "leaked_dependencies": fields.List(fields.Nested(leaked_dependency_fields)), -} -app_server_fields = { - "id": fields.String, - "name": fields.String, - "server_code": fields.String, - "description": fields.String, - "status": fields.String, - "parameters": JsonStringField, - "created_at": TimestampField, - "updated_at": TimestampField, -} +class AppPartial(ResponseModel): + id: str + name: str + max_active_requests: int | None = None + description: str | None = Field(default=None, validation_alias=AliasChoices("desc_or_prompt", "description")) + mode: str = Field(validation_alias="mode_compatible_with_agent") + icon_type: str | IconType | None = None + icon: str | None = None + icon_background: str | None = None + model_config_: ModelConfigPartial | None = Field( + default=None, + validation_alias=AliasChoices("app_model_config", "model_config"), + alias="model_config", + ) + workflow: WorkflowPartial | None = None + use_icon_as_answer_icon: bool | None = None + created_by: str | None = None + created_at: int | None = None + updated_by: str | None = None + updated_at: int | None = None + tags: list[Tag] = Field(default_factory=list) + access_mode: str | None = None + create_user_name: str | None = None + author_name: str | None = None + has_draft_trigger: bool | None = None + + @computed_field(return_type=str | None) # type: ignore[misc] + @property + def icon_url(self) -> str | None: + return _build_icon_url(self.icon_type, self.icon) + + @field_validator("icon_type", mode="before") + @classmethod + def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: + if isinstance(value, IconType): + return value.value + return value + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +class AppDetail(ResponseModel): + id: str + name: str + description: str | None = None + mode: str = Field(validation_alias="mode_compatible_with_agent") + icon_type: str | IconType | None = None + icon: str | None = None + icon_background: str | None = None + enable_site: bool + enable_api: bool + model_config_: ModelConfig | None = Field( + default=None, + validation_alias=AliasChoices("app_model_config", "model_config"), + alias="model_config", + ) + workflow: WorkflowPartial | None = None + tracing: JSONValue | None = None + use_icon_as_answer_icon: bool | None = None + created_by: str | None = None + created_at: int | None = None + updated_by: str | None = None + updated_at: int | None = None + access_mode: str | None = None + tags: list[Tag] = Field(default_factory=list) + + @field_validator("icon_type", mode="before") + @classmethod + def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: + if isinstance(value, IconType): + return value.value + return value + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +class AppDetailWithSite(AppDetail): + api_base_url: str | None = None + max_active_requests: int | None = None + deleted_tools: list[DeletedTool] = Field(default_factory=list) + site: Site | None = None + + @computed_field(return_type=str | None) # type: ignore[misc] + @property + def icon_url(self) -> str | None: + return _build_icon_url(self.icon_type, self.icon) + + +class AppPagination(ResponseModel): + page: int + limit: int = Field(validation_alias=AliasChoices("per_page", "limit")) + total: int + has_more: bool = Field(validation_alias=AliasChoices("has_next", "has_more")) + data: list[AppPartial] = Field(validation_alias=AliasChoices("items", "data")) + + +class AppExportResponse(ResponseModel): + data: str + + +class LeakedDependency(ResponseModel): + type: str + value: JSONValue + current_identifier: str | None = None + + +class AppImport(ResponseModel): + id: str + status: str + app_id: str | None = None + app_mode: str | None = None + current_dsl_version: str | None = None + imported_dsl_version: str | None = None + error: str | None = None + + +class AppImportCheckDependencies(ResponseModel): + leaked_dependencies: list[LeakedDependency] = Field(default_factory=list) + + +class AppServer(ResponseModel): + id: str + name: str + server_code: str + description: str | None = None + status: str + parameters: JSONValue | None = None + created_at: int | None = None + updated_at: int | None = None + + @field_validator("parameters", mode="before") + @classmethod + def _parse_parameters(cls, value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + return value + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return _to_timestamp(value) + + +__all__ = [ + "AppDetail", + "AppDetailKernel", + "AppDetailWithSite", + "AppExportResponse", + "AppImport", + "AppImportCheckDependencies", + "AppPagination", + "AppPartial", + "AppServer", + "AppSite", + "DeletedTool", + "LeakedDependency", + "ModelConfig", + "ModelConfigPartial", + "RelatedAppList", + "ResponseModel", + "Site", + "Tag", + "WorkflowPartial", +] diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py new file mode 100644 index 0000000000..97659b5f6d --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import builtins +import sys +from datetime import datetime +from importlib import util +from pathlib import Path +from types import ModuleType, SimpleNamespace + +from flask.views import MethodView + +from core.variables import SecretVariable, StringVariable + +# kombu references MethodView as a global when importing celery/kombu pools. +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +def _load_workflow_module(): + module_name = "controllers.console.app.workflow" + if module_name in sys.modules: + return sys.modules[module_name] + + root = Path(__file__).resolve().parents[5] + module_path = root / "controllers" / "console" / "app" / "workflow.py" + + class _StubNamespace: + def __init__(self): + self.models: dict[str, dict] = {} + self.payload = None + + def schema_model(self, name, schema): + self.models[name] = schema + + def _decorator(self, obj): + return obj + + def doc(self, *args, **kwargs): + return self._decorator + + def expect(self, *args, **kwargs): + return self._decorator + + def response(self, *args, **kwargs): + return self._decorator + + def route(self, *args, **kwargs): + def decorator(obj): + return obj + + return decorator + + stub_namespace = _StubNamespace() + + original_console = sys.modules.get("controllers.console") + original_app_pkg = sys.modules.get("controllers.console.app") + + console_module = ModuleType("controllers.console") + console_module.__path__ = [str(root / "controllers" / "console")] + console_module.console_ns = stub_namespace + console_module.api = None + console_module.bp = None + sys.modules["controllers.console"] = console_module + + app_package = ModuleType("controllers.console.app") + app_package.__path__ = [str(root / "controllers" / "console" / "app")] + sys.modules["controllers.console.app"] = app_package + console_module.app = app_package + + spec = util.spec_from_file_location(module_name, module_path) + module = util.module_from_spec(spec) + sys.modules[module_name] = module + + try: + assert spec.loader is not None + spec.loader.exec_module(module) + finally: + if original_console is not None: + sys.modules["controllers.console"] = original_console + else: + sys.modules.pop("controllers.console", None) + if original_app_pkg is not None: + sys.modules["controllers.console.app"] = original_app_pkg + else: + sys.modules.pop("controllers.console.app", None) + + return module + + +_workflow_module = _load_workflow_module() +WorkflowResponse = _workflow_module.WorkflowResponse +WorkflowPaginationResponse = _workflow_module.WorkflowPaginationResponse +WorkflowRunNodeExecutionResponse = _workflow_module.WorkflowRunNodeExecutionResponse + + +def _ts(hour: int = 12) -> datetime: + return datetime(2024, 1, 1, hour, 0, 0) + + +def _workflow_stub(identifier: str = "wf-1") -> SimpleNamespace: + return SimpleNamespace( + id=identifier, + graph_dict={"nodes": [], "edges": []}, + features_dict={"file_upload": {"enabled": True}}, + unique_hash=f"hash-{identifier}", + version="draft", + marked_name="Workflow", + marked_comment="Comment", + created_by_account=SimpleNamespace(id="acct-1", name="Alice", email="alice@example.com"), + created_at=_ts(), + updated_by_account=None, + updated_at=_ts(13), + tool_published=True, + environment_variables=[ + StringVariable(id="env-1", name="API_KEY", value="123", description="visible"), + SecretVariable(id="env-2", name="SECRET", value="encrypted", description="hidden"), + ], + conversation_variables=[ + StringVariable(id="conv-1", name="topic", value="science", description="desc"), + ], + rag_pipeline_variables=[ + { + "label": "Field", + "variable": "field", + "type": "text-input", + "belong_to_node_id": "node-1", + "allow_file_extension": [".txt"], + "allow_file_upload_methods": ["local_file"], + } + ], + ) + + +def test_workflow_response_masks_secret_environment_variables(): + workflow_obj = _workflow_stub() + + serialized = WorkflowResponse.model_validate(workflow_obj, from_attributes=True).model_dump(mode="json") + + assert serialized["id"] == "wf-1" + assert serialized["hash"] == "hash-wf-1" + assert serialized["environment_variables"][0]["value"] == "123" + assert serialized["environment_variables"][1]["value"] == "*" * 20 + assert serialized["conversation_variables"][0]["value_type"] == "string" + assert serialized["graph"] == {"nodes": [], "edges": []} + assert serialized["features"] == {"file_upload": {"enabled": True}} + assert serialized["rag_pipeline_variables"][0]["allow_file_extension"] == [".txt"] + assert serialized["created_at"] == int(_ts().timestamp()) + + +def test_workflow_node_execution_response_serializes_nested_entities(): + node_execution = SimpleNamespace( + id="node-1", + index=1, + predecessor_node_id=None, + node_id="node-1", + node_type="tool", + title="Tool Node", + inputs_dict={"foo": "bar"}, + process_data_dict={"step": 1}, + outputs_dict={"result": "ok"}, + status="succeeded", + error=None, + elapsed_time=1.23, + execution_metadata_dict={"tool_info": {"provider_type": "builtin"}}, + extras={"icon": "icon-url"}, + created_at=_ts(), + created_by_role="account", + created_by_account=SimpleNamespace(id="acct-1", name="Alice", email="alice@example.com"), + created_by_end_user=SimpleNamespace(id="end-1", type="end_user", is_anonymous=False, session_id="sess-1"), + finished_at=_ts(13), + inputs_truncated=False, + outputs_truncated=False, + process_data_truncated=False, + ) + + serialized = WorkflowRunNodeExecutionResponse.model_validate(node_execution, from_attributes=True).model_dump( + mode="json" + ) + + assert serialized["created_by_account"]["name"] == "Alice" + assert serialized["created_by_end_user"]["session_id"] == "sess-1" + assert serialized["created_at"] == int(_ts().timestamp()) + assert serialized["inputs"] == {"foo": "bar"} + assert serialized["execution_metadata"] == {"tool_info": {"provider_type": "builtin"}} + + +def test_workflow_pagination_serializes_workflow_items(): + workflows = [_workflow_stub("wf-1"), _workflow_stub("wf-2")] + + serialized = WorkflowPaginationResponse.model_validate( + {"items": workflows, "page": 2, "limit": 5, "has_more": True}, + from_attributes=True, + ).model_dump(mode="json") + + assert serialized["page"] == 2 + assert serialized["limit"] == 5 + assert serialized["has_more"] is True + assert serialized["items"][1]["id"] == "wf-2" From 228fa46489933f4e5deb605f9ded9fdaa6a4c642 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 7 Jan 2026 19:51:20 +0900 Subject: [PATCH 02/17] fix --- api/controllers/console/app/app.py | 12 +++++++----- api/controllers/console/app/app_import.py | 2 +- api/controllers/console/app/mcp_server.py | 4 ++-- api/controllers/console/app/site.py | 20 ++++++++------------ api/fields/app_fields.py | 16 ++++++++-------- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index ae7b3da263..648384f982 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,10 +1,11 @@ import re import uuid -from typing import Literal +from datetime import datetime +from typing import Any, Literal, TypeAlias from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy import select from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest @@ -20,6 +21,7 @@ from controllers.console.wraps import ( is_admin_or_owner_required, setup_required, ) +from core.file import helpers as file_helpers from core.ops.ops_trace_manager import OpsTraceManager from core.workflow.enums import NodeType from extensions.ext_database import db @@ -37,7 +39,7 @@ from fields.app_fields import ( WorkflowPartial, ) from libs.login import current_account_with_tenant, login_required -from models import App, Workflow +from models import App, IconType, Workflow from services.app_dsl_service import AppDslService, ImportMode from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService @@ -218,8 +220,8 @@ def _to_timestamp(value: datetime | int | None) -> int | None: def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None: if icon is None or icon_type is None: return None - icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type) - if icon_type_value.lower() != IconType.IMAGE.value: + icon_type_value = icon_type if isinstance(icon_type, IconType) else str(icon_type) + if icon_type_value.lower() != IconType.IMAGE: return None return file_helpers.get_signed_file_url(icon) diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index 05ae6caf51..93f2b94bcc 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -117,7 +117,7 @@ class AppImportConfirmApi(Resource): class AppImportCheckDependenciesApi(Resource): @setup_required @login_required - @get_app_model + @get_app_model(mode=None) @account_initialization_required @edit_permission_required def get(self, app_model: App): diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 5a5ac7fcd4..d12ae107d0 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -54,7 +54,7 @@ class AppMCPServerController(Resource): @login_required @account_initialization_required @setup_required - @get_app_model + @get_app_model(mode=None) def get(self, app_model): server = db.session.query(AppMCPServer).where(AppMCPServer.app_id == app_model.id).first() return AppServer.model_validate(server, from_attributes=True).model_dump(mode="json") if server else None @@ -66,7 +66,7 @@ class AppMCPServerController(Resource): @console_ns.response(201, "MCP server configuration created successfully", console_ns.models[AppServer.__name__]) @console_ns.response(403, "Insufficient permissions") @account_initialization_required - @get_app_model + @get_app_model(mode=None) @login_required @setup_required @edit_permission_required diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index 28a0b36e48..2deff767c3 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import NotFound from constants.languages import supported_language +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( @@ -14,7 +15,7 @@ from controllers.console.wraps import ( setup_required, ) from extensions.ext_database import db -from fields.app_fields import AppSite +from fields.app_fields import AppSiteModel from libs.datetime_utils import naive_utc_now from libs.login import current_account_with_tenant, login_required from models import Site @@ -48,16 +49,11 @@ class AppSiteUpdatePayload(BaseModel): return supported_language(value) -console_ns.schema_model( - AppSiteUpdatePayload.__name__, - AppSiteUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) - -console_ns.schema_model(AppSite.__name__, AppSite.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) +register_schema_models(console_ns, AppSiteUpdatePayload, AppSiteModel) def _dump_site(site: Site) -> dict: - return AppSite.model_validate(site, from_attributes=True).model_dump(mode="json") + return AppSiteModel.model_validate(site, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps//site") @@ -66,14 +62,14 @@ class AppSite(Resource): @console_ns.doc(description="Update application site configuration") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[AppSiteUpdatePayload.__name__]) - @console_ns.response(200, "Site configuration updated successfully", console_ns.models[AppSite.__name__]) + @console_ns.response(200, "Site configuration updated successfully", console_ns.models[AppSiteModel.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "App not found") @setup_required @login_required @edit_permission_required @account_initialization_required - @get_app_model + @get_app_model(mode=None) def post(self, app_model): args = AppSiteUpdatePayload.model_validate(console_ns.payload or {}) current_user, _ = current_account_with_tenant() @@ -115,14 +111,14 @@ class AppSiteAccessTokenReset(Resource): @console_ns.doc("reset_app_site_access_token") @console_ns.doc(description="Reset access token for application site") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Access token reset successfully", console_ns.models[AppSite.__name__]) + @console_ns.response(200, "Access token reset successfully", console_ns.models[AppSiteModel.__name__]) @console_ns.response(403, "Insufficient permissions (admin/owner required)") @console_ns.response(404, "App or site not found") @setup_required @login_required @is_admin_or_owner_required @account_initialization_required - @get_app_model + @get_app_model(mode=None) def post(self, app_model): current_user, _ = current_account_with_tenant() site = db.session.query(Site).where(Site.app_id == app_model.id).first() diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 013164b55d..542c4dc329 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -31,8 +31,8 @@ def _to_timestamp(value: datetime | int | None) -> int | None: def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None: if icon is None or icon_type is None: return None - icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type) - if icon_type_value.lower() != IconType.IMAGE.value: + icon_type_value = icon_type if isinstance(icon_type, IconType) else str(icon_type) + if icon_type_value.lower() != IconType.IMAGE: return None return file_helpers.get_signed_file_url(icon) @@ -149,7 +149,7 @@ class AppDetailKernel(ResponseModel): @classmethod def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: if isinstance(value, IconType): - return value.value + return value return value @@ -192,7 +192,7 @@ class Site(ResponseModel): @classmethod def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: if isinstance(value, IconType): - return value.value + return value return value @field_validator("created_at", "updated_at", mode="before") @@ -201,7 +201,7 @@ class Site(ResponseModel): return _to_timestamp(value) -class AppSite(Site): +class AppSiteModel(Site): app_id: str | None = None @@ -246,7 +246,7 @@ class AppPartial(ResponseModel): @classmethod def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: if isinstance(value, IconType): - return value.value + return value return value @field_validator("created_at", "updated_at", mode="before") @@ -284,7 +284,7 @@ class AppDetail(ResponseModel): @classmethod def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: if isinstance(value, IconType): - return value.value + return value return value @field_validator("created_at", "updated_at", mode="before") @@ -373,7 +373,7 @@ __all__ = [ "AppPagination", "AppPartial", "AppServer", - "AppSite", + "AppSiteModel", "DeletedTool", "LeakedDependency", "ModelConfig", From 412511e1c1b8eddbf4389cf39739e127f03f6345 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 7 Jan 2026 19:54:58 +0900 Subject: [PATCH 03/17] unused --- api/controllers/console/app/app.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 648384f982..cc4859f65e 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -211,21 +211,6 @@ class ResponseModel(BaseModel): ) -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - -def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None: - if icon is None or icon_type is None: - return None - icon_type_value = icon_type if isinstance(icon_type, IconType) else str(icon_type) - if icon_type_value.lower() != IconType.IMAGE: - return None - return file_helpers.get_signed_file_url(icon) - - register_schema_models( console_ns, AppListQuery, From 51163f81d8f539ffdf9552069ece3ac485561136 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:56:53 +0000 Subject: [PATCH 04/17] [autofix.ci] apply automated fixes --- api/controllers/console/app/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index cc4859f65e..b1d0897411 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,6 +1,5 @@ import re import uuid -from datetime import datetime from typing import Any, Literal, TypeAlias from flask import request @@ -21,7 +20,6 @@ from controllers.console.wraps import ( is_admin_or_owner_required, setup_required, ) -from core.file import helpers as file_helpers from core.ops.ops_trace_manager import OpsTraceManager from core.workflow.enums import NodeType from extensions.ext_database import db @@ -39,7 +37,7 @@ from fields.app_fields import ( WorkflowPartial, ) from libs.login import current_account_with_tenant, login_required -from models import App, IconType, Workflow +from models import App, Workflow from services.app_dsl_service import AppDslService, ImportMode from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService From ce77f4f940c545b704dbbd998797431bee7ab9ba Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 7 Jan 2026 20:01:54 +0900 Subject: [PATCH 05/17] . --- .../app/test_workflow_response_models.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py index 97659b5f6d..3e5ef40e2b 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py @@ -88,7 +88,6 @@ def _load_workflow_module(): _workflow_module = _load_workflow_module() -WorkflowResponse = _workflow_module.WorkflowResponse WorkflowPaginationResponse = _workflow_module.WorkflowPaginationResponse WorkflowRunNodeExecutionResponse = _workflow_module.WorkflowRunNodeExecutionResponse @@ -130,23 +129,6 @@ def _workflow_stub(identifier: str = "wf-1") -> SimpleNamespace: ], ) - -def test_workflow_response_masks_secret_environment_variables(): - workflow_obj = _workflow_stub() - - serialized = WorkflowResponse.model_validate(workflow_obj, from_attributes=True).model_dump(mode="json") - - assert serialized["id"] == "wf-1" - assert serialized["hash"] == "hash-wf-1" - assert serialized["environment_variables"][0]["value"] == "123" - assert serialized["environment_variables"][1]["value"] == "*" * 20 - assert serialized["conversation_variables"][0]["value_type"] == "string" - assert serialized["graph"] == {"nodes": [], "edges": []} - assert serialized["features"] == {"file_upload": {"enabled": True}} - assert serialized["rag_pipeline_variables"][0]["allow_file_extension"] == [".txt"] - assert serialized["created_at"] == int(_ts().timestamp()) - - def test_workflow_node_execution_response_serializes_nested_entities(): node_execution = SimpleNamespace( id="node-1", From f906cc1259d71c156412b753f1931766dae50cfb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:03:49 +0000 Subject: [PATCH 06/17] [autofix.ci] apply automated fixes --- .../controllers/console/app/test_workflow_response_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py index 3e5ef40e2b..38fb5a5e11 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py @@ -129,6 +129,7 @@ def _workflow_stub(identifier: str = "wf-1") -> SimpleNamespace: ], ) + def test_workflow_node_execution_response_serializes_nested_entities(): node_execution = SimpleNamespace( id="node-1", From 69fff730338273e4720e515d0fd2c66b5ece03d1 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 7 Jan 2026 20:07:40 +0900 Subject: [PATCH 07/17] . --- .../app/test_workflow_response_models.py | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py index 38fb5a5e11..d02b0d0f4c 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py @@ -86,12 +86,6 @@ def _load_workflow_module(): return module - -_workflow_module = _load_workflow_module() -WorkflowPaginationResponse = _workflow_module.WorkflowPaginationResponse -WorkflowRunNodeExecutionResponse = _workflow_module.WorkflowRunNodeExecutionResponse - - def _ts(hour: int = 12) -> datetime: return datetime(2024, 1, 1, hour, 0, 0) @@ -128,54 +122,3 @@ def _workflow_stub(identifier: str = "wf-1") -> SimpleNamespace: } ], ) - - -def test_workflow_node_execution_response_serializes_nested_entities(): - node_execution = SimpleNamespace( - id="node-1", - index=1, - predecessor_node_id=None, - node_id="node-1", - node_type="tool", - title="Tool Node", - inputs_dict={"foo": "bar"}, - process_data_dict={"step": 1}, - outputs_dict={"result": "ok"}, - status="succeeded", - error=None, - elapsed_time=1.23, - execution_metadata_dict={"tool_info": {"provider_type": "builtin"}}, - extras={"icon": "icon-url"}, - created_at=_ts(), - created_by_role="account", - created_by_account=SimpleNamespace(id="acct-1", name="Alice", email="alice@example.com"), - created_by_end_user=SimpleNamespace(id="end-1", type="end_user", is_anonymous=False, session_id="sess-1"), - finished_at=_ts(13), - inputs_truncated=False, - outputs_truncated=False, - process_data_truncated=False, - ) - - serialized = WorkflowRunNodeExecutionResponse.model_validate(node_execution, from_attributes=True).model_dump( - mode="json" - ) - - assert serialized["created_by_account"]["name"] == "Alice" - assert serialized["created_by_end_user"]["session_id"] == "sess-1" - assert serialized["created_at"] == int(_ts().timestamp()) - assert serialized["inputs"] == {"foo": "bar"} - assert serialized["execution_metadata"] == {"tool_info": {"provider_type": "builtin"}} - - -def test_workflow_pagination_serializes_workflow_items(): - workflows = [_workflow_stub("wf-1"), _workflow_stub("wf-2")] - - serialized = WorkflowPaginationResponse.model_validate( - {"items": workflows, "page": 2, "limit": 5, "has_more": True}, - from_attributes=True, - ).model_dump(mode="json") - - assert serialized["page"] == 2 - assert serialized["limit"] == 5 - assert serialized["has_more"] is True - assert serialized["items"][1]["id"] == "wf-2" From 67aff407ef27482bbbb0b51a2c3ccf8a6ce7e869 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:09:34 +0000 Subject: [PATCH 08/17] [autofix.ci] apply automated fixes --- .../controllers/console/app/test_workflow_response_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py index d02b0d0f4c..e4de1e752d 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_response_models.py @@ -86,6 +86,7 @@ def _load_workflow_module(): return module + def _ts(hour: int = 12) -> datetime: return datetime(2024, 1, 1, hour, 0, 0) From 72d605ce3b612890148bbd4c59f5877734d31ae1 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 7 Jan 2026 20:46:05 +0900 Subject: [PATCH 09/17] . --- api/controllers/console/app/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index b1d0897411..c6d723ede2 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -12,6 +12,7 @@ from werkzeug.exceptions import BadRequest from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model +from core.file import helpers as _file_helpers from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, @@ -43,6 +44,9 @@ from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService from services.feature_service import FeatureService +# Re-export file helper utilities so tests can stub deterministic URLs. +file_helpers = _file_helpers + ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] From 2819add5c7195204e249f8253330781a2a419b89 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:47:57 +0000 Subject: [PATCH 10/17] [autofix.ci] apply automated fixes --- api/controllers/console/app/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index c6d723ede2..906457ca92 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -12,7 +12,6 @@ from werkzeug.exceptions import BadRequest from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model -from core.file import helpers as _file_helpers from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, @@ -21,6 +20,7 @@ from controllers.console.wraps import ( is_admin_or_owner_required, setup_required, ) +from core.file import helpers as _file_helpers from core.ops.ops_trace_manager import OpsTraceManager from core.workflow.enums import NodeType from extensions.ext_database import db From ae1abc4a91a49013aeb1cd228d7af27ed7bfb132 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 2 Feb 2026 15:42:13 +0900 Subject: [PATCH 11/17] dedup --- api/controllers/console/app/app.py | 237 +----------------- .../clickzetta_volume_storage.py | 3 +- api/fields/app_fields.py | 1 + 3 files changed, 3 insertions(+), 238 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 7c2db6c4b4..8abe6f6957 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime from typing import Any, Literal, TypeAlias from flask import request @@ -40,7 +41,6 @@ from fields.app_fields import ( WorkflowPartial, ) from libs.login import current_account_with_tenant, login_required -from models import App, Workflow from models import App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService, ImportMode @@ -192,241 +192,6 @@ def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | return file_helpers.get_signed_file_url(icon) -class Tag(ResponseModel): - id: str - name: str - type: str - - -class WorkflowPartial(ResponseModel): - id: str - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class ModelConfigPartial(ResponseModel): - model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) - pre_prompt: str | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class ModelConfig(ResponseModel): - opening_statement: str | None = None - suggested_questions: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("suggested_questions_list", "suggested_questions") - ) - suggested_questions_after_answer: JSONValue | None = Field( - default=None, - validation_alias=AliasChoices("suggested_questions_after_answer_dict", "suggested_questions_after_answer"), - ) - speech_to_text: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("speech_to_text_dict", "speech_to_text") - ) - text_to_speech: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("text_to_speech_dict", "text_to_speech") - ) - retriever_resource: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("retriever_resource_dict", "retriever_resource") - ) - annotation_reply: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("annotation_reply_dict", "annotation_reply") - ) - more_like_this: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("more_like_this_dict", "more_like_this") - ) - sensitive_word_avoidance: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("sensitive_word_avoidance_dict", "sensitive_word_avoidance") - ) - external_data_tools: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("external_data_tools_list", "external_data_tools") - ) - model: JSONValue | None = Field(default=None, validation_alias=AliasChoices("model_dict", "model")) - user_input_form: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("user_input_form_list", "user_input_form") - ) - dataset_query_variable: str | None = None - pre_prompt: str | None = None - agent_mode: JSONValue | None = Field(default=None, validation_alias=AliasChoices("agent_mode_dict", "agent_mode")) - prompt_type: str | None = None - chat_prompt_config: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("chat_prompt_config_dict", "chat_prompt_config") - ) - completion_prompt_config: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("completion_prompt_config_dict", "completion_prompt_config") - ) - dataset_configs: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("dataset_configs_dict", "dataset_configs") - ) - file_upload: JSONValue | None = Field( - default=None, validation_alias=AliasChoices("file_upload_dict", "file_upload") - ) - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class Site(ResponseModel): - access_token: str | None = Field(default=None, validation_alias="code") - code: str | None = None - title: str | None = None - icon_type: str | IconType | None = None - icon: str | None = None - icon_background: str | None = None - description: str | None = None - default_language: str | None = None - chat_color_theme: str | None = None - chat_color_theme_inverted: bool | None = None - customize_domain: str | None = None - copyright: str | None = None - privacy_policy: str | None = None - custom_disclaimer: str | None = None - customize_token_strategy: str | None = None - prompt_public: bool | None = None - app_base_url: str | None = None - show_workflow_steps: bool | None = None - use_icon_as_answer_icon: bool | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - - @computed_field(return_type=str | None) # type: ignore - @property - def icon_url(self) -> str | None: - return _build_icon_url(self.icon_type, self.icon) - - @field_validator("icon_type", mode="before") - @classmethod - def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: - if isinstance(value, IconType): - return value.value - return value - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class DeletedTool(ResponseModel): - type: str - tool_name: str - provider_id: str - - -class AppPartial(ResponseModel): - id: str - name: str - max_active_requests: int | None = None - description: str | None = Field(default=None, validation_alias=AliasChoices("desc_or_prompt", "description")) - mode: str = Field(validation_alias="mode_compatible_with_agent") - icon_type: str | None = None - icon: str | None = None - icon_background: str | None = None - model_config_: ModelConfigPartial | None = Field( - default=None, - validation_alias=AliasChoices("app_model_config", "model_config"), - alias="model_config", - ) - workflow: WorkflowPartial | None = None - use_icon_as_answer_icon: bool | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - tags: list[Tag] = Field(default_factory=list) - access_mode: str | None = None - create_user_name: str | None = None - author_name: str | None = None - has_draft_trigger: bool | None = None - - @computed_field(return_type=str | None) # type: ignore - @property - def icon_url(self) -> str | None: - return _build_icon_url(self.icon_type, self.icon) - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class AppDetail(ResponseModel): - id: str - name: str - description: str | None = None - mode: str = Field(validation_alias="mode_compatible_with_agent") - icon: str | None = None - icon_background: str | None = None - enable_site: bool - enable_api: bool - model_config_: ModelConfig | None = Field( - default=None, - validation_alias=AliasChoices("app_model_config", "model_config"), - alias="model_config", - ) - workflow: WorkflowPartial | None = None - tracing: JSONValue | None = None - use_icon_as_answer_icon: bool | None = None - created_by: str | None = None - created_at: int | None = None - updated_by: str | None = None - updated_at: int | None = None - access_mode: str | None = None - tags: list[Tag] = Field(default_factory=list) - - @field_validator("created_at", "updated_at", mode="before") - @classmethod - def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) - - -class AppDetailWithSite(AppDetail): - icon_type: str | None = None - api_base_url: str | None = None - max_active_requests: int | None = None - deleted_tools: list[DeletedTool] = Field(default_factory=list) - site: Site | None = None - - @computed_field(return_type=str | None) # type: ignore - @property - def icon_url(self) -> str | None: - return _build_icon_url(self.icon_type, self.icon) - - -class AppPagination(ResponseModel): - page: int - limit: int = Field(validation_alias=AliasChoices("per_page", "limit")) - total: int - has_more: bool = Field(validation_alias=AliasChoices("has_next", "has_more")) - data: list[AppPartial] = Field(validation_alias=AliasChoices("items", "data")) - - -class AppExportResponse(ResponseModel): - data: str - - register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum) register_schema_models( diff --git a/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py b/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py index c1608f58a5..18eed4e481 100644 --- a/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py +++ b/api/extensions/storage/clickzetta_volume/clickzetta_volume_storage.py @@ -390,8 +390,7 @@ class ClickZettaVolumeStorage(BaseStorage): """ content = self.load_once(filename) - with Path(target_filepath).open("wb") as f: - f.write(content) + Path(target_filepath).write_bytes(content) logger.debug("File %s downloaded from ClickZetta Volume to %s", filename, target_filepath) diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 542c4dc329..63a9506307 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -294,6 +294,7 @@ class AppDetail(ResponseModel): class AppDetailWithSite(AppDetail): + icon_type: str | None = None api_base_url: str | None = None max_active_requests: int | None = None deleted_tools: list[DeletedTool] = Field(default_factory=list) From c83d3301abbb4da966a3eeca77934400dfafe23c Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 2 Feb 2026 15:50:06 +0900 Subject: [PATCH 12/17] Update api/fields/app_fields.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/fields/app_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 63a9506307..f9342cdeab 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -147,7 +147,7 @@ class AppDetailKernel(ResponseModel): @field_validator("icon_type", mode="before") @classmethod - def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: + def _normalize_icon_type(cls, value: str | IconType | None) -> str | IconType | None: if isinstance(value, IconType): return value return value From 53ea5107490cba40de32cd5ee0501c3a32133a68 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 2 Feb 2026 15:51:09 +0900 Subject: [PATCH 13/17] Update api/fields/app_fields.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/fields/app_fields.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index f9342cdeab..27022b74ad 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -294,17 +294,11 @@ class AppDetail(ResponseModel): class AppDetailWithSite(AppDetail): - icon_type: str | None = None api_base_url: str | None = None max_active_requests: int | None = None deleted_tools: list[DeletedTool] = Field(default_factory=list) site: Site | None = None - @computed_field(return_type=str | None) # type: ignore[misc] - @property - def icon_url(self) -> str | None: - return _build_icon_url(self.icon_type, self.icon) - class AppPagination(ResponseModel): page: int From 3f2af4b1f7577d98a543c7f23c915011864bfddc Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 2 Feb 2026 15:51:21 +0900 Subject: [PATCH 14/17] Update api/controllers/console/app/mcp_server.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/controllers/console/app/mcp_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index d12ae107d0..c167dec53f 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -57,7 +57,7 @@ class AppMCPServerController(Resource): @get_app_model(mode=None) def get(self, app_model): server = db.session.query(AppMCPServer).where(AppMCPServer.app_id == app_model.id).first() - return AppServer.model_validate(server, from_attributes=True).model_dump(mode="json") if server else None + return _dump_server(server) @console_ns.doc("create_app_mcp_server") @console_ns.doc(description="Create MCP server configuration for an application") From fa6cbf16481d9e169fa6f5506cfd3b7d81735610 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 2 Feb 2026 15:53:06 +0900 Subject: [PATCH 15/17] . --- api/fields/app_fields.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 27022b74ad..338f9cfaec 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -280,6 +280,11 @@ class AppDetail(ResponseModel): access_mode: str | None = None tags: list[Tag] = Field(default_factory=list) + @computed_field(return_type=str | None) # type: ignore[misc] + @property + def icon_url(self) -> str | None: + return _build_icon_url(self.icon_type, self.icon) + @field_validator("icon_type", mode="before") @classmethod def _normalize_icon_type(cls, value: str | IconType | None) -> str | None: From d8c323ed101b2e4931a9800da58223ac1566f53f Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 2 Feb 2026 16:03:30 +0900 Subject: [PATCH 16/17] . --- api/controllers/console/datasets/datasets.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index cf949c5af6..d4ede15ff6 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -1,3 +1,5 @@ +from controllers.console.datasets.hit_testing import _get_or_create_model +from libs.helper import AppIconUrlField from typing import Any, cast from flask import request @@ -96,6 +98,22 @@ dataset_detail_model = get_or_create_model("DatasetDetail", dataset_detail_field file_info_model = get_or_create_model("DatasetFileInfo", file_info_fields) +app_detail_kernel_fields = { + "id": fields.String, + "name": fields.String, + "description": fields.String, + "mode": fields.String(attribute="mode_compatible_with_agent"), + "icon_type": fields.String, + "icon": fields.String, + "icon_background": fields.String, + "icon_url": AppIconUrlField, +} + +related_app_list = { + "data": fields.List(fields.Nested(app_detail_kernel_fields)), + "total": fields.Integer, +} + dataset_query_detail_model = _get_or_create_model("DatasetQueryDetail", dataset_query_detail_fields) register_schema_models(console_ns, AppDetailKernel, RelatedAppList) content_fields_copy = content_fields.copy() From b29af6f5d09d14170e401e437b5ae3486682ed8c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 07:07:55 +0000 Subject: [PATCH 17/17] [autofix.ci] apply automated fixes --- api/controllers/console/datasets/datasets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index d4ede15ff6..f4fde46898 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -1,5 +1,3 @@ -from controllers.console.datasets.hit_testing import _get_or_create_model -from libs.helper import AppIconUrlField from typing import Any, cast from flask import request @@ -18,6 +16,7 @@ from controllers.console.apikey import ( ) from controllers.console.app.error import ProviderNotInitializeError from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError +from controllers.console.datasets.hit_testing import _get_or_create_model from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_rate_limit_check, @@ -53,6 +52,7 @@ from fields.dataset_fields import ( weighted_score_fields, ) from fields.document_fields import document_status_fields +from libs.helper import AppIconUrlField from libs.login import current_account_with_tenant, login_required from models import ApiToken, Dataset, Document, DocumentSegment, UploadFile from models.dataset import DatasetPermissionEnum