feat(api): include Site settings in App DSL (#42863)

This commit is contained in:
林玮 (Jade Lin) authored and GitHub committed 2026-09-28 06:17:44 +00:00
1 parent 0c5f2507d0
commit 6441436b0c
15 files changed
+490 -14

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ from models.agent import Agent
from models.agent_config_entities import AgentSoulConfig
from models.model import App
from services.entities.dsl_entities import make_app_dsl
from services.entities.site_dsl import SiteDsl
AGENT_PACKAGE_SCHEMA_VERSION = 1
AGENT_PACKAGE_REF_KEY = "package_ref"
@@ -76,6 +77,7 @@ class AgentAppDsl(BaseModel):
version: str = Field(min_length=1)
kind: Literal["app"]
app: dict[str, Any]
site: SiteDsl | None = None
agent: AgentAppReference
agent_packages: dict[str, AgentPackage]
dependencies: list[PluginDependency] = Field(default_factory=list)
@@ -179,9 +179,9 @@ class RosterAgentPackageManifest(AgentPackageResources):
if set(apps) != {item.path for item in self.apps}:
raise ValueError("app members must match the package app index")
self.validate_packages(app.package for app in apps.values())
validate_icon_references(
self.icons, [metadata for app in apps.values() for metadata in (app.app, app.package.metadata.model_dump())]
)
icon_metadata = [item for app in apps.values() for item in (app.app, app.package.metadata.model_dump())]
icon_metadata.extend(app.site.model_dump() for app in apps.values() if app.site is not None)
validate_icon_references(self.icons, icon_metadata)
@dataclass(kw_only=True)
@@ -49,6 +49,7 @@ from services.agent.roster_package_entities import (
RosterAgentPackageManifest,
)
from services.agent.roster_service import AgentRosterService
from services.entities.site_dsl import SiteDsl
from services.plugin.dependencies_analysis import DependenciesAnalysisService
@@ -136,6 +137,10 @@ class RosterAgentPackageExporter:
)
app = make_agent_app_dsl(app_model, package_ref="agent_1", packages={"agent_1": package}, dependencies=[])
resources.collect_icon(session=session, tenant_id=tenant_id, metadata=app.app)
if (site := app_model.site_with_session(session=session)) is not None:
site_data = SiteDsl.from_site(site).model_dump(mode="json")
resources.collect_icon(session=session, tenant_id=tenant_id, metadata=site_data)
app.site = SiteDsl.model_validate(site_data)
audit = RosterAgentPackageAudit(ref=agent.id)
dependency_ids = extract_agent_soul_dependencies(package.soul)
@@ -41,6 +41,8 @@ from services.app_creation_records import create_installed_app_record, create_si
from services.app_dsl_service import AppDslService
from services.app_service import AppService
from services.entities.dsl_entities import DslImportWarning
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.feature_service import FeatureService
from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE, is_valid_image_icon
logger = logging.getLogger(__name__)
@@ -74,6 +76,10 @@ class RosterAgentPackageImporter:
self._resources.validate(resources=package.manifest, agent_package=agent_package)
try:
# Resolve billing before uploading package members or opening the write transaction.
allow_premium_site_settings = app_dsl.site is None or FeatureService.can_import_premium_site_settings(
tenant_id
)
icons = self._resources.materialize_icons(
archive=package, icons=package.manifest.icons, tenant_id=tenant_id, account_id=account.id
)
@@ -87,6 +93,11 @@ class RosterAgentPackageImporter:
)
if app_metadata.icon_type == "image" and app_metadata.icon in icons:
app_metadata.icon = icons[app_metadata.icon]
site_data = app_dsl.site.model_copy(deep=True) if app_dsl.site is not None else None
if site_data is not None and site_data.icon_type == "image":
site_icon = site_data.icon
if site_icon is not None and site_icon in icons:
site_data = site_data.model_copy(update={"icon": icons[site_icon]})
materialized, skill_warnings = self._resources.materialize(
archive=package,
resources=package.manifest,
@@ -106,6 +117,8 @@ class RosterAgentPackageImporter:
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
site_data=site_data,
allow_premium_site_settings=allow_premium_site_settings,
soul=resolved_soul,
)
except Exception as exc:
@@ -152,6 +165,8 @@ class RosterAgentPackageImporter:
metadata: AgentPackageMetadata,
soul: AgentSoulConfig,
app_metadata: AgentPackageMetadata | None = None,
site_data: SiteDsl | None = None,
allow_premium_site_settings: bool,
) -> tuple[str, str]:
with session_factory.create_session() as session, session.begin():
name = AgentDslService(session).unique_roster_name(tenant_id=tenant_id, requested=metadata.name)
@@ -230,6 +245,17 @@ class RosterAgentPackageImporter:
.values(used=True, used_by=account.id, used_at=naive_utc_now())
)
create_site_record(app=app, account=account, session=session)
if site_data is not None:
site = app.site_with_session(session=session)
if site is None:
raise RuntimeError("Imported App Site is unavailable")
apply_site_dsl(
site=site,
app=app,
data=site_data,
session=session,
allow_premium_settings=allow_premium_site_settings,
)
create_installed_app_record(app=app, session=session)
return app.id, agent.id
+40 -2
View File
@@ -70,8 +70,10 @@ from services.entities.dsl_entities import (
PendingImportOwner,
make_app_dsl,
)
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
from services.feature_service import FeatureService
from services.icon_configuration import (
DEFAULT_ICON,
DEFAULT_ICON_TYPE,
@@ -145,7 +147,7 @@ class AppDslService:
import_app_id: str | None = None,
package: AppImportPackage | None = None,
) -> Import:
"""Import an App DSL, materializing validated archive resources before database writes."""
"""Import an App DSL after checking Site entitlements and staging archive resources."""
self._warnings = []
import_id = str(uuid.uuid4())
@@ -247,6 +249,14 @@ class AppDslService:
error=missing_app_section_error(original_top_level_keys),
)
allow_premium_site_settings = True
if status != ImportStatus.PENDING and data.get("site") is not None:
# Resolve billing before archive uploads or database writes.
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
allow_premium_site_settings = FeatureService.can_import_premium_site_settings(tenant_id)
if package is not None and package.has_resources:
tenant_id = account.current_tenant_id
if tenant_id is None:
@@ -344,6 +354,7 @@ class AppDslService:
icon_background=icon_background,
dependencies=check_dependencies_pending_data,
import_app_id=import_app_id,
allow_premium_site_settings=allow_premium_site_settings,
)
draft_var_srv = WorkflowDraftVariableService(session=self._session)
@@ -411,6 +422,13 @@ class AppDslService:
data = yaml.safe_load(pending_data.yaml_content)
self._warnings = list(pending_data.warnings)
allow_premium_site_settings = True
if data.get("site") is not None:
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
allow_premium_site_settings = FeatureService.can_import_premium_site_settings(tenant_id)
app = None
if pending_data.app_id:
app = self._load_app_for_overwrite(account, pending_data.app_id)
@@ -434,6 +452,7 @@ class AppDslService:
icon_type=pending_data.icon_type,
icon=pending_data.icon,
icon_background=pending_data.icon_background,
allow_premium_site_settings=allow_premium_site_settings,
)
# Delete import info from Redis
@@ -574,6 +593,7 @@ class AppDslService:
icon_background: str | None = None,
dependencies: list[PluginDependency] | None = None,
import_app_id: str | None = None,
allow_premium_site_settings: bool = True,
) -> App:
"""Create a new app or update an existing one."""
app_data = data.get("app", {})
@@ -581,6 +601,7 @@ class AppDslService:
if not app_mode:
raise ValueError("loss app mode")
app_mode = AppMode(app_mode)
site_data = SiteDsl.model_validate(data["site"]) if data.get("site") is not None else None
if app_mode == AppMode.AGENT:
self._ensure_agent_import_permission(account, app=app)
@@ -636,6 +657,18 @@ class AppDslService:
self._session.flush()
app_was_created.send(app, account=account, session=self._session)
if site_data is not None:
site = app.site_with_session(session=self._session)
if site is None:
raise ValueError("App Site is unavailable")
apply_site_dsl(
site=site,
app=app,
data=site_data,
session=self._session,
allow_premium_settings=allow_premium_site_settings,
)
# save dependencies
if dependencies:
self.cache_import_dependencies(app_id=app.id, dependencies=dependencies)
@@ -805,7 +838,7 @@ class AppDslService:
version_id: uuid.UUID | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
) -> AppDslExportData:
"""Load portable data using the caller's transaction, without requesting plugin dependencies."""
"""Load portable App and Site data without requesting plugin dependencies."""
app_mode = AppMode.value_of(app_model.mode)
if app_mode == AppMode.AGENT:
@@ -831,6 +864,11 @@ class AppDslService:
else:
dependencies = cls._append_model_config_export_data(export_data, app_model, session=session)
if (site := app_model.site_with_session(session=session)) is not None:
export_data["site"] = SiteDsl.from_site(site).model_dump(mode="json")
else:
export_data.pop("site", None)
return AppDslExportData(app_model.tenant_id, export_data, dependencies)
@staticmethod
+18 -2
View File
@@ -1,4 +1,4 @@
"""Package App DSLs and their node-bound Agent resources."""
"""Package App DSLs with Site icons and node-bound Agent resources."""
from __future__ import annotations
@@ -37,6 +37,7 @@ from services.agent.roster_package_entities import (
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.dsl_content import DSL_MAX_SIZE
from services.entities.dsl_entities import DslImportWarning
from services.entities.site_dsl import SiteDsl
class AppPackageManifest(BaseModel):
@@ -83,6 +84,8 @@ class PreparedAppPackage(PreparedPackageArchive):
importer.validate(resources=resources, agent_package=self.agents[ref])
mapping = importer.materialize_icons(archive=self, icons=self.icons, tenant_id=tenant_id, account_id=account_id)
metadata = [data["app"], *(agent.metadata for agent in self.agents.values())]
if isinstance(data.get("site"), dict):
metadata.append(data["site"])
for item in metadata:
if isinstance(item, dict):
if item.get("icon_type") == "image" and item.get("icon") in mapping:
@@ -143,9 +146,20 @@ class AppPackageService(RosterAgentPackageReader):
):
raise InvalidRosterAgentPackageError("App package DSL is invalid")
agents = self._validate_agents(app_data, manifest)
site_data = app_data.get("site")
if site_data is not None:
try:
SiteDsl.model_validate(site_data)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("App package Site DSL is invalid") from exc
try:
validate_icon_references(
manifest.icons, [app_data["app"], *(agent.metadata.model_dump() for agent in agents.values())]
manifest.icons,
[
app_data["app"],
*(agent.metadata.model_dump() for agent in agents.values()),
*([site_data] if site_data else []),
],
)
except ValueError as exc:
raise InvalidRosterAgentPackageError("App package icon references are invalid") from exc
@@ -223,6 +237,8 @@ class AppPackageService(RosterAgentPackageReader):
resource_exporter=resources,
)
resources.collect_icon(session=session, tenant_id=app_model.tenant_id, metadata=prepared.data["app"])
if isinstance(prepared.data.get("site"), dict):
resources.collect_icon(session=session, tenant_id=app_model.tenant_id, metadata=prepared.data["site"])
resources.collect_workspace_skills()
if resources.packages:
prepared.data["agent_packages"] = {
+85
View File
@@ -0,0 +1,85 @@
"""Portable, presentation-only Site settings for App DSL exports."""
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from sqlalchemy.orm import Session
from constants.languages import get_valid_language, supported_language
from models.model import App, IconType, Site
from services.icon_configuration import is_valid_image_icon
_SITE_EXPORT_CONTEXT = object()
class SiteDsl(BaseModel):
"""Keep deployment identity and access policy owned by the destination Site.
The allowlist deliberately excludes database and owner ids, the site code,
custom domain, token strategy, public prompt setting, and status. The App's
enable_site flag is also outside this schema. These values identify or
expose the destination deployment, so an import must not replace them.
"""
model_config = ConfigDict(extra="forbid")
title: str | None = Field(default=None, max_length=255)
description: str | None = None
icon_type: IconType | None = None
icon: str | None = None
icon_background: str | None = None
default_language: str | None = Field(default=None, min_length=1, max_length=255)
chat_color_theme: str | None = None
chat_color_theme_inverted: bool | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = Field(default=None, max_length=512)
show_workflow_steps: bool | None = None
use_icon_as_answer_icon: bool | None = None
@classmethod
def from_site(cls, site: Site) -> "SiteDsl":
"""Export persisted Site values as an importable DSL, including legacy locales."""
return cls.model_validate(site, from_attributes=True, context=_SITE_EXPORT_CONTEXT)
@field_validator("default_language")
@classmethod
def validate_language(cls, value: str | None, info: ValidationInfo) -> str | None:
if value is None:
return None
if info.context is _SITE_EXPORT_CONTEXT:
return get_valid_language(value)
return supported_language(value)
@model_validator(mode="after")
def validate_required_values(self) -> "SiteDsl":
required = {
"title",
"default_language",
"chat_color_theme_inverted",
"custom_disclaimer",
"show_workflow_steps",
"use_icon_as_answer_icon",
}
values = self.model_dump(exclude_unset=True)
if any(values[name] is None for name in required & values.keys()):
raise ValueError("Required Site settings cannot be null")
return self
def apply_site_dsl(*, site: Site, app: App, data: SiteDsl, session: Session, allow_premium_settings: bool) -> None:
"""Apply portable Site fields, omitting unavailable paid settings and image ids."""
values = data.model_dump(exclude_unset=True)
if not allow_premium_settings:
# Cloud free workspaces must not retain paid Site settings after import.
values.pop("copyright", None)
values.pop("input_placeholder", None)
icon_type = values.get("icon_type", site.icon_type)
icon = values.get("icon", site.icon)
if {"icon_type", "icon"} & values.keys() and not is_valid_image_icon(
session=session, tenant_id=app.tenant_id, icon_type=icon_type, icon=icon
):
# Standalone YAML cannot carry uploaded image bytes across workspaces.
# Match App DSL imports: discard a foreign image id without a warning.
values.update(icon_type=app.icon_type, icon=app.icon, icon_background=app.icon_background)
for name, value in values.items():
setattr(site, name, value)
+7
View File
@@ -7,6 +7,13 @@ from services.entities import feature_entities
class FeatureService:
@classmethod
def can_import_premium_site_settings(cls, tenant_id: str) -> bool:
"""Keep Cloud-only Web app customization out of free workspace imports."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return True
return cls.get_workspace_plan(tenant_id).is_paid
@classmethod
def get_workspace_plan(cls, tenant_id: str) -> CloudPlan:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
@@ -40,6 +40,7 @@ from services import app_dsl_service
from services.account_service import AccountService, TenantService
from services.agent.dsl_entities import AGENT_PACKAGE_REF_KEY, make_portable_agent_package
from services.agent.dsl_service import AgentDslService
from services.app_creation_records import create_site_record
from services.app_dsl_service import (
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX,
CURRENT_DSL_VERSION,
@@ -104,6 +105,7 @@ def _app_stub(**overrides: Any) -> App:
app = MagicMock(spec=App)
for key, value in (defaults | overrides).items():
object.__setattr__(app, key, value)
app.site_with_session.return_value = None
return app
@@ -1202,6 +1204,9 @@ class TestAppDslService:
assert "source-skill-file-id" not in yaml_content
assert "source-config-file-id" not in yaml_content
mock_external_service_dependencies["app_was_created"].send.side_effect = (
lambda app, *, account, session, **_kwargs: create_site_record(app=app, account=account, session=session)
)
result = AppDslService(db_session_with_containers).import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
@@ -1229,6 +1234,9 @@ class TestAppDslService:
assert imported_app is not None
assert imported_app.enable_site is False
assert imported_app.enable_api is False
imported_site = imported_app.site_with_session(session=db_session_with_containers)
assert imported_site is not None
assert imported_site.title == exported_data["site"]["title"]
draft = db_session_with_containers.scalar(
select(AgentConfigDraft).where(
AgentConfigDraft.agent_id == imported_agent.id,
@@ -55,6 +55,7 @@ from services.agent.roster_package_entities import (
from services.agent.roster_package_importer import RosterAgentPackageImporter
from services.app_dsl_service import AppDslService
from services.app_service import AppService
from services.feature_service import FeatureService
from services.file_service import FileService
from services.plugin.dependencies_analysis import DependenciesAnalysisService
@@ -848,9 +849,18 @@ def test_import_restores_app_and_agent_image_icons(
app = yaml.safe_load(members["app.yaml"])
app["app"].update(icon_type="image", icon="i_000001")
agent_icon = "i_000001" if shared_icon else "i_000002"
site_icon = "i_000001" if shared_icon else "i_000003"
app["site"] = {
"title": "Imported Site",
"icon_type": "image",
"icon": site_icon,
"use_icon_as_answer_icon": True,
"copyright": "Source copyright",
"input_placeholder": "Source placeholder",
}
app["agent_packages"]["agent_1"]["metadata"].update(icon_type="image", icon=agent_icon)
manifest["icons"] = []
for icon_id in sorted({"i_000001", agent_icon}):
for icon_id in sorted({"i_000001", agent_icon, site_icon}):
payload = f"image-{icon_id}".encode()
members[f"{icon_id}.png"] = payload
manifest["icons"].append(
@@ -866,6 +876,7 @@ def test_import_restores_app_and_agent_image_icons(
members["manifest.yaml"] = yaml.safe_dump(manifest).encode()
storage = _MemoryStorage()
monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None)
monkeypatch.setattr(FeatureService, "can_import_premium_site_settings", lambda _tenant_id: False)
importer = RosterAgentPackageImporter(storage_backend=storage)
if duplicate_name:
importer.import_package(source=io.BytesIO(_zip(members)), tenant_id="destination", account=_account())
@@ -873,9 +884,11 @@ def test_import_restores_app_and_agent_image_icons(
with sqlite_session_factory() as session:
imported_app = session.get(App, result.app_id)
agent = session.get(Agent, result.agent_id)
site = session.scalar(select(Site).where(Site.app_id == result.app_id))
assert imported_app is not None
assert agent is not None
for owner, original_id in [(imported_app, "i_000001"), (agent, agent_icon)]:
assert site is not None
for owner, original_id in [(imported_app, "i_000001"), (agent, agent_icon), (site, site_icon)]:
assert owner.icon_type == "image"
upload = session.get(UploadFile, owner.icon)
assert upload is not None
@@ -884,3 +897,8 @@ def test_import_restores_app_and_agent_image_icons(
assert storage.files[upload.key] == members[f"{original_id}.png"]
assert imported_app.name == agent.name == ("Imported Agent import" if duplicate_name else "Imported Agent")
assert (imported_app.icon == agent.icon) is shared_icon
assert site.title == "Imported Site"
assert site.use_icon_as_answer_icon is True
assert (site.icon == imported_app.icon) is shared_icon
assert site.copyright is None
assert site.input_placeholder is None
@@ -28,8 +28,8 @@ from models.agent import (
AgentStatus,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus
from models.model import App, AppMode, IconType
from models.enums import AppStatus, CustomizeTokenStrategy
from models.model import App, AppMode, IconType, Site
from models.skill import AgentSkillBindingSnapshot, Skill, SkillVersion, SkillVersionManifest
from models.tools import ToolFile
from services.agent import roster_package_exporter as roster_package_exporter_module
@@ -899,8 +899,17 @@ def test_export_accepts_legacy_agent_and_preserves_caller_transaction(
created_by="account-1",
updated_by="account-1",
)
assert agent.app_id is not None
site = Site(
app_id=agent.app_id,
title="Agent Site",
default_language="en",
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
icon_type=IconType.EMOJI,
icon="S",
)
sqlite_session.add_all(
[_app("33333333-3333-4333-8333-333333333333"), skill_file, config_file, agent, snapshot, draft]
[_app("33333333-3333-4333-8333-333333333333"), site, skill_file, config_file, agent, snapshot, draft]
)
sqlite_session.commit()
@@ -927,6 +936,8 @@ def test_export_accepts_legacy_agent_and_preserves_caller_transaction(
app_model = sqlite_session.get(App, agent.app_id)
assert app_model is not None
standalone_dsl = yaml.safe_load(AppDslService.export_dsl(app_model, session=sqlite_session))
assert standalone_dsl["site"]["title"] == "Agent Site"
assert standalone_dsl["site"]["default_language"] == "en-US"
with exporter.export(tenant_id="tenant-1", agent_id=agent.id, version_id=None) as exported:
archive_bytes = exported.archive.read()
assert exported.filename == "research-agent.ifpkg"
@@ -935,6 +946,10 @@ def test_export_accepts_legacy_agent_and_preserves_caller_transaction(
manifest_data = yaml.safe_load(archive.read("manifest.yaml"))
app_data = yaml.safe_load(archive.read("app.yaml"))
exported_app = AgentAppDsl.model_validate(app_data)
assert exported_app.site is not None
assert exported_app.site.title == "Agent Site"
assert exported_app.site.default_language == "en-US"
assert exported_app.site.icon == "S"
exported_soul = exported_app.package.soul
assert exported_soul.model is not None
assert exported_soul.model.credential_ref is None
@@ -967,7 +982,7 @@ def test_export_accepts_legacy_agent_and_preserves_caller_transaction(
"sha256": hashlib.sha256(archive.read("app.yaml")).hexdigest(),
}
]
assert set(app_data) == {"version", "kind", "app", "agent", "agent_packages", "dependencies"}
assert set(app_data) == {"version", "kind", "app", "site", "agent", "agent_packages", "dependencies"}
assert app_data["kind"] == "app"
assert app_data["app"]["mode"] == "agent"
assert app_data["app"]["icon"] == "R"
@@ -5,6 +5,7 @@ from unittest.mock import Mock
import pytest
import yaml
from pydantic import ValidationError
from sqlalchemy import event, select
from sqlalchemy.orm import Session, sessionmaker
@@ -12,11 +13,13 @@ from constants.dsl_version import CURRENT_APP_DSL_VERSION
from core.rbac import RBACPermission, RBACResourceScope
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from models import Account, App, AppMode
from models.model import AppModelConfig, AppModelConfigDict, IconType
from models.enums import CustomizeTokenStrategy
from models.model import AppModelConfig, AppModelConfigDict, IconType, Site
from models.workflow import Workflow
from services.agent.dsl_entities import AgentPackage
from services.app_dsl_service import AppDslService, PendingData
from services.entities.dsl_entities import ImportStatus
from services.entities.site_dsl import SiteDsl
from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
from tests.unit_tests.config_override import apply_config_overrides
@@ -482,6 +485,186 @@ def test_create_or_update_app_silently_discards_invalid_image_icon(sqlite_sessio
assert service._warnings == []
@pytest.mark.parametrize("allow_premium_site_settings", [False, True])
def test_create_or_update_app_applies_site_settings_without_changing_access(
sqlite_session: Session, allow_premium_site_settings: bool
) -> None:
app = _app(tenant_id=_TENANT_ID)
site = Site(
app_id=app.id,
title="Original Site",
default_language="en-US",
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
code="destination-code",
copyright="Existing copyright",
input_placeholder="Existing placeholder",
)
sqlite_session.add_all([app, site])
sqlite_session.flush()
service = AppDslService(session=sqlite_session)
service._create_or_update_app(
app=app,
data={
"app": {"mode": AppMode.CHAT.value, "icon_type": "emoji", "icon": "robot"},
"site": {
"title": "Imported Site",
"icon_type": "image",
"icon": "55555555-5555-4555-8555-555555555555",
"use_icon_as_answer_icon": True,
"copyright": "Imported copyright",
"input_placeholder": "Imported placeholder",
},
"model_config": {"model": {}},
},
account=_account(tenant_id=_TENANT_ID),
allow_premium_site_settings=allow_premium_site_settings,
)
assert site.title == "Imported Site"
assert site.use_icon_as_answer_icon is True
assert site.icon_type == app.icon_type
assert site.icon == app.icon
assert service._warnings == []
assert site.code == "destination-code"
assert site.customize_token_strategy == CustomizeTokenStrategy.NOT_ALLOW
assert site.copyright == ("Imported copyright" if allow_premium_site_settings else "Existing copyright")
assert site.input_placeholder == ("Imported placeholder" if allow_premium_site_settings else "Existing placeholder")
def test_create_or_update_app_rejects_null_required_site_setting_before_mutation(unbound_session: Session) -> None:
app = _app()
with pytest.raises(ValueError, match="Required Site settings cannot be null"):
AppDslService(unbound_session)._create_or_update_app(
app=app,
data={"app": {"mode": AppMode.CHAT.value}, "site": {"title": None}},
account=_account(),
)
assert app.name == "Existing app"
def test_import_app_resolves_site_entitlement_before_database_writes(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
service = AppDslService(unbound_session)
create_or_update = Mock(return_value=_app())
entitlement = Mock(return_value=False)
monkeypatch.setattr(service, "_create_or_update_app", create_or_update)
monkeypatch.setattr("services.app_dsl_service.FeatureService.can_import_premium_site_settings", entitlement)
monkeypatch.setattr("services.app_dsl_service.WorkflowDraftVariableService", Mock())
result = service.import_app(
account=_account(),
import_mode="yaml-content",
yaml_content=yaml.safe_dump(
{
"version": CURRENT_APP_DSL_VERSION,
"kind": "app",
"app": {"name": "Imported", "mode": "chat"},
"site": {"copyright": "Source copyright"},
}
),
)
assert result.status == ImportStatus.COMPLETED
entitlement.assert_called_once_with("tenant-1")
assert create_or_update.call_args.kwargs["allow_premium_site_settings"] is False
entitlement.reset_mock()
result = service.import_app(
account=_account(),
import_mode="yaml-content",
yaml_content=yaml.safe_dump(
{"version": CURRENT_APP_DSL_VERSION, "kind": "app", "app": {"name": "Legacy", "mode": "chat"}}
),
)
assert result.status == ImportStatus.COMPLETED
entitlement.assert_not_called()
def test_confirm_import_rechecks_site_entitlement(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None:
pending = PendingData(
tenant_id="tenant-1",
account_id="account-1",
import_mode="yaml-content",
yaml_content=yaml.safe_dump(
{
"version": CURRENT_APP_DSL_VERSION,
"kind": "app",
"app": {"name": "Imported", "mode": "chat"},
"site": {"input_placeholder": "Source placeholder"},
}
),
)
monkeypatch.setattr("services.app_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
monkeypatch.setattr("services.app_dsl_service.redis_client.delete", Mock())
entitlement = Mock(return_value=False)
monkeypatch.setattr("services.app_dsl_service.FeatureService.can_import_premium_site_settings", entitlement)
service = AppDslService(unbound_session)
create_or_update = Mock(return_value=_app())
monkeypatch.setattr(service, "_create_or_update_app", create_or_update)
result = service.confirm_import(import_id="pending-import", account=_account())
assert result.status == ImportStatus.COMPLETED
entitlement.assert_called_once_with("tenant-1")
assert create_or_update.call_args.kwargs["allow_premium_site_settings"] is False
@pytest.mark.parametrize(
("title", "language", "expected_language"),
[("Exported Site", "en-US", "en-US"), ("", "en", "en-US")],
)
def test_load_export_data_includes_site_presentation_settings(
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
title: str,
language: str,
expected_language: str,
) -> None:
app = _app()
site = Site(
app_id=app.id,
title=title,
default_language=language,
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
icon_type=IconType.EMOJI,
icon="🌱",
use_icon_as_answer_icon=True,
code="private-code",
)
sqlite_session.add_all([app, site])
sqlite_session.flush()
monkeypatch.setattr(AppDslService, "_append_model_config_export_data", Mock(return_value=[]))
data = AppDslService.load_export_data(app, session=sqlite_session).data
assert data["site"]["title"] == title
assert data["site"]["default_language"] == expected_language
assert SiteDsl.model_validate(data["site"]).title == title
assert data["site"]["icon"] == "🌱"
assert data["site"]["use_icon_as_answer_icon"] is True
assert (
not {
"id",
"app_id",
"code",
"customize_domain",
"customize_token_strategy",
"prompt_public",
"status",
}
& data["site"].keys()
)
def test_site_dsl_import_rejects_unsupported_language() -> None:
with pytest.raises(ValidationError, match="en is not a valid language"):
SiteDsl.model_validate({"default_language": "en"})
def test_create_or_update_app_flushes_new_model_config_before_signal(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
@@ -791,6 +974,7 @@ def test_export_dsl_preserves_envelope_and_mode_specific_content(
monkeypatch.setattr(AppDslService, "_append_workflow_export_data", Mock(side_effect=append_workflow))
monkeypatch.setattr(AppDslService, "_append_model_config_export_data", Mock(side_effect=append_model))
monkeypatch.setattr(App, "site_with_session", Mock(return_value=None))
data = yaml.safe_load(AppDslService.export_dsl(app, session=unbound_session))
assert data["version"] == CURRENT_APP_DSL_VERSION
assert data["kind"] == "app"
@@ -57,6 +57,7 @@ def test_rejects_unsupported_manifests(overrides: dict[str, object]) -> None:
"kind: app\napp: {mode: agent}",
"kind: app\napp: {mode: unknown}",
"kind: app\napp: {mode: workflow, mode: chat}",
"kind: app\napp: {mode: workflow}\nsite: not-a-map",
],
)
def test_rejects_invalid_or_agent_dsl_in_ordinary_container(dsl: str) -> None:
@@ -0,0 +1,31 @@
from collections.abc import Callable
from unittest.mock import Mock
import pytest
from enums import CloudPlan, DeploymentEdition
from services.feature_service import FeatureService
@pytest.mark.parametrize(
("edition", "plan", "expected"),
[
(DeploymentEdition.COMMUNITY, CloudPlan.SANDBOX, True),
(DeploymentEdition.ENTERPRISE, CloudPlan.SANDBOX, True),
(DeploymentEdition.CLOUD, CloudPlan.SANDBOX, False),
(DeploymentEdition.CLOUD, CloudPlan.PROFESSIONAL, True),
],
)
def test_site_import_entitlement(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
edition: DeploymentEdition,
plan: CloudPlan,
expected: bool,
) -> None:
config_overrides(DEPLOYMENT_EDITION=edition)
get_plan = Mock(return_value=plan)
monkeypatch.setattr(FeatureService, "get_workspace_plan", get_plan)
assert FeatureService.can_import_premium_site_settings("tenant-1") is expected
assert get_plan.call_count == (1 if edition == DeploymentEdition.CLOUD else 0)
@@ -77,6 +77,42 @@ def test_app_icon_export_deduplicates_uploads_and_restores_references(monkeypatc
assert restore.call_args.kwargs["tenant_id"] == "destination"
def test_app_package_restores_independent_site_image_icon(monkeypatch: pytest.MonkeyPatch) -> None:
app_icon_id, site_icon_id = str(uuid4()), str(uuid4())
backend = Mock()
backend.load_stream.side_effect = lambda key: iter([key.encode()])
exporter = AgentPackageResourceExporter(storage_backend=backend)
monkeypatch.setattr(
exporter,
"_upload_files",
Mock(
side_effect=lambda *, file_ids, **_kwargs: {
file_id: SimpleNamespace(key=f"source/{file_id}.png", extension="png") for file_id in file_ids
}
),
)
app = {"mode": "chat", "icon_type": "image", "icon": app_icon_id}
site = {"title": "Site", "icon_type": "image", "icon": site_icon_id}
exporter.collect_icon(session=Mock(), tenant_id="source", metadata=app)
exporter.collect_icon(session=Mock(), tenant_id="source", metadata=site)
with AppPackageService().export(
dsl=yaml.safe_dump({"kind": "app", "app": app, "site": site}), name="App", resources=exporter
) as exported:
prepared = AppPackageService().read_package(exported.archive)
assert prepared is not None
with prepared:
assert len(prepared.icons) == 2
monkeypatch.setattr(
AgentPackageResourceImporter,
"materialize_icons",
Mock(return_value={app["icon"]: "new-app-icon", site["icon"]: "new-site-icon"}),
)
data = yaml.safe_load(prepared.dsl)
prepared.materialize_icons(data=data, tenant_id="destination", account_id="account")
assert data["app"]["icon"] == "new-app-icon"
assert data["site"]["icon"] == "new-site-icon"
def test_icon_export_rejects_unavailable_tenant_upload(monkeypatch: pytest.MonkeyPatch) -> None:
exporter = AgentPackageResourceExporter(storage_backend=Mock())
monkeypatch.setattr(exporter, "_upload_files", Mock(return_value={}))
@@ -136,6 +172,7 @@ def test_roster_export_embeds_agent_icon(monkeypatch: pytest.MonkeyPatch) -> Non
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata, make_agent_app_dsl
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.entities.site_dsl import SiteDsl
backend = Mock()
backend.load_stream.side_effect = lambda _: iter([b"icon"])
@@ -160,8 +197,11 @@ def test_roster_export_embeds_agent_icon(monkeypatch: pytest.MonkeyPatch) -> Non
},
dependencies=[],
)
app.site = SiteDsl(title="Agent Site", icon_type="image", icon="i_000001")
with RosterAgentPackageExporter(storage_backend=backend)._build_archive(app=app, resources=resources) as exported:
with RosterAgentPackageReader().read(exported.archive) as prepared:
assert prepared.apps["app.yaml"].package.metadata.icon == "i_000001"
assert prepared.apps["app.yaml"].site is not None
assert prepared.apps["app.yaml"].site.icon == "i_000001"
assert prepared.manifest.icons[0].path == "i_000001.png"
assert RosterAgentPackageReader().read_member_bytes(prepared, "i_000001.png", max_bytes=4) == b"icon"