From 6c5ff9be1acd64e328efffa2d3698bb4e75f55f6 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 14 Aug 2026 23:40:41 +0800 Subject: [PATCH] refactor(api): reuse app definition queries for service API site --- api/controllers/service_api/app/site.py | 22 ++-- .../app_definition_query_repository.py | 27 ++++- api/services/app_definition_query_service.py | 25 +++++ .../controllers/service_api/test_site.py | 106 ------------------ .../pyrefly.toml | 1 - .../controllers/service_api/app/test_app.py | 58 ++++++++++ .../test_app_definition_query_repository.py | 62 +++++++++- .../test_app_definition_query_service.py | 8 ++ 8 files changed, 185 insertions(+), 124 deletions(-) delete mode 100644 api/tests/test_containers_integration_tests/controllers/service_api/test_site.py diff --git a/api/controllers/service_api/app/site.py b/api/controllers/service_api/app/site.py index 35098ca1367..c6bc0313f8c 100644 --- a/api/controllers/service_api/app/site.py +++ b/api/controllers/service_api/app/site.py @@ -1,14 +1,14 @@ from flask_restx import Resource -from sqlalchemy import select from werkzeug.exceptions import Forbidden from controllers.common.fields import Site as SiteResponse from controllers.common.schema import register_response_schema_models from controllers.service_api import service_api_ns from controllers.service_api.wraps import validate_app_token -from extensions.ext_database import db -from models.account import TenantStatus -from models.model import App, Site +from extensions.ext_application_services import application_services +from libs.helper import dump_response +from models.model import App +from services.app_definition_query_service import AppDefinitionUnavailableError register_response_schema_models(service_api_ns, SiteResponse) @@ -49,13 +49,9 @@ class AppSiteApi(Resource): Returns the site configuration for the application including theme, icons, and text. """ - site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) + try: + configuration = application_services().app_definitions.get_site_configuration(app_model.id) + except AppDefinitionUnavailableError: + raise Forbidden() from None - if not site: - raise Forbidden() - - assert app_model.tenant - if app_model.tenant.status == TenantStatus.ARCHIVE: - raise Forbidden() - - return SiteResponse.model_validate(site).model_dump(mode="json") + return dump_response(SiteResponse, configuration) diff --git a/api/repositories/app_definition_query_repository.py b/api/repositories/app_definition_query_repository.py index f05db4b654b..45566550600 100644 --- a/api/repositories/app_definition_query_repository.py +++ b/api/repositories/app_definition_query_repository.py @@ -11,13 +11,14 @@ from core.app.apps.agent_app.app_variable_projection import agent_app_variables_ from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from models.agent import AgentConfigSnapshot from models.agent_config_entities import AgentSoulConfig -from models.model import App, AppMode, AppModelConfig, load_annotation_reply_config +from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config from models.tools import ApiToolProvider from models.workflow import Workflow from services.app_definition_query_service import ( AppDefinitionQuery, AppDefinitionSummary, AppParameterConfig, + AppSiteConfiguration, AppToolIconSource, ) @@ -145,6 +146,30 @@ class AppDefinitionQueryRepository(AppDefinitionQuery): author_name=app.author_name_with_session(session=session), ) + @override + def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None: + with self._session_factory() as session: + site = session.scalar(select(Site).where(Site.app_id == app_id).limit(1)) + 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, + ) + @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 823ea646f6b..6ebd367a961 100644 --- a/api/services/app_definition_query_service.py +++ b/api/services/app_definition_query_service.py @@ -28,6 +28,23 @@ class AppDefinitionSummary(NamedTuple): author_name: str | None +class AppSiteConfiguration(NamedTuple): + title: str + chat_color_theme: str | None + chat_color_theme_inverted: bool + icon_type: str | None + icon: str | None + icon_background: str | None + description: str | None + copyright: str | None + privacy_policy: str | None + input_placeholder: str | None + custom_disclaimer: str | None + default_language: str + show_workflow_steps: bool + use_icon_as_answer_icon: bool + + class AppDefinitionQuery(Protocol): def get_published_parameter_config( self, @@ -40,6 +57,8 @@ class AppDefinitionQuery(Protocol): def get_summary(self, app_id: str) -> AppDefinitionSummary | None: ... + def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None: ... + class AppDefinitionUnavailableError(ValueError): """Raised when an app definition is unavailable.""" @@ -111,3 +130,9 @@ class AppDefinitionQueryService: if summary is None: raise AppDefinitionUnavailableError("App not found") return summary + + def get_site_configuration(self, app_id: str) -> AppSiteConfiguration: + configuration = self._definitions.get_site_configuration(app_id) + if configuration is None: + raise AppDefinitionUnavailableError("Site not found") + return configuration diff --git a/api/tests/test_containers_integration_tests/controllers/service_api/test_site.py b/api/tests/test_containers_integration_tests/controllers/service_api/test_site.py deleted file mode 100644 index c1b20cd02ba..00000000000 --- a/api/tests/test_containers_integration_tests/controllers/service_api/test_site.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Testcontainers integration tests for Service API Site controller. -""" - -from __future__ import annotations - -import pytest -from flask import Flask -from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden - -from controllers.service_api.app.site import AppSiteApi -from models.account import Tenant, TenantStatus -from models.model import App, AppMode, Site - - -@pytest.fixture -def app(flask_app_with_containers) -> Flask: - return flask_app_with_containers - - -from inspect import unwrap - - -def _create_tenant(db_session: Session, *, status: TenantStatus = TenantStatus.NORMAL) -> Tenant: - tenant = Tenant(name="service-api-site-tenant", status=status) - db_session.add(tenant) - db_session.commit() - return tenant - - -def _create_app(db_session: Session, tenant_id: str) -> App: - app_model = App( - tenant_id=tenant_id, - mode=AppMode.CHAT, - name="service-api-site-app", - enable_site=True, - enable_api=True, - status="normal", - ) - db_session.add(app_model) - db_session.commit() - return app_model - - -def _create_site(db_session: Session, app_id: str) -> Site: - site = Site( - app_id=app_id, - title="Service API Site", - icon_type="emoji", - icon="robot", - icon_background="#ffffff", - description="Service API test site", - default_language="en-US", - prompt_public=True, - show_workflow_steps=True, - customize_token_strategy="not_allow", - use_icon_as_answer_icon=False, - chat_color_theme="light", - chat_color_theme_inverted=False, - ) - db_session.add(site) - db_session.commit() - return site - - -class TestAppSiteApi: - def test_get_site_success(self, app: Flask, db_session_with_containers: Session) -> None: - tenant = _create_tenant(db_session_with_containers) - app_model = _create_app(db_session_with_containers, tenant.id) - _create_site(db_session_with_containers, app_model.id) - - with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}): - api = AppSiteApi() - response = unwrap(api.get)(api, app_model=app_model) - - assert response["title"] == "Service API Site" - assert response["icon"] == "robot" - assert response["description"] == "Service API test site" - - def test_get_site_not_found(self, app: Flask, db_session_with_containers: Session) -> None: - tenant = _create_tenant(db_session_with_containers) - app_model = _create_app(db_session_with_containers, tenant.id) - - with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}): - api = AppSiteApi() - with pytest.raises(Forbidden): - unwrap(api.get)(api, app_model=app_model) - - def test_get_site_tenant_archived(self, app: Flask, db_session_with_containers: Session) -> None: - tenant = _create_tenant(db_session_with_containers) - app_model = _create_app(db_session_with_containers, tenant.id) - _create_site(db_session_with_containers, app_model.id) - - archived_tenant = db_session_with_containers.get(Tenant, tenant.id) - assert archived_tenant is not None - archived_tenant.status = TenantStatus.ARCHIVE - db_session_with_containers.commit() - - app_model = db_session_with_containers.get(App, app_model.id) - assert app_model is not None - - with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}): - api = AppSiteApi() - with pytest.raises(Forbidden): - unwrap(api.get)(api, app_model=app_model) diff --git a/api/tests/test_containers_integration_tests/pyrefly.toml b/api/tests/test_containers_integration_tests/pyrefly.toml index 6bdd09b3057..cf707c4f947 100644 --- a/api/tests/test_containers_integration_tests/pyrefly.toml +++ b/api/tests/test_containers_integration_tests/pyrefly.toml @@ -16,7 +16,6 @@ project-excludes = [ "controllers/console/test_apikey.py", "controllers/console/workspace/test_workspace_wraps.py", "controllers/service_api/dataset/test_dataset.py", - "controllers/service_api/test_site.py", "controllers/web/test_conversation.py", "controllers/web/test_site.py", "controllers/web/test_wraps.py", 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 69169e9174c..a6ff026f6d5 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 @@ -17,8 +17,10 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.exceptions import Forbidden, Unauthorized from controllers.service_api.app import app as app_controller +from controllers.service_api.app import site as site_controller from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError +from controllers.service_api.app.site import AppSiteApi from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.base import TypeBase @@ -27,6 +29,7 @@ from services.app_definition_query_service import ( AppDefinitionNotPublishedError, AppDefinitionSummary, AppDefinitionUnavailableError, + AppSiteConfiguration, ) @@ -290,6 +293,61 @@ def test_get_info_maps_unavailable_app( AppInfoApi().get() +def test_get_site_configuration_queries_authenticated_app( + flask_app: Flask, + authenticated_controller: AppDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> None: + app_definitions = Mock() + app_definitions.get_site_configuration.return_value = AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=False, + icon_type="emoji", + icon="robot", + icon_background="#ffffff", + description="A test site", + copyright=None, + privacy_policy=None, + input_placeholder="Ask anything", + custom_disclaimer=None, + default_language="en-US", + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + monkeypatch.setattr( + site_controller, + "application_services", + Mock(return_value=SimpleNamespace(app_definitions=app_definitions)), + ) + + with flask_app.test_request_context("/site", headers={"Authorization": "Bearer token"}): + response = AppSiteApi().get() + + app_definitions.get_site_configuration.assert_called_once_with(authenticated_controller.app_id) + assert response["title"] == "Test Site" + assert response["icon"] == "robot" + assert response["icon_url"] is None + + +@pytest.mark.usefixtures("authenticated_controller") +def test_get_site_configuration_maps_missing_site_to_forbidden( + flask_app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + app_definitions = Mock() + app_definitions.get_site_configuration.side_effect = AppDefinitionUnavailableError("Site not found") + monkeypatch.setattr( + site_controller, + "application_services", + Mock(return_value=SimpleNamespace(app_definitions=app_definitions)), + ) + + with flask_app.test_request_context("/site", headers={"Authorization": "Bearer token"}): + with pytest.raises(Forbidden): + AppSiteApi().get() + + @pytest.mark.parametrize("state", ["missing", "disabled", "archived", "ownerless"]) def test_authentication_rejects_empty_or_invisible_database_state( flask_app: Flask, 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 b6b7c529a06..c47943c80df 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 @@ -5,12 +5,17 @@ from sqlalchemy.orm import Session, sessionmaker from core.tools.entities.tool_entities import ApiProviderSchemaType from models.account import Account -from models.enums import TagType -from models.model import App, AppMode, AppModelConfig, Tag, TagBinding +from models.enums import CustomizeTokenStrategy, TagType +from models.model import App, AppMode, AppModelConfig, IconType, Site, Tag, TagBinding from models.tools import ApiToolProvider from models.workflow import Workflow, WorkflowKind, WorkflowType from repositories.app_definition_query_repository import AppDefinitionQueryRepository -from services.app_definition_query_service import AppDefinitionSummary, AppParameterConfig, AppToolIconSource +from services.app_definition_query_service import ( + AppDefinitionSummary, + AppParameterConfig, + AppSiteConfiguration, + AppToolIconSource, +) _APP_ID = "11111111-1111-1111-1111-111111111111" _TENANT_ID = "22222222-2222-2222-2222-222222222222" @@ -290,6 +295,57 @@ def test_get_summary_returns_only_tenant_scoped_app_tags( assert result.author_name is None +def test_get_site_configuration_returns_none_for_missing_site( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = AppDefinitionQueryRepository(session_factory=sqlite_session_factory) + + assert repository.get_site_configuration(_APP_ID) is None + + +def test_get_site_configuration_maps_site_fields(sqlite_session_factory: sessionmaker[Session]) -> None: + with sqlite_session_factory.begin() as session: + site = Site( + app_id=_APP_ID, + title="Test Site", + icon_type=IconType.IMAGE, + icon="11111111-1111-4111-8111-111111111111", + icon_background="#ffffff", + description="A test site", + default_language="en-US", + chat_color_theme="light", + chat_color_theme_inverted=True, + copyright="Copyright", + privacy_policy="Privacy", + input_placeholder="Ask anything", + show_workflow_steps=False, + use_icon_as_answer_icon=True, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + prompt_public=True, + ) + site.custom_disclaimer = "Disclaimer" + session.add(site) + + result = AppDefinitionQueryRepository(session_factory=sqlite_session_factory).get_site_configuration(_APP_ID) + + assert result == AppSiteConfiguration( + title="Test Site", + chat_color_theme="light", + chat_color_theme_inverted=True, + icon_type=IconType.IMAGE.value, + icon="11111111-1111-4111-8111-111111111111", + icon_background="#ffffff", + description="A test site", + copyright="Copyright", + privacy_policy="Privacy", + input_placeholder="Ask anything", + custom_disclaimer="Disclaimer", + default_language="en-US", + show_workflow_steps=False, + use_icon_as_answer_icon=True, + ) + + 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_app_definition_query_service.py b/api/tests/unit_tests/services/test_app_definition_query_service.py index de0ac4c1c65..4a5d69bb15b 100644 --- a/api/tests/unit_tests/services/test_app_definition_query_service.py +++ b/api/tests/unit_tests/services/test_app_definition_query_service.py @@ -145,3 +145,11 @@ def test_get_summary_rejects_missing_app() -> None: with pytest.raises(AppDefinitionUnavailableError, match="App not found"): service.get_summary("missing") + + +def test_get_site_configuration_rejects_missing_site() -> None: + service, definitions = _service() + definitions.get_site_configuration.return_value = None + + with pytest.raises(AppDefinitionUnavailableError, match="Site not found"): + service.get_site_configuration("app-1")