mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(api): include Site settings in App DSL (#42863)
This commit is contained in:
1 parent
0c5f2507d0
commit
6441436b0c
15 files changed
+490
-14
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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,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"
|
||||
Reference in new issue
Block a user