diff --git a/api/services/agent/dsl_entities.py b/api/services/agent/dsl_entities.py index 4231e6c4033..6fa9392c911 100644 --- a/api/services/agent/dsl_entities.py +++ b/api/services/agent/dsl_entities.py @@ -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) diff --git a/api/services/agent/roster_package_entities.py b/api/services/agent/roster_package_entities.py index 20fa86aa920..13c27b3c692 100644 --- a/api/services/agent/roster_package_entities.py +++ b/api/services/agent/roster_package_entities.py @@ -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) diff --git a/api/services/agent/roster_package_exporter.py b/api/services/agent/roster_package_exporter.py index ed2bb631576..2029ecb0560 100644 --- a/api/services/agent/roster_package_exporter.py +++ b/api/services/agent/roster_package_exporter.py @@ -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) diff --git a/api/services/agent/roster_package_importer.py b/api/services/agent/roster_package_importer.py index a7557ddc3a4..a7a01c77c7d 100644 --- a/api/services/agent/roster_package_importer.py +++ b/api/services/agent/roster_package_importer.py @@ -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 diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 5515f3ed170..526e3862bcb 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -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 diff --git a/api/services/app_package_service.py b/api/services/app_package_service.py index f99538e0a76..5d244723f69 100644 --- a/api/services/app_package_service.py +++ b/api/services/app_package_service.py @@ -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"] = { diff --git a/api/services/entities/site_dsl.py b/api/services/entities/site_dsl.py new file mode 100644 index 00000000000..8a5b40c5a34 --- /dev/null +++ b/api/services/entities/site_dsl.py @@ -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) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index ee9c896c067..c740a0d1461 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -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: diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index f1e104c98c1..386aaf13d54 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -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, diff --git a/api/tests/unit_tests/services/agent/test_roster_package_importer.py b/api/tests/unit_tests/services/agent/test_roster_package_importer.py index a46a049e94a..4fde54aa79b 100644 --- a/api/tests/unit_tests/services/agent/test_roster_package_importer.py +++ b/api/tests/unit_tests/services/agent/test_roster_package_importer.py @@ -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 diff --git a/api/tests/unit_tests/services/agent/test_roster_package_service.py b/api/tests/unit_tests/services/agent/test_roster_package_service.py index 657c816565a..fea59db8b78 100644 --- a/api/tests/unit_tests/services/agent/test_roster_package_service.py +++ b/api/tests/unit_tests/services/agent/test_roster_package_service.py @@ -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" diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index cc2ba0a6c20..8280c4b41b0 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -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" diff --git a/api/tests/unit_tests/services/test_app_package_service.py b/api/tests/unit_tests/services/test_app_package_service.py index 159adc9398e..7bab910816b 100644 --- a/api/tests/unit_tests/services/test_app_package_service.py +++ b/api/tests/unit_tests/services/test_app_package_service.py @@ -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: diff --git a/api/tests/unit_tests/services/test_feature_service_site_import.py b/api/tests/unit_tests/services/test_feature_service_site_import.py new file mode 100644 index 00000000000..9415f2d5cc0 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_site_import.py @@ -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) diff --git a/api/tests/unit_tests/services/test_package_icons.py b/api/tests/unit_tests/services/test_package_icons.py index 975c09b7dec..b31bdf0901e 100644 --- a/api/tests/unit_tests/services/test_package_icons.py +++ b/api/tests/unit_tests/services/test_package_icons.py @@ -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"