refactor(api): reuse app definition queries for service API site

This commit is contained in:
hjlarry 2026-08-14 23:40:41 +08:00
parent dfac3e524e
commit 6c5ff9be1a
8 changed files with 185 additions and 124 deletions

View File

@ -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)

View File

@ -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}:

View File

@ -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

View File

@ -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)

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -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")