diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index d716ab9fd2b..85b54ce7cc9 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -5,7 +5,7 @@ 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 libs.login import current_account_with_tenant, login_required -from models.model import AppMode +from models.model import App, AppMode from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator from services.entities.agent_entities import ComposerSavePayload @@ -19,7 +19,7 @@ class WorkflowAgentComposerApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) - def get(self, app_model, node_id: str): + def get(self, app_model: App, node_id: str): _, tenant_id = current_account_with_tenant() return AgentComposerService.load_workflow_composer( tenant_id=tenant_id, @@ -33,7 +33,7 @@ class WorkflowAgentComposerApi(Resource): @account_initialization_required @edit_permission_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) - def put(self, app_model, node_id: str): + def put(self, app_model: App, node_id: str): account, tenant_id = current_account_with_tenant() payload = ComposerSavePayload.model_validate(console_ns.payload or {}) return AgentComposerService.save_workflow_composer( @@ -52,7 +52,7 @@ class WorkflowAgentComposerValidateApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) - def post(self, app_model, node_id: str): + def post(self, app_model: App, node_id: str): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_save_payload(payload) return {"result": "success", "errors": []} @@ -64,7 +64,7 @@ class WorkflowAgentComposerCandidatesApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) - def get(self, app_model, node_id: str): + def get(self, app_model: App, node_id: str): return AgentComposerService.get_workflow_candidates(app_id=app_model.id) @@ -74,7 +74,7 @@ class WorkflowAgentComposerImpactApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) - def post(self, app_model, node_id: str): + def post(self, app_model: App, node_id: str): _, tenant_id = current_account_with_tenant() payload = ComposerSavePayload.model_validate(console_ns.payload or {}) current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None @@ -91,7 +91,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource): @account_initialization_required @edit_permission_required @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) - def post(self, app_model, node_id: str): + def post(self, app_model: App, node_id: str): account, tenant_id = current_account_with_tenant() payload = ComposerSavePayload.model_validate(console_ns.payload or {}) return AgentComposerService.save_workflow_composer( @@ -109,7 +109,7 @@ class AgentAppComposerApi(Resource): @login_required @account_initialization_required @get_app_model() - def get(self, app_model): + def get(self, app_model: App): _, tenant_id = current_account_with_tenant() return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id) @@ -119,7 +119,7 @@ class AgentAppComposerApi(Resource): @account_initialization_required @edit_permission_required @get_app_model() - def put(self, app_model): + def put(self, app_model: App): account, tenant_id = current_account_with_tenant() payload = ComposerSavePayload.model_validate(console_ns.payload or {}) return AgentComposerService.save_agent_app_composer( @@ -137,7 +137,7 @@ class AgentAppComposerValidateApi(Resource): @login_required @account_initialization_required @get_app_model() - def post(self, app_model): + def post(self, app_model: App): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_save_payload(payload) return {"result": "success", "errors": []} @@ -149,5 +149,5 @@ class AgentAppComposerCandidatesApi(Resource): @login_required @account_initialization_required @get_app_model() - def get(self, app_model): + def get(self, app_model: App): return AgentComposerService.get_agent_app_candidates(app_id=app_model.id) diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 133c57d34d3..57470dc9770 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -9,18 +9,25 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import to_timestamp -from libs.login import current_account_with_tenant, login_required +from libs.helper import dump_response, to_timestamp +from libs.login import login_required +from models import Account from models.dataset import Dataset from models.enums import ApiTokenType from models.model import ApiToken, App from services.api_token_service import ApiTokenCache from . import console_ns -from .wraps import account_initialization_required, edit_permission_required, setup_required +from .wraps import ( + account_initialization_required, + edit_permission_required, + setup_required, + with_current_tenant_id, + with_current_user, +) class ApiKeyItem(ResponseModel): @@ -40,7 +47,7 @@ class ApiKeyList(ResponseModel): data: list[ApiKeyItem] -register_schema_models(console_ns, ApiKeyItem, ApiKeyList) +register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList) def _get_resource(resource_id, tenant_id, resource_model): @@ -64,10 +71,11 @@ class BaseApiKeyListResource(Resource): token_prefix: str | None = None max_keys = 10 - def get(self, resource_id): + def get(self, resource_id: str, current_tenant_id: str) -> dict[str, object]: + return dump_response(ApiKeyList, self._get_api_key_list(resource_id, current_tenant_id)) + + def _get_api_key_list(self, resource_id: str, current_tenant_id: str) -> ApiKeyList: assert self.resource_id_field is not None, "resource_id_field must be set" - resource_id = str(resource_id) - _, current_tenant_id = current_account_with_tenant() _get_resource(resource_id, current_tenant_id, self.resource_model) keys = db.session.scalars( @@ -75,13 +83,14 @@ class BaseApiKeyListResource(Resource): ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id ) ).all() - return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json") + return ApiKeyList.model_validate({"data": keys}, from_attributes=True) @edit_permission_required - def post(self, resource_id): + def post(self, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]: + return dump_response(ApiKeyItem, self._create_api_key(resource_id, current_tenant_id)), 201 + + def _create_api_key(self, resource_id: str, current_tenant_id: str) -> ApiToken: assert self.resource_id_field is not None, "resource_id_field must be set" - resource_id = str(resource_id) - _, current_tenant_id = current_account_with_tenant() _get_resource(resource_id, current_tenant_id, self.resource_model) current_key_count: int = ( db.session.scalar( @@ -108,7 +117,7 @@ class BaseApiKeyListResource(Resource): api_token.type = self.resource_type db.session.add(api_token) db.session.commit() - return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 201 + return api_token class BaseApiKeyResource(Resource): @@ -118,9 +127,20 @@ class BaseApiKeyResource(Resource): resource_model: type | None = None resource_id_field: str | None = None - def delete(self, resource_id: str, api_key_id: str): + def delete( + self, resource_id: str, api_key_id: str, current_tenant_id: str, current_user: Account + ) -> tuple[str, int]: + self._delete_api_key(resource_id, api_key_id, current_tenant_id, current_user) + return "", 204 + + def _delete_api_key( + self, + resource_id: str, + api_key_id: str, + current_tenant_id: str, + current_user: Account, + ) -> None: assert self.resource_id_field is not None, "resource_id_field must be set" - current_user, current_tenant_id = current_account_with_tenant() _get_resource(resource_id, current_tenant_id, self.resource_model) if not current_user.is_admin_or_owner: @@ -147,8 +167,6 @@ class BaseApiKeyResource(Resource): db.session.execute(delete(ApiToken).where(ApiToken.id == api_key_id)) db.session.commit() - return "", 204 - @console_ns.route("/apps//api-keys") class AppApiKeyListResource(BaseApiKeyListResource): @@ -156,18 +174,21 @@ class AppApiKeyListResource(BaseApiKeyListResource): @console_ns.doc(description="Get all API keys for an app") @console_ns.doc(params={"resource_id": "App ID"}) @console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) - def get(self, resource_id: UUID): + @with_current_tenant_id + def get(self, current_tenant_id: str, resource_id: UUID) -> dict[str, object]: """Get all API keys for an app""" - return super().get(resource_id) + return dump_response(ApiKeyList, self._get_api_key_list(str(resource_id), current_tenant_id)) @console_ns.doc("create_app_api_key") @console_ns.doc(description="Create a new API key for an app") @console_ns.doc(params={"resource_id": "App ID"}) @console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(400, "Maximum keys exceeded") - def post(self, resource_id: UUID): + @with_current_tenant_id + @edit_permission_required + def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]: """Create a new API key for an app""" - return super().post(resource_id) + return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201 resource_type = ApiTokenType.APP resource_model = App @@ -181,9 +202,14 @@ class AppApiKeyResource(BaseApiKeyResource): @console_ns.doc(description="Delete an API key for an app") @console_ns.doc(params={"resource_id": "App ID", "api_key_id": "API key ID"}) @console_ns.response(204, "API key deleted successfully") - def delete(self, resource_id: UUID, api_key_id: UUID): + @with_current_user + @with_current_tenant_id + def delete( + self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID + ) -> tuple[str, int]: """Delete an API key for an app""" - return super().delete(str(resource_id), str(api_key_id)) + self._delete_api_key(str(resource_id), str(api_key_id), current_tenant_id, current_user) + return "", 204 resource_type = ApiTokenType.APP resource_model = App @@ -196,18 +222,21 @@ class DatasetApiKeyListResource(BaseApiKeyListResource): @console_ns.doc(description="Get all API keys for a dataset") @console_ns.doc(params={"resource_id": "Dataset ID"}) @console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) - def get(self, resource_id: UUID): + @with_current_tenant_id + def get(self, current_tenant_id: str, resource_id: UUID) -> dict[str, object]: """Get all API keys for a dataset""" - return super().get(resource_id) + return dump_response(ApiKeyList, self._get_api_key_list(str(resource_id), current_tenant_id)) @console_ns.doc("create_dataset_api_key") @console_ns.doc(description="Create a new API key for a dataset") @console_ns.doc(params={"resource_id": "Dataset ID"}) @console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(400, "Maximum keys exceeded") - def post(self, resource_id: UUID): + @with_current_tenant_id + @edit_permission_required + def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]: """Create a new API key for a dataset""" - return super().post(resource_id) + return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201 resource_type = ApiTokenType.DATASET resource_model = Dataset @@ -221,9 +250,14 @@ class DatasetApiKeyResource(BaseApiKeyResource): @console_ns.doc(description="Delete an API key for a dataset") @console_ns.doc(params={"resource_id": "Dataset ID", "api_key_id": "API key ID"}) @console_ns.response(204, "API key deleted successfully") - def delete(self, resource_id: UUID, api_key_id: UUID): + @with_current_user + @with_current_tenant_id + def delete( + self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID + ) -> tuple[str, int]: """Delete an API key for a dataset""" - return super().delete(str(resource_id), str(api_key_id)) + self._delete_api_key(str(resource_id), str(api_key_id), current_tenant_id, current_user) + return "", 204 resource_type = ApiTokenType.DATASET resource_model = Dataset diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index c05600ced55..277c86ced3c 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -8,7 +8,7 @@ from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required from libs.helper import uuid_value from libs.login import login_required -from models.model import AppMode +from models.model import App, AppMode from services.agent_service import AgentService @@ -39,7 +39,7 @@ class AgentLogApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.AGENT_CHAT]) - def get(self, app_model): + def get(self, app_model: App): """Get agent logs""" args = AgentLogQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 5a965208c61..06555e5842f 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -573,7 +573,7 @@ class AppApi(Resource): @account_initialization_required @enterprise_license_required @get_app_model(mode=None) - def get(self, app_model): + def get(self, app_model: App): """Get app detail""" app_service = AppService() @@ -581,7 +581,7 @@ class AppApi(Resource): if FeatureService.get_system_features().webapp_auth.enabled: app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) - app_model.access_mode = app_setting.access_mode + app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined] response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True) return response_model.model_dump(mode="json") @@ -598,7 +598,7 @@ class AppApi(Resource): @account_initialization_required @get_app_model(mode=None) @edit_permission_required - def put(self, app_model): + def put(self, app_model: App): """Update app""" args = UpdateAppPayload.model_validate(console_ns.payload) @@ -627,7 +627,7 @@ class AppApi(Resource): @login_required @account_initialization_required @edit_permission_required - def delete(self, app_model): + def delete(self, app_model: App): """Delete app""" app_service = AppService() app_service.delete_app(app_model) @@ -648,7 +648,7 @@ class AppCopyApi(Resource): @account_initialization_required @get_app_model(mode=None) @edit_permission_required - def post(self, app_model): + def post(self, app_model: App): """Copy app""" # The role of the current user in the ta table must be admin, owner, or editor current_user, _ = current_account_with_tenant() @@ -709,7 +709,7 @@ class AppExportApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_model): + def get(self, app_model: App): """Export app""" args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) @@ -731,7 +731,7 @@ class AppPublishToCreatorsPlatformApi(Resource): @account_initialization_required @get_app_model(mode=None) @edit_permission_required - def post(self, app_model): + def post(self, app_model: App): """Publish app to Creators Platform""" from configs import dify_config from core.helper.creators import get_redirect_url, upload_dsl @@ -762,7 +762,7 @@ class AppNameApi(Resource): @account_initialization_required @get_app_model(mode=None) @edit_permission_required - def post(self, app_model): + def post(self, app_model: App): args = AppNamePayload.model_validate(console_ns.payload) app_service = AppService() @@ -784,7 +784,7 @@ class AppIconApi(Resource): @account_initialization_required @get_app_model(mode=None) @edit_permission_required - def post(self, app_model): + def post(self, app_model: App): args = AppIconPayload.model_validate(console_ns.payload or {}) app_service = AppService() @@ -811,7 +811,7 @@ class AppSiteStatus(Resource): @account_initialization_required @get_app_model(mode=None) @edit_permission_required - def post(self, app_model): + def post(self, app_model: App): args = AppSiteStatusPayload.model_validate(console_ns.payload) app_service = AppService() @@ -833,7 +833,7 @@ class AppApiStatus(Resource): @is_admin_or_owner_required @account_initialization_required @get_app_model(mode=None) - def post(self, app_model): + def post(self, app_model: App): args = AppApiStatusPayload.model_validate(console_ns.payload) app_service = AppService() @@ -874,7 +874,7 @@ class AppTraceApi(Resource): @account_initialization_required @edit_permission_required @get_app_model - def post(self, app_model): + def post(self, app_model: App): # add app trace args = AppTracePayload.model_validate(console_ns.payload) diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index 5b673f33940..acf2215e45b 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -70,7 +70,7 @@ class ChatMessageAudioApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) - def post(self, app_model): + def post(self, app_model: App): file = request.files["file"] try: @@ -171,7 +171,7 @@ class TextModesApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): try: args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index fddfe2f4bcc..8983a33d16f 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -33,7 +33,7 @@ from libs import helper from libs.helper import uuid_value from libs.login import current_user, login_required from models import Account -from models.model import AppMode +from models.model import App, AppMode from services.app_generate_service import AppGenerateService from services.app_task_service import AppTaskService from services.errors.llm import InvokeRateLimitError @@ -84,7 +84,7 @@ class CompletionMessageApi(Resource): @login_required @account_initialization_required @get_app_model(mode=AppMode.COMPLETION) - def post(self, app_model): + def post(self, app_model: App): args_model = CompletionMessagePayload.model_validate(console_ns.payload) args = args_model.model_dump(exclude_none=True, by_alias=True) @@ -131,7 +131,7 @@ class CompletionMessageStopApi(Resource): @login_required @account_initialization_required @get_app_model(mode=AppMode.COMPLETION) - def post(self, app_model, task_id: str): + def post(self, app_model: App, task_id: str): if not isinstance(current_user, Account): raise ValueError("current_user must be an Account instance") @@ -159,7 +159,7 @@ class ChatMessageApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT]) @edit_permission_required - def post(self, app_model): + def post(self, app_model: App): args_model = ChatMessagePayload.model_validate(console_ns.payload) args = args_model.model_dump(exclude_none=True, by_alias=True) @@ -212,7 +212,7 @@ class ChatMessageStopApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) - def post(self, app_model, task_id: str): + def post(self, app_model: App, task_id: str): if not isinstance(current_user, Account): raise ValueError("current_user must be an Account instance") diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index 0ca7a082863..a5b1a8c77da 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -33,7 +33,7 @@ from fields.conversation_fields import ( from libs.datetime_utils import naive_utc_now, parse_time_range from libs.login import current_account_with_tenant, login_required from models import Conversation, EndUser, Message, MessageAnnotation -from models.model import AppMode +from models.model import App, AppMode from services.conversation_service import ConversationService from services.errors.conversation import ConversationNotExistsError @@ -93,7 +93,7 @@ class CompletionConversationApi(Resource): @account_initialization_required @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required - def get(self, app_model): + def get(self, app_model: App): current_user, _ = current_account_with_tenant() args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) @@ -165,7 +165,7 @@ class CompletionConversationDetailApi(Resource): @account_initialization_required @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required - def get(self, app_model, conversation_id: UUID): + def get(self, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) return ConversationMessageDetailResponse.model_validate( _get_conversation(app_model, conversation_id_str), from_attributes=True @@ -182,7 +182,7 @@ class CompletionConversationDetailApi(Resource): @account_initialization_required @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required - def delete(self, app_model, conversation_id: UUID): + def delete(self, app_model: App, conversation_id: UUID): current_user, _ = current_account_with_tenant() conversation_id_str = str(conversation_id) @@ -207,7 +207,7 @@ class ChatConversationApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @edit_permission_required - def get(self, app_model): + def get(self, app_model: App): current_user, _ = current_account_with_tenant() args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) @@ -318,7 +318,7 @@ class ChatConversationDetailApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @edit_permission_required - def get(self, app_model, conversation_id: UUID): + def get(self, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) return ConversationDetailResponse.model_validate( _get_conversation(app_model, conversation_id_str), from_attributes=True @@ -335,7 +335,7 @@ class ChatConversationDetailApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @account_initialization_required @edit_permission_required - def delete(self, app_model, conversation_id: UUID): + def delete(self, app_model: App, conversation_id: UUID): current_user, _ = current_account_with_tenant() conversation_id_str = str(conversation_id) diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index 5951f7405a2..beaef482756 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -19,7 +19,7 @@ from fields.base import ResponseModel from libs.helper import to_timestamp from libs.login import login_required from models import ConversationVariable -from models.model import AppMode +from models.model import App, AppMode class ConversationVariablesQuery(BaseModel): @@ -94,7 +94,7 @@ class ConversationVariablesApi(Resource): @login_required @account_initialization_required @get_app_model(mode=AppMode.ADVANCED_CHAT) - def get(self, app_model): + def get(self, app_model: App): args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) stmt = ( diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index a5259527eae..e5ef15c3e13 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -17,7 +17,7 @@ from fields.base import ResponseModel from libs.helper import to_timestamp from libs.login import current_account_with_tenant, login_required from models.enums import AppMCPServerStatus -from models.model import AppMCPServer +from models.model import App, AppMCPServer class MCPServerCreatePayload(BaseModel): @@ -73,7 +73,7 @@ class AppMCPServerController(Resource): @account_initialization_required @setup_required @get_app_model - def get(self, app_model): + def get(self, app_model: App): server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1)) if server is None: return {} @@ -92,7 +92,7 @@ class AppMCPServerController(Resource): @login_required @setup_required @edit_permission_required - def post(self, app_model): + def post(self, app_model: App): _, current_tenant_id = current_account_with_tenant() payload = MCPServerCreatePayload.model_validate(console_ns.payload or {}) @@ -127,7 +127,7 @@ class AppMCPServerController(Resource): @setup_required @account_initialization_required @edit_permission_required - def put(self, app_model): + def put(self, app_model: App): payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {}) server = db.session.get(AppMCPServer, payload.id) if not server: diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 7445fed86c5..15b3437bf93 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -45,7 +45,7 @@ from libs.helper import to_timestamp, uuid_value from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.login import current_account_with_tenant, login_required from models.enums import FeedbackFromSource, FeedbackRating -from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback +from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback from services.errors.conversation import ConversationNotExistsError from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError from services.message_service import MessageService, attach_message_extra_contents @@ -180,7 +180,7 @@ class ChatMessageListApi(Resource): @setup_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @edit_permission_required - def get(self, app_model): + def get(self, app_model: App): args = ChatMessagesQuery.model_validate(request.args.to_dict()) conversation = db.session.scalar( @@ -257,7 +257,7 @@ class MessageFeedbackApi(Resource): @setup_required @login_required @account_initialization_required - def post(self, app_model): + def post(self, app_model: App): current_user, _ = current_account_with_tenant() args = MessageFeedbackPayload.model_validate(console_ns.payload) @@ -314,7 +314,7 @@ class MessageAnnotationCountApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): count = db.session.scalar( select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id) ) @@ -337,7 +337,7 @@ class MessageSuggestedQuestionApi(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) - def get(self, app_model, message_id: UUID): + def get(self, app_model: App, message_id: UUID): current_user, _ = current_account_with_tenant() message_id_str = str(message_id) @@ -379,7 +379,7 @@ class MessageFeedbackExportApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): args = FeedbackExportQuery.model_validate(request.args.to_dict()) # Import the service function @@ -417,7 +417,7 @@ class MessageApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model, message_id: UUID): + def get(self, app_model: App, message_id: UUID): message_id_str = str(message_id) message = db.session.scalar( diff --git a/api/controllers/console/app/model_config.py b/api/controllers/console/app/model_config.py index 1869cbf5f6d..a893b669112 100644 --- a/api/controllers/console/app/model_config.py +++ b/api/controllers/console/app/model_config.py @@ -16,7 +16,7 @@ from events.app_event import app_model_config_was_updated from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from libs.login import current_account_with_tenant, login_required -from models.model import AppMode, AppModelConfig +from models.model import App, AppMode, AppModelConfig from services.app_model_config_service import AppModelConfigService @@ -52,7 +52,7 @@ class ModelConfigResource(Resource): @edit_permission_required @account_initialization_required @get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION]) - def post(self, app_model): + def post(self, app_model: App): """Modify app model config""" current_user, current_tenant_id = current_account_with_tenant() # validate config diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index 9991d78d949..ca7f194a350 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -20,6 +20,7 @@ from fields.base import ResponseModel from libs.datetime_utils import naive_utc_now from libs.login import current_account_with_tenant, login_required from models import Site +from models.model import App class AppSiteUpdatePayload(BaseModel): @@ -84,7 +85,7 @@ class AppSite(Resource): @edit_permission_required @account_initialization_required @get_app_model - def post(self, app_model): + def post(self, app_model: App): args = AppSiteUpdatePayload.model_validate(console_ns.payload or {}) current_user, _ = current_account_with_tenant() site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) @@ -133,7 +134,7 @@ class AppSiteAccessTokenReset(Resource): @is_admin_or_owner_required @account_initialization_required @get_app_model - def post(self, app_model): + def post(self, app_model: App): current_user, _ = current_account_with_tenant() site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index d23b2837c91..dd3cf273a28 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -15,6 +15,7 @@ from libs.datetime_utils import parse_time_range from libs.helper import convert_datetime_to_date from libs.login import current_account_with_tenant, login_required from models import AppMode +from models.model import App class StatisticTimeRangeQuery(BaseModel): @@ -47,7 +48,7 @@ class DailyMessageStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -61,8 +62,12 @@ FROM WHERE app_id = :app_id AND invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) @@ -104,7 +109,7 @@ class DailyConversationStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -118,8 +123,12 @@ FROM WHERE app_id = :app_id AND invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) @@ -160,7 +169,7 @@ class DailyTerminalsStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -174,8 +183,12 @@ FROM WHERE app_id = :app_id AND invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) @@ -217,7 +230,7 @@ class DailyTokenCostStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -232,8 +245,12 @@ FROM WHERE app_id = :app_id AND invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) @@ -277,7 +294,7 @@ class AverageSessionInteractionStatistic(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -299,8 +316,12 @@ FROM WHERE c.app_id = :app_id AND m.invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) @@ -353,7 +374,7 @@ class UserSatisfactionRateStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -371,8 +392,12 @@ LEFT JOIN WHERE m.app_id = :app_id AND m.invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) @@ -419,7 +444,7 @@ class AverageResponseTimeStatistic(Resource): @login_required @account_initialization_required @get_app_model(mode=AppMode.COMPLETION) - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -433,8 +458,12 @@ FROM WHERE app_id = :app_id AND invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) @@ -476,7 +505,7 @@ class TokensPerSecondStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -492,8 +521,12 @@ FROM WHERE app_id = :app_id AND invoke_from != :invoke_from""" - arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER} assert account.timezone is not None + arg_dict: dict[str, object] = { + "tz": account.timezone, + "app_id": app_model.id, + "invoke_from": InvokeFrom.DEBUGGER, + } try: start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone) diff --git a/api/controllers/console/app/workflow_statistic.py b/api/controllers/console/app/workflow_statistic.py index ca899d87844..7b5a628561c 100644 --- a/api/controllers/console/app/workflow_statistic.py +++ b/api/controllers/console/app/workflow_statistic.py @@ -11,7 +11,7 @@ from extensions.ext_database import db from libs.datetime_utils import parse_time_range from libs.login import current_account_with_tenant, login_required from models.enums import WorkflowRunTriggeredFrom -from models.model import AppMode +from models.model import App, AppMode from repositories.factory import DifyAPIRepositoryFactory @@ -46,7 +46,7 @@ class WorkflowDailyRunsStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -86,7 +86,7 @@ class WorkflowDailyTerminalsStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -126,7 +126,7 @@ class WorkflowDailyTokenCostStatistic(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -166,7 +166,7 @@ class WorkflowAverageAppInteractionStatistic(Resource): @login_required @account_initialization_required @get_app_model(mode=[AppMode.WORKFLOW]) - def get(self, app_model): + def get(self, app_model: App): account, _ = current_account_with_tenant() args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index 7544c4dbdc4..33f6fb14aed 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -5,12 +5,12 @@ from pydantic import BaseModel, Field from controllers.common.schema import register_response_schema_models, register_schema_models from fields.base import ResponseModel -from libs.login import current_account_with_tenant, login_required +from libs.login import login_required from services.auth.api_key_auth_service import ApiKeyAuthService from .. import console_ns from ..auth.error import ApiKeyAuthFailedError -from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required +from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required, with_current_tenant_id class ApiKeyAuthBindingPayload(BaseModel): @@ -42,8 +42,8 @@ class ApiKeyAuthDataSource(Resource): @setup_required @login_required @account_initialization_required - def get(self): - _, current_tenant_id = current_account_with_tenant() + @with_current_tenant_id + def get(self, current_tenant_id: str): data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(current_tenant_id) if data_source_api_key_bindings: return { @@ -69,9 +69,9 @@ class ApiKeyAuthDataSourceBinding(Resource): @account_initialization_required @is_admin_or_owner_required @console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__]) - def post(self): + @with_current_tenant_id + def post(self, current_tenant_id: str): # The role of the current user in the table must be admin or owner - _, current_tenant_id = current_account_with_tenant() payload = ApiKeyAuthBindingPayload.model_validate(console_ns.payload) data = payload.model_dump() ApiKeyAuthService.validate_api_key_auth_args(data) @@ -89,10 +89,9 @@ class ApiKeyAuthDataSourceBindingDelete(Resource): @account_initialization_required @is_admin_or_owner_required @console_ns.response(204, "Binding deleted successfully") - def delete(self, binding_id: UUID): + @with_current_tenant_id + def delete(self, current_tenant_id: str, binding_id: UUID): # The role of the current user in the table must be admin or owner - _, current_tenant_id = current_account_with_tenant() - ApiKeyAuthService.delete_provider_auth(current_tenant_id, str(binding_id)) return "", 204 diff --git a/api/controllers/console/auth/oauth_server.py b/api/controllers/console/auth/oauth_server.py index 727428c8e7f..7e485589779 100644 --- a/api/controllers/console/auth/oauth_server.py +++ b/api/controllers/console/auth/oauth_server.py @@ -8,9 +8,9 @@ from flask_restx import Resource from pydantic import BaseModel from werkzeug.exceptions import BadRequest, NotFound -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import account_initialization_required, setup_required, with_current_user from graphon.model_runtime.utils.encoders import jsonable_encoder -from libs.login import current_account_with_tenant, login_required +from libs.login import login_required from models import Account from models.model import OAuthProviderApp from services.oauth_server import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthGrantType, OAuthServerService @@ -133,12 +133,10 @@ class OAuthServerUserAuthorizeApi(Resource): @setup_required @login_required @account_initialization_required + @with_current_user @oauth_server_client_id_required - def post(self, oauth_provider_app: OAuthProviderApp): - current_user, _ = current_account_with_tenant() - account = current_user - user_account_id = account.id - + def post(self, oauth_provider_app: OAuthProviderApp, current_user: Account): + user_account_id = current_user.id code = OAuthServerService.sign_oauth_authorization_code(oauth_provider_app.client_id, user_account_id) return jsonable_encoder( { diff --git a/api/controllers/console/explore/audio.py b/api/controllers/console/explore/audio.py index ab660d9dc3a..42b611cafd6 100644 --- a/api/controllers/console/explore/audio.py +++ b/api/controllers/console/explore/audio.py @@ -20,6 +20,7 @@ from controllers.console.app.error import ( from controllers.console.explore.wraps import InstalledAppResource from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError +from models.model import InstalledApp from services.audio_service import AudioService from services.errors.audio import ( AudioTooLargeServiceError, @@ -40,8 +41,10 @@ register_schema_model(console_ns, TextToAudioPayload) endpoint="installed_app_audio", ) class ChatAudioApi(InstalledAppResource): - def post(self, installed_app): + def post(self, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() file = request.files["file"] @@ -81,8 +84,10 @@ class ChatAudioApi(InstalledAppResource): ) class ChatTextApi(InstalledAppResource): @console_ns.expect(console_ns.models[TextToAudioPayload.__name__]) - def post(self, installed_app): + def post(self, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() try: payload = TextToAudioPayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/explore/completion.py b/api/controllers/console/explore/completion.py index 72e2d923da9..c08e8690a84 100644 --- a/api/controllers/console/explore/completion.py +++ b/api/controllers/console/explore/completion.py @@ -31,7 +31,7 @@ from libs import helper from libs.datetime_utils import naive_utc_now from libs.login import current_user from models import Account -from models.model import AppMode +from models.model import AppMode, InstalledApp from services.app_generate_service import AppGenerateService from services.app_task_service import AppTaskService from services.errors.llm import InvokeRateLimitError @@ -83,8 +83,10 @@ register_response_schema_models(console_ns, SimpleResultResponse) ) class CompletionApi(InstalledAppResource): @console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__]) - def post(self, installed_app): + def post(self, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() if app_model.mode != AppMode.COMPLETION: raise NotCompletionAppError() @@ -133,8 +135,10 @@ class CompletionApi(InstalledAppResource): ) class CompletionStopApi(InstalledAppResource): @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) - def post(self, installed_app, task_id: str): + def post(self, installed_app: InstalledApp, task_id: str): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() if app_model.mode != AppMode.COMPLETION: raise NotCompletionAppError() @@ -157,8 +161,10 @@ class CompletionStopApi(InstalledAppResource): ) class ChatApi(InstalledAppResource): @console_ns.expect(console_ns.models[ChatMessagePayload.__name__]) - def post(self, installed_app): + def post(self, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -209,8 +215,10 @@ class ChatApi(InstalledAppResource): ) class ChatStopApi(InstalledAppResource): @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) - def post(self, installed_app, task_id: str): + def post(self, installed_app: InstalledApp, task_id: str): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() diff --git a/api/controllers/console/explore/conversation.py b/api/controllers/console/explore/conversation.py index a3ae59aaf7f..68e18a0207b 100644 --- a/api/controllers/console/explore/conversation.py +++ b/api/controllers/console/explore/conversation.py @@ -8,6 +8,7 @@ from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console.app.error import AppUnavailableError from controllers.console.explore.error import NotChatAppError from controllers.console.explore.wraps import InstalledAppResource from core.app.entities.app_invoke_entities import InvokeFrom @@ -20,7 +21,7 @@ from fields.conversation_fields import ( from libs.helper import UUIDStrOrEmpty from libs.login import current_user from models import Account -from models.model import AppMode +from models.model import AppMode, InstalledApp from services.conversation_service import ConversationService from services.errors.conversation import ConversationNotExistsError, LastConversationNotExistsError from services.web_conversation_service import WebConversationService @@ -44,8 +45,10 @@ register_response_schema_models(console_ns, ResultResponse) ) class ConversationListApi(InstalledAppResource): @console_ns.expect(console_ns.models[ConversationListQuery.__name__]) - def get(self, installed_app): + def get(self, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -92,8 +95,10 @@ class ConversationListApi(InstalledAppResource): ) class ConversationApi(InstalledAppResource): @console_ns.response(204, "Conversation deleted successfully") - def delete(self, installed_app, c_id: UUID): + def delete(self, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -115,8 +120,10 @@ class ConversationApi(InstalledAppResource): ) class ConversationRenameApi(InstalledAppResource): @console_ns.expect(console_ns.models[ConversationRenamePayload.__name__]) - def post(self, installed_app, c_id: UUID): + def post(self, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -146,8 +153,10 @@ class ConversationRenameApi(InstalledAppResource): ) class ConversationPinApi(InstalledAppResource): @console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__]) - def patch(self, installed_app, c_id: UUID): + def patch(self, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -170,8 +179,10 @@ class ConversationPinApi(InstalledAppResource): ) class ConversationUnPinApi(InstalledAppResource): @console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__]) - def patch(self, installed_app, c_id: UUID): + def patch(self, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() diff --git a/api/controllers/console/explore/installed_app.py b/api/controllers/console/explore/installed_app.py index 4ad3dbc85fc..bd4d1ef49ff 100644 --- a/api/controllers/console/explore/installed_app.py +++ b/api/controllers/console/explore/installed_app.py @@ -262,7 +262,7 @@ class InstalledAppApi(InstalledAppResource): """ @console_ns.response(204, "App uninstalled successfully") - def delete(self, installed_app): + def delete(self, installed_app: InstalledApp): _, current_tenant_id = current_account_with_tenant() if installed_app.app_owner_tenant_id == current_tenant_id: raise BadRequest("You can't uninstall an app owned by the current tenant") @@ -273,7 +273,7 @@ class InstalledAppApi(InstalledAppResource): return "", 204 @console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__]) - def patch(self, installed_app): + def patch(self, installed_app: InstalledApp): payload = InstalledAppUpdatePayload.model_validate(console_ns.payload or {}) commit_args = False diff --git a/api/controllers/console/explore/message.py b/api/controllers/console/explore/message.py index ffb07916ecd..a19355f90ba 100644 --- a/api/controllers/console/explore/message.py +++ b/api/controllers/console/explore/message.py @@ -10,6 +10,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload, Messag from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console.app.error import ( AppMoreLikeThisDisabledError, + AppUnavailableError, CompletionRequestError, ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, @@ -30,7 +31,7 @@ from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from models import Account from models.enums import FeedbackRating -from models.model import AppMode +from models.model import AppMode, InstalledApp from services.app_generate_service import AppGenerateService from services.errors.app import MoreLikeThisDisabledError from services.errors.conversation import ConversationNotExistsError @@ -61,8 +62,10 @@ register_response_schema_models(console_ns, ResultResponse, SuggestedQuestionsRe class MessageListApi(InstalledAppResource): @console_ns.expect(console_ns.models[MessageListQuery.__name__]) @with_current_user - def get(self, current_user: Account, installed_app): + def get(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -98,8 +101,10 @@ class MessageFeedbackApi(InstalledAppResource): @console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__]) @console_ns.response(200, "Feedback submitted successfully", console_ns.models[ResultResponse.__name__]) @with_current_user - def post(self, current_user: Account, installed_app, message_id: UUID): + def post(self, current_user: Account, installed_app: InstalledApp, message_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() message_id_str = str(message_id) @@ -126,8 +131,10 @@ class MessageFeedbackApi(InstalledAppResource): class MessageMoreLikeThisApi(InstalledAppResource): @console_ns.expect(console_ns.models[MoreLikeThisQuery.__name__]) @with_current_user - def get(self, current_user: Account, installed_app, message_id: UUID): + def get(self, current_user: Account, installed_app: InstalledApp, message_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() if app_model.mode != "completion": raise NotCompletionAppError() @@ -172,8 +179,10 @@ class MessageMoreLikeThisApi(InstalledAppResource): class MessageSuggestedQuestionApi(InstalledAppResource): @console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__]) @with_current_user - def get(self, current_user: Account, installed_app, message_id: UUID): + def get(self, current_user: Account, installed_app: InstalledApp, message_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() diff --git a/api/controllers/console/explore/saved_message.py b/api/controllers/console/explore/saved_message.py index fc863b78d7f..cf48eeea725 100644 --- a/api/controllers/console/explore/saved_message.py +++ b/api/controllers/console/explore/saved_message.py @@ -7,12 +7,14 @@ from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.app.error import AppUnavailableError from controllers.console.explore.error import NotCompletionAppError from controllers.console.explore.wraps import InstalledAppResource from controllers.console.wraps import with_current_user from fields.conversation_fields import ResultResponse from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem from models import Account +from models.model import InstalledApp from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService @@ -24,8 +26,10 @@ register_response_schema_models(console_ns, ResultResponse) class SavedMessageListApi(InstalledAppResource): @console_ns.expect(console_ns.models[SavedMessageListQuery.__name__]) @with_current_user - def get(self, current_user: Account, installed_app): + def get(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() if app_model.mode != "completion": raise NotCompletionAppError() @@ -48,8 +52,10 @@ class SavedMessageListApi(InstalledAppResource): @console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__]) @console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__]) @with_current_user - def post(self, current_user: Account, installed_app): + def post(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() if app_model.mode != "completion": raise NotCompletionAppError() @@ -69,8 +75,10 @@ class SavedMessageListApi(InstalledAppResource): class SavedMessageApi(InstalledAppResource): @console_ns.response(204, "Saved message deleted successfully") @with_current_user - def delete(self, current_user: Account, installed_app, message_id: UUID): + def delete(self, current_user: Account, installed_app: InstalledApp, message_id: UUID): app_model = installed_app.app + if app_model is None: + raise AppUnavailableError() message_id_str = str(message_id) diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 3ef006c051f..5197120c135 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -28,7 +28,7 @@ from controllers.console.wraps import ( from extensions.ext_database import db from fields.file_fields import FileResponse, UploadConfig from libs.login import login_required -from models.account import Account +from models import Account from services.file_service import FileService from . import console_ns diff --git a/api/controllers/console/remote_files.py b/api/controllers/console/remote_files.py index 93435d11515..9f7fe6379c9 100644 --- a/api/controllers/console/remote_files.py +++ b/api/controllers/console/remote_files.py @@ -18,7 +18,7 @@ from extensions.ext_database import db from fields.file_fields import FileWithSignedUrl, RemoteFileInfo from graphon.file import helpers as file_helpers from libs.login import login_required -from models.account import Account +from models import Account from services.file_service import FileService diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index a37e56e2b8d..4e2ea2060d6 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -9,9 +9,16 @@ from werkzeug.exceptions import Forbidden from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from controllers.console.wraps import ( + account_initialization_required, + edit_permission_required, + setup_required, + with_current_tenant_id, + with_current_user, +) from fields.base import ResponseModel -from libs.login import current_account_with_tenant, login_required +from libs.login import login_required +from models import Account from models.enums import TagType from services.tag_service import ( SaveTagPayload, @@ -92,8 +99,8 @@ class TagListApi(Resource): params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."} ) @console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])}) - def get(self): - _, current_tenant_id = current_account_with_tenant() + @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) @@ -109,9 +116,9 @@ class TagListApi(Resource): @setup_required @login_required @account_initialization_required - def post(self): - current_user, _ = current_account_with_tenant() - # The role of the current user in the ta table must be admin, owner, or editor + @with_current_user + def post(self, current_user: Account): + # Allow users with edit permission, or dataset editors (including dataset operators). if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() @@ -132,8 +139,8 @@ class TagUpdateDeleteApi(Resource): @setup_required @login_required @account_initialization_required - def patch(self, tag_id: UUID): - current_user, _ = current_account_with_tenant() + @with_current_user + def patch(self, current_user: Account, tag_id: UUID): tag_id_str = str(tag_id) # The role of the current user in the ta table must be admin, owner, or editor if not (current_user.has_edit_permission or current_user.is_dataset_editor): @@ -163,20 +170,19 @@ class TagUpdateDeleteApi(Resource): return "", 204 -def _require_tag_binding_edit_permission() -> None: +def _require_tag_binding_edit_permission(current_user: Account) -> None: """ Ensure the current account can edit tag bindings. Tag binding operations are allowed for users who can edit resources (app/dataset) within the current tenant. """ - current_user, _ = current_account_with_tenant() # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() -def _create_tag_bindings() -> tuple[dict[str, str], int]: - _require_tag_binding_edit_permission() +def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: + _require_tag_binding_edit_permission(current_user) payload = TagBindingPayload.model_validate(console_ns.payload or {}) TagService.save_tag_binding( @@ -189,8 +195,8 @@ def _create_tag_bindings() -> tuple[dict[str, str], int]: return {"result": "success"}, 200 -def _remove_tag_bindings() -> tuple[dict[str, str], int]: - _require_tag_binding_edit_permission() +def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: + _require_tag_binding_edit_permission(current_user) payload = TagBindingRemovePayload.model_validate(console_ns.payload or {}) TagService.delete_tag_binding( @@ -213,8 +219,9 @@ class TagBindingCollectionApi(Resource): @setup_required @login_required @account_initialization_required - def post(self): - return _create_tag_bindings() + @with_current_user + def post(self, current_user: Account): + return _create_tag_bindings(current_user) @console_ns.route("/tag-bindings/remove") @@ -228,5 +235,6 @@ class TagBindingRemoveApi(Resource): @setup_required @login_required @account_initialization_required - def post(self): - return _remove_tag_bindings() + @with_current_user + def post(self, current_user: Account): + return _remove_tag_bindings(current_user) diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 1f770faa112..93a830e8eee 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -77,7 +77,7 @@ register_response_schema_models(console_ns, SimpleResultDataResponse, Verificati def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool: if role != TenantAccountRole.DATASET_OPERATOR: return True - return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled + return FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True).dataset_operator_enabled def _normalize_invitee_emails(emails: list[str]) -> list[str]: @@ -113,7 +113,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int) -> None: if new_member_count <= 0: return - features = FeatureService.get_features(tenant_id=tenant_id) + features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True) if dify_config.ENTERPRISE_ENABLED: workspace_members = features.workspace_members diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 77501eed72d..57a37f20f19 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -166,10 +166,10 @@ class TenantListApi(Resource): if tenant_plan: plan = tenant_plan["plan"] or CloudPlan.SANDBOX else: - features = FeatureService.get_features(tenant.id) + features = FeatureService.get_features(tenant.id, exclude_vector_space=True) plan = features.billing.subscription.plan or CloudPlan.SANDBOX elif not is_enterprise_only: - features = FeatureService.get_features(tenant.id) + features = FeatureService.get_features(tenant.id, exclude_vector_space=True) plan = features.billing.subscription.plan or CloudPlan.SANDBOX # Create a dictionary with tenant attributes diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index ad406f2a9e5..603645278f5 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -96,21 +96,28 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): _, current_tenant_id = current_account_with_tenant() - features = FeatureService.get_features(current_tenant_id) + if resource == "vector_space": + if not dify_config.BILLING_ENABLED: + return view(*args, **kwargs) + + vector_space = FeatureService.get_vector_space(current_tenant_id) + if 0 < vector_space.limit <= vector_space.size: + abort( + 403, + "The capacity of the knowledge storage space has reached the limit of your subscription.", + ) + return view(*args, **kwargs) + + features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True) if features.billing.enabled: members = features.members apps = features.apps - vector_space = features.vector_space documents_upload_quota = features.documents_upload_quota annotation_quota_limit = features.annotation_quota_limit if resource == "members" and 0 < members.limit <= members.size: abort(403, "The number of members has reached the limit of your subscription.") elif resource == "apps" and 0 < apps.limit <= apps.size: abort(403, "The number of apps has reached the limit of your subscription.") - elif resource == "vector_space" and 0 < vector_space.limit <= vector_space.size: - abort( - 403, "The capacity of the knowledge storage space has reached the limit of your subscription." - ) elif resource == "documents" and 0 < documents_upload_quota.limit <= documents_upload_quota.size: # The api of file upload is used in the multiple places, # so we need to check the source of the request from datasets @@ -140,7 +147,7 @@ def cloud_edition_billing_knowledge_limit_check[**P, R]( @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): _, current_tenant_id = current_account_with_tenant() - features = FeatureService.get_features(current_tenant_id) + features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True) if features.billing.enabled: if resource == "add_segment": if features.billing.subscription.plan == CloudPlan.SANDBOX: @@ -291,7 +298,7 @@ def knowledge_pipeline_publish_enabled[**P, R](view: Callable[P, R]) -> Callable @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): _, current_tenant_id = current_account_with_tenant() - features = FeatureService.get_features(current_tenant_id) + features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True) if features.knowledge_pipeline.publish_enabled: return view(*args, **kwargs) abort(403) diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index b9389ccc479..013ea34a6ab 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from sqlalchemy import select from werkzeug.exceptions import Forbidden, NotFound, Unauthorized +from configs import dify_config from enums.cloud_plan import CloudPlan from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -140,20 +141,26 @@ def cloud_edition_billing_resource_check[**P, R]( def interceptor(view: Callable[P, R]): def decorated(*args: P.args, **kwargs: P.kwargs): api_token = validate_and_get_api_token(api_token_type) - features = FeatureService.get_features(api_token.tenant_id) + if resource == "vector_space": + if not dify_config.BILLING_ENABLED: + return view(*args, **kwargs) + + vector_space = FeatureService.get_vector_space(api_token.tenant_id) + if 0 < vector_space.limit <= vector_space.size: + raise Forbidden("The capacity of the vector space has reached the limit of your subscription.") + return view(*args, **kwargs) + + features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True) if features.billing.enabled: members = features.members apps = features.apps - vector_space = features.vector_space documents_upload_quota = features.documents_upload_quota if resource == "members" and 0 < members.limit <= members.size: raise Forbidden("The number of members has reached the limit of your subscription.") elif resource == "apps" and 0 < apps.limit <= apps.size: raise Forbidden("The number of apps has reached the limit of your subscription.") - elif resource == "vector_space" and 0 < vector_space.limit <= vector_space.size: - raise Forbidden("The capacity of the vector space has reached the limit of your subscription.") elif resource == "documents" and 0 < documents_upload_quota.limit <= documents_upload_quota.size: raise Forbidden("The number of documents has reached the limit of your subscription.") else: @@ -174,7 +181,7 @@ def cloud_edition_billing_knowledge_limit_check[**P, R]( @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): api_token = validate_and_get_api_token(api_token_type) - features = FeatureService.get_features(api_token.tenant_id) + features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True) if features.billing.enabled: if resource == "add_segment": if features.billing.subscription.plan == CloudPlan.SANDBOX: diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py index e3d87549bb7..6d05743bfee 100644 --- a/api/controllers/web/app.py +++ b/api/controllers/web/app.py @@ -12,7 +12,7 @@ from controllers.common.schema import register_response_schema_models, register_ from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from libs.passport import PassportService from libs.token import extract_webapp_passport -from models.model import App, AppMode +from models.model import App, AppMode, EndUser from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService from services.feature_service import FeatureService @@ -56,7 +56,7 @@ class AppParameterApi(WebApiResource): 500: "Internal Server Error", } ) - def get(self, app_model: App, end_user): + def get(self, app_model: App, end_user: EndUser): """Retrieve app parameters.""" if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: workflow = app_model.workflow @@ -92,7 +92,7 @@ class AppMeta(WebApiResource): 500: "Internal Server Error", } ) - def get(self, app_model: App, end_user): + def get(self, app_model: App, end_user: EndUser): """Get app meta""" return AppService().get_app_meta(app_model) diff --git a/api/controllers/web/completion.py b/api/controllers/web/completion.py index f08d08ab7da..258493303f3 100644 --- a/api/controllers/web/completion.py +++ b/api/controllers/web/completion.py @@ -29,7 +29,7 @@ from core.errors.error import ( from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from libs.helper import uuid_value -from models.model import AppMode +from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService from services.app_task_service import AppTaskService from services.errors.llm import InvokeRateLimitError @@ -86,7 +86,7 @@ class CompletionApi(WebApiResource): 500: "Internal Server Error", } ) - def post(self, app_model, end_user): + def post(self, app_model: App, end_user: EndUser): if app_model.mode != AppMode.COMPLETION: raise NotCompletionAppError() @@ -140,7 +140,7 @@ class CompletionStopApi(WebApiResource): } ) @web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__]) - def post(self, app_model, end_user, task_id: str): + def post(self, app_model: App, end_user: EndUser, task_id: str): if app_model.mode != AppMode.COMPLETION: raise NotCompletionAppError() @@ -169,7 +169,7 @@ class ChatApi(WebApiResource): 500: "Internal Server Error", } ) - def post(self, app_model, end_user): + def post(self, app_model: App, end_user: EndUser): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -226,7 +226,7 @@ class ChatStopApi(WebApiResource): } ) @web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__]) - def post(self, app_model, end_user, task_id: str): + def post(self, app_model: App, end_user: EndUser, task_id: str): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index 00db29a6063..7803b11f4e2 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -19,7 +19,7 @@ from fields.conversation_fields import ( SimpleConversation, ) from libs.helper import uuid_value -from models.model import AppMode +from models.model import App, AppMode, EndUser from services.conversation_service import ConversationService from services.errors.conversation import ConversationNotExistsError, LastConversationNotExistsError from services.web_conversation_service import WebConversationService @@ -81,7 +81,7 @@ class ConversationListApi(WebApiResource): 500: "Internal Server Error", } ) - def get(self, app_model, end_user): + def get(self, app_model: App, end_user: EndUser): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -127,7 +127,7 @@ class ConversationApi(WebApiResource): 500: "Internal Server Error", } ) - def delete(self, app_model, end_user, c_id: UUID): + def delete(self, app_model: App, end_user: EndUser, c_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -166,7 +166,7 @@ class ConversationRenameApi(WebApiResource): 500: "Internal Server Error", } ) - def post(self, app_model, end_user, c_id: UUID): + def post(self, app_model: App, end_user: EndUser, c_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -204,7 +204,7 @@ class ConversationPinApi(WebApiResource): } ) @web_ns.response(200, "Conversation pinned successfully", web_ns.models[ResultResponse.__name__]) - def patch(self, app_model, end_user, c_id: UUID): + def patch(self, app_model: App, end_user: EndUser, c_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -235,7 +235,7 @@ class ConversationUnPinApi(WebApiResource): } ) @web_ns.response(200, "Conversation unpinned successfully", web_ns.models[ResultResponse.__name__]) - def patch(self, app_model, end_user, c_id: UUID): + def patch(self, app_model: App, end_user: EndUser, c_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index 61284901040..e08a3373643 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -13,6 +13,7 @@ from controllers.web import web_ns from controllers.web.wraps import WebApiResource from extensions.ext_database import db from fields.file_fields import FileResponse +from models.model import App, EndUser from services.file_service import FileService register_schema_models(web_ns, FileResponse) @@ -31,7 +32,7 @@ class FileApi(WebApiResource): } ) @web_ns.response(201, "File uploaded successfully", web_ns.models[FileResponse.__name__]) - def post(self, app_model, end_user): + def post(self, app_model: App, end_user: EndUser): """Upload a file for use in web applications. Accepts file uploads for use within web applications, supporting diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index cf0363b66ea..ee584336799 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -27,7 +27,7 @@ from fields.message_fields import SuggestedQuestionsResponse, WebMessageInfinite from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from models.enums import FeedbackRating -from models.model import AppMode +from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService from services.errors.app import MoreLikeThisDisabledError from services.errors.conversation import ConversationNotExistsError @@ -81,7 +81,7 @@ class MessageListApi(WebApiResource): 500: "Internal Server Error", } ) - def get(self, app_model, end_user): + def get(self, app_model: App, end_user: EndUser): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() @@ -133,7 +133,7 @@ class MessageFeedbackApi(WebApiResource): } ) @web_ns.response(200, "Feedback submitted successfully", web_ns.models[ResultResponse.__name__]) - def post(self, app_model, end_user, message_id: UUID): + def post(self, app_model: App, end_user: EndUser, message_id: UUID): message_id_str = str(message_id) payload = MessageFeedbackPayload.model_validate(web_ns.payload or {}) @@ -167,7 +167,7 @@ class MessageMoreLikeThisApi(WebApiResource): 500: "Internal Server Error", } ) - def get(self, app_model, end_user, message_id: UUID): + def get(self, app_model: App, end_user: EndUser, message_id: UUID): if app_model.mode != "completion": raise NotCompletionAppError() @@ -223,7 +223,7 @@ class MessageSuggestedQuestionApi(WebApiResource): 500: "Internal Server Error", } ) - def get(self, app_model, end_user, message_id: UUID): + def get(self, app_model: App, end_user: EndUser, message_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: raise NotChatAppError() diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index e9f727097b7..c18c05d3e99 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -13,6 +13,7 @@ from core.helper import ssrf_proxy from extensions.ext_database import db from fields.file_fields import FileWithSignedUrl, RemoteFileInfo from graphon.file import helpers as file_helpers +from models.model import App, EndUser from services.file_service import FileService from ..common.schema import register_response_schema_models, register_schema_models @@ -41,7 +42,7 @@ class RemoteFileInfoApi(WebApiResource): } ) @web_ns.response(200, "Remote file info", web_ns.models[RemoteFileInfo.__name__]) - def get(self, app_model, end_user, url): + def get(self, app_model: App, end_user: EndUser, url: str): """Get information about a remote file. Retrieves basic information about a file located at a remote URL, @@ -85,7 +86,7 @@ class RemoteFileUploadApi(WebApiResource): } ) @web_ns.response(201, "Remote file uploaded", web_ns.models[FileWithSignedUrl.__name__]) - def post(self, app_model, end_user): + def post(self, app_model: App, end_user: EndUser): """Upload a file from a remote URL. Downloads a file from the provided remote URL and uploads it diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py index 766cfc6c60d..7ce72e56ab9 100644 --- a/api/controllers/web/saved_message.py +++ b/api/controllers/web/saved_message.py @@ -11,6 +11,7 @@ from controllers.web.error import NotCompletionAppError from controllers.web.wraps import WebApiResource from fields.conversation_fields import ResultResponse from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem +from models.model import App, EndUser from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService @@ -43,7 +44,7 @@ class SavedMessageListApi(WebApiResource): 500: "Internal Server Error", } ) - def get(self, app_model, end_user): + def get(self, app_model: App, end_user: EndUser): if app_model.mode != "completion": raise NotCompletionAppError() @@ -77,7 +78,7 @@ class SavedMessageListApi(WebApiResource): } ) @web_ns.response(200, "Message saved successfully", web_ns.models[ResultResponse.__name__]) - def post(self, app_model, end_user): + def post(self, app_model: App, end_user: EndUser): if app_model.mode != "completion": raise NotCompletionAppError() @@ -106,7 +107,7 @@ class SavedMessageApi(WebApiResource): 500: "Internal Server Error", } ) - def delete(self, app_model, end_user, message_id: UUID): + def delete(self, app_model: App, end_user: EndUser, message_id: UUID): message_id_str = str(message_id) if app_model.mode != "completion": diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 7d2080dd91c..19b04b7acc6 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -10,7 +10,7 @@ from controllers.web.wraps import WebApiResource from extensions.ext_database import db from libs.helper import AppIconUrlField from models.account import TenantStatus -from models.model import App, Site +from models.model import App, EndUser, Site from services.feature_service import FeatureService @@ -70,7 +70,7 @@ class AppSiteApi(WebApiResource): } ) @marshal_with(app_fields) - def get(self, app_model, end_user): + def get(self, app_model: App, end_user: EndUser): """Retrieve app site info.""" # get site site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) @@ -78,10 +78,10 @@ class AppSiteApi(WebApiResource): if not site: raise Forbidden() - if app_model.tenant.status == TenantStatus.ARCHIVE: + if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE: raise Forbidden() - can_replace_logo = FeatureService.get_features(app_model.tenant_id).can_replace_logo + can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo) @@ -119,6 +119,6 @@ def serialize_site(site: Site) -> dict[str, Any]: def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]: - can_replace_logo = FeatureService.get_features(app_model.tenant_id).can_replace_logo + can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo) return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields)) diff --git a/api/core/app/apps/base_app_runner.py b/api/core/app/apps/base_app_runner.py index 1251b397e27..0ca682e87a2 100644 --- a/api/core/app/apps/base_app_runner.py +++ b/api/core/app/apps/base_app_runner.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Union from core.app.app_config.entities import ExternalDataVariableEntity, PromptTemplateEntity from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom +from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import ( AppGenerateEntity, EasyUIBasedAppGenerateEntity, @@ -292,46 +293,51 @@ class AppRunner: prompt_messages: list[PromptMessage] = [] text = "" usage = None - for result in invoke_result: - if not agent: - queue_manager.publish(QueueLLMChunkEvent(chunk=result), PublishFrom.APPLICATION_MANAGER) - else: - queue_manager.publish(QueueAgentMessageEvent(chunk=result), PublishFrom.APPLICATION_MANAGER) + try: + for result in invoke_result: + if not agent: + queue_manager.publish(QueueLLMChunkEvent(chunk=result), PublishFrom.APPLICATION_MANAGER) + else: + queue_manager.publish(QueueAgentMessageEvent(chunk=result), PublishFrom.APPLICATION_MANAGER) - message = result.delta.message - if isinstance(message.content, str): - text += message.content - elif isinstance(message.content, list): - for content in message.content: - if isinstance(content, str): - text += content - elif isinstance(content, TextPromptMessageContent): - text += content.data - elif isinstance(content, ImagePromptMessageContent): - if message_id and user_id and tenant_id: - try: - self._handle_multimodal_image_content( - content=content, - message_id=message_id, - user_id=user_id, - tenant_id=tenant_id, - queue_manager=queue_manager, - ) - except Exception: - _logger.exception("Failed to handle multimodal image output") + message = result.delta.message + if isinstance(message.content, str): + text += message.content + elif isinstance(message.content, list): + for content in message.content: + if isinstance(content, str): + text += content + elif isinstance(content, TextPromptMessageContent): + text += content.data + elif isinstance(content, ImagePromptMessageContent): + if message_id and user_id and tenant_id: + try: + self._handle_multimodal_image_content( + content=content, + message_id=message_id, + user_id=user_id, + tenant_id=tenant_id, + queue_manager=queue_manager, + ) + except Exception: + _logger.exception("Failed to handle multimodal image output") + else: + _logger.warning("Received multimodal output but missing required parameters") else: - _logger.warning("Received multimodal output but missing required parameters") - else: - text += content.data if hasattr(content, "data") else str(content) + text += content.data if hasattr(content, "data") else str(content) - if not model: - model = result.model + if not model: + model = result.model - if not prompt_messages: - prompt_messages = list(result.prompt_messages) + if not prompt_messages: + prompt_messages = list(result.prompt_messages) - if result.delta.usage: - usage = result.delta.usage + if result.delta.usage: + usage = result.delta.usage + except GenerateTaskStoppedError: + # Explicitly close provider stream to stop in-flight token generation ASAP. + invoke_result.close() + raise if usage is None: usage = LLMUsage.empty_usage() diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 9faa70a0b87..0ba668a5e87 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -534,7 +534,9 @@ class ProviderManager: cache_key = f"tenant:{tenant_id}:model_load_balancing_enabled" cache_result = redis_client.get(cache_key) if cache_result is None: - model_load_balancing_enabled = FeatureService.get_features(tenant_id).model_load_balancing_enabled + model_load_balancing_enabled = FeatureService.get_features( + tenant_id, exclude_vector_space=True + ).model_load_balancing_enabled redis_client.setex(cache_key, 120, str(model_load_balancing_enabled)) else: cache_result = cache_result.decode("utf-8") diff --git a/api/libs/workspace_permission.py b/api/libs/workspace_permission.py index dd42a7facf3..435b07dd6ea 100644 --- a/api/libs/workspace_permission.py +++ b/api/libs/workspace_permission.py @@ -58,7 +58,7 @@ def check_workspace_owner_transfer_permission(workspace_id: str) -> None: Raises: Forbidden: If either billing plan or workspace policy prohibits ownership transfer """ - features = FeatureService.get_features(workspace_id) + features = FeatureService.get_features(workspace_id, exclude_vector_space=True) if not features.is_allow_transfer_workspace: raise Forbidden("Your current plan does not allow workspace ownership transfer") diff --git a/api/schedule/clean_unused_datasets_task.py b/api/schedule/clean_unused_datasets_task.py index 0b0fc1b2293..849274311a2 100644 --- a/api/schedule/clean_unused_datasets_task.py +++ b/api/schedule/clean_unused_datasets_task.py @@ -112,7 +112,7 @@ def clean_unused_datasets_task(): features_cache_key = f"features:{dataset.tenant_id}" plan_cache = redis_client.get(features_cache_key) if plan_cache is None: - features = FeatureService.get_features(dataset.tenant_id) + features = FeatureService.get_features(dataset.tenant_id, exclude_vector_space=True) redis_client.setex(features_cache_key, 600, features.billing.subscription.plan) plan = features.billing.subscription.plan else: diff --git a/api/schedule/mail_clean_document_notify_task.py b/api/schedule/mail_clean_document_notify_task.py index 2cc0192a4a1..1a76a4aa306 100644 --- a/api/schedule/mail_clean_document_notify_task.py +++ b/api/schedule/mail_clean_document_notify_task.py @@ -45,7 +45,7 @@ def mail_clean_document_notify_task(): dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id].append(dataset_auto_disable_log) url = f"{dify_config.CONSOLE_WEB_URL}/datasets" for tenant_id, tenant_dataset_auto_disable_logs in dataset_auto_disable_logs_map.items(): - features = FeatureService.get_features(tenant_id) + features = FeatureService.get_features(tenant_id, exclude_vector_space=True) plan = features.billing.subscription.plan if plan != CloudPlan.SANDBOX: knowledge_details = [] diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index aa6b8ffc6ec..e1762c686ff 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -521,7 +521,7 @@ class AppAnnotationService: ) # Check annotation quota limit - features = FeatureService.get_features(current_tenant_id) + features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True) if features.billing.enabled: annotation_quota_limit = features.annotation_quota_limit if annotation_quota_limit.limit < len(result) + annotation_quota_limit.size: diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 6021d46c724..5c59a5f8dd1 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -192,6 +192,9 @@ class BillingService: params["exclude_vector_space"] = "true" billing_info = cls._send_request("GET", "/subscription/info", params=params) + if exclude_vector_space and billing_info.get("vector_space") is None: + # Unset proto message fields can be serialized as null; the light billing contract treats it as absent. + billing_info.pop("vector_space", None) return _billing_info_adapter.validate_python(billing_info) @classmethod diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 1332390d46a..2e593ea71f9 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -1325,7 +1325,7 @@ class DatasetService: def get_dataset_auto_disable_logs(dataset_id: str) -> AutoDisableLogsDict: assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None - features = FeatureService.get_features(current_user.current_tenant_id) + features = FeatureService.get_features(current_user.current_tenant_id, exclude_vector_space=True) if not features.billing.enabled or features.billing.subscription.plan == CloudPlan.SANDBOX: return { "document_ids": [], @@ -2007,7 +2007,7 @@ class DocumentService: assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None - features = FeatureService.get_features(current_user.current_tenant_id) + features = FeatureService.get_features(current_user.current_tenant_id, exclude_vector_space=True) if features.billing.enabled: if not knowledge_config.original_document_id: @@ -2798,7 +2798,7 @@ class DocumentService: assert current_user.current_tenant_id is not None assert knowledge_config.data_source - features = FeatureService.get_features(current_user.current_tenant_id) + features = FeatureService.get_features(current_user.current_tenant_id, exclude_vector_space=True) if features.billing.enabled: count = 0 diff --git a/api/services/document_indexing_proxy/base.py b/api/services/document_indexing_proxy/base.py index 56e47857c96..02df6752f30 100644 --- a/api/services/document_indexing_proxy/base.py +++ b/api/services/document_indexing_proxy/base.py @@ -41,7 +41,7 @@ class DocumentTaskProxyBase(ABC): @cached_property def features(self): - return FeatureService.get_features(self._tenant_id) + return FeatureService.get_features(self._tenant_id, exclude_vector_space=True) @abstractmethod def _send_to_direct_queue(self, task_func: Callable[..., Any]): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index e8ecfcc8082..182fee1f6ab 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -130,7 +130,7 @@ class FeatureModel(FeatureResponseModel): education: EducationModel = EducationModel() members: LimitationModel = LimitationModel(size=0, limit=1) apps: LimitationModel = LimitationModel(size=0, limit=10) - vector_space: LimitationModel = LimitationModel(size=0, limit=5) + vector_space: LimitationModel | None = LimitationModel(size=0, limit=5) knowledge_rate_limit: int = 10 annotation_quota_limit: LimitationModel = LimitationModel(size=0, limit=10) documents_upload_quota: LimitationModel = LimitationModel(size=0, limit=50) @@ -189,6 +189,8 @@ class FeatureService: @classmethod def get_features(cls, tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel: features = FeatureModel() + if exclude_vector_space: + features.vector_space = None cls._fulfill_params_from_env(features) @@ -349,6 +351,7 @@ class FeatureService: features.apps.limit = billing_info["apps"]["limit"] if not exclude_vector_space: + assert features.vector_space is not None cls._fulfill_vector_space_from_billing_info(features.vector_space, billing_info) if "documents_upload_quota" in billing_info: diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py index 8b4983e5f70..c266d4f9586 100644 --- a/api/services/human_input_delivery_test_service.py +++ b/api/services/human_input_delivery_test_service.py @@ -136,7 +136,7 @@ class EmailDeliveryTestHandler: ) -> DeliveryTestResult: if not isinstance(method, EmailDeliveryMethod): raise DeliveryTestUnsupportedError("Delivery method does not support test send.") - features = FeatureService.get_features(context.tenant_id) + features = FeatureService.get_features(context.tenant_id, exclude_vector_space=True) if not features.human_input_email_delivery_enabled: raise DeliveryTestError("Email delivery is not available for current plan.") if not mail.is_inited(): diff --git a/api/services/rag_pipeline/rag_pipeline_task_proxy.py b/api/services/rag_pipeline/rag_pipeline_task_proxy.py index 1a7b104a70c..52ebbce65a9 100644 --- a/api/services/rag_pipeline/rag_pipeline_task_proxy.py +++ b/api/services/rag_pipeline/rag_pipeline_task_proxy.py @@ -29,7 +29,7 @@ class RagPipelineTaskProxy: @cached_property def features(self): - return FeatureService.get_features(self._dataset_tenant_id) + return FeatureService.get_features(self._dataset_tenant_id, exclude_vector_space=True) def _upload_invoke_entities(self) -> str: text = [item.model_dump() for item in self._rag_pipeline_invoke_entities] diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py index eb4671cfaa3..70114a83f0b 100644 --- a/api/services/workspace_service.py +++ b/api/services/workspace_service.py @@ -33,7 +33,7 @@ class WorkspaceService: assert tenant_account_join is not None, "TenantAccountJoin not found" tenant_info["role"] = tenant_account_join.role - feature = FeatureService.get_features(tenant.id) + feature = FeatureService.get_features(tenant.id, exclude_vector_space=True) can_replace_logo = feature.can_replace_logo if can_replace_logo and TenantService.has_roles(tenant, [TenantAccountRole.OWNER, TenantAccountRole.ADMIN]): diff --git a/api/tasks/document_indexing_task.py b/api/tasks/document_indexing_task.py index 31dad7937cd..c173606d74d 100644 --- a/api/tasks/document_indexing_task.py +++ b/api/tasks/document_indexing_task.py @@ -66,6 +66,7 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]): try: if features.billing.enabled: vector_space = features.vector_space + assert vector_space is not None count = len(document_ids) batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT) if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1: diff --git a/api/tasks/duplicate_document_indexing_task.py b/api/tasks/duplicate_document_indexing_task.py index 71f367c5e7e..69747ba497d 100644 --- a/api/tasks/duplicate_document_indexing_task.py +++ b/api/tasks/duplicate_document_indexing_task.py @@ -92,6 +92,7 @@ def _duplicate_document_indexing_task(dataset_id: str, document_ids: Sequence[st try: if features.billing.enabled: vector_space = features.vector_space + assert vector_space is not None count = len(document_ids) if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1: raise ValueError("Your current plan does not support batch upload, please upgrade your plan.") diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py index 2a60be7762a..8ed50071df5 100644 --- a/api/tasks/mail_human_input_delivery_task.py +++ b/api/tasks/mail_human_input_delivery_task.py @@ -157,7 +157,7 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, if form is None: logger.warning("Human input form not found, form_id=%s", form_id) return - features = FeatureService.get_features(form.tenant_id) + features = FeatureService.get_features(form.tenant_id, exclude_vector_space=True) if not features.human_input_email_delivery_enabled: logger.info( "Human input email delivery is not available for tenant=%s, form_id=%s", diff --git a/api/tasks/retry_document_indexing_task.py b/api/tasks/retry_document_indexing_task.py index 0df5896ce36..fa02afda15f 100644 --- a/api/tasks/retry_document_indexing_task.py +++ b/api/tasks/retry_document_indexing_task.py @@ -52,6 +52,7 @@ def retry_document_indexing_task(dataset_id: str, document_ids: list[str], user_ try: if features.billing.enabled: vector_space = features.vector_space + assert vector_space is not None if 0 < vector_space.limit <= vector_space.size: raise ValueError( "Your total number of documents plus the number of uploads have over the limit of " diff --git a/api/tasks/sync_website_document_indexing_task.py b/api/tasks/sync_website_document_indexing_task.py index 06eb460311f..5bdeac7e9df 100644 --- a/api/tasks/sync_website_document_indexing_task.py +++ b/api/tasks/sync_website_document_indexing_task.py @@ -39,6 +39,7 @@ def sync_website_document_indexing_task(dataset_id: str, document_id: str): try: if features.billing.enabled: vector_space = features.vector_space + assert vector_space is not None if 0 < vector_space.limit <= vector_space.size: raise ValueError( "Your total number of documents plus the number of uploads have over the limit of " diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py index 00309c25d65..5eb9f71e695 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_bearer_auth.py @@ -19,6 +19,7 @@ def test_get_api_key_auth_data_source( test_client_with_containers: FlaskClient, ) -> None: account, tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers) binding = DataSourceApiKeyAuthBinding( tenant_id=tenant.id, category="api_key", @@ -26,8 +27,16 @@ def test_get_api_key_auth_data_source( credentials=json.dumps({"auth_type": "api_key", "config": {"api_key": "encrypted"}}), disabled=False, ) - db_session_with_containers.add(binding) + foreign_binding = DataSourceApiKeyAuthBinding( + tenant_id=foreign_tenant.id, + category="api_key", + provider="foreign_provider", + credentials=json.dumps({"auth_type": "api_key", "config": {"api_key": "encrypted"}}), + disabled=False, + ) + db_session_with_containers.add_all([binding, foreign_binding]) db_session_with_containers.commit() + authenticate_console_client(test_client_with_containers, foreign_account) response = test_client_with_containers.get( "/console/api/api-key-auth/data-source", @@ -60,20 +69,23 @@ def test_create_binding_successful( db_session_with_containers: Session, test_client_with_containers: FlaskClient, ) -> None: - account, _tenant = create_console_account_and_tenant(db_session_with_containers) + account, tenant = create_console_account_and_tenant(db_session_with_containers) + tenant_id = tenant.id + payload = {"category": "api_key", "provider": "custom", "credentials": {"key": "value"}} with ( patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.validate_api_key_auth_args"), - patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth"), + patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth") as create_auth, ): response = test_client_with_containers.post( "/console/api/api-key-auth/data-source/binding", - json={"category": "api_key", "provider": "custom", "credentials": {"key": "value"}}, + json=payload, headers=authenticate_console_client(test_client_with_containers, account), ) assert response.status_code == 200 assert response.get_json() == {"result": "success"} + create_auth.assert_called_once_with(tenant_id, payload) def test_create_binding_failure( @@ -129,3 +141,35 @@ def test_delete_binding_successful( ) is None ) + + +def test_delete_binding_scopes_to_authenticated_tenant( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + account, _tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_binding = DataSourceApiKeyAuthBinding( + tenant_id=foreign_tenant.id, + category="api_key", + provider="custom_provider", + credentials=json.dumps({"auth_type": "api_key", "config": {"api_key": "encrypted"}}), + disabled=False, + ) + db_session_with_containers.add(foreign_binding) + db_session_with_containers.commit() + foreign_binding_id = foreign_binding.id + authenticate_console_client(test_client_with_containers, foreign_account) + + response = test_client_with_containers.delete( + f"/console/api/api-key-auth/data-source/{foreign_binding_id}", + headers=authenticate_console_client(test_client_with_containers, account), + ) + + assert response.status_code == 204 + assert ( + db_session_with_containers.scalar( + select(DataSourceApiKeyAuthBinding).where(DataSourceApiKeyAuthBinding.id == foreign_binding_id) + ) + is not None + ) diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/test_external.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_external.py new file mode 100644 index 00000000000..d6b7e9e636f --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_external.py @@ -0,0 +1,116 @@ +"""Integration tests for console external knowledge API endpoints.""" + +from __future__ import annotations + +import json + +from flask.testing import FlaskClient +from sqlalchemy.orm import Session + +from models.dataset import ExternalKnowledgeApis +from tests.test_containers_integration_tests.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, +) + + +def _create_external_api( + db_session: Session, + *, + tenant_id: str, + account_id: str, + name: str, +) -> ExternalKnowledgeApis: + external_api = ExternalKnowledgeApis( + tenant_id=tenant_id, + created_by=account_id, + updated_by=account_id, + name=name, + description=f"{name} description", + settings=json.dumps( + { + "endpoint": "https://example.com", + "api_key": "test-api-key", + } + ), + ) + db_session.add(external_api) + db_session.commit() + return external_api + + +def test_external_api_template_list_filters_paginates_and_scopes_to_authenticated_tenant( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + """Exercise the real list route, including query parsing, DB lookup, and tenant isolation.""" + account, tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers) + account_id = account.id + tenant_id = tenant.id + foreign_account_id = foreign_account.id + foreign_tenant_id = foreign_tenant.id + headers = authenticate_console_client(test_client_with_containers, account) + + _create_external_api( + db_session_with_containers, + tenant_id=tenant_id, + account_id=account_id, + name="Alpha Primary", + ) + _create_external_api( + db_session_with_containers, + tenant_id=tenant_id, + account_id=account_id, + name="Alpha Secondary", + ) + _create_external_api( + db_session_with_containers, + tenant_id=tenant_id, + account_id=account_id, + name="Beta Unmatched", + ) + _create_external_api( + db_session_with_containers, + tenant_id=foreign_tenant_id, + account_id=foreign_account_id, + name="Alpha Foreign", + ) + + response = test_client_with_containers.get( + "/console/api/datasets/external-knowledge-api?page=1&limit=1&keyword=Alpha", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json is not None + assert response.json["page"] == 1 + assert response.json["limit"] == 1 + assert response.json["total"] == 2 + assert response.json["has_more"] is True + assert len(response.json["data"]) == 1 + + first_page_item = response.json["data"][0] + assert first_page_item["tenant_id"] == tenant_id + assert first_page_item["name"] in {"Alpha Primary", "Alpha Secondary"} + assert first_page_item["settings"] == { + "endpoint": "https://example.com", + "api_key": "test-api-key", + } + assert first_page_item["dataset_bindings"] == [] + + second_response = test_client_with_containers.get( + "/console/api/datasets/external-knowledge-api?page=2&limit=1&keyword=Alpha", + headers=headers, + ) + + assert second_response.status_code == 200 + assert second_response.json is not None + assert second_response.json["page"] == 2 + assert second_response.json["limit"] == 1 + assert second_response.json["total"] == 2 + assert len(second_response.json["data"]) == 1 + + second_page_item = second_response.json["data"][0] + assert second_page_item["name"] in {"Alpha Primary", "Alpha Secondary"} + assert second_response.json["data"][0]["tenant_id"] == tenant_id diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py b/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py index e7852b8fe12..058f4e5fa34 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py +++ b/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py @@ -6,11 +6,13 @@ from unittest.mock import patch import pytest from flask.testing import FlaskClient +from sqlalchemy import select from sqlalchemy.orm import Session from constants import HIDDEN_VALUE from libs.rsa import generate_key_pair -from models import Tenant +from models.api_based_extension import APIBasedExtension +from services.api_based_extension_service import APIBasedExtensionService from tests.test_containers_integration_tests.controllers.console.helpers import ( authenticate_console_client, create_console_account_and_tenant, @@ -27,13 +29,14 @@ def _masked_api_key(api_key: str) -> str: def api_extension_client( db_session_with_containers: Session, test_client_with_containers: FlaskClient, -) -> tuple[FlaskClient, dict[str, str], Tenant]: +) -> tuple[FlaskClient, dict[str, str], str]: account, tenant = create_console_account_and_tenant(db_session_with_containers) + tenant_id = tenant.id tenant.encrypt_public_key = generate_key_pair(tenant.id) db_session_with_containers.commit() headers = authenticate_console_client(test_client_with_containers, account) - return test_client_with_containers, headers, tenant + return test_client_with_containers, headers, tenant_id @pytest.fixture(autouse=True) @@ -44,9 +47,10 @@ def mock_api_based_extension_ping(): def test_create_response_masks_plaintext_api_key( - api_extension_client: tuple[FlaskClient, dict[str, str], Tenant], + api_extension_client: tuple[FlaskClient, dict[str, str], str], + db_session_with_containers: Session, ) -> None: - client, headers, _ = api_extension_client + client, headers, tenant_id = api_extension_client api_key = "plain-secret-12345" response = client.post( @@ -62,10 +66,57 @@ def test_create_response_masks_plaintext_api_key( assert response.status_code == 201 assert response.json is not None assert response.json["api_key"] == _masked_api_key(api_key) + extension = db_session_with_containers.scalar( + select(APIBasedExtension).where(APIBasedExtension.id == response.json["id"]).limit(1) + ) + assert extension is not None + assert extension.tenant_id == tenant_id + + +def test_list_scopes_api_based_extensions_to_authenticated_tenant( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + account, tenant = create_console_account_and_tenant(db_session_with_containers) + account_headers = authenticate_console_client(test_client_with_containers, account) + tenant.encrypt_public_key = generate_key_pair(tenant.id) + _foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_tenant_id = foreign_tenant.id + foreign_tenant.encrypt_public_key = generate_key_pair(foreign_tenant.id) + db_session_with_containers.commit() + + account_create_response = test_client_with_containers.post( + "/console/api/api-based-extension", + headers=account_headers, + json={ + "name": "Tenant API", + "api_endpoint": "https://tenant.example.com/hook", + "api_key": "tenant-secret-12345", + }, + ) + assert account_create_response.status_code == 201 + + APIBasedExtensionService.save( + APIBasedExtension( + tenant_id=foreign_tenant_id, + name="Foreign API", + api_endpoint="https://foreign.example.com/hook", + api_key="foreign-secret-12345", + ) + ) + + response = test_client_with_containers.get( + "/console/api/api-based-extension", + headers=account_headers, + ) + + assert response.status_code == 200 + assert response.json is not None + assert [item["name"] for item in response.json] == ["Tenant API"] def test_update_response_masks_new_plaintext_api_key( - api_extension_client: tuple[FlaskClient, dict[str, str], Tenant], + api_extension_client: tuple[FlaskClient, dict[str, str], str], ) -> None: client, headers, _ = api_extension_client new_api_key = "new-secret-67890" @@ -96,7 +147,7 @@ def test_update_response_masks_new_plaintext_api_key( def test_update_response_masks_existing_plaintext_api_key_when_hidden_value_is_submitted( - api_extension_client: tuple[FlaskClient, dict[str, str], Tenant], + api_extension_client: tuple[FlaskClient, dict[str, str], str], ) -> None: client, headers, _ = api_extension_client existing_api_key = "old-secret-12345" diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py b/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py index b55eaa1d58a..8559501c898 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py +++ b/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py @@ -7,9 +7,11 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask from flask.testing import FlaskClient -from sqlalchemy import delete +from sqlalchemy import delete, select from sqlalchemy.orm import Session +from models import Account +from models.account import AccountStatus, TenantAccountRole from models.enums import ApiTokenType from models.model import ApiToken, App, AppMode from tests.test_containers_integration_tests.controllers.console.helpers import ( @@ -58,6 +60,24 @@ class TestAppApiKeyListResource: assert data["token"].startswith("app-") assert data["id"] is not None + def test_create_api_key_persists_authenticated_tenant( + self, + setup_app: tuple[FlaskClient, dict[str, str], App], + db_session_with_containers: Session, + ) -> None: + client, headers, app = setup_app + tenant_id = app.tenant_id + + resp = client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) + + assert resp.status_code == 201 + assert resp.json is not None + api_token = db_session_with_containers.scalar(select(ApiToken).where(ApiToken.id == resp.json["id"])) + assert api_token is not None + assert api_token.tenant_id == tenant_id + assert api_token.app_id == app.id + assert api_token.type == ApiTokenType.APP + def test_get_keys_after_create(self, setup_app: tuple[FlaskClient, dict[str, str], App]) -> None: client, headers, app = setup_app client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) @@ -93,6 +113,21 @@ class TestAppApiKeyListResource: ) assert resp.status_code == 404 + def test_get_foreign_app_keys_not_found( + self, + setup_app: tuple[FlaskClient, dict[str, str], App], + db_session_with_containers: Session, + ) -> None: + client, headers, _ = setup_app + foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_app = create_console_app( + db_session_with_containers, foreign_tenant.id, foreign_account.id, AppMode.CHAT + ) + + resp = client.get(f"/console/api/apps/{foreign_app.id}/api-keys", headers=headers) + + assert resp.status_code == 404 + class TestAppApiKeyResource: """Tests for DELETE /apps//api-keys/.""" @@ -139,16 +174,13 @@ class TestAppApiKeyResource: resource.resource_model = MagicMock() resource.resource_id_field = "app_id" - non_admin = MagicMock() - non_admin.is_admin_or_owner = False + non_admin = Account(name="Normal User", email="normal@example.com", status=AccountStatus.ACTIVE) + non_admin.id = "normal-user" + non_admin.role = TenantAccountRole.NORMAL with ( flask_app_with_containers.test_request_context("/"), - patch( - "controllers.console.apikey.current_account_with_tenant", - return_value=(non_admin, "tenant-id"), - ), patch("controllers.console.apikey._get_resource"), ): with pytest.raises(Forbidden): - BaseApiKeyResource.delete(resource, "rid", "kid") + BaseApiKeyResource.delete(resource, "rid", "kid", "tenant-id", non_admin) diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_feature.py b/api/tests/test_containers_integration_tests/controllers/console/test_feature.py new file mode 100644 index 00000000000..9eb76c81520 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/console/test_feature.py @@ -0,0 +1,65 @@ +"""Integration tests for console feature endpoints.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +from flask.testing import FlaskClient +from sqlalchemy.orm import Session + +from services.feature_service import FeatureModel, FeatureService, LimitationModel +from tests.test_containers_integration_tests.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, +) + + +def test_feature_list_returns_current_tenant_configuration_without_vector_space( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + """Exercise auth, tenant injection, and the feature response shaping contract.""" + account, tenant = create_console_account_and_tenant(db_session_with_containers) + tenant_id = tenant.id + headers = authenticate_console_client(test_client_with_containers, account) + feature_model = FeatureModel( + members=LimitationModel(size=1, limit=2), + apps=LimitationModel(size=3, limit=4), + vector_space=LimitationModel(size=5, limit=6), + ) + + with patch.object(FeatureService, "get_features", return_value=feature_model) as get_features: + response = test_client_with_containers.get( + "/console/api/features", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json is not None + assert response.json["members"] == {"size": 1, "limit": 2} + assert response.json["apps"] == {"size": 3, "limit": 4} + assert "vector_space" not in response.json + get_features.assert_called_once_with(tenant_id, exclude_vector_space=True) + + +def test_feature_vector_space_returns_current_tenant_usage( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + """Exercise tenant injection and vector-space response serialization through the registered route.""" + account, tenant = create_console_account_and_tenant(db_session_with_containers) + tenant_id = tenant.id + headers = authenticate_console_client(test_client_with_containers, account) + + vector_space = SimpleNamespace(model_dump=lambda: {"size": 0, "limit": 100}) + + with patch.object(FeatureService, "get_vector_space", return_value=vector_space) as get_vector_space: + response = test_client_with_containers.get( + "/console/api/features/vector-space", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json == {"size": 0, "limit": 100} + get_vector_space.assert_called_once_with(tenant_id) diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_files.py b/api/tests/test_containers_integration_tests/controllers/console/test_files.py new file mode 100644 index 00000000000..8985c1ba66a --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/console/test_files.py @@ -0,0 +1,101 @@ +"""Integration tests for console file endpoints.""" + +from __future__ import annotations + +from io import BytesIO + +from flask.testing import FlaskClient +from sqlalchemy import select +from sqlalchemy.orm import Session + +from configs import dify_config +from models.model import UploadFile +from tests.test_containers_integration_tests.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, +) + + +def test_file_upload_config_returns_console_limits( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + """Exercise the authenticated upload-config route and response contract.""" + account, _tenant = create_console_account_and_tenant(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + + response = test_client_with_containers.get( + "/console/api/files/upload", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json == { + "file_size_limit": dify_config.UPLOAD_FILE_SIZE_LIMIT, + "batch_count_limit": dify_config.UPLOAD_FILE_BATCH_LIMIT, + "file_upload_limit": dify_config.BATCH_UPLOAD_LIMIT, + "image_file_size_limit": dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT, + "video_file_size_limit": dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT, + "audio_file_size_limit": dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT, + "workflow_file_upload_limit": dify_config.WORKFLOW_FILE_UPLOAD_LIMIT, + "image_file_batch_limit": dify_config.IMAGE_FILE_BATCH_LIMIT, + "single_chunk_attachment_limit": dify_config.SINGLE_CHUNK_ATTACHMENT_LIMIT, + "attachment_image_file_size_limit": dify_config.ATTACHMENT_IMAGE_FILE_SIZE_LIMIT, + } + + +def test_file_upload_persists_file_for_authenticated_current_user( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + """Exercise real upload behavior plus current-user and tenant propagation.""" + account, tenant = create_console_account_and_tenant(db_session_with_containers) + account_id = account.id + tenant_id = tenant.id + headers = authenticate_console_client(test_client_with_containers, account) + content = b"hello from console integration" + + response = test_client_with_containers.post( + "/console/api/files/upload", + headers=headers, + data={"file": (BytesIO(content), "tenant-owned.txt")}, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + assert response.json is not None + assert response.json["name"] == "tenant-owned.txt" + assert response.json["size"] == len(content) + assert response.json["extension"] == "txt" + assert response.json["mime_type"] == "text/plain" + assert response.json["created_by"] == account_id + + upload_file = db_session_with_containers.scalar( + select(UploadFile).where(UploadFile.id == response.json["id"]).limit(1) + ) + assert upload_file is not None + assert upload_file.tenant_id == tenant_id + assert upload_file.created_by == account_id + assert upload_file.name == "tenant-owned.txt" + assert upload_file.size == len(content) + assert f"/{tenant_id}/" in upload_file.key + + +def test_file_upload_rejects_missing_file_after_authentication( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + """Exercise the route's validation path with a real authenticated account.""" + account, _tenant = create_console_account_and_tenant(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + + response = test_client_with_containers.post( + "/console/api/files/upload", + headers=headers, + data={}, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + assert response.json is not None + assert response.json["code"] == "no_file_uploaded" diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py index 454b8096d18..718ff05d22a 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py @@ -127,7 +127,7 @@ class TestEmailDeliveryTestHandler: monkeypatch.setattr( service_module.FeatureService, "get_features", - lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=False), + lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=False), ) handler = EmailDeliveryTestHandler(session_factory=MagicMock()) context = DeliveryTestContext( @@ -142,7 +142,7 @@ class TestEmailDeliveryTestHandler: monkeypatch.setattr( service_module.FeatureService, "get_features", - lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True), + lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), ) monkeypatch.setattr(service_module.mail, "is_inited", lambda: False) @@ -159,7 +159,7 @@ class TestEmailDeliveryTestHandler: monkeypatch.setattr( service_module.FeatureService, "get_features", - lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True), + lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), ) monkeypatch.setattr(service_module.mail, "is_inited", lambda: True) @@ -178,7 +178,7 @@ class TestEmailDeliveryTestHandler: monkeypatch.setattr( service_module.FeatureService, "get_features", - lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True), + lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), ) monkeypatch.setattr(service_module.mail, "is_inited", lambda: True) mock_mail_send = MagicMock() @@ -214,7 +214,7 @@ class TestEmailDeliveryTestHandler: monkeypatch.setattr( service_module.FeatureService, "get_features", - lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True), + lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), ) monkeypatch.setattr(service_module.mail, "is_inited", lambda: True) mock_mail_send = MagicMock() diff --git a/api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py b/api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py new file mode 100644 index 00000000000..51bbc33079d --- /dev/null +++ b/api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import PropertyMock, patch + +from controllers.console import console_ns +from controllers.console.auth.data_source_bearer_auth import ( + ApiKeyAuthDataSource, + ApiKeyAuthDataSourceBinding, + ApiKeyAuthDataSourceBindingDelete, +) + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +def _payload_patch(payload: dict): + return patch.object( + type(console_ns), + "payload", + new_callable=PropertyMock, + return_value=payload, + ) + + +def test_list_data_source_auth_uses_injected_tenant_id() -> None: + api = ApiKeyAuthDataSource() + method = _unwrap(api.get) + binding = SimpleNamespace( + id="binding-1", + category="api_key", + provider="custom", + disabled=False, + created_at=datetime(2026, 1, 1, tzinfo=UTC), + updated_at=datetime(2026, 1, 2, tzinfo=UTC), + ) + + with patch( + "controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.get_provider_auth_list", + return_value=[binding], + ) as get_provider_auth_list: + result = method(api, "tenant-1") + + get_provider_auth_list.assert_called_once_with("tenant-1") + assert result["sources"][0]["id"] == "binding-1" + assert result["sources"][0]["provider"] == "custom" + + +def test_create_data_source_auth_binding_uses_injected_tenant_id() -> None: + api = ApiKeyAuthDataSourceBinding() + method = _unwrap(api.post) + payload = { + "category": "api_key", + "provider": "custom", + "credentials": {"auth_type": "api_key", "config": {"api_key": "secret"}}, + } + + with ( + _payload_patch(payload), + patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.validate_api_key_auth_args"), + patch("controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.create_provider_auth") as create_auth, + ): + result, status = method(api, "tenant-1") + + create_auth.assert_called_once_with("tenant-1", payload) + assert result == {"result": "success"} + assert status == 200 + + +def test_delete_data_source_auth_binding_uses_injected_tenant_id() -> None: + api = ApiKeyAuthDataSourceBindingDelete() + method = _unwrap(api.delete) + + with patch( + "controllers.console.auth.data_source_bearer_auth.ApiKeyAuthService.delete_provider_auth" + ) as delete_provider_auth: + result, status = method(api, "tenant-1", "binding-1") + + delete_provider_auth.assert_called_once_with("tenant-1", "binding-1") + assert result == "" + assert status == 204 diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_server.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_server.py new file mode 100644 index 00000000000..1508d7b50ec --- /dev/null +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_server.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from unittest.mock import patch + +from controllers.console.auth.oauth_server import OAuthServerUserAuthorizeApi +from models import Account +from models.account import AccountStatus, TenantAccountRole +from models.model import OAuthProviderApp + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +def _make_account() -> Account: + account = Account( + name="Test User", + email="test@example.com", + status=AccountStatus.ACTIVE, + ) + account.id = "account-1" + account.role = TenantAccountRole.OWNER + return account + + +def _make_oauth_provider_app() -> OAuthProviderApp: + return OAuthProviderApp( + app_icon="icon", + client_id="client-1", + client_secret="secret", + app_label={"en-US": "Test App"}, + redirect_uris=["https://example.com/callback"], + scope="read", + ) + + +def test_oauth_authorize_uses_injected_current_user() -> None: + api = OAuthServerUserAuthorizeApi() + method = _unwrap(api.post) + account = _make_account() + oauth_provider_app = _make_oauth_provider_app() + + with patch( + "controllers.console.auth.oauth_server.OAuthServerService.sign_oauth_authorization_code", + return_value="authorization-code", + ) as sign_oauth_authorization_code: + response = method(api, oauth_provider_app, account) + + sign_oauth_authorization_code.assert_called_once_with("client-1", "account-1") + assert response == {"code": "authorization-code"} diff --git a/api/tests/unit_tests/controllers/console/datasets/test_external.py b/api/tests/unit_tests/controllers/console/datasets/test_external.py index 3ed65b1ffbf..3e76e6c21a3 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_external.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_external.py @@ -70,13 +70,14 @@ class TestExternalApiTemplateListApi: ExternalDatasetService, "get_external_knowledge_apis", return_value=([api_item], 1), - ), + ) as get_external_knowledge_apis, ): - resp, status = method(api, "id") + resp, status = method(api, "tenant-1") assert status == 200 assert resp["total"] == 1 assert resp["data"][0]["id"] == "1" + get_external_knowledge_apis.assert_called_once_with(1, 20, "tenant-1", None) def test_post_forbidden(self, app: Flask, current_user): current_user.is_dataset_editor = False @@ -321,13 +322,14 @@ class TestExternalApiTemplateListApiAdvanced: patch( "controllers.console.datasets.external.ExternalDatasetService.get_external_knowledge_apis", return_value=(templates, 25), - ), + ) as get_external_knowledge_apis, ): - resp, status = method(api, "id") + resp, status = method(api, "tenant-1") assert status == 200 assert resp["total"] == 25 assert len(resp["data"]) == 3 + get_external_knowledge_apis.assert_called_once_with(1, 20, "tenant-1", None) class TestExternalDatasetCreateApiAdvanced: diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index f4916f013cb..32b39de515e 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -13,6 +13,8 @@ from controllers.console.tag.tags import ( TagListApi, TagUpdateDeleteApi, ) +from models import Account +from models.account import AccountStatus, TenantAccountRole from models.enums import TagType from services.tag_service import UpdateTagPayload @@ -35,20 +37,26 @@ def app(): @pytest.fixture def admin_user(): - return MagicMock( - id="user-1", - has_edit_permission=True, - is_dataset_editor=True, + account = Account( + name="Admin User", + email="admin@example.com", + status=AccountStatus.ACTIVE, ) + account.id = "user-1" + account.role = TenantAccountRole.OWNER + return account @pytest.fixture def readonly_user(): - return MagicMock( - id="user-2", - has_edit_permission=False, - is_dataset_editor=False, + account = Account( + name="Readonly User", + email="readonly@example.com", + status=AccountStatus.ACTIVE, ) + account.id = "user-2" + account.role = TenantAccountRole.NORMAL + return account @pytest.fixture @@ -80,10 +88,6 @@ class TestTagListApi: with app.test_request_context("/?type=knowledge"): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(MagicMock(), "tenant-1"), - ), patch( "controllers.console.tag.tags.TagService.get_tags", return_value=[ @@ -96,7 +100,7 @@ class TestTagListApi: ], ), ): - result, status = method(api) + result, status = method(api, "tenant-1") assert status == 200 assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}] @@ -109,17 +113,13 @@ class TestTagListApi: with app.test_request_context("/", json=payload): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), payload_patch(payload), patch( "controllers.console.tag.tags.TagService.save_tags", return_value=tag, ), ): - result, status = method(api) + result, status = method(api, admin_user) assert status == 200 assert result["name"] == "test-tag" @@ -133,14 +133,10 @@ class TestTagListApi: with app.test_request_context("/", json=payload): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(readonly_user, None), - ), payload_patch(payload), ): with pytest.raises(Forbidden): - method(api) + method(api, readonly_user) class TestTagUpdateDeleteApi: @@ -152,10 +148,6 @@ class TestTagUpdateDeleteApi: with app.test_request_context("/", json=payload): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), payload_patch(payload), patch( "controllers.console.tag.tags.TagService.update_tags", @@ -166,7 +158,7 @@ class TestTagUpdateDeleteApi: return_value=3, ), ): - result, status = method(api, "tag-1") + result, status = method(api, admin_user, "tag-1") assert status == 200 update_payload, tag_id = update_tags_mock.call_args.args @@ -182,14 +174,10 @@ class TestTagUpdateDeleteApi: with app.test_request_context("/", json=payload): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(readonly_user, None), - ), payload_patch(payload), ): with pytest.raises(Forbidden): - method(api, "tag-1") + method(api, readonly_user, "tag-1") def test_delete_success(self, app: Flask, admin_user): api = TagUpdateDeleteApi() @@ -197,10 +185,6 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, "tenant-1"), - ), patch("controllers.console.tag.tags.TagService.delete_tag") as delete_mock, ): result, status = method(api, "tag-1") @@ -222,14 +206,10 @@ class TestTagBindingCollectionApi: with app.test_request_context("/", json=payload): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), payload_patch(payload), patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock, ): - result, status = method(api) + result, status = method(api, admin_user) save_mock.assert_called_once() assert status == 200 @@ -241,14 +221,10 @@ class TestTagBindingCollectionApi: with app.test_request_context("/", json={}): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(readonly_user, None), - ), payload_patch({}), ): with pytest.raises(Forbidden): - method(api) + method(api, readonly_user) class TestTagBindingRemoveApi: @@ -264,14 +240,10 @@ class TestTagBindingRemoveApi: with app.test_request_context("/", json=payload): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), payload_patch(payload), patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock, ): - result, status = method(api) + result, status = method(api, admin_user) delete_mock.assert_called_once() delete_payload = delete_mock.call_args.args[0] @@ -285,14 +257,10 @@ class TestTagBindingRemoveApi: with app.test_request_context("/", json={}): with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(readonly_user, None), - ), payload_patch({}), ): with pytest.raises(Forbidden): - method(api) + method(api, readonly_user) class TestTagResponseModel: diff --git a/api/tests/unit_tests/controllers/console/test_apikey.py b/api/tests/unit_tests/controllers/console/test_apikey.py new file mode 100644 index 00000000000..1517ff5ed8e --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_apikey.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from types import SimpleNamespace +from typing import cast +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import Forbidden + +from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResource +from models import Account +from models.account import AccountStatus, TenantAccountRole +from models.enums import ApiTokenType +from models.model import ApiToken, App + + +def _make_list_resource() -> BaseApiKeyListResource: + resource = BaseApiKeyListResource() + resource.resource_type = ApiTokenType.APP + resource.resource_model = App + resource.resource_id_field = "app_id" + resource.token_prefix = "app-" + return resource + + +def _make_key_resource() -> BaseApiKeyResource: + resource = BaseApiKeyResource() + resource.resource_type = ApiTokenType.APP + resource.resource_model = App + resource.resource_id_field = "app_id" + return resource + + +def _make_account(role: TenantAccountRole) -> Account: + account = Account( + name="Test User", + email=f"{role.value}@example.com", + status=AccountStatus.ACTIVE, + ) + account.id = f"{role.value}-user" + account.role = role + return account + + +def test_list_api_keys_uses_injected_tenant_id() -> None: + resource = _make_list_resource() + api_key = SimpleNamespace( + id="key-1", + type=ApiTokenType.APP, + token="app-token", + last_used_at=None, + created_at=None, + ) + + with ( + patch("controllers.console.apikey._get_resource") as get_resource, + patch("controllers.console.apikey.db") as db_mock, + ): + db_mock.session.scalars.return_value.all.return_value = [api_key] + + result = resource.get("app-1", "tenant-1") + + get_resource.assert_called_once_with("app-1", "tenant-1", App) + assert result == { + "data": [ + { + "id": "key-1", + "type": "app", + "token": "app-token", + "last_used_at": None, + "created_at": None, + } + ] + } + + +def test_create_api_key_uses_injected_tenant_id() -> None: + resource = _make_list_resource() + raw_post = cast( + Callable[[BaseApiKeyListResource, str, str], tuple[dict[str, object], int]], + inspect.unwrap(BaseApiKeyListResource.post), + ) + + def add_api_token(api_token: ApiToken) -> None: + api_token.id = "key-1" + + with ( + patch("controllers.console.apikey._get_resource") as get_resource, + patch("controllers.console.apikey.db") as db_mock, + patch("controllers.console.apikey.ApiToken.generate_api_key", return_value="app-generated-token"), + ): + db_mock.session.scalar.return_value = 0 + db_mock.session.add.side_effect = add_api_token + + result, status = raw_post(resource, "app-1", "tenant-1") + + get_resource.assert_called_once_with("app-1", "tenant-1", App) + assert status == 201 + assert result["token"] == "app-generated-token" + api_token = db_mock.session.add.call_args.args[0] + assert api_token.app_id == "app-1" + assert api_token.tenant_id == "tenant-1" + assert api_token.type == ApiTokenType.APP + db_mock.session.commit.assert_called_once() + + +def test_delete_api_key_rejects_non_admin_account() -> None: + resource = _make_key_resource() + + with ( + patch("controllers.console.apikey._get_resource") as get_resource, + patch("controllers.console.apikey.db") as db_mock, + ): + with pytest.raises(Forbidden): + resource.delete("app-1", "key-1", "tenant-1", _make_account(TenantAccountRole.NORMAL)) + + get_resource.assert_called_once_with("app-1", "tenant-1", App) + db_mock.session.scalar.assert_not_called() + + +def test_delete_api_key_uses_injected_user_and_tenant() -> None: + resource = _make_key_resource() + api_key = SimpleNamespace(token="app-token", type=ApiTokenType.APP) + + with ( + patch("controllers.console.apikey._get_resource") as get_resource, + patch("controllers.console.apikey.db") as db_mock, + patch("controllers.console.apikey.ApiTokenCache.delete") as delete_cache, + ): + db_mock.session.scalar.return_value = api_key + + result, status = resource.delete("app-1", "key-1", "tenant-1", _make_account(TenantAccountRole.OWNER)) + + get_resource.assert_called_once_with("app-1", "tenant-1", App) + delete_cache.assert_called_once_with("app-token", ApiTokenType.APP) + db_mock.session.execute.assert_called_once() + db_mock.session.commit.assert_called_once() + assert result == "" + assert status == 204 diff --git a/api/tests/unit_tests/controllers/console/test_files.py b/api/tests/unit_tests/controllers/console/test_files.py index d5664866647..f6ef1cb8241 100644 --- a/api/tests/unit_tests/controllers/console/test_files.py +++ b/api/tests/unit_tests/controllers/console/test_files.py @@ -19,6 +19,8 @@ from controllers.console.files import ( FilePreviewApi, FileSupportTypeApi, ) +from models import Account +from models.account import AccountStatus, TenantAccountRole def unwrap(func): @@ -53,14 +55,15 @@ def mock_decorators(): @pytest.fixture def mock_current_user(): - user = MagicMock() - user.is_dataset_editor = True + user = Account(name="Test User", email="user-1@example.com", status=AccountStatus.ACTIVE) + user.id = "user-1" + user.role = TenantAccountRole.OWNER return user @pytest.fixture -def mock_current_tenant_id(): - return "tenant-123" +def mock_account_context(mock_current_user): + return mock_current_user @pytest.fixture @@ -91,15 +94,15 @@ class TestFileApiGet: class TestFileApiPost: - def test_no_file_uploaded(self, app: Flask, mock_current_user): + def test_no_file_uploaded(self, app: Flask, mock_account_context): api = FileApi() post_method = unwrap(api.post) with app.test_request_context(method="POST", data={}): with pytest.raises(NoFileUploadedError): - post_method(api, mock_current_user) + post_method(api, mock_account_context) - def test_too_many_files(self, app: Flask, mock_current_user): + def test_too_many_files(self, app: Flask, mock_account_context): api = FileApi() post_method = unwrap(api.post) @@ -114,9 +117,9 @@ class TestFileApiPost: mock_request.form.get.return_value = None with pytest.raises(TooManyFilesError): - post_method(api, mock_current_user) + post_method(api, mock_account_context) - def test_filename_missing(self, app: Flask, mock_current_user): + def test_filename_missing(self, app: Flask, mock_account_context): api = FileApi() post_method = unwrap(api.post) @@ -126,10 +129,10 @@ class TestFileApiPost: with app.test_request_context(method="POST", data=data): with pytest.raises(FilenameNotExistsError): - post_method(api, mock_current_user) + post_method(api, mock_account_context) def test_dataset_upload_without_permission(self, app: Flask, mock_current_user): - mock_current_user.is_dataset_editor = False + mock_current_user.role = TenantAccountRole.NORMAL api = FileApi() post_method = unwrap(api.post) @@ -143,7 +146,7 @@ class TestFileApiPost: with pytest.raises(Forbidden): post_method(api, mock_current_user) - def test_successful_upload(self, app: Flask, mock_current_user, mock_file_service): + def test_successful_upload(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -171,13 +174,13 @@ class TestFileApiPost: } with app.test_request_context(method="POST", data=data): - response, status = post_method(api, mock_current_user) + response, status = post_method(api, mock_account_context) assert status == 201 assert response["id"] == "file-id-123" assert response["name"] == "test.txt" - def test_upload_with_invalid_source(self, app: Flask, mock_current_user, mock_file_service): + def test_upload_with_invalid_source(self, app: Flask, mock_account_context, mock_file_service): """Test that invalid source parameter gets normalized to None""" api = FileApi() post_method = unwrap(api.post) @@ -208,7 +211,7 @@ class TestFileApiPost: } with app.test_request_context(method="POST", data=data): - response, status = post_method(api, mock_current_user) + response, status = post_method(api, mock_account_context) assert status == 201 assert response["id"] == "file-id-456" @@ -217,7 +220,7 @@ class TestFileApiPost: call_kwargs = mock_file_service.upload_file.call_args[1] assert call_kwargs["source"] is None - def test_file_too_large_error(self, app: Flask, mock_current_user, mock_file_service): + def test_file_too_large_error(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -232,9 +235,9 @@ class TestFileApiPost: with app.test_request_context(method="POST", data=data): with pytest.raises(FileTooLargeError): - post_method(api, mock_current_user) + post_method(api, mock_account_context) - def test_unsupported_file_type(self, app: Flask, mock_current_user, mock_file_service): + def test_unsupported_file_type(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -249,9 +252,9 @@ class TestFileApiPost: with app.test_request_context(method="POST", data=data): with pytest.raises(UnsupportedFileTypeError): - post_method(api, mock_current_user) + post_method(api, mock_account_context) - def test_blocked_extension(self, app: Flask, mock_current_user, mock_file_service): + def test_blocked_extension(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -266,17 +269,17 @@ class TestFileApiPost: with app.test_request_context(method="POST", data=data): with pytest.raises(BlockedFileExtensionError): - post_method(api, mock_current_user) + post_method(api, mock_account_context) class TestFilePreviewApi: - def test_get_preview(self, app: Flask, mock_current_tenant_id, mock_file_service): + def test_get_preview(self, app: Flask, mock_account_context, mock_file_service): api = FilePreviewApi() get_method = unwrap(api.get) mock_file_service.get_file_preview.return_value = "preview text" with app.test_request_context(): - result = get_method(api, mock_current_tenant_id, "1234") + result = get_method(api, "tenant-123", "1234") assert result == {"content": "preview text"} diff --git a/api/tests/unit_tests/controllers/console/test_remote_files.py b/api/tests/unit_tests/controllers/console/test_remote_files.py index ae620b1e522..7c7abdcf2d8 100644 --- a/api/tests/unit_tests/controllers/console/test_remote_files.py +++ b/api/tests/unit_tests/controllers/console/test_remote_files.py @@ -10,6 +10,8 @@ import pytest from controllers.common.errors import FileTooLargeError, RemoteFileUploadError, UnsupportedFileTypeError from controllers.console import remote_files as remote_files_module +from models import Account +from models.account import AccountStatus, TenantAccountRole from services.errors.file import FileTooLargeError as ServiceFileTooLargeError from services.errors.file import UnsupportedFileTypeError as ServiceUnsupportedFileTypeError @@ -20,6 +22,17 @@ def _unwrap(func): return func +def _make_account(account_id: str = "u1") -> Account: + account = Account( + name="Test User", + email=f"{account_id}@example.com", + status=AccountStatus.ACTIVE, + ) + account.id = account_id + account.role = TenantAccountRole.OWNER + return account + + class _FakeResponse: def __init__( self, @@ -48,7 +61,6 @@ def _mock_upload_dependencies( *, file_size_within_limit: bool = True, ): - current_user = SimpleNamespace(id="u1") file_info = SimpleNamespace( filename="report.txt", extension=".txt", @@ -64,6 +76,7 @@ def _mock_upload_dependencies( file_service_cls = MagicMock() file_service_cls.is_file_size_within_limit.return_value = file_size_within_limit monkeypatch.setattr(remote_files_module, "FileService", file_service_cls) + current_user = _make_account() monkeypatch.setattr(remote_files_module, "db", SimpleNamespace(engine=object())) monkeypatch.setattr( remote_files_module.file_helpers, @@ -226,7 +239,7 @@ def test_remote_file_upload_raises_when_fallback_get_still_not_ok(app, monkeypat with app.test_request_context(method="POST", json={"url": url}): with pytest.raises(RemoteFileUploadError, match=f"Failed to fetch file from {url}: bad gateway"): - handler(api, SimpleNamespace(id="u1")) + handler(api, _make_account()) def test_remote_file_upload_raises_on_httpx_request_error(app, monkeypatch: pytest.MonkeyPatch) -> None: @@ -243,7 +256,7 @@ def test_remote_file_upload_raises_on_httpx_request_error(app, monkeypatch: pyte with app.test_request_context(method="POST", json={"url": url}): with pytest.raises(RemoteFileUploadError, match=f"Failed to fetch file from {url}: network down"): - handler(api, SimpleNamespace(id="u1")) + handler(api, _make_account()) def test_remote_file_upload_rejects_oversized_file(app, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index fe033144d67..c392ffc69dd 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask from flask_login import LoginManager, UserMixin +from werkzeug.exceptions import HTTPException from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout from controllers.console.workspace.error import AccountNotInitializedError @@ -17,8 +18,11 @@ from controllers.console.wraps import ( only_edition_enterprise, only_edition_self_hosted, setup_required, + with_current_tenant_id, + with_current_user, ) -from models.account import AccountStatus +from models import Account +from models.account import AccountStatus, TenantAccountRole from services.feature_service import LicenseStatus @@ -33,6 +37,17 @@ class MockUser(UserMixin): return self.id +def make_account(account_id: str = "account-1") -> Account: + account = Account( + name="Test Account", + email=f"{account_id}@example.com", + status=AccountStatus.ACTIVE, + ) + account.id = account_id + account.role = TenantAccountRole.OWNER + return account + + def create_app_with_login(): """Create a Flask app with LoginManager configured.""" app = Flask(__name__) @@ -84,6 +99,42 @@ class TestAccountInitialization: protected_view() +class TestCurrentContextInjection: + """Test request context injection decorators.""" + + def test_with_current_tenant_id_injects_tenant_id(self): + class Handler: + @with_current_tenant_id + def get(self, current_tenant_id: str): + return current_tenant_id + + with patch("controllers.console.wraps.current_account_with_tenant", return_value=(MagicMock(), "tenant-123")): + assert Handler().get() == "tenant-123" + + def test_with_current_user_injects_account(self): + current_user = make_account() + + class Handler: + @with_current_user + def get(self, injected_user): + return injected_user + + with patch("controllers.console.wraps.current_account_with_tenant", return_value=(current_user, "tenant-123")): + assert Handler().get() is current_user + + def test_stacked_current_context_injectors_preserve_argument_order(self): + current_user = make_account() + + class Handler: + @with_current_user + @with_current_tenant_id + def get(self, current_tenant_id: str, injected_user): + return current_tenant_id, injected_user + + with patch("controllers.console.wraps.current_account_with_tenant", return_value=(current_user, "tenant-123")): + assert Handler().get() == ("tenant-123", current_user) + + class TestEditionChecks: """Test edition-specific decorators""" @@ -114,7 +165,7 @@ class TestEditionChecks: # Act & Assert with app.test_request_context(): with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): - with pytest.raises(Exception) as exc_info: + with pytest.raises(HTTPException) as exc_info: cloud_view() assert exc_info.value.code == 404 @@ -177,7 +228,7 @@ class TestBillingEnabled: with app.test_request_context(): with patch("controllers.console.wraps.dify_config.BILLING_ENABLED", False): with patch("controllers.console.wraps.FeatureService.get_features") as get_features: - with pytest.raises(Exception) as exc_info: + with pytest.raises(HTTPException) as exc_info: billing_view() assert exc_info.value.code == 403 @@ -204,11 +255,43 @@ class TestBillingResourceLimits: with patch( "controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("test_user"), "tenant123") ): - with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): + with patch( + "controllers.console.wraps.FeatureService.get_features", return_value=mock_features + ) as get_features: result = add_member() # Assert assert result == "member_added" + get_features.assert_called_once_with("tenant123", exclude_vector_space=True) + + def test_should_load_vector_space_from_dedicated_quota_api(self): + """Test vector-space limit checks avoid loading the full feature payload.""" + # Arrange + mock_vector_space = MagicMock() + mock_vector_space.limit = 10 + mock_vector_space.size = 5 + + @cloud_edition_billing_resource_check("vector_space") + def add_segment(): + return "segment_added" + + # Act + with patch( + "controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("test_user"), "tenant123") + ): + with ( + patch("controllers.console.wraps.dify_config.BILLING_ENABLED", True), + patch( + "controllers.console.wraps.FeatureService.get_vector_space", return_value=mock_vector_space + ) as get_vector_space, + patch("controllers.console.wraps.FeatureService.get_features") as get_features, + ): + result = add_segment() + + # Assert + assert result == "segment_added" + get_vector_space.assert_called_once_with("tenant123") + get_features.assert_not_called() def test_should_reject_when_over_resource_limit(self): """Test that requests are rejected when over resource limits""" @@ -230,7 +313,7 @@ class TestBillingResourceLimits: return_value=(MockUser("test_user"), "tenant123"), ): with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): - with pytest.raises(Exception) as exc_info: + with pytest.raises(HTTPException) as exc_info: add_member() assert exc_info.value.code == 403 assert "members has reached the limit" in str(exc_info.value.description) @@ -255,7 +338,7 @@ class TestBillingResourceLimits: return_value=(MockUser("test_user"), "tenant123"), ): with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): - with pytest.raises(Exception) as exc_info: + with pytest.raises(HTTPException) as exc_info: upload_document() assert exc_info.value.code == 403 @@ -329,7 +412,7 @@ class TestRateLimiting: with patch( "controllers.console.wraps.FeatureService.get_knowledge_rate_limit", return_value=mock_rate_limit ): - with pytest.raises(Exception) as exc_info: + with pytest.raises(HTTPException) as exc_info: knowledge_request() # Verify error diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index 0ee1877630f..95c69d30c29 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -139,7 +139,7 @@ class TestTenantListApi: assert result["workspaces"][0]["plan"] == CloudPlan.TEAM assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL get_plan_bulk_mock.assert_called_once_with(["t1", "t2"]) - get_features_mock.assert_called_once_with("t2") + get_features_mock.assert_called_once_with("t2", exclude_vector_space=True) def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(self, app: Flask): """Test fallback to FeatureService when bulk billing returns empty result. @@ -235,7 +235,7 @@ class TestTenantListApi: assert status == 200 assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX - get_features_mock.assert_called_once_with("t1") + get_features_mock.assert_called_once_with("t1", exclude_vector_space=True) def test_get_enterprise_only_skips_feature_service(self, app: Flask): api = TenantListApi() diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py index fe8fc025485..2e1051ab6bb 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py @@ -872,6 +872,11 @@ class TestSegmentApiPost: mock_features.billing.enabled = False mock_feature_svc.get_features.return_value = mock_features + mock_vector_space = Mock() + mock_vector_space.limit = 10 + mock_vector_space.size = 0 + mock_feature_svc.get_vector_space.return_value = mock_vector_space + mock_rate_limit = Mock() mock_rate_limit.enabled = False mock_feature_svc.get_knowledge_rate_limit.return_value = mock_rate_limit @@ -1209,6 +1214,10 @@ class TestDatasetSegmentApiUpdate: mock_features = Mock() mock_features.billing.enabled = False mock_feature_svc.get_features.return_value = mock_features + mock_vector_space = Mock() + mock_vector_space.limit = 10 + mock_vector_space.size = 0 + mock_feature_svc.get_vector_space.return_value = mock_vector_space mock_rate_limit = Mock() mock_rate_limit.enabled = False mock_feature_svc.get_knowledge_rate_limit.return_value = mock_rate_limit @@ -1710,6 +1719,10 @@ class TestChildChunkApiPost: mock_features = Mock() mock_features.billing.enabled = False mock_feature_svc.get_features.return_value = mock_features + mock_vector_space = Mock() + mock_vector_space.limit = 10 + mock_vector_space.size = 0 + mock_feature_svc.get_vector_space.return_value = mock_vector_space mock_rate_limit = Mock() mock_rate_limit.enabled = False mock_feature_svc.get_knowledge_rate_limit.return_value = mock_rate_limit diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 61ec397193f..2185e65326c 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -950,7 +950,8 @@ class TestDocumentAddByTextApi: """Configure mocks to neutralise billing/auth decorators. ``cloud_edition_billing_resource_check`` calls - ``FeatureService.get_features`` and + ``FeatureService.get_vector_space`` for vector-space checks and + ``FeatureService.get_features`` for other resource checks. ``cloud_edition_billing_rate_limit_check`` calls ``FeatureService.get_knowledge_rate_limit``. Both call ``validate_and_get_api_token`` first. @@ -963,6 +964,11 @@ class TestDocumentAddByTextApi: mock_features.billing.enabled = False mock_feature_svc.get_features.return_value = mock_features + mock_vector_space = Mock() + mock_vector_space.limit = 10 + mock_vector_space.size = 0 + mock_feature_svc.get_vector_space.return_value = mock_vector_space + mock_rate_limit = Mock() mock_rate_limit.enabled = False mock_feature_svc.get_knowledge_rate_limit.return_value = mock_rate_limit @@ -1140,6 +1146,10 @@ def _setup_billing_mocks(mock_validate_token, mock_feature_svc, tenant_id: str): mock_features = Mock() mock_features.billing.enabled = False mock_feature_svc.get_features.return_value = mock_features + mock_vector_space = Mock() + mock_vector_space.limit = 10 + mock_vector_space.size = 0 + mock_feature_svc.get_vector_space.return_value = mock_vector_space mock_rate_limit = Mock() mock_rate_limit.enabled = False mock_feature_svc.get_knowledge_rate_limit.return_value = mock_rate_limit diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index 6e8d971c0de..0b5c5d95b69 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -265,6 +265,65 @@ class TestCloudEditionBillingResourceCheck: # Assert assert result == "member_added" + mock_get_features.assert_called_once_with("tenant123", exclude_vector_space=True) + + @patch("controllers.service_api.wraps.validate_and_get_api_token") + @patch("controllers.service_api.wraps.FeatureService.get_features") + @patch("controllers.service_api.wraps.FeatureService.get_vector_space") + def test_loads_vector_space_from_dedicated_quota_api( + self, mock_get_vector_space, mock_get_features, mock_validate_token, app: Flask + ): + """Test vector-space resource checks avoid loading the full feature payload.""" + # Arrange + mock_validate_token.return_value = Mock(tenant_id="tenant123") + + mock_vector_space = Mock() + mock_vector_space.limit = 10 + mock_vector_space.size = 5 + mock_get_vector_space.return_value = mock_vector_space + + @cloud_edition_billing_resource_check("vector_space", "dataset") + def add_segment(): + return "segment_added" + + # Act + with ( + app.test_request_context("/", method="GET"), + patch("controllers.service_api.wraps.dify_config.BILLING_ENABLED", True), + ): + result = add_segment() + + # Assert + assert result == "segment_added" + mock_get_vector_space.assert_called_once_with("tenant123") + mock_get_features.assert_not_called() + + @patch("controllers.service_api.wraps.validate_and_get_api_token") + @patch("controllers.service_api.wraps.FeatureService.get_features") + def test_loads_features_when_checking_non_vector_space_limit( + self, mock_get_features, mock_validate_token, app: Flask + ): + """Test non-vector-space resource checks keep using the light feature payload.""" + # Arrange + mock_validate_token.return_value = Mock(tenant_id="tenant123") + + mock_features = Mock() + mock_features.billing.enabled = True + mock_features.documents_upload_quota.limit = 10 + mock_features.documents_upload_quota.size = 5 + mock_get_features.return_value = mock_features + + @cloud_edition_billing_resource_check("documents", "dataset") + def upload_document(): + return "document_uploaded" + + # Act + with app.test_request_context("/", method="GET"): + result = upload_document() + + # Assert + assert result == "document_uploaded" + mock_get_features.assert_called_once_with("tenant123", exclude_vector_space=True) @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_features") diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 5f2dc19aab4..f9d49237b79 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -126,7 +126,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): monkeypatch.setattr( site_module.FeatureService, "get_features", - lambda tenant_id: SimpleNamespace(can_replace_logo=True), + lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True), ) with app.test_request_context("/api/form/human_input/token-1", method="GET"): @@ -245,7 +245,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F monkeypatch.setattr( site_module.FeatureService, "get_features", - lambda tenant_id: SimpleNamespace(can_replace_logo=True), + lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True), ) with app.test_request_context("/api/form/human_input/token-1", method="GET"): diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py index c6eedf7be7e..cd1e5babf88 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py @@ -12,6 +12,7 @@ from core.app.app_config.entities import ( PromptTemplateEntity, ) from core.app.apps.base_app_runner import AppRunner +from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueAgentMessageEvent, QueueLLMChunkEvent, QueueMessageEndEvent from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage @@ -41,6 +42,23 @@ class _QueueRecorder: self.events.append(event) +class _ClosableStream: + def __init__(self, chunks: list[LLMResultChunk]) -> None: + self._chunks = chunks + self.closed = False + + def __iter__(self): + return self + + def __next__(self): + if not self._chunks: + raise StopIteration + return self._chunks.pop(0) + + def close(self) -> None: + self.closed = True + + class TestAppRunner: def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() @@ -331,6 +349,28 @@ class TestAppRunner: assert queue.events[-1].llm_result.usage == usage exception_logger.assert_called_once() + def test_handle_invoke_result_stream_closes_generator_when_stopped(self): + runner = AppRunner() + chunk = LLMResultChunk( + model="stream-model", + prompt_messages=[AssistantPromptMessage(content="prompt")], + delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content="a")), + ) + stream = _ClosableStream([chunk]) + + queue_manager = SimpleNamespace( + publish=MagicMock(side_effect=GenerateTaskStoppedError("stopped")), + ) + + with pytest.raises(GenerateTaskStoppedError): + runner._handle_invoke_result_stream( + invoke_result=stream, + queue_manager=queue_manager, + agent=False, + ) + + assert stream.closed is True + def test_handle_multimodal_image_content_fallback_return_branch(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() diff --git a/api/tests/unit_tests/libs/test_workspace_permission.py b/api/tests/unit_tests/libs/test_workspace_permission.py index 89586ccf26b..48d9b351a4d 100644 --- a/api/tests/unit_tests/libs/test_workspace_permission.py +++ b/api/tests/unit_tests/libs/test_workspace_permission.py @@ -36,7 +36,7 @@ class TestWorkspacePermissionHelper: # Should not raise check_workspace_owner_transfer_permission("test-workspace-id") - mock_feature_service.get_features.assert_called_once_with("test-workspace-id") + mock_feature_service.get_features.assert_called_once_with("test-workspace-id", exclude_vector_space=True) @patch("libs.workspace_permission.EnterpriseService") @patch("libs.workspace_permission.dify_config") diff --git a/api/tests/unit_tests/services/test_batch_indexing_base.py b/api/tests/unit_tests/services/test_batch_indexing_base.py index bd68b67d89b..8d07fc30509 100644 --- a/api/tests/unit_tests/services/test_batch_indexing_base.py +++ b/api/tests/unit_tests/services/test_batch_indexing_base.py @@ -344,7 +344,7 @@ class TestDispatchRouting: proxy._dispatch() # Assert - mock_features.assert_called_once_with(TENANT_ID) + mock_features.assert_called_once_with(TENANT_ID, exclude_vector_space=True) class TestBaseRouterHelpers: diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index e7a195a4727..67d1cc02913 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -343,6 +343,37 @@ class TestBillingServiceSubscriptionInfo: params={"tenant_id": tenant_id, "exclude_vector_space": "true"}, ) + def test_get_info_exclude_vector_space_normalizes_null_field(self, mock_send_request): + """When billing serializes skipped vector_space as null, get_info treats it as absent.""" + # Arrange + tenant_id = "tenant-123" + expected_response = { + "enabled": True, + "subscription": {"plan": "professional", "interval": "month", "education": False}, + "members": {"size": 1, "limit": 50}, + "apps": {"size": 1, "limit": 200}, + "vector_space": None, + "knowledge_rate_limit": {"limit": 1000}, + "documents_upload_quota": {"size": 0, "limit": 1000}, + "annotation_quota_limit": {"size": 0, "limit": 5000}, + "docs_processing": "top-priority", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_pipeline_publish_enabled": True, + } + mock_send_request.return_value = expected_response + + # Act + result = BillingService.get_info(tenant_id, exclude_vector_space=True) + + # Assert + assert "vector_space" not in result + mock_send_request.assert_called_once_with( + "GET", + "/subscription/info", + params={"tenant_id": tenant_id, "exclude_vector_space": "true"}, + ) + def test_get_vector_space_success(self, mock_send_request): """Test successful retrieval of vector-space usage and limit.""" # Arrange diff --git a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py index f5879d973d6..352a765de28 100644 --- a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py +++ b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py @@ -39,7 +39,7 @@ def fake_features(monkeypatch: pytest.MonkeyPatch): ) monkeypatch.setattr( "services.dataset_service.FeatureService.get_features", - lambda tenant_id: features, + lambda tenant_id, **_kwargs: features, ) return features diff --git a/api/tests/unit_tests/services/test_document_indexing_task_proxy.py b/api/tests/unit_tests/services/test_document_indexing_task_proxy.py index 98c30c37228..28de9efa57f 100644 --- a/api/tests/unit_tests/services/test_document_indexing_task_proxy.py +++ b/api/tests/unit_tests/services/test_document_indexing_task_proxy.py @@ -75,7 +75,7 @@ class TestDocumentIndexingTaskProxy: assert features1 == mock_features assert features2 == mock_features assert features1 is features2 # Should be the same instance due to caching - mock_feature_service.get_features.assert_called_once_with("tenant-123") + mock_feature_service.get_features.assert_called_once_with("tenant-123", exclude_vector_space=True) @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") def test_send_to_direct_queue(self, mock_task): diff --git a/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py b/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py index 68bafe3d5e8..20358d6a0cd 100644 --- a/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py +++ b/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py @@ -94,7 +94,7 @@ class TestDuplicateDocumentIndexingTaskProxy: assert features1 == mock_features assert features2 == mock_features assert features1 is features2 # Should be the same instance due to caching - mock_feature_service.get_features.assert_called_once_with("tenant-123") + mock_feature_service.get_features.assert_called_once_with("tenant-123", exclude_vector_space=True) @patch( "services.document_indexing_proxy.duplicate_document_indexing_task_proxy.normal_duplicate_document_indexing_task" diff --git a/api/tests/unit_tests/services/test_feature_service_vector_space.py b/api/tests/unit_tests/services/test_feature_service_vector_space.py new file mode 100644 index 00000000000..dc0e7ddb548 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_vector_space.py @@ -0,0 +1,37 @@ +from unittest.mock import patch + +from services.feature_service import FeatureService + + +def test_get_features_exclude_vector_space_sets_vector_space_to_none(): + tenant_id = "tenant-id" + billing_info = { + "enabled": True, + "subscription": {"plan": "pro", "interval": "monthly", "education": False}, + "members": {"size": 1, "limit": 10}, + "apps": {"size": 2, "limit": 20}, + "documents_upload_quota": {"size": 3, "limit": 100}, + "annotation_quota_limit": {"size": 4, "limit": 50}, + "docs_processing": "standard", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_rate_limit": {"limit": 100}, + "knowledge_pipeline_publish_enabled": True, + } + + with ( + patch("services.feature_service.dify_config") as mock_config, + patch("services.feature_service.BillingService.get_info", return_value=billing_info) as get_info, + patch("services.feature_service.BillingService.get_quota_info", return_value={}), + ): + mock_config.BILLING_ENABLED = True + mock_config.ENTERPRISE_ENABLED = False + mock_config.CAN_REPLACE_LOGO = False + mock_config.MODEL_LB_ENABLED = False + mock_config.DATASET_OPERATOR_ENABLED = False + mock_config.EDUCATION_ENABLED = False + + features = FeatureService.get_features(tenant_id, exclude_vector_space=True) + + assert features.vector_space is None + get_info.assert_called_once_with(tenant_id, exclude_vector_space=True) diff --git a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py index f5a48b14168..cfc685e4cbd 100644 --- a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py +++ b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py @@ -144,7 +144,7 @@ class TestRagPipelineTaskProxy: assert features1 == mock_features assert features2 == mock_features assert features1 is features2 # Should be the same instance due to caching - mock_feature_service.get_features.assert_called_once_with("tenant-123") + mock_feature_service.get_features.assert_called_once_with("tenant-123", exclude_vector_space=True) @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") diff --git a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py index 37b7a854518..f8f9ec99719 100644 --- a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py @@ -54,7 +54,7 @@ def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: py monkeypatch.setattr( task_module.FeatureService, "get_features", - lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True), + lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), ) jobs: Sequence[task_module._EmailDeliveryJob] = [_build_job(recipient_count=2)] monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: jobs) @@ -78,7 +78,7 @@ def test_dispatch_human_input_email_task_skips_when_feature_disabled(monkeypatch monkeypatch.setattr( task_module.FeatureService, "get_features", - lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=False), + lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=False), ) monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: []) @@ -109,7 +109,7 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py monkeypatch.setattr( task_module.FeatureService, "get_features", - lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True), + lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), ) monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job]) monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: variable_pool) @@ -142,7 +142,7 @@ def test_dispatch_human_input_email_task_sanitizes_subject( monkeypatch.setattr( task_module.FeatureService, "get_features", - lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True), + lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), ) monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job]) monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None) diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx index 6de6b167666..5fa35015c7b 100644 --- a/packages/dify-ui/src/autocomplete/index.stories.tsx +++ b/packages/dify-ui/src/autocomplete/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Virtualizer } from '@tanstack/react-virtual' import type { RefObject } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, useTransition } from 'react' import { Autocomplete, AutocompleteClear, @@ -159,6 +159,29 @@ const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, in const getSuggestionLabel = (item: Suggestion) => item.label +async function searchSuggestions( + suggestions: Suggestion[], + query: string, + filter: (item: string, query: string) => boolean, +): Promise<{ items: Suggestion[], error: string | null }> { + await new Promise(resolve => window.setTimeout(resolve, 500)) + + if (query === 'will_error') { + return { + items: [], + error: 'Failed to load suggestions. Please try again.', + } + } + + return { + items: suggestions.filter(item => ( + filter(item.label, query) + || (item.description ? filter(item.description, query) : false) + )), + error: null, + } +} + const SuggestionItem = ({ item, dense, @@ -227,6 +250,7 @@ const BasicTagAutocomplete = ({ @@ -311,32 +335,64 @@ const LimitedStatus = ({ } const AsyncSearchDemo = () => { - const [value, setValue] = useState('agent') - const [loading, setLoading] = useState(false) - const [items, setItems] = useState(remoteSuggestions) + const [searchValue, setSearchValue] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [error, setError] = useState(null) + const [isPending, startTransition] = useTransition() + const { contains } = useAutocompleteFilter() + const abortControllerRef = useRef(null) - useEffect(() => { - setLoading(true) - const timeout = window.setTimeout(() => { - setItems( - value.trim() - ? remoteSuggestions.filter(item => item.label.toLowerCase().includes(value.trim().toLowerCase())) - : remoteSuggestions, - ) - setLoading(false) - }, 500) + const status = (() => { + if (isPending) + return 'Searching remote suggestions…' - return () => window.clearTimeout(timeout) - }, [value]) + if (error) + return error + + if (searchValue === '') + return null + + if (searchResults.length === 0) + return `No remote suggestion matches "${searchValue}".` + + return `${searchResults.length} remote suggestion${searchResults.length === 1 ? '' : 's'} found` + })() return (
{ + setSearchValue(nextSearchValue) + + const controller = new AbortController() + abortControllerRef.current?.abort() + abortControllerRef.current = controller + + if (nextSearchValue === '') { + setSearchResults([]) + setError(null) + return + } + + startTransition(async () => { + setError(null) + + const result = await searchSuggestions(remoteSuggestions, nextSearchValue, contains) + + if (controller.signal.aborted) + return + + startTransition(() => { + setSearchResults(result.items) + setError(result.error) + }) + }) + }} itemToStringValue={getSuggestionLabel} - openOnInputClick + filter={null} + mode="list" > - + - {loading ? 'Loading suggestions…' : `${items.length} remote suggestions`} + {status} {(item: Suggestion) => ( )} - No remote suggestion. Keep the typed query.
@@ -467,6 +522,7 @@ const FuzzyMatchingDemo = () => { onValueChange={setValue} filter={contains} itemToStringValue={getSuggestionLabel} + mode="list" openOnInputClick > @@ -567,6 +623,7 @@ export const GroupedSuggestions: Story = { @@ -595,6 +652,7 @@ export const LimitResults: Story = { items={workflowSuggestions} itemToStringValue={getSuggestionLabel} limit={5} + mode="list" openOnInputClick > @@ -627,6 +685,7 @@ export const CommandPalette: Story = { inline items={commandGroups} itemToStringValue={getSuggestionLabel} + mode="list" autoHighlight="always" keepHighlight > @@ -649,6 +708,7 @@ const VirtualizedLongSuggestionsDemo = () => { { @@ -686,6 +746,7 @@ export const Empty: Story = { items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="private-release-note" + mode="list" openOnInputClick > @@ -710,7 +771,7 @@ export const Empty: Story = { export const DisabledAndReadOnly: Story = { render: () => (
- + @@ -724,7 +785,7 @@ export const DisabledAndReadOnly: Story = { - + diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx index c788490268d..8cf40951af7 100644 --- a/packages/dify-ui/src/combobox/index.stories.tsx +++ b/packages/dify-ui/src/combobox/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Virtualizer } from '@tanstack/react-virtual' import type { RefObject } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, useTransition } from 'react' import { Combobox, ComboboxChip, @@ -26,6 +26,7 @@ import { ComboboxStatus, ComboboxTrigger, ComboboxValue, + useComboboxFilter, useComboboxFilteredItems, } from '.' import { cn } from '../cn' @@ -178,8 +179,34 @@ const defaultPopupDataSource = dataSourceOptions[1]! const readOnlyDataSource = dataSourceOptions[2]! const defaultTool = toolGroups[0]!.items[0]! const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!] +const defaultAsyncReviewers = [reviewerOptions[1]!] const defaultTag = tagOptions[2]! +const getOptionLabel = (option: Option) => option.label + +async function searchOptions( + options: Option[], + query: string, + filter: (item: string, query: string) => boolean, +): Promise<{ items: Option[], error: string | null }> { + await new Promise(resolve => window.setTimeout(resolve, 450)) + + if (query === 'will_error') { + return { + items: [], + error: 'Failed to fetch matches. Please try again.', + } + } + + return { + items: options.filter(option => ( + filter(option.label, query) + || (option.meta ? filter(option.meta, query) : false) + )), + error: null, + } +} + const renderOptionItem = (option: Option) => ( @@ -348,35 +375,88 @@ const VirtualizedLongListDemo = () => { } const AsyncDirectoryDemo = () => { - const [inputValue, setInputValue] = useState('ma') - const [value, setValue] = useState