From 669e852885ac2974448833612b52ac721a89aa0d Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 19 Aug 2026 09:21:53 +0800 Subject: [PATCH 1/3] refactor(api): extract web app runtime bootstrap --- api/.importlinter | 16 ++ api/controllers/web/site.py | 65 +++---- api/extensions/ext_application_services.py | 10 +- .../app_definition_query_repository.py | 75 +++++++-- api/services/app_definition_query_service.py | 1 + api/services/file_service.py | 8 + api/services/web_app_runtime_query_service.py | 103 ++++++++++++ .../controllers/web/test_human_input_form.py | 2 +- .../controllers/web/test_site.py | 38 +---- .../controllers/service_api/app/test_app.py | 1 + .../controllers/web/test_human_input_form.py | 7 +- .../unit_tests/controllers/web/test_site.py | 158 ++++++++---------- .../test_app_definition_query_repository.py | 80 ++++++++- .../unit_tests/services/test_file_service.py | 35 ++++ .../test_web_app_runtime_query_service.py | 155 +++++++++++++++++ 15 files changed, 562 insertions(+), 192 deletions(-) create mode 100644 api/services/web_app_runtime_query_service.py create mode 100644 api/tests/unit_tests/services/test_web_app_runtime_query_service.py diff --git a/api/.importlinter b/api/.importlinter index d7492912e7fe91..924c0b80a177ab 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -124,6 +124,22 @@ forbidden_modules = sqlalchemy werkzeug +[importlinter:contract:web-app-runtime-query-service-boundary] +name = Web app runtime query application service does not directly depend on transport or ORM modules +type = forbidden +source_modules = + services.web_app_runtime_query_service +forbidden_modules = + controllers + extensions + flask + models + repositories + services.feature_service_gateway + sqlalchemy + werkzeug +allow_indirect_imports = True + [importlinter:contract:feature-query-service-boundary] name = Feature query application service is framework and persistence neutral type = forbidden diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 827fdce72b0b7d..54914b9bfb42b7 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -1,23 +1,19 @@ from typing import Any, Self from pydantic import AliasChoices, Field -from sqlalchemy import select from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.schema import register_response_schema_models from controllers.web import web_ns from controllers.web.wraps import WebApiResource -from enums import DeploymentEdition -from extensions.ext_database import db -from extensions.storage.storage_type import StorageType +from extensions.ext_application_services import application_services from fields.base import ResponseModel -from libs.helper import build_icon_url -from models.account import Tenant, TenantStatus -from models.model import App, AppMode, EndUser, IconType, Site +from libs.helper import build_icon_url, dump_response +from models.account import Tenant +from models.model import App, AppMode, EndUser, Site from services.entities.feature_entities import FeatureModel -from services.feature_service import FeatureService -from services.file_service import FileService +from services.web_app_runtime_query_service import WebAppRuntimeUnavailableError class WebSiteResponse(ResponseModel): @@ -128,17 +124,6 @@ def from_app_site( ) -def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None: - """Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere.""" - if site.icon_type != IconType.IMAGE or not site.icon: - return None - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and ( - StorageType(dify_config.STORAGE_TYPE) == StorageType.S3 - ): - return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id) - return build_icon_url(site.icon_type, site.icon) - - @web_ns.route("/site") class AppSiteApi(WebApiResource): @web_ns.doc("Get App Site Info") @@ -156,25 +141,21 @@ class AppSiteApi(WebApiResource): @web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__]) 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)) - - if site is None: - raise Forbidden() - - tenant = app_model.tenant - if tenant is None or tenant.status == TenantStatus.ARCHIVE: - raise Forbidden() - - features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True) - - return WebAppSiteResponse.from_app_site( - tenant=tenant, - app_model=app_model, - mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())), - site=site, - end_user_id=end_user.id, - features=features, - can_replace_logo=features.can_replace_logo, - icon_url=_build_site_icon_url(site=site, tenant_id=tenant.id), - ).model_dump(mode="json") + try: + bootstrap = application_services().web_app_runtime.get_bootstrap(app_model.id) + except WebAppRuntimeUnavailableError: + raise Forbidden() from None + + return dump_response( + WebAppSiteResponse, + { + "app_id": bootstrap.app_id, + "mode": bootstrap.mode, + "end_user_id": end_user.id, + "enable_site": bootstrap.enable_site, + "site": bootstrap.site, + "plan": bootstrap.plan, + "can_replace_logo": bootstrap.can_replace_logo, + "custom_config": bootstrap.custom_config, + }, + ) diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 21705761aff69f..2aed2e8c423fbe 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -42,10 +42,12 @@ from services.feature_query_service import FeatureQueryService from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway +from services.file_service import FileService from services.init_validation_service import InitValidationService from services.schema_definition_service import SchemaDefinitionService from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner from services.setup_service import SetupService +from services.web_app_runtime_query_service import WebAppRuntimeQueryService from services.webapp_access_query_service import ( WebAppAccessQueryService, WebAppAccessUnavailableError, @@ -82,6 +84,7 @@ class ApplicationServices: app_definitions: AppDefinitionQueryService data_source_api_key_auth: DataSourceApiKeyAuthService webapp_access: WebAppAccessQueryService + web_app_runtime: WebAppRuntimeQueryService explore_banner_queries: ExploreBannerQueryService schema_definitions: SchemaDefinitionService setup: SetupService @@ -100,6 +103,7 @@ def build_application_services( ) -> ApplicationServices: installation_state = InstallationStateRepository(client=database_client) data_source_api_key_auth_bindings = SQLAlchemyDataSourceApiKeyAuthBindingRepository(session_factory=database_client) + app_definition_repository = AppDefinitionQueryRepository(session_factory=database_client) return ApplicationServices( account_activation=AccountActivationService( tokens=RegisterServiceInvitationTokenStore(), @@ -113,7 +117,7 @@ def build_application_services( ), ), app_definitions=AppDefinitionQueryService( - definitions=AppDefinitionQueryRepository(session_factory=database_client), + definitions=app_definition_repository, builtin_icon_url_prefix=( dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/" ), @@ -129,6 +133,10 @@ def build_application_services( access_mode_for_app=_get_enterprise_webapp_access_mode, is_user_allowed_for_app=_is_user_allowed_to_access_webapp, ), + web_app_runtime=WebAppRuntimeQueryService( + runtime=app_definition_repository, + file_service=FileService(database_client), + ), explore_banner_queries=ExploreBannerQueryService( banners=ExploreBannerQueryRepository(client=database_client), enabled=FeatureService.is_explore_banner_enabled(), diff --git a/api/repositories/app_definition_query_repository.py b/api/repositories/app_definition_query_repository.py index 455665506005cb..21d1f9729b5e76 100644 --- a/api/repositories/app_definition_query_repository.py +++ b/api/repositories/app_definition_query_repository.py @@ -9,6 +9,7 @@ from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError +from models.account import Tenant from models.agent import AgentConfigSnapshot from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config @@ -21,6 +22,27 @@ AppSiteConfiguration, AppToolIconSource, ) +from services.web_app_runtime_query_service import WebAppRuntimeRecord + + +def _map_site_configuration(site: Site) -> AppSiteConfiguration: + return AppSiteConfiguration( + title=site.title, + chat_color_theme=site.chat_color_theme, + chat_color_theme_inverted=site.chat_color_theme_inverted, + icon_type=site.icon_type.value if site.icon_type is not None else None, + icon=site.icon, + icon_background=site.icon_background, + description=site.description, + copyright=site.copyright, + privacy_policy=site.privacy_policy, + input_placeholder=site.input_placeholder, + custom_disclaimer=site.custom_disclaimer, + default_language=site.default_language, + prompt_public=site.prompt_public, + show_workflow_steps=site.show_workflow_steps, + use_icon_as_answer_icon=site.use_icon_as_answer_icon, + ) def _get_public_agent_parameter_config(app: App, *, session: Session) -> AppParameterConfig: @@ -153,23 +175,46 @@ def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None: if site is None: return None - return AppSiteConfiguration( - title=site.title, - chat_color_theme=site.chat_color_theme, - chat_color_theme_inverted=site.chat_color_theme_inverted, - icon_type=site.icon_type.value if site.icon_type is not None else None, - icon=site.icon, - icon_background=site.icon_background, - description=site.description, - copyright=site.copyright, - privacy_policy=site.privacy_policy, - input_placeholder=site.input_placeholder, - custom_disclaimer=site.custom_disclaimer, - default_language=site.default_language, - show_workflow_steps=site.show_workflow_steps, - use_icon_as_answer_icon=site.use_icon_as_answer_icon, + return _map_site_configuration(site) + + def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: + with self._session_factory() as session: + app = session.get(App, app_id) + if app is None: + return None + + site = session.scalar(select(Site).where(Site.app_id == app_id).limit(1)) + if site is None: + return None + + tenant = session.get(Tenant, app.tenant_id) + if tenant is None: + return None + + app_id = app.id + tenant_id = app.tenant_id + enable_site = app.enable_site + site_configuration = _map_site_configuration(site) + plan = tenant.plan + tenant_status = tenant.status.value + tenant_custom_config_json = tenant.custom_config + return WebAppRuntimeRecord( + app_id=app_id, + tenant_id=tenant_id, + enable_site=enable_site, + site=site_configuration, + plan=plan, + tenant_status=tenant_status, + tenant_custom_config_json=tenant_custom_config_json, ) + def resolve_compatible_app_mode(self, app_id: str) -> str | None: + with self._session_factory() as session: + app = session.get(App, app_id) + if app is None: + return None + return AppMode.value_of(app.mode_compatible_with_agent_with_session(session=session)).value + @staticmethod def _get_tools(session: Session, app: App) -> list[dict[str, Any]]: if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: diff --git a/api/services/app_definition_query_service.py b/api/services/app_definition_query_service.py index 6ebd367a9619bf..6815911b119859 100644 --- a/api/services/app_definition_query_service.py +++ b/api/services/app_definition_query_service.py @@ -41,6 +41,7 @@ class AppSiteConfiguration(NamedTuple): input_placeholder: str | None custom_disclaimer: str | None default_language: str + prompt_public: bool show_workflow_steps: bool use_icon_as_answer_icon: bool diff --git a/api/services/file_service.py b/api/services/file_service.py index 3ddc81ab41f3e1..4497639eb25c6b 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -20,6 +20,7 @@ VIDEO_EXTENSIONS, ) from core.rag.extractor.extract_processor import ExtractProcessor +from enums import DeploymentEdition from extensions.ext_storage import storage from extensions.storage.storage_type import StorageType from graphon.file import helpers as file_helpers @@ -179,6 +180,13 @@ def get_file_presigned_url(self, *, file_id: str, tenant_id: str) -> str: content_type=content_type, ) + def get_icon_url(self, file_id: str, tenant_id: str) -> str: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and ( + StorageType(dify_config.STORAGE_TYPE) == StorageType.S3 + ): + return self.get_file_presigned_url(file_id=file_id, tenant_id=tenant_id) + return file_helpers.get_signed_file_url(upload_file_id=file_id) + def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) -> UploadFile: if len(text_name) > 200: text_name = text_name[:200] diff --git a/api/services/web_app_runtime_query_service.py b/api/services/web_app_runtime_query_service.py new file mode 100644 index 00000000000000..5a34ae86917828 --- /dev/null +++ b/api/services/web_app_runtime_query_service.py @@ -0,0 +1,103 @@ +"""Application service for building the public Web app runtime bootstrap.""" + +import json +from collections.abc import Mapping +from typing import NamedTuple, Protocol, cast + +from configs import dify_config +from services.app_definition_query_service import AppSiteConfiguration +from services.feature_service import FeatureService +from services.file_service import FileService + + +class WebAppRuntimeRecord(NamedTuple): + app_id: str + tenant_id: str + enable_site: bool + site: AppSiteConfiguration + plan: str + tenant_status: str + # Keep this lazy: workspaces without custom branding never parsed this legacy field. + tenant_custom_config_json: str | None + + +class WebAppBootstrap(NamedTuple): + app_id: str + mode: str + enable_site: bool + site: Mapping[str, str | bool | None] + plan: str + can_replace_logo: bool + custom_config: Mapping[str, str | bool | None] | None + + +class WebAppRuntimeQuery(Protocol): + def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: ... + + def resolve_compatible_app_mode(self, app_id: str) -> str | None: ... + + +class WebAppRuntimeUnavailableError(ValueError): + """Raised when the admitted Web app can no longer be bootstrapped.""" + + +_ARCHIVED_TENANT_STATUS = "archive" + + +class WebAppRuntimeQueryService: + def __init__( + self, + *, + runtime: WebAppRuntimeQuery, + file_service: FileService, + ) -> None: + self._runtime = runtime + self._file_service = file_service + + def get_bootstrap(self, app_id: str) -> WebAppBootstrap: + record = self._runtime.get_runtime_record(app_id) + if record is None or record.tenant_status == _ARCHIVED_TENANT_STATUS: + raise WebAppRuntimeUnavailableError("Site not found") + + features = FeatureService.get_features(record.tenant_id, exclude_vector_space=True) + mode = self._runtime.resolve_compatible_app_mode(app_id) + if mode is None: + raise WebAppRuntimeUnavailableError("Site not found") + site_icon_url = ( + self._file_service.get_icon_url(record.site.icon, record.tenant_id) + if record.site.icon_type == "image" and record.site.icon + else None + ) + + site = cast(dict[str, str | bool | None], record.site._asdict()) + site["icon_url"] = site_icon_url + if features.billing.enabled and not features.webapp_copyright_enabled: + site["copyright"] = None + site["input_placeholder"] = None + + custom_config = None + if features.can_replace_logo: + tenant_custom_config = ( + cast(Mapping[str, str | bool | None], json.loads(record.tenant_custom_config_json)) + if record.tenant_custom_config_json + else {} + ) + replace_webapp_logo = ( + f"{dify_config.FILES_URL}/files/workspaces/{record.tenant_id}/webapp-logo" + if tenant_custom_config.get("replace_webapp_logo") + else None + ) + custom_config = { + "remove_webapp_brand": tenant_custom_config.get("remove_webapp_brand", False), + "replace_webapp_logo": replace_webapp_logo, + } + + return WebAppBootstrap( + app_id=record.app_id, + mode=mode, + enable_site=record.enable_site, + site=site, + plan=record.plan, + can_replace_logo=features.can_replace_logo, + custom_config=custom_config, + ) diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py index 5c3ad5e199b8ab..d26965a0e99925 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -231,7 +231,7 @@ def mock_get_features(tenant_id: str, exclude_vector_space: bool = False) -> Fea return features monkeypatch.setattr( - "controllers.web.site.FeatureService.get_features", + "controllers.web.human_input_form.FeatureService.get_features", mock_get_features, ) diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index cdba83851fc726..e5f6f83d768a4b 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -9,10 +9,7 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden -from configs import dify_config from controllers.web.site import AppSiteApi, WebAppSiteResponse, WebModelConfigResponse -from enums import DeploymentEdition -from extensions.storage.storage_type import StorageType from models import Tenant, TenantStatus from models.account import TenantCustomConfigDict from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, EndUser, Site @@ -82,7 +79,7 @@ def _site_model(*, app_id: str) -> Site: class TestAppSiteApi: - @patch("controllers.web.site.FeatureService.get_features") + @patch("services.feature_service.FeatureService.get_features") def test_happy_path(self, mock_features: MagicMock, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) @@ -100,39 +97,6 @@ def test_happy_path(self, mock_features: MagicMock, app: Flask, db_session_with_ assert result["enable_site"] is True assert result["mode"] == AppMode.CHAT - @patch("controllers.web.site.FileService.get_file_presigned_url") - @patch("controllers.web.site.FeatureService.get_features") - def test_image_icon_uses_s3_presigned_url( - self, - mock_features: MagicMock, - mock_get_file_presigned_url: MagicMock, - app: Flask, - db_session_with_containers: Session, - ) -> None: - app.config["RESTX_MASK_HEADER"] = "X-Fields" - tenant = _create_tenant(db_session_with_containers) - app_model = _create_app(db_session_with_containers, tenant.id) - site = _create_site(db_session_with_containers, app_model.id) - site.icon_type = "image" - site.icon = "11111111-1111-4111-8111-111111111111" - db_session_with_containers.commit() - end_user = _end_user(tenant.id, app_model.id) - mock_features.return_value = FeatureModel(can_replace_logo=False) - mock_get_file_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test" - - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), - app.test_request_context("/site"), - ): - result = AppSiteApi().get(app_model, end_user) - - assert result["site"]["icon_url"] == "https://s3.example.com/icon.png?signature=test" - mock_get_file_presigned_url.assert_called_once_with( - file_id="11111111-1111-4111-8111-111111111111", - tenant_id=tenant.id, - ) - def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py index a6ff026f6d5921..38d7eb7e678f68 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_app.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py @@ -312,6 +312,7 @@ def test_get_site_configuration_queries_authenticated_app( input_placeholder="Ask anything", custom_disclaimer=None, default_language="en-US", + prompt_public=False, show_workflow_steps=True, use_icon_as_answer_icon=False, ) 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 985c1e41ba9ada..5d9ba9e42e41a9 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 @@ -14,7 +14,6 @@ from werkzeug.exceptions import Forbidden import controllers.web.human_input_form as human_input_module -import controllers.web.site as site_module from controllers.web.error import WebFormRateLimitExceededError from core.workflow.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource from core.workflow.nodes.human_input.enums import ValueSourceType @@ -139,7 +138,7 @@ def get_definition(self): monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) monkeypatch.setattr( - site_module.FeatureService, + human_input_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True), ) @@ -259,7 +258,7 @@ def get_definition(self): def mock_get_features(tenant_id: str, exclude_vector_space: bool = False): return FeatureModel(can_replace_logo=True) - monkeypatch.setattr(site_module.FeatureService, "get_features", mock_get_features) + monkeypatch.setattr(human_input_module.FeatureService, "get_features", mock_get_features) with app.test_request_context("/api/form/human_input/token-1", method="GET"): response = HumanInputFormApi().get("token-1") @@ -360,7 +359,7 @@ def get_definition(self): monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) monkeypatch.setattr( - site_module.FeatureService, + human_input_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True), ) diff --git a/api/tests/unit_tests/controllers/web/test_site.py b/api/tests/unit_tests/controllers/web/test_site.py index 011d6b6a51e6f2..8440796984ad48 100644 --- a/api/tests/unit_tests/controllers/web/test_site.py +++ b/api/tests/unit_tests/controllers/web/test_site.py @@ -1,106 +1,82 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch -from configs import dify_config -from controllers.web import site as site_module -from enums import DeploymentEdition -from extensions.storage.storage_type import StorageType -from models.model import AppMode, IconType, Site -from services.entities.feature_entities import FeatureModel - - -def test_app_site_api_returns_legacy_agent_compatible_mode() -> None: - app_model = MagicMock() - app_model.id = "app-id" - app_model.tenant_id = "tenant-id" - app_model.tenant = MagicMock(id="tenant-id", status="normal") - app_model.mode_compatible_with_agent_with_session.return_value = AppMode.AGENT_CHAT - end_user = MagicMock(id="end-user-id") - site = Site() - response = MagicMock() - response.model_dump.return_value = {"mode": AppMode.AGENT_CHAT} - - with ( - patch.object(site_module, "db") as mock_db, - patch.object(site_module.FeatureService, "get_features", return_value=FeatureModel(can_replace_logo=False)), - patch.object(site_module, "_build_site_icon_url", return_value=None), - patch.object(site_module.WebAppSiteResponse, "from_app_site", return_value=response) as mock_from_app_site, - ): - mock_db.session.scalar.return_value = site - result = site_module.AppSiteApi().get(app_model, end_user) +import pytest +from werkzeug.exceptions import Forbidden - assert result["mode"] == AppMode.AGENT_CHAT - app_model.mode_compatible_with_agent_with_session.assert_called_once_with(session=mock_db.session()) - mock_from_app_site.assert_called_once_with( - tenant=app_model.tenant, - app_model=app_model, - mode=AppMode.AGENT_CHAT, - site=site, - end_user_id=end_user.id, - features=FeatureModel(can_replace_logo=False), - can_replace_logo=False, - icon_url=None, +from controllers.web import site as site_module +from services.app_definition_query_service import AppSiteConfiguration +from services.web_app_runtime_query_service import WebAppBootstrap, WebAppRuntimeUnavailableError + + +def _bootstrap() -> WebAppBootstrap: + site = AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=False, + icon_type="image", + icon="file-1", + icon_background="#ffffff", + description="Description", + copyright="Copyright", + privacy_policy="Privacy", + input_placeholder="Ask anything", + custom_disclaimer="Disclaimer", + default_language="en-US", + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, ) - - -def test_build_site_icon_url_uses_s3_presigned_url() -> None: - site = Site( - icon_type=IconType.IMAGE, - icon="11111111-1111-4111-8111-111111111111", + return WebAppBootstrap( + app_id="app-id", + mode="agent-chat", + enable_site=True, + site={**site._asdict(), "icon_url": "https://files.example.com/icon.png"}, + plan="pro", + can_replace_logo=True, + custom_config={ + "remove_webapp_brand": True, + "replace_webapp_logo": "https://files.example.com/files/workspaces/tenant-id/webapp-logo", + }, ) - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), - patch.object(site_module, "db") as mock_db, - patch.object(site_module, "FileService") as mock_file_service, - patch.object(site_module, "build_icon_url") as mock_build_icon_url, - ): - mock_file_service.return_value.get_file_presigned_url.return_value = ( - "https://s3.example.com/icon.png?signature=test" - ) - - result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") - - assert result == "https://s3.example.com/icon.png?signature=test" - mock_file_service.assert_called_once_with(mock_db.engine) - mock_file_service.return_value.get_file_presigned_url.assert_called_once_with( - file_id="11111111-1111-4111-8111-111111111111", - tenant_id="tenant-id", - ) - mock_build_icon_url.assert_not_called() - -def test_build_site_icon_url_keeps_preview_url_for_self_hosted_s3() -> None: - site = Site( - icon_type=IconType.IMAGE, - icon="11111111-1111-4111-8111-111111111111", - ) +def test_app_site_api_queries_the_admitted_app_runtime() -> None: + app_model = MagicMock(id="app-id") + end_user = MagicMock(id="end-user-id") + web_app_runtime = MagicMock() + web_app_runtime.get_bootstrap.return_value = _bootstrap() - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), - patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), - patch.object(site_module, "FileService") as mock_file_service, - patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), + with patch.object( + site_module, + "application_services", + return_value=SimpleNamespace(web_app_runtime=web_app_runtime), ): - result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") + result = site_module.AppSiteApi().get(app_model, end_user) - assert result == "https://api.example.com/files/icon/file-preview" - mock_file_service.assert_not_called() + assert result["app_id"] == "app-id" + assert result["mode"] == "agent-chat" + assert result["end_user_id"] == "end-user-id" + assert result["site"]["prompt_public"] is True + assert result["site"]["icon_url"] == "https://files.example.com/icon.png" + assert result["model_config"] is None + assert result["custom_config"] == { + "remove_webapp_brand": True, + "replace_webapp_logo": "https://files.example.com/files/workspaces/tenant-id/webapp-logo", + } + web_app_runtime.get_bootstrap.assert_called_once_with("app-id") -def test_build_site_icon_url_keeps_preview_url_for_non_s3_storage() -> None: - site = Site( - icon_type=IconType.IMAGE, - icon="11111111-1111-4111-8111-111111111111", - ) +def test_app_site_api_maps_unavailable_runtime_to_forbidden() -> None: + web_app_runtime = MagicMock() + web_app_runtime.get_bootstrap.side_effect = WebAppRuntimeUnavailableError with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "STORAGE_TYPE", StorageType.LOCAL), - patch.object(site_module, "FileService") as mock_file_service, - patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), + patch.object( + site_module, + "application_services", + return_value=SimpleNamespace(web_app_runtime=web_app_runtime), + ), + pytest.raises(Forbidden), ): - result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") - - assert result == "https://api.example.com/files/icon/file-preview" - mock_file_service.assert_not_called() + site_module.AppSiteApi().get(MagicMock(id="app-id"), MagicMock(id="end-user-id")) diff --git a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py index c47943c80df73e..97d0bd3378553f 100644 --- a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py +++ b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.tools.entities.tool_entities import ApiProviderSchemaType -from models.account import Account +from models.account import Account, Tenant, TenantStatus from models.enums import CustomizeTokenStrategy, TagType from models.model import App, AppMode, AppModelConfig, IconType, Site, Tag, TagBinding from models.tools import ApiToolProvider @@ -16,6 +16,7 @@ AppSiteConfiguration, AppToolIconSource, ) +from services.web_app_runtime_query_service import WebAppRuntimeRecord _APP_ID = "11111111-1111-1111-1111-111111111111" _TENANT_ID = "22222222-2222-2222-2222-222222222222" @@ -341,11 +342,88 @@ def test_get_site_configuration_maps_site_fields(sqlite_session_factory: session input_placeholder="Ask anything", custom_disclaimer="Disclaimer", default_language="en-US", + prompt_public=True, show_workflow_steps=False, use_icon_as_answer_icon=True, ) +def test_get_runtime_record_maps_app_tenant_and_site( + sqlite_session_factory: sessionmaker[Session], +) -> None: + tenant_custom_config = '{"remove_webapp_brand":true,"replace_webapp_logo":"logo-file"}' + with sqlite_session_factory.begin() as session: + tenant = Tenant( + name="Test Tenant", + plan="pro", + status=TenantStatus.NORMAL, + custom_config=tenant_custom_config, + ) + tenant.id = _TENANT_ID + session.add(tenant) + app = _persist_app(session) + app_model_config = AppModelConfig( + app_id=app.id, + agent_mode=json.dumps({"enabled": True, "strategy": "react"}), + ) + session.add(app_model_config) + session.flush() + app.app_model_config_id = app_model_config.id + session.add( + Site( + app_id=app.id, + title="Test Site", + icon_type=IconType.IMAGE, + icon="11111111-1111-4111-8111-111111111111", + icon_background="#ffffff", + default_language="en-US", + chat_color_theme="light", + chat_color_theme_inverted=False, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + ) + + repository = AppDefinitionQueryRepository(session_factory=sqlite_session_factory) + + assert repository.get_runtime_record(_APP_ID) == WebAppRuntimeRecord( + app_id=_APP_ID, + tenant_id=_TENANT_ID, + enable_site=True, + site=AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=False, + icon_type=IconType.IMAGE.value, + icon="11111111-1111-4111-8111-111111111111", + icon_background="#ffffff", + description=None, + copyright=None, + privacy_policy=None, + input_placeholder=None, + custom_disclaimer="", + default_language="en-US", + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ), + plan="pro", + tenant_status=TenantStatus.NORMAL.value, + tenant_custom_config_json=tenant_custom_config, + ) + assert repository.resolve_compatible_app_mode(_APP_ID) == AppMode.AGENT_CHAT.value + + +def test_get_runtime_record_returns_none_for_missing_app( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = AppDefinitionQueryRepository(session_factory=sqlite_session_factory) + + assert repository.get_runtime_record(_APP_ID) is None + + def _tool(provider_type: str, provider_id: str, tool_name: str) -> dict[str, object]: return { "provider_type": provider_type, diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index 0994abecf65296..f345a2ec0124db 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -11,6 +11,7 @@ from werkzeug.exceptions import NotFound from configs import dify_config +from enums import DeploymentEdition from extensions.storage.storage_type import StorageType from models.base import TypeBase from models.enums import CreatorUserRole @@ -296,6 +297,40 @@ def test_get_file_presigned_url_not_found(self, file_service: FileService): with pytest.raises(NotFound, match="File not found"): file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id") + def test_get_icon_url_uses_direct_storage_url_for_cloud_s3(self, file_service: FileService): + with ( + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), + patch.object(file_service, "get_file_presigned_url", return_value="direct-url") as get_presigned_url, + ): + result = file_service.get_icon_url("file_id", "tenant_id") + + assert result == "direct-url" + get_presigned_url.assert_called_once_with(file_id="file_id", tenant_id="tenant_id") + + @pytest.mark.parametrize( + ("deployment_edition", "storage_type"), + [ + (DeploymentEdition.COMMUNITY, StorageType.S3), + (DeploymentEdition.CLOUD, StorageType.LOCAL), + ], + ) + def test_get_icon_url_uses_preview_url_outside_cloud_s3( + self, + file_service: FileService, + deployment_edition: DeploymentEdition, + storage_type: StorageType, + ): + with ( + patch.object(dify_config, "DEPLOYMENT_EDITION", deployment_edition), + patch.object(dify_config, "STORAGE_TYPE", storage_type), + patch("services.file_service.file_helpers.get_signed_file_url", return_value="preview-url") as get_url, + ): + result = file_service.get_icon_url("file_id", "tenant_id") + + assert result == "preview-url" + get_url.assert_called_once_with(upload_file_id="file_id") + def test_upload_text_success(self, file_service: FileService, db_session: Session): # Setup text = "sample text" diff --git a/api/tests/unit_tests/services/test_web_app_runtime_query_service.py b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py new file mode 100644 index 00000000000000..cfe6ae539d7132 --- /dev/null +++ b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py @@ -0,0 +1,155 @@ +from collections.abc import Iterator +from unittest.mock import MagicMock, create_autospec, patch + +import pytest + +from configs import dify_config +from services.app_definition_query_service import AppSiteConfiguration +from services.entities.feature_entities import FeatureModel +from services.file_service import FileService +from services.web_app_runtime_query_service import ( + WebAppBootstrap, + WebAppRuntimeQuery, + WebAppRuntimeQueryService, + WebAppRuntimeRecord, + WebAppRuntimeUnavailableError, +) + +_FILES_URL = "https://files.example.com" + + +@pytest.fixture(autouse=True) +def workspace_features() -> Iterator[MagicMock]: + with ( + patch.object(dify_config, "FILES_URL", _FILES_URL), + patch( + "services.web_app_runtime_query_service.FeatureService.get_features", + return_value=FeatureModel(), + ) as get_features, + ): + yield get_features + + +def _site_configuration() -> AppSiteConfiguration: + return AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=False, + icon_type="image", + icon="file-1", + icon_background="#ffffff", + description="Description", + copyright="Copyright", + privacy_policy="Privacy", + input_placeholder="Ask anything", + custom_disclaimer="Disclaimer", + default_language="en-US", + prompt_public=True, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + + +def _runtime_record( + *, + tenant_status: str = "normal", + tenant_custom_config_json: str | None = '{"remove_webapp_brand":true,"replace_webapp_logo":"file-2"}', +) -> WebAppRuntimeRecord: + return WebAppRuntimeRecord( + app_id="app-1", + tenant_id="tenant-1", + enable_site=True, + site=_site_configuration(), + plan="pro", + tenant_status=tenant_status, + tenant_custom_config_json=tenant_custom_config_json, + ) + + +def _service( + runtime: MagicMock, + *, + file_service: MagicMock | None = None, +) -> WebAppRuntimeQueryService: + if file_service is None: + file_service = MagicMock(spec=FileService) + file_service.get_icon_url.return_value = None + return WebAppRuntimeQueryService( + runtime=runtime, + file_service=file_service, + ) + + +@pytest.mark.parametrize("record", [None, _runtime_record(tenant_status="archive")]) +def test_get_bootstrap_rejects_unavailable_runtime(record: WebAppRuntimeRecord | None) -> None: + runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) + runtime.get_runtime_record.return_value = record + + with pytest.raises(WebAppRuntimeUnavailableError, match="Site not found"): + _service(runtime).get_bootstrap("app-1") + + runtime.resolve_compatible_app_mode.assert_not_called() + + +def test_get_bootstrap_rejects_missing_compatible_mode() -> None: + runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) + runtime.get_runtime_record.return_value = _runtime_record() + runtime.resolve_compatible_app_mode.return_value = None + + with pytest.raises(WebAppRuntimeUnavailableError, match="Site not found"): + _service(runtime).get_bootstrap("app-1") + + +def test_get_bootstrap_applies_feature_and_branding_policy_after_record_load( + workspace_features: MagicMock, +) -> None: + runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) + record = _runtime_record() + features = FeatureModel(can_replace_logo=True, webapp_copyright_enabled=False) + features.billing.enabled = True + events: list[str] = [] + runtime.get_runtime_record.side_effect = lambda _app_id: events.append("record") or record + runtime.resolve_compatible_app_mode.side_effect = lambda _app_id: events.append("mode") or "agent-chat" + workspace_features.side_effect = lambda _tenant_id, **_kwargs: events.append("features") or features + file_service = MagicMock(spec=FileService) + file_service.get_icon_url.side_effect = lambda *_args, **_kwargs: events.append("icon") or "https://icon" + + result = _service(runtime, file_service=file_service).get_bootstrap("app-1") + + assert result == WebAppBootstrap( + app_id="app-1", + mode="agent-chat", + enable_site=True, + site={ + **record.site._asdict(), + "copyright": None, + "input_placeholder": None, + "icon_url": "https://icon", + }, + plan="pro", + can_replace_logo=True, + custom_config={ + "remove_webapp_brand": True, + "replace_webapp_logo": "https://files.example.com/files/workspaces/tenant-1/webapp-logo", + }, + ) + assert events == ["record", "features", "mode", "icon"] + workspace_features.assert_called_once_with("tenant-1", exclude_vector_space=True) + file_service.get_icon_url.assert_called_once_with("file-1", "tenant-1") + + +def test_get_bootstrap_skips_legacy_custom_config_when_branding_is_not_allowed( + workspace_features: MagicMock, +) -> None: + runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) + record = _runtime_record(tenant_custom_config_json="not-json") + runtime.get_runtime_record.return_value = record + runtime.resolve_compatible_app_mode.return_value = "chat" + + workspace_features.return_value = FeatureModel(can_replace_logo=False) + + result = _service(runtime).get_bootstrap("app-1") + + assert result.site == {**record.site._asdict(), "icon_url": None} + assert result.can_replace_logo is False + assert result.custom_config is None From d319f63627c9a5c52651fb0f5875a6c6615bcd61 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 19 Aug 2026 16:16:45 +0800 Subject: [PATCH 2/3] refactor(api): inject web app runtime dependencies --- api/.importlinter | 4 ++- api/extensions/ext_application_services.py | 5 ++- api/services/web_app_runtime_query_service.py | 13 +++++--- .../test_web_app_runtime_query_service.py | 32 +++++++++---------- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/api/.importlinter b/api/.importlinter index 924c0b80a177ab..013b78bc689d9c 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -125,16 +125,18 @@ forbidden_modules = werkzeug [importlinter:contract:web-app-runtime-query-service-boundary] -name = Web app runtime query application service does not directly depend on transport or ORM modules +name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules type = forbidden source_modules = services.web_app_runtime_query_service forbidden_modules = + configs controllers extensions flask models repositories + services.feature_service services.feature_service_gateway sqlalchemy werkzeug diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 2aed2e8c423fbe..44bfadc6e99465 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -104,6 +104,7 @@ def build_application_services( installation_state = InstallationStateRepository(client=database_client) data_source_api_key_auth_bindings = SQLAlchemyDataSourceApiKeyAuthBindingRepository(session_factory=database_client) app_definition_repository = AppDefinitionQueryRepository(session_factory=database_client) + feature_gateway = FeatureServiceGateway() return ApplicationServices( account_activation=AccountActivationService( tokens=RegisterServiceInvitationTokenStore(), @@ -136,6 +137,8 @@ def build_application_services( web_app_runtime=WebAppRuntimeQueryService( runtime=app_definition_repository, file_service=FileService(database_client), + workspace_features=feature_gateway.get_workspace_features, + files_url=dify_config.FILES_URL, ), explore_banner_queries=ExploreBannerQueryService( banners=ExploreBannerQueryRepository(client=database_client), @@ -149,7 +152,7 @@ def build_application_services( setup_required=deployment_edition != DeploymentEdition.CLOUD, ), feature_queries=FeatureQueryService( - features=FeatureServiceGateway(), + features=feature_gateway, trial_models=FeatureService.get_trial_models(), app_dsl_version=CURRENT_APP_DSL_VERSION, ), diff --git a/api/services/web_app_runtime_query_service.py b/api/services/web_app_runtime_query_service.py index 5a34ae86917828..c96009e16ed81c 100644 --- a/api/services/web_app_runtime_query_service.py +++ b/api/services/web_app_runtime_query_service.py @@ -1,12 +1,11 @@ """Application service for building the public Web app runtime bootstrap.""" import json -from collections.abc import Mapping +from collections.abc import Callable, Mapping from typing import NamedTuple, Protocol, cast -from configs import dify_config from services.app_definition_query_service import AppSiteConfiguration -from services.feature_service import FeatureService +from services.entities.feature_entities import FeatureModel from services.file_service import FileService @@ -50,16 +49,20 @@ def __init__( *, runtime: WebAppRuntimeQuery, file_service: FileService, + workspace_features: Callable[[str], FeatureModel], + files_url: str, ) -> None: self._runtime = runtime self._file_service = file_service + self._workspace_features = workspace_features + self._files_url = files_url def get_bootstrap(self, app_id: str) -> WebAppBootstrap: record = self._runtime.get_runtime_record(app_id) if record is None or record.tenant_status == _ARCHIVED_TENANT_STATUS: raise WebAppRuntimeUnavailableError("Site not found") - features = FeatureService.get_features(record.tenant_id, exclude_vector_space=True) + features = self._workspace_features(record.tenant_id) mode = self._runtime.resolve_compatible_app_mode(app_id) if mode is None: raise WebAppRuntimeUnavailableError("Site not found") @@ -83,7 +86,7 @@ def get_bootstrap(self, app_id: str) -> WebAppBootstrap: else {} ) replace_webapp_logo = ( - f"{dify_config.FILES_URL}/files/workspaces/{record.tenant_id}/webapp-logo" + f"{self._files_url}/files/workspaces/{record.tenant_id}/webapp-logo" if tenant_custom_config.get("replace_webapp_logo") else None ) diff --git a/api/tests/unit_tests/services/test_web_app_runtime_query_service.py b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py index cfe6ae539d7132..7be913575302f7 100644 --- a/api/tests/unit_tests/services/test_web_app_runtime_query_service.py +++ b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py @@ -1,9 +1,7 @@ -from collections.abc import Iterator -from unittest.mock import MagicMock, create_autospec, patch +from unittest.mock import MagicMock, create_autospec import pytest -from configs import dify_config from services.app_definition_query_service import AppSiteConfiguration from services.entities.feature_entities import FeatureModel from services.file_service import FileService @@ -18,16 +16,9 @@ _FILES_URL = "https://files.example.com" -@pytest.fixture(autouse=True) -def workspace_features() -> Iterator[MagicMock]: - with ( - patch.object(dify_config, "FILES_URL", _FILES_URL), - patch( - "services.web_app_runtime_query_service.FeatureService.get_features", - return_value=FeatureModel(), - ) as get_features, - ): - yield get_features +@pytest.fixture +def workspace_features() -> MagicMock: + return MagicMock(return_value=FeatureModel()) def _site_configuration() -> AppSiteConfiguration: @@ -70,13 +61,18 @@ def _service( runtime: MagicMock, *, file_service: MagicMock | None = None, + workspace_features: MagicMock | None = None, ) -> WebAppRuntimeQueryService: if file_service is None: file_service = MagicMock(spec=FileService) file_service.get_icon_url.return_value = None + if workspace_features is None: + workspace_features = MagicMock(return_value=FeatureModel()) return WebAppRuntimeQueryService( runtime=runtime, file_service=file_service, + workspace_features=workspace_features, + files_url=_FILES_URL, ) @@ -114,7 +110,11 @@ def test_get_bootstrap_applies_feature_and_branding_policy_after_record_load( file_service = MagicMock(spec=FileService) file_service.get_icon_url.side_effect = lambda *_args, **_kwargs: events.append("icon") or "https://icon" - result = _service(runtime, file_service=file_service).get_bootstrap("app-1") + result = _service( + runtime, + file_service=file_service, + workspace_features=workspace_features, + ).get_bootstrap("app-1") assert result == WebAppBootstrap( app_id="app-1", @@ -134,7 +134,7 @@ def test_get_bootstrap_applies_feature_and_branding_policy_after_record_load( }, ) assert events == ["record", "features", "mode", "icon"] - workspace_features.assert_called_once_with("tenant-1", exclude_vector_space=True) + workspace_features.assert_called_once_with("tenant-1") file_service.get_icon_url.assert_called_once_with("file-1", "tenant-1") @@ -148,7 +148,7 @@ def test_get_bootstrap_skips_legacy_custom_config_when_branding_is_not_allowed( workspace_features.return_value = FeatureModel(can_replace_logo=False) - result = _service(runtime).get_bootstrap("app-1") + result = _service(runtime, workspace_features=workspace_features).get_bootstrap("app-1") assert result.site == {**record.site._asdict(), "icon_url": None} assert result.can_replace_logo is False From 6eb329fd6ff73e005bb36ff4b50224c9be23b823 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 20 Aug 2026 12:03:15 +0800 Subject: [PATCH 3/3] refactor(api): load web app runtime mode in one session --- .../app_definition_query_repository.py | 13 +++++-------- api/services/web_app_runtime_query_service.py | 8 ++------ .../test_app_definition_query_repository.py | 17 +++++++++++++---- .../test_web_app_runtime_query_service.py | 17 +++-------------- 4 files changed, 23 insertions(+), 32 deletions(-) diff --git a/api/repositories/app_definition_query_repository.py b/api/repositories/app_definition_query_repository.py index 21d1f9729b5e76..0907040e787eb4 100644 --- a/api/repositories/app_definition_query_repository.py +++ b/api/repositories/app_definition_query_repository.py @@ -9,7 +9,7 @@ from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError -from models.account import Tenant +from models.account import Tenant, TenantStatus from models.agent import AgentConfigSnapshot from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config @@ -198,9 +198,13 @@ def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: plan = tenant.plan tenant_status = tenant.status.value tenant_custom_config_json = tenant.custom_config + mode = AppMode.value_of(app.mode).value + if tenant.status != TenantStatus.ARCHIVE: + mode = AppMode.value_of(app.mode_compatible_with_agent_with_session(session=session)).value return WebAppRuntimeRecord( app_id=app_id, tenant_id=tenant_id, + mode=mode, enable_site=enable_site, site=site_configuration, plan=plan, @@ -208,13 +212,6 @@ def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: tenant_custom_config_json=tenant_custom_config_json, ) - def resolve_compatible_app_mode(self, app_id: str) -> str | None: - with self._session_factory() as session: - app = session.get(App, app_id) - if app is None: - return None - return AppMode.value_of(app.mode_compatible_with_agent_with_session(session=session)).value - @staticmethod def _get_tools(session: Session, app: App) -> list[dict[str, Any]]: if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: diff --git a/api/services/web_app_runtime_query_service.py b/api/services/web_app_runtime_query_service.py index c96009e16ed81c..559b0685d85468 100644 --- a/api/services/web_app_runtime_query_service.py +++ b/api/services/web_app_runtime_query_service.py @@ -12,6 +12,7 @@ class WebAppRuntimeRecord(NamedTuple): app_id: str tenant_id: str + mode: str enable_site: bool site: AppSiteConfiguration plan: str @@ -33,8 +34,6 @@ class WebAppBootstrap(NamedTuple): class WebAppRuntimeQuery(Protocol): def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: ... - def resolve_compatible_app_mode(self, app_id: str) -> str | None: ... - class WebAppRuntimeUnavailableError(ValueError): """Raised when the admitted Web app can no longer be bootstrapped.""" @@ -63,9 +62,6 @@ def get_bootstrap(self, app_id: str) -> WebAppBootstrap: raise WebAppRuntimeUnavailableError("Site not found") features = self._workspace_features(record.tenant_id) - mode = self._runtime.resolve_compatible_app_mode(app_id) - if mode is None: - raise WebAppRuntimeUnavailableError("Site not found") site_icon_url = ( self._file_service.get_icon_url(record.site.icon, record.tenant_id) if record.site.icon_type == "image" and record.site.icon @@ -97,7 +93,7 @@ def get_bootstrap(self, app_id: str) -> WebAppBootstrap: return WebAppBootstrap( app_id=record.app_id, - mode=mode, + mode=record.mode, enable_site=record.enable_site, site=site, plan=record.plan, diff --git a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py index 97d0bd3378553f..cc9c851bddf519 100644 --- a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py +++ b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py @@ -348,15 +348,24 @@ def test_get_site_configuration_maps_site_fields(sqlite_session_factory: session ) -def test_get_runtime_record_maps_app_tenant_and_site( +@pytest.mark.parametrize( + ("tenant_status", "expected_mode"), + [ + (TenantStatus.NORMAL, AppMode.AGENT_CHAT.value), + (TenantStatus.ARCHIVE, AppMode.CHAT.value), + ], +) +def test_get_runtime_record_maps_app_tenant_site_and_compatible_mode( sqlite_session_factory: sessionmaker[Session], + tenant_status: TenantStatus, + expected_mode: str, ) -> None: tenant_custom_config = '{"remove_webapp_brand":true,"replace_webapp_logo":"logo-file"}' with sqlite_session_factory.begin() as session: tenant = Tenant( name="Test Tenant", plan="pro", - status=TenantStatus.NORMAL, + status=tenant_status, custom_config=tenant_custom_config, ) tenant.id = _TENANT_ID @@ -391,6 +400,7 @@ def test_get_runtime_record_maps_app_tenant_and_site( assert repository.get_runtime_record(_APP_ID) == WebAppRuntimeRecord( app_id=_APP_ID, tenant_id=_TENANT_ID, + mode=expected_mode, enable_site=True, site=AppSiteConfiguration( title="Test Site", @@ -410,10 +420,9 @@ def test_get_runtime_record_maps_app_tenant_and_site( use_icon_as_answer_icon=False, ), plan="pro", - tenant_status=TenantStatus.NORMAL.value, + tenant_status=tenant_status.value, tenant_custom_config_json=tenant_custom_config, ) - assert repository.resolve_compatible_app_mode(_APP_ID) == AppMode.AGENT_CHAT.value def test_get_runtime_record_returns_none_for_missing_app( diff --git a/api/tests/unit_tests/services/test_web_app_runtime_query_service.py b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py index 7be913575302f7..5464c33ec91ffe 100644 --- a/api/tests/unit_tests/services/test_web_app_runtime_query_service.py +++ b/api/tests/unit_tests/services/test_web_app_runtime_query_service.py @@ -43,12 +43,14 @@ def _site_configuration() -> AppSiteConfiguration: def _runtime_record( *, + mode: str = "agent-chat", tenant_status: str = "normal", tenant_custom_config_json: str | None = '{"remove_webapp_brand":true,"replace_webapp_logo":"file-2"}', ) -> WebAppRuntimeRecord: return WebAppRuntimeRecord( app_id="app-1", tenant_id="tenant-1", + mode=mode, enable_site=True, site=_site_configuration(), plan="pro", @@ -84,17 +86,6 @@ def test_get_bootstrap_rejects_unavailable_runtime(record: WebAppRuntimeRecord | with pytest.raises(WebAppRuntimeUnavailableError, match="Site not found"): _service(runtime).get_bootstrap("app-1") - runtime.resolve_compatible_app_mode.assert_not_called() - - -def test_get_bootstrap_rejects_missing_compatible_mode() -> None: - runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) - runtime.get_runtime_record.return_value = _runtime_record() - runtime.resolve_compatible_app_mode.return_value = None - - with pytest.raises(WebAppRuntimeUnavailableError, match="Site not found"): - _service(runtime).get_bootstrap("app-1") - def test_get_bootstrap_applies_feature_and_branding_policy_after_record_load( workspace_features: MagicMock, @@ -105,7 +96,6 @@ def test_get_bootstrap_applies_feature_and_branding_policy_after_record_load( features.billing.enabled = True events: list[str] = [] runtime.get_runtime_record.side_effect = lambda _app_id: events.append("record") or record - runtime.resolve_compatible_app_mode.side_effect = lambda _app_id: events.append("mode") or "agent-chat" workspace_features.side_effect = lambda _tenant_id, **_kwargs: events.append("features") or features file_service = MagicMock(spec=FileService) file_service.get_icon_url.side_effect = lambda *_args, **_kwargs: events.append("icon") or "https://icon" @@ -133,7 +123,7 @@ def test_get_bootstrap_applies_feature_and_branding_policy_after_record_load( "replace_webapp_logo": "https://files.example.com/files/workspaces/tenant-1/webapp-logo", }, ) - assert events == ["record", "features", "mode", "icon"] + assert events == ["record", "features", "icon"] workspace_features.assert_called_once_with("tenant-1") file_service.get_icon_url.assert_called_once_with("file-1", "tenant-1") @@ -144,7 +134,6 @@ def test_get_bootstrap_skips_legacy_custom_config_when_branding_is_not_allowed( runtime: MagicMock = create_autospec(WebAppRuntimeQuery, instance=True, spec_set=True) record = _runtime_record(tenant_custom_config_json="not-json") runtime.get_runtime_record.return_value = record - runtime.resolve_compatible_app_mode.return_value = "chat" workspace_features.return_value = FeatureModel(can_replace_logo=False)