feat(web): refine onboarding UI (#37433)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Jingyi
2026-06-15 08:47:15 +00:00
committed by GitHub
co-authored by yyh Joel hjlarry fatelei Asuka Minato autofix-ci[bot] dependabot[bot] Xiyuan Chen gigglewang Yunlu Wen chariri Evan yyh
parent 4b15b0e6a6
commit 9b74df21d0
995 changed files with 35089 additions and 10332 deletions
+2
View File
@@ -11,6 +11,7 @@ from .data_migration import (
migration_data_wizard,
)
from .plugin import (
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,
@@ -49,6 +50,7 @@ from .vector import (
__all__ = [
"add_qdrant_index",
"archive_workflow_runs",
"backfill_plugin_auto_upgrade",
"clean_expired_messages",
"clean_workflow_runs",
"cleanup_orphaned_draft_variables",
+108 -1
View File
@@ -1,10 +1,11 @@
import json
import logging
import time
from typing import Any, cast
import click
from pydantic import TypeAdapter
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.engine import CursorResult
from configs import dify_config
@@ -15,11 +16,13 @@ from core.plugin.plugin_service import PluginService
from core.tools.utils.system_encryption import encrypt_system_params
from extensions.ext_database import db
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from models.tools import ToolOAuthSystemClient
from services.plugin.data_migration import PluginDataMigration
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_migration import PluginMigration
logger = logging.getLogger(__name__)
@@ -402,6 +405,110 @@ def migrate_data_for_plugin():
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
)
if limit is not None:
stmt = stmt.limit(limit)
return stmt
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
yield from db.session.scalars(stmt)
@click.command(
"backfill-plugin-auto-upgrade",
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
)
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
def backfill_plugin_auto_upgrade(
tenant_id: tuple[str, ...],
limit: int | None,
batch_size: int,
dry_run: bool,
):
"""
Backfill historical auto-upgrade strategies after the category column exists.
Missing category rows are created from the tenant's tool/default row. Pure default
strategies become latest for model plugins and fix-only for all other categories.
Tenants with include/exclude plugin IDs are split
by installed plugin category using plugin daemon metadata.
"""
start_at = time.perf_counter()
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
if dry_run:
elapsed = time.perf_counter() - start_at
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
return
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
backfilled_count = 0
created_count = 0
normalized_count = 0
skipped_count = 0
failed_count = 0
for index, current_tenant_id in enumerate(tenant_ids, start=1):
try:
result = PluginAutoUpgradeService.backfill_strategy_categories(
current_tenant_id,
)
except Exception as e:
failed_count += 1
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
continue
if result.created_count > 0:
backfilled_count += 1
created_count += result.created_count
elif not result.normalized:
skipped_count += 1
if result.normalized:
normalized_count += 1
if batch_size > 0 and index % batch_size == 0:
click.echo(
click.style(
f"Processed {index}/{candidate_count} tenants. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={time.perf_counter() - start_at:.2f}s",
fg="yellow",
)
)
elapsed = time.perf_counter() - start_at
click.echo(
click.style(
f"Backfill plugin auto-upgrade strategy categories completed. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={elapsed:.2f}s",
fg="green",
)
)
@click.command("extract-plugins", help="Extract plugins.")
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)
+134 -44
View File
@@ -1,6 +1,7 @@
import logging
import re
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal, cast
@@ -41,12 +42,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
from libs.helper import build_icon_url, to_timestamp
from libs.helper import build_icon_url, dump_response, to_timestamp
from libs.login import login_required
from models import Account, App, DatasetPermissionEnum, Workflow
from models.model import IconType
from services.app_dsl_service import AppDslService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
@@ -73,10 +74,14 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
class AppListQuery(BaseModel):
class AppListBaseQuery(BaseModel):
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter")
sort_by: AppListSortBy = Field(
default="last_modified",
description="Sort apps by last modified, recently created, or earliest created",
)
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
@@ -119,6 +124,14 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in creator_ids.") from exc
class AppListQuery(AppListBaseQuery):
pass
class StarredAppListQuery(AppListBaseQuery):
pass
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
@@ -387,6 +400,7 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | None = None
is_starred: bool = False
@computed_field(return_type=str | None) # type: ignore
@property
@@ -456,12 +470,54 @@ class AppExportResponse(ResponseModel):
data: str
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in apps]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in apps:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in apps:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
AppListQuery,
StarredAppListQuery,
CreateAppPayload,
UpdateAppPayload,
CopyAppPayload,
@@ -521,6 +577,7 @@ class AppListApi(Resource):
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
@@ -534,46 +591,7 @@ class AppListApi(Resource):
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in app_pagination.items]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in app_pagination.items:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == current_tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@@ -609,6 +627,78 @@ class AppListApi(Resource):
return app_detail.model_dump(mode="json"), 201
@console_ns.route("/apps/starred")
class StarredAppListApi(Resource):
@console_ns.doc("list_starred_apps")
@console_ns.doc(description="Get applications starred by the current account")
@console_ns.doc(params=query_params_from_model(StarredAppListQuery))
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
@with_current_user_id
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = StarredAppListParams(
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
is_created_by_me=args.is_created_by_me,
)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params)
if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@console_ns.route("/apps/<uuid:app_id>/star")
class AppStarApi(Resource):
@console_ns.doc("star_app")
@console_ns.doc(description="Star an application for the current account")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def post(self, session: Session, current_user_id: str, app_model: App):
AppService.star_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.doc("unstar_app")
@console_ns.doc(description="Remove the current account's star from an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def delete(self, session: Session, current_user_id: str, app_model: App):
AppService.unstar_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.route("/apps/<uuid:app_id>")
class AppApi(Resource):
@console_ns.doc("get_app_detail")
@@ -628,7 +718,7 @@ class AppApi(Resource):
if FeatureService.get_system_features().webapp_auth.enabled:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
app_model.access_mode = app_setting.access_mode
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@@ -66,6 +66,10 @@ class RecommendedAppListResponse(ResponseModel):
categories: list[str]
class LearnDifyAppListResponse(ResponseModel):
recommended_apps: list[RecommendedAppResponse]
class RecommendedAppDetailResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
@@ -76,10 +80,19 @@ register_schema_models(
RecommendedAppInfoResponse,
RecommendedAppResponse,
RecommendedAppListResponse,
LearnDifyAppListResponse,
)
register_response_schema_models(console_ns, RecommendedAppDetailResponse)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@@ -90,13 +103,7 @@ class RecommendedAppListApi(Resource):
def get(self, current_user: Account):
# language args
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language = args.language
if language and language in languages:
language_prefix = language
elif current_user.interface_language:
language_prefix = current_user.interface_language
else:
language_prefix = languages[0]
language_prefix = _resolve_language(args.language, current_user)
return RecommendedAppListResponse.model_validate(
RecommendedAppService.get_recommended_apps_and_categories(db.session, language_prefix),
@@ -104,6 +111,23 @@ class RecommendedAppListApi(Resource):
).model_dump(mode="json")
@console_ns.route("/explore/apps/learn-dify")
class LearnDifyAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return LearnDifyAppListResponse.model_validate(
RecommendedAppService.get_learn_dify_apps(db.session, language_prefix),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
+273 -83
View File
@@ -1,10 +1,11 @@
import io
from collections.abc import Mapping
from typing import Any, Literal
from datetime import datetime
from typing import Any, Literal, TypedDict
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, ConfigDict, Field, RootModel
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
@@ -26,15 +27,33 @@ from controllers.console.wraps import (
with_current_user,
with_current_user_id,
)
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.provider_ids import ToolProviderID
from services.entities.model_provider_entities import ProviderEntityResponse
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService
from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class ParserList(BaseModel):
@@ -42,6 +61,11 @@ class ParserList(BaseModel):
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class PluginCategoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class ParserLatest(BaseModel):
plugin_ids: list[str]
@@ -100,8 +124,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
class ParserDynamicOptions(BaseModel):
@@ -137,13 +161,64 @@ class PluginAutoUpgradeSettingsPayload(BaseModel):
include_plugins: list[str] = Field(default_factory=list)
class ParserPreferencesChange(BaseModel):
permission: PluginPermissionSettingsPayload
class PluginAutoUpgradeChangeResponse(ResponseModel):
success: bool
message: str | None = None
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
class PluginDeclarationResponse(ResponseModel):
version: str
author: str | None
name: str
description: I18nObject
icon: str
icon_dark: str | None = None
label: I18nObject
category: PluginCategory
created_at: datetime
resource: Mapping[str, Any]
plugins: Mapping[str, list[str] | None]
tags: list[str] = Field(default_factory=list)
repo: str | None = None
verified: bool = False
tool: Mapping[str, Any] | None = None
model: ProviderEntityResponse | None = None
endpoint: Mapping[str, Any] | None = None
agent_strategy: Mapping[str, Any] | None = None
datasource: Mapping[str, Any] | None = None
trigger: Mapping[str, Any] | None = None
meta: Mapping[str, Any]
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserReadme(BaseModel):
@@ -157,6 +232,63 @@ class PluginDebuggingKeyResponse(ResponseModel):
port: int
class PluginCategoryInstalledPluginResponse(ResponseModel):
id: str
name: str
tenant_id: str
plugin_id: str
plugin_unique_identifier: str
endpoints_active: int
endpoints_setups: int
installation_id: str
declaration: PluginDeclarationResponse
runtime_type: str
version: str
created_at: datetime
updated_at: datetime
source: PluginInstallationSource
checksum: str
meta: Mapping[str, Any]
class PluginCategoryBuiltinToolResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
author: str
name: str
label: I18nObject
description: I18nObject
parameters: list[Mapping[str, Any]] | None = None
labels: list[str]
output_schema: Mapping[str, object]
class PluginCategoryBuiltinToolProviderResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
id: str
author: str
name: str
plugin_id: str | None
plugin_unique_identifier: str | None
description: I18nObject
icon: str | Mapping[str, str]
icon_dark: str | Mapping[str, str] | None
label: I18nObject
type: ToolProviderType
team_credentials: Mapping[str, object]
is_team_authorization: bool
allow_delete: bool
tools: list[PluginCategoryBuiltinToolResponse]
labels: list[str]
class PluginCategoryListResponse(ResponseModel):
plugins: list[PluginCategoryInstalledPluginResponse]
builtin_tools: list[PluginCategoryBuiltinToolProviderResponse]
has_more: bool
class PluginDaemonOperationResponse(RootModel[Any]):
root: Any
@@ -200,11 +332,6 @@ class PluginOperationSuccessResponse(ResponseModel):
message: str | None = None
class PluginPreferencesResponse(ResponseModel):
permission: PluginPermissionSettingsPayload
auto_upgrade: PluginAutoUpgradeSettingsPayload
class PluginReadmeResponse(ResponseModel):
readme: str
@@ -212,6 +339,7 @@ class PluginReadmeResponse(ResponseModel):
register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@@ -228,13 +356,21 @@ register_schema_models(
ParserPermissionChange,
ParserDynamicOptions,
ParserDynamicOptionsWithCredentials,
ParserPreferencesChange,
ParserAutoUpgradeChange,
ParserAutoUpgradeFetch,
ParserExcludePlugin,
ParserReadme,
)
register_response_schema_models(
console_ns,
PluginAutoUpgradeChangeResponse,
PluginAutoUpgradeFetchResponse,
PluginAutoUpgradeSettingsResponseModel,
BinaryFileResponse,
PluginCategoryBuiltinToolProviderResponse,
PluginCategoryBuiltinToolResponse,
PluginCategoryInstalledPluginResponse,
PluginCategoryListResponse,
PluginDaemonOperationResponse,
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
@@ -243,7 +379,6 @@ register_response_schema_models(
PluginManifestResponse,
PluginOperationSuccessResponse,
PluginPermissionResponse,
PluginPreferencesResponse,
PluginReadmeResponse,
PluginTaskResponse,
PluginTasksResponse,
@@ -254,12 +389,36 @@ register_response_schema_models(
register_enum_models(
console_ns,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": strategy.strategy_setting,
"upgrade_time_of_day": strategy.upgrade_time_of_day,
"upgrade_mode": strategy.upgrade_mode,
"exclude_plugins": strategy.exclude_plugins,
"include_plugins": strategy.include_plugins,
}
def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
"""
Read the uploaded file and validate its actual size before delegating to the plugin service.
@@ -274,6 +433,33 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
}
builtin_providers = []
for provider in ToolManager.list_hardcoded_providers():
if is_filtered(
include_set=dify_config.POSITION_TOOL_INCLUDES_SET,
exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET,
data=provider,
name_func=lambda provider_controller: provider_controller.entity.identity.name,
):
continue
user_provider = ToolTransformService.builtin_provider_to_user_provider(
provider_controller=provider,
db_provider=db_builtin_providers.get(provider.entity.identity.name),
decrypt_credentials=False,
)
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
builtin_providers.append(user_provider)
return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)]
@console_ns.route("/workspaces/current/plugin/debugging-key")
class PluginDebuggingKeyApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__])
@@ -312,6 +498,41 @@ class PluginListApi(Resource):
return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total})
@console_ns.route("/workspaces/current/plugin/<string:category>/list")
class PluginCategoryListApi(Resource):
@console_ns.doc(params=query_params_from_model(PluginCategoryListQuery))
@console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, category: str):
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
try:
plugin_category = PluginCategory(category)
except ValueError:
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
try:
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
builtin_tools = []
if plugin_category == PluginCategory.Tool:
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
return dump_response(
PluginCategoryListResponse,
{
"plugins": jsonable_encoder(plugins.list),
"builtin_tools": builtin_tools,
"has_more": plugins.has_more,
},
)
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
class PluginListLatestVersionsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
@@ -713,11 +934,13 @@ class PluginChangePermissionApi(Resource):
args = ParserPermissionChange.model_validate(console_ns.payload)
return {
"success": PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
}
set_permission_result = PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/permission/fetch")
@@ -806,10 +1029,10 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/preferences/change")
class PluginChangePreferencesApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@console_ns.route("/workspaces/current/plugin/auto-upgrade/change")
class PluginChangeAutoUpgradeApi(Resource):
@console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -819,38 +1042,17 @@ class PluginChangePreferencesApi(Resource):
if not user.is_admin_or_owner:
raise Forbidden()
args = ParserPreferencesChange.model_validate(console_ns.payload)
permission = args.permission
install_permission = permission.install_permission
debug_permission = permission.debug_permission
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
auto_upgrade = args.auto_upgrade
strategy_setting = auto_upgrade.strategy_setting
upgrade_time_of_day = auto_upgrade.upgrade_time_of_day
upgrade_mode = auto_upgrade.upgrade_mode
exclude_plugins = auto_upgrade.exclude_plugins
include_plugins = auto_upgrade.include_plugins
# set permission
set_permission_result = PluginPermissionService.change_permission(
tenant_id,
install_permission,
debug_permission,
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
# set auto upgrade strategy
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
tenant_id,
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
auto_upgrade.strategy_setting,
auto_upgrade.upgrade_time_of_day,
auto_upgrade.upgrade_mode,
auto_upgrade.exclude_plugins,
auto_upgrade.include_plugins,
category=args.category,
)
if not set_auto_upgrade_strategy_result:
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
@@ -858,49 +1060,35 @@ class PluginChangePreferencesApi(Resource):
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
class PluginFetchPreferencesApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginPreferencesResponse.__name__])
@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch")
class PluginFetchAutoUpgradeApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch))
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
permission = PluginPermissionService.get_permission(tenant_id)
permission_dict = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category)
auto_upgrade_dict = (
_auto_upgrade_settings_to_dict(auto_upgrade)
if auto_upgrade
else _default_auto_upgrade_settings(tenant_id, args.category)
)
if permission:
permission_dict["install_permission"] = permission.install_permission
permission_dict["debug_permission"] = permission.debug_permission
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
auto_upgrade_dict = {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
if auto_upgrade:
auto_upgrade_dict = {
"strategy_setting": auto_upgrade.strategy_setting,
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
"upgrade_mode": auto_upgrade.upgrade_mode,
"exclude_plugins": auto_upgrade.exclude_plugins,
"include_plugins": auto_upgrade.include_plugins,
return jsonable_encoder(
{
"category": args.category,
"auto_upgrade": auto_upgrade_dict,
}
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
)
@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude")
@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude")
class PluginAutoUpgradeExcludePluginApi(Resource):
@console_ns.expect(console_ns.models[ParserExcludePlugin.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -909,7 +1097,9 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
# exclude one single plugin
args = ParserExcludePlugin.model_validate(console_ns.payload)
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)})
return jsonable_encoder(
{"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)}
)
@console_ns.route("/workspaces/current/plugin/readme")
+11 -4
View File
@@ -31,9 +31,9 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import TimestampField, dump_response, to_timestamp
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.login import login_required
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.billing_service import BillingService, SubscriptionPlan
from services.enterprise.enterprise_service import EnterpriseService
@@ -219,6 +219,7 @@ tenants_fields = {
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
@@ -234,7 +235,12 @@ class TenantListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
tenants = TenantService.get_join_tenants(current_user)
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
(tenant, membership)
for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id)
if tenant.status == TenantStatus.NORMAL
]
tenants = [tenant for tenant, _ in tenant_rows]
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
@@ -247,7 +253,7 @@ class TenantListApi(Resource):
if not tenant_plans:
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
for tenant in tenants:
for tenant, membership in tenant_rows:
plan: str = CloudPlan.SANDBOX
if is_saas:
tenant_plan = tenant_plans.get(tenant.id)
@@ -266,6 +272,7 @@ class TenantListApi(Resource):
"name": tenant.name,
"status": tenant.status,
"created_at": tenant.created_at,
"last_opened_at": membership.last_opened_at,
"plan": plan,
"current": tenant.id == current_tenant_id if current_tenant_id else False,
}
@@ -168,6 +168,7 @@ class PluginInstallTask(BasePluginEntity):
class PluginInstallTaskStartResponse(BaseModel):
all_installed: bool = Field(description="Whether all plugins are installed.")
task_id: str = Field(description="The ID of the install task.")
task: PluginInstallTask | None = Field(default=None, description="The install task.")
class PluginVerification(BaseModel):
@@ -206,6 +207,11 @@ class PluginListResponse(BaseModel):
total: int
class PluginListWithoutTotalResponse(BaseModel):
list: list[PluginEntity]
has_more: bool
class PluginDynamicSelectOptionsResponse(BaseModel):
options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.")
+12
View File
@@ -6,6 +6,7 @@ from requests import HTTPError
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
MissingPluginDependency,
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@@ -16,6 +17,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginReadmeResponse,
)
from core.plugin.impl.base import BasePluginClient
@@ -74,6 +76,16 @@ class PluginInstaller(BasePluginClient):
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def list_plugins_by_category(
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/{category.value}/list",
PluginListWithoutTotalResponse,
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def upload_pkg(
self,
tenant_id: str,
+15
View File
@@ -31,6 +31,7 @@ from core.helper.marketplace import download_plugin_pkg
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@@ -41,6 +42,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginModelProviderEntity,
PluginVerification,
)
@@ -437,6 +439,19 @@ class PluginService:
PluginService._reconcile_endpoint_counts(tenant_id, user_id, plugins.list)
return plugins
@staticmethod
def list_by_category(
tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
"""
List plugins in one category with a has-more cursor signal and without calculating total.
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
This keeps pagination usable before category is persisted on installation rows.
"""
manager = PluginInstaller()
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
@staticmethod
def _normalize_endpoint_count(value: object) -> int:
"""Convert daemon endpoint counters to safe non-negative integers.
+2
View File
@@ -5,6 +5,7 @@ def init_app(app: DifyApp):
from commands import (
add_qdrant_index,
archive_workflow_runs,
backfill_plugin_auto_upgrade,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@@ -53,6 +54,7 @@ def init_app(app: DifyApp):
upgrade_db,
fix_app_site_missing,
migrate_data_for_plugin,
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,
@@ -0,0 +1,26 @@
"""add tenant account join last opened at
Revision ID: b7c2d9e8a1f4
Revises: 9f4b7c2d1a80
Create Date: 2026-06-05 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b7c2d9e8a1f4"
down_revision = "9f4b7c2d1a80"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.add_column(sa.Column("last_opened_at", sa.DateTime(), nullable=True))
def downgrade():
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.drop_column("last_opened_at")
@@ -0,0 +1,42 @@
"""add plugin auto upgrade category
Revision ID: f6a7b8c9d012
Revises: b7c2d9e8a1f4
Create Date: 2026-05-15 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6a7b8c9d012"
down_revision = "b7c2d9e8a1f4"
branch_labels = None
depends_on = None
LEGACY_CATEGORY = "tool"
UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy"
UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time"
STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies"
def upgrade():
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.add_column(
sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False)
)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"])
batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"])
def downgrade():
op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'"))
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.drop_index(UPGRADE_TIME_INDEX_NAME)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.drop_column("category")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"])
@@ -0,0 +1,26 @@
"""add learn dify flag to recommended apps
Revision ID: f5e8a9c0d2b3
Revises: f6a7b8c9d012
Create Date: 2026-05-18 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f5e8a9c0d2b3"
down_revision = "f6a7b8c9d012"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False))
def downgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.drop_column("is_learn_dify")
@@ -0,0 +1,38 @@
"""add app stars
Revision ID: c4d5e6f7a8b9
Revises: f5e8a9c0d2b3
Create Date: 2026-06-08 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models.types
# revision identifiers, used by Alembic.
revision = "c4d5e6f7a8b9"
down_revision = "f5e8a9c0d2b3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"app_stars",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name="app_star_pkey"),
sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"),
)
with op.batch_alter_table("app_stars", schema=None) as batch_op:
batch_op.create_index("app_star_tenant_account_idx", ["tenant_id", "account_id"], unique=False)
batch_op.create_index("app_star_app_idx", ["app_id"], unique=False)
def downgrade() -> None:
op.drop_table("app_stars")
+2
View File
@@ -73,6 +73,7 @@ from .model import (
AppMCPServer,
AppMode,
AppModelConfig,
AppStar,
Conversation,
DatasetRetrieverResource,
DifySetup,
@@ -175,6 +176,7 @@ __all__ = [
"AppMCPServer",
"AppMode",
"AppModelConfig",
"AppStar",
"AppTrigger",
"AppTriggerStatus",
"AppTriggerType",
+17 -1
View File
@@ -301,6 +301,7 @@ class TenantAccountJoin(TypeBase):
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.current_timestamp(), nullable=False, init=False, onupdate=func.current_timestamp()
)
last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
class AccountIntegrate(TypeBase):
@@ -389,6 +390,14 @@ class TenantPluginPermission(TypeBase):
class TenantPluginAutoUpgradeStrategy(TypeBase):
class PluginCategory(enum.StrEnum):
TOOL = "tool"
MODEL = "model"
EXTENSION = "extension"
AGENT_STRATEGY = "agent-strategy"
DATASOURCE = "datasource"
TRIGGER = "trigger"
class StrategySetting(enum.StrEnum):
DISABLED = "disabled"
FIX_ONLY = "fix_only"
@@ -402,13 +411,20 @@ class TenantPluginAutoUpgradeStrategy(TypeBase):
__tablename__ = "tenant_plugin_auto_upgrade_strategies"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"),
sa.UniqueConstraint("tenant_id", name="unique_tenant_plugin_auto_upgrade_strategy"),
sa.UniqueConstraint("tenant_id", "category", name="unique_tenant_plugin_auto_upgrade_strategy"),
sa.Index("idx_tenant_plugin_auto_upgrade_strategy_time", "upgrade_time_of_day"),
)
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
category: Mapped[PluginCategory] = mapped_column(
EnumText(PluginCategory, length=32),
nullable=False,
server_default="tool",
default=PluginCategory.TOOL,
)
strategy_setting: Mapped[StrategySetting] = mapped_column(
EnumText(StrategySetting, length=16),
nullable=False,
+31
View File
@@ -397,6 +397,12 @@ class App(Base):
__tablename__ = "apps"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="app_pkey"), sa.Index("app_tenant_id_idx", "tenant_id"))
if TYPE_CHECKING:
# Response-only attributes attached by app list/detail enrichers.
access_mode: str | None
has_draft_trigger: bool
is_starred: bool
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id: Mapped[str] = mapped_column(StringUUID)
name: Mapped[str] = mapped_column(String(255))
@@ -654,6 +660,28 @@ class App(Base):
return None
class AppStar(Base):
"""Account-scoped star marker for apps in a workspace."""
__tablename__ = "app_stars"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="app_star_pkey"),
sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"),
sa.Index("app_star_tenant_account_idx", "tenant_id", "account_id"),
sa.Index("app_star_app_idx", "app_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
@override
def __repr__(self) -> str:
return f"<AppStar app_id={self.app_id} account_id={self.account_id}>"
class AppModelConfig(TypeBase):
__tablename__ = "app_model_configs"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="app_model_config_pkey"), sa.Index("app_app_id_idx", "app_id"))
@@ -907,6 +935,9 @@ class RecommendedApp(TypeBase):
custom_disclaimer: Mapped[str] = mapped_column(LongText, default="")
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
is_learn_dify: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
)
install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
language: Mapped[str] = mapped_column(
String(255),
+350 -59
View File
@@ -531,6 +531,7 @@ Get list of applications with pagination and filtering
| mode | query | App mode filter | No | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all |
| name | query | Filter by app name | No | string |
| page | query | Page number (1-99999) | No | integer, <br>**Default:** 1 |
| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified |
| tag_ids | query | Filter by tag IDs | No | [ string ] |
#### Responses
@@ -600,6 +601,28 @@ Create a new application
| 200 | Import confirmed | **application/json**: [Import](#import)<br> |
| 400 | Import failed | **application/json**: [Import](#import)<br> |
### [GET] /apps/starred
Get applications starred by the current account
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| creator_ids | query | Filter by creator account IDs | No | [ string ] |
| is_created_by_me | query | Filter by creator | No | boolean |
| limit | query | Page size (1-100) | No | integer, <br>**Default:** 20 |
| mode | query | App mode filter | No | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all |
| name | query | Filter by app name | No | string |
| page | query | Page number (1-99999) | No | integer, <br>**Default:** 1 |
| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified |
| tag_ids | query | Filter by tag IDs | No | [ string ] |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AppPagination](#apppagination)<br> |
### [POST] /apps/workflows/online-users
Get workflow online users
@@ -2045,6 +2068,38 @@ Reset access token for application site
| 403 | Insufficient permissions (admin/owner required) | |
| 404 | App or site not found | |
### [DELETE] /apps/{app_id}/star
Remove the current account's star from an application
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 404 | App not found | |
### [POST] /apps/{app_id}/star
Star an application for the current account
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 404 | App not found | |
### [GET] /apps/{app_id}/statistics/average-response-time
Get average response time statistics for an application
@@ -5573,6 +5628,19 @@ Check if dataset is in use
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [RecommendedAppListResponse](#recommendedapplistresponse)<br> |
### [GET] /explore/apps/learn-dify
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| language | query | Language code for recommended app localization | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [LearnDifyAppListResponse](#learndifyapplistresponse)<br> |
### [GET] /explore/apps/{app_id}
#### Parameters
@@ -9391,6 +9459,45 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
### [POST] /workspaces/current/plugin/auto-upgrade/change
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [ParserAutoUpgradeChange](#parserautoupgradechange)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginAutoUpgradeChangeResponse](#pluginautoupgradechangeresponse)<br> |
### [POST] /workspaces/current/plugin/auto-upgrade/exclude
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [ParserExcludePlugin](#parserexcludeplugin)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SuccessResponse](#successresponse)<br> |
### [GET] /workspaces/current/plugin/auto-upgrade/fetch
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| category | query | | Yes | string, <br>**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginAutoUpgradeFetchResponse](#pluginautoupgradefetchresponse)<br> |
### [GET] /workspaces/current/plugin/debugging-key
#### Responses
@@ -9570,39 +9677,6 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginPermissionResponse](#pluginpermissionresponse)<br> |
### [POST] /workspaces/current/plugin/preferences/autoupgrade/exclude
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [ParserExcludePlugin](#parserexcludeplugin)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginOperationSuccessResponse](#pluginoperationsuccessresponse)<br> |
### [POST] /workspaces/current/plugin/preferences/change
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [ParserPreferencesChange](#parserpreferenceschange)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginOperationSuccessResponse](#pluginoperationsuccessresponse)<br> |
### [GET] /workspaces/current/plugin/preferences/fetch
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginPreferencesResponse](#pluginpreferencesresponse)<br> |
### [GET] /workspaces/current/plugin/readme
#### Parameters
@@ -9744,6 +9818,21 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)<br> |
### [GET] /workspaces/current/plugin/{category}/list
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| page | query | Page number | No | integer, <br>**Default:** 1 |
| page_size | query | Page size (1-256) | No | integer, <br>**Default:** 256 |
| category | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginCategoryListResponse](#plugincategorylistresponse)<br> |
### [GET] /workspaces/current/tool-labels
#### Responses
@@ -10641,7 +10730,7 @@ Default namespace
| deprecated | boolean | | No |
| features | [ [ModelFeature](#modelfeature) ] | | No |
| fetch_from | [FetchFrom](#fetchfrom) | | Yes |
| label | [I18nObject](#i18nobject) | | Yes |
| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
| model | string | | Yes |
| model_properties | object | | Yes |
| model_type | [ModelType](#modeltype) | | Yes |
@@ -12167,6 +12256,7 @@ Enum class for api provider schema type.
| mode | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all | App mode filter<br>*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
| name | string | Filter by app name | No |
| page | integer, <br>**Default:** 1 | Page number (1-99999) | No |
| sort_by | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified | Sort apps by last modified, recently created, or earliest created<br>*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No |
| tag_ids | [ string ] | Filter by tag IDs | No |
#### AppMCPServerResponse
@@ -12222,6 +12312,7 @@ AppMCPServer Status Enum
| icon_background | string | | No |
| icon_type | string | | No |
| id | string | | Yes |
| is_starred | boolean | | No |
| max_active_requests | integer | | No |
| mode_compatible_with_agent | string | | Yes |
| name | string | | Yes |
@@ -13050,10 +13141,10 @@ Model class for credential form schema.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| default | string | | No |
| label | [I18nObject](#i18nobject) | | Yes |
| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
| max_length | integer | | No |
| options | [ [FormOption](#formoption) ] | | No |
| placeholder | [I18nObject](#i18nobject) | | No |
| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No |
| required | boolean, <br>**Default:** true | | No |
| show_on | [ [FormShowOnObject](#formshowonobject) ], <br>**Default:** | | No |
| type | [FormType](#formtype) | | Yes |
@@ -14473,8 +14564,8 @@ Enum class for fetch from.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| label | [I18nObject](#i18nobject) | | Yes |
| placeholder | [I18nObject](#i18nobject) | | No |
| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No |
#### FileInfo
@@ -14605,7 +14696,7 @@ Model class for form option.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| label | [I18nObject](#i18nobject) | | Yes |
| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
| show_on | [ [FormShowOnObject](#formshowonobject) ], <br>**Default:** | | No |
| value | string | | Yes |
@@ -14837,6 +14928,8 @@ Model class for i18n object.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| en_US | string | | Yes |
| ja_JP | string | | No |
| pt_BR | string | | No |
| zh_Hans | string | | No |
#### IconInfo
@@ -15098,6 +15191,12 @@ Enum class for large language model mode.
| ---- | ---- | ----------- | -------- |
| LLMMode | string | Enum class for large language model mode. | |
#### LearnDifyAppListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| recommended_apps | [ [RecommendedAppResponse](#recommendedappresponse) ] | | Yes |
#### LegacyEndpointUpdatePayload
| Name | Type | Description | Required |
@@ -15932,8 +16031,8 @@ Model class for parameter rule.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| default | | | No |
| help | [I18nObject](#i18nobject) | | No |
| label | [I18nObject](#i18nobject) | | Yes |
| help | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No |
| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
| max | number | | No |
| min | number | | No |
| name | string | | Yes |
@@ -15983,6 +16082,19 @@ Enum class for parameter type.
| file_name | string | | Yes |
| plugin_unique_identifier | string | | Yes |
#### ParserAutoUpgradeChange
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
#### ParserAutoUpgradeFetch
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [PluginCategory](#plugincategory) | | Yes |
#### ParserCreateCredential
| Name | Type | Description | Required |
@@ -16079,6 +16191,7 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [PluginCategory](#plugincategory) | | Yes |
| plugin_id | string | | Yes |
#### ParserGetCredentials
@@ -16166,8 +16279,8 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_permission | [DebugPermission](#debugpermission) | | Yes |
| install_permission | [InstallPermission](#installpermission) | | Yes |
| debug_permission | [DebugPermission](#debugpermission) | | No |
| install_permission | [InstallPermission](#installpermission) | | No |
#### ParserPluginIdentifierQuery
@@ -16197,13 +16310,6 @@ Enum class for parameter type.
| model | string | | Yes |
| model_type | [ModelType](#modeltype) | | Yes |
#### ParserPreferencesChange
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes |
| permission | [PluginPermissionSettingsPayload](#pluginpermissionsettingspayload) | | Yes |
#### ParserPreferredProviderType
| Name | Type | Description | Required |
@@ -16348,6 +16454,20 @@ Shared permission levels for resources (datasets, credentials, etc.)
| unit | string | | No |
| variable | string | | Yes |
#### PluginAutoUpgradeChangeResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| message | string | | No |
| success | boolean | | Yes |
#### PluginAutoUpgradeFetchResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsResponseModel](#pluginautoupgradesettingsresponsemodel) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
#### PluginAutoUpgradeSettingsPayload
| Name | Type | Description | Required |
@@ -16358,6 +16478,90 @@ Shared permission levels for resources (datasets, credentials, etc.)
| upgrade_mode | [UpgradeMode](#upgrademode) | | No |
| upgrade_time_of_day | integer | | No |
#### PluginAutoUpgradeSettingsResponseModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| exclude_plugins | [ string ] | | Yes |
| include_plugins | [ string ] | | Yes |
| strategy_setting | [StrategySetting](#strategysetting) | | Yes |
| upgrade_mode | [UpgradeMode](#upgrademode) | | Yes |
| upgrade_time_of_day | integer | | Yes |
#### PluginCategory
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginCategory | string | | |
#### PluginCategoryBuiltinToolProviderResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allow_delete | boolean | | Yes |
| author | string | | Yes |
| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes |
| icon | string<br>object | | Yes |
| icon_dark | string<br>object | | Yes |
| id | string | | Yes |
| is_team_authorization | boolean | | Yes |
| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes |
| labels | [ string ] | | Yes |
| name | string | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| team_credentials | object | | Yes |
| tools | [ [PluginCategoryBuiltinToolResponse](#plugincategorybuiltintoolresponse) ] | | Yes |
| type | [ToolProviderType](#toolprovidertype) | | Yes |
#### PluginCategoryBuiltinToolResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | Yes |
| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes |
| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes |
| labels | [ string ] | | Yes |
| name | string | | Yes |
| output_schema | object | | Yes |
| parameters | [ object ] | | No |
#### PluginCategoryInstalledPluginResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| checksum | string | | Yes |
| created_at | dateTime | | Yes |
| declaration | [PluginDeclarationResponse](#plugindeclarationresponse) | | Yes |
| endpoints_active | integer | | Yes |
| endpoints_setups | integer | | Yes |
| id | string | | Yes |
| installation_id | string | | Yes |
| meta | object | | Yes |
| name | string | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| runtime_type | string | | Yes |
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
| tenant_id | string | | Yes |
| updated_at | dateTime | | Yes |
| version | string | | Yes |
#### PluginCategoryListQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| page | integer, <br>**Default:** 1 | Page number | No |
| page_size | integer, <br>**Default:** 256 | Page size (1-256) | No |
#### PluginCategoryListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| builtin_tools | [ [PluginCategoryBuiltinToolProviderResponse](#plugincategorybuiltintoolproviderresponse) ] | | Yes |
| has_more | boolean | | Yes |
| plugins | [ [PluginCategoryInstalledPluginResponse](#plugincategoryinstalledpluginresponse) ] | | Yes |
#### PluginDaemonOperationResponse
| Name | Type | Description | Required |
@@ -16372,6 +16576,32 @@ Shared permission levels for resources (datasets, credentials, etc.)
| key | string | | Yes |
| port | integer | | Yes |
#### PluginDeclarationResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_strategy | object | | No |
| author | string | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
| created_at | dateTime | | Yes |
| datasource | object | | No |
| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes |
| endpoint | object | | No |
| icon | string | | Yes |
| icon_dark | string | | No |
| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes |
| meta | object | | Yes |
| model | [ProviderEntityResponse](#providerentityresponse) | | No |
| name | string | | Yes |
| plugins | object | | Yes |
| repo | string | | No |
| resource | object | | Yes |
| tags | [ string ] | | No |
| tool | object | | No |
| trigger | object | | No |
| verified | boolean | | No |
| version | string | | Yes |
#### PluginDependency
| Name | Type | Description | Required |
@@ -16405,6 +16635,12 @@ Shared permission levels for resources (datasets, credentials, etc.)
| ---- | ---- | ----------- | -------- |
| PluginInstallationScope | string | | |
#### PluginInstallationSource
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginInstallationSource | string | | |
#### PluginInstallationsResponse
| Name | Type | Description | Required |
@@ -16457,13 +16693,6 @@ Shared permission levels for resources (datasets, credentials, etc.)
| debug_permission | [DebugPermission](#debugpermission) | | No |
| install_permission | [InstallPermission](#installpermission) | | No |
#### PluginPreferencesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes |
| permission | [PluginPermissionSettingsPayload](#pluginpermissionsettingspayload) | | Yes |
#### PluginReadmeResponse
| Name | Type | Description | Required |
@@ -16550,14 +16779,35 @@ Model class for provider credential schema.
| error | string | | No |
| result | string, <br>**Available values:** "error", "success" | *Enum:* `"error"`, `"success"` | Yes |
#### ProviderEntityResponse
Runtime provider response with codegen-safe model pricing schemas.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| background | string | | No |
| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes |
| description | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No |
| help | [ProviderHelpEntity](#providerhelpentity) | | No |
| icon_small | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No |
| icon_small_dark | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No |
| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
| model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No |
| models | [ [AIModelEntityResponse](#aimodelentityresponse) ], <br>**Default:** | | No |
| position | object | | No |
| provider | string | | Yes |
| provider_credential_schema | [ProviderCredentialSchema](#providercredentialschema) | | No |
| provider_name | string | | No |
| supported_model_types | [ [ModelType](#modeltype) ] | | Yes |
#### ProviderHelpEntity
Model class for provider help.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| title | [I18nObject](#i18nobject) | | Yes |
| url | [I18nObject](#i18nobject) | | Yes |
| title | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
| url | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes |
#### ProviderModelWithStatusEntity
@@ -17473,6 +17723,19 @@ Query parameters for listing snippet published workflows.
| updated_by | [SimpleAccount](#simpleaccount) | | No |
| version | string | | Yes |
#### StarredAppListQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| creator_ids | [ string ] | Filter by creator account IDs | No |
| is_created_by_me | boolean | Filter by creator | No |
| limit | integer, <br>**Default:** 20 | Page size (1-100) | No |
| mode | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all | App mode filter<br>*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
| name | string | Filter by app name | No |
| page | integer, <br>**Default:** 1 | Page number (1-99999) | No |
| sort_by | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified | Sort apps by last modified, recently created, or earliest created<br>*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No |
| tag_ids | [ string ] | Filter by tag IDs | No |
#### StatisticTimeRangeQuery
| Name | Type | Description | Required |
@@ -17817,6 +18080,14 @@ Tag type
| ---- | ---- | ----------- | -------- |
| ToolProviderOpaqueResponse | | | |
#### ToolProviderType
Enum class for tool provider
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ToolProviderType | string | Enum class for tool provider | |
#### TraceAppConfigResponse
| Name | Type | Description | Required |
@@ -19236,6 +19507,26 @@ Workflow tool configuration
| use_count | integer | | No |
| version | integer | | No |
#### core__tools__entities__common_entities__I18nObject
Model class for i18n object.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| en_US | string | | Yes |
| ja_JP | string | | No |
| pt_BR | string | | No |
| zh_Hans | string | | No |
#### graphon__model_runtime__entities__common_entities__I18nObject
Model class for i18n object.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| en_US | string | | Yes |
| zh_Hans | string | | No |
## FastOpenAPI Preview (OpenAPI 3.1)
### Dify API (FastOpenAPI PoC)
@@ -73,6 +73,7 @@ def check_upgradable_plugin_task():
strategy.upgrade_mode,
strategy.exclude_plugins,
strategy.include_plugins,
strategy.category,
)
# Only sleep if batch_interval_time > 0.0001 AND current batch is not the last one
+14 -9
View File
@@ -70,6 +70,7 @@ from services.errors.account import (
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
from tasks.mail_change_mail_task import (
@@ -263,6 +264,7 @@ class AccountService:
account.set_tenant_id(available_ta.tenant_id)
available_ta.current = True
available_ta.last_opened_at = naive_utc_now()
db.session.commit()
AccountService._refresh_account_last_active(account)
@@ -1167,15 +1169,17 @@ class TenantService:
db.session.add(tenant)
db.session.commit()
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
db.session.add(plugin_upgrade_strategy)
for category in TenantPluginAutoUpgradeStrategy.PluginCategory:
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
category=category,
strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category),
upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id),
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
db.session.add(plugin_upgrade_strategy)
db.session.commit()
tenant.encrypt_public_key = generate_key_pair(tenant.id)
@@ -1447,6 +1451,7 @@ class TenantService:
.values(current=False)
)
tenant_account_join.current = True
tenant_account_join.last_opened_at = naive_utc_now()
# Set the current tenant for the account
account.set_tenant_id(tenant_account_join.tenant_id)
db.session.commit()
+172 -42
View File
@@ -23,7 +23,7 @@ from graphon.model_runtime.entities.model_entities import ModelPropertyKey, Mode
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
from libs.datetime_utils import naive_utc_now
from libs.login import current_user
from models import Account
from models import Account, AppStar
from models.agent import Agent, AgentIconType, AgentScope, AgentSource, AgentStatus
from models.model import App, AppMode, AppModelConfig, IconType, Site
from models.tools import ApiToolProvider
@@ -36,19 +36,29 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t
logger = logging.getLogger(__name__)
AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"]
class AppListParams(BaseModel):
class AppListBaseParams(BaseModel):
page: int = Field(default=1, ge=1)
limit: int = Field(default=20, ge=1, le=100)
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all"
sort_by: AppListSortBy = "last_modified"
name: str | None = None
tag_ids: list[str] | None = None
creator_ids: list[str] | None = None
is_created_by_me: bool | None = None
class AppListParams(AppListBaseParams):
status: str | None = None
openapi_visible: bool = False
class StarredAppListParams(AppListBaseParams):
pass
class CreateAppParams(BaseModel):
name: str = Field(min_length=1)
description: str | None = None
@@ -62,6 +72,83 @@ class CreateAppParams(BaseModel):
class AppService:
@staticmethod
def _build_app_list_filters(
user_id: str, tenant_id: str, params: AppListBaseParams
) -> list[sa.ColumnElement[bool]]:
filters = [App.tenant_id == tenant_id, App.is_universal == False]
if params.mode == "workflow":
filters.append(App.mode == AppMode.WORKFLOW)
elif params.mode == "completion":
filters.append(App.mode == AppMode.COMPLETION)
elif params.mode == "chat":
filters.append(App.mode == AppMode.CHAT)
elif params.mode == "advanced-chat":
filters.append(App.mode == AppMode.ADVANCED_CHAT)
elif params.mode == "agent-chat":
filters.append(App.mode == AppMode.AGENT_CHAT)
elif params.mode == "agent":
filters.append(App.mode == AppMode.AGENT)
if isinstance(params, AppListParams):
if params.status:
filters.append(App.status == params.status)
# OpenAPI surface visibility gate. Pushed into the query so
# `pagination.total` reflects only apps the openapi caller can
# actually reach; post-filtering by enable_api after the page
# arrives would make `total` page-dependent.
if params.openapi_visible:
filters.append(App.enable_api.is_(True))
if params.is_created_by_me:
filters.append(App.created_by == user_id)
if params.creator_ids:
filters.append(App.created_by.in_(params.creator_ids))
if params.name:
from libs.helper import escape_like_pattern
name = params.name[:30]
escaped_name = escape_like_pattern(name)
filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\"))
if params.tag_ids and len(params.tag_ids) > 0:
target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True)
if target_ids and len(target_ids) > 0:
filters.append(App.id.in_(target_ids))
else:
return []
return filters
@staticmethod
def _build_app_list_order_by(sort_by: AppListSortBy) -> sa.ColumnElement[Any]:
return {
"last_modified": App.updated_at.desc(),
"recently_created": App.created_at.desc(),
"earliest_created": App.created_at.asc(),
}[sort_by]
@staticmethod
def get_starred_app_ids(
session: Session | scoped_session,
*,
tenant_id: str,
account_id: str,
app_ids: Sequence[str],
) -> set[str]:
"""Return app IDs starred by this account within the tenant."""
if not app_ids:
return set()
starred_app_ids = session.scalars(
select(AppStar.app_id).where(
AppStar.tenant_id == tenant_id,
AppStar.account_id == account_id,
AppStar.app_id.in_(list(app_ids)),
)
).all()
return set(starred_app_ids)
@staticmethod
def get_app_by_id(
session: Session | scoped_session,
@@ -109,61 +196,104 @@ class AppService:
def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None:
"""
Get app list with pagination
Get app list with pagination, filters, and explicit sort order.
:param user_id: user id
:param tenant_id: tenant id
:param params: query parameters
:return:
"""
filters = [App.tenant_id == tenant_id, App.is_universal == False]
filters = self._build_app_list_filters(user_id, tenant_id, params)
if not filters:
return None
if params.mode == "workflow":
filters.append(App.mode == AppMode.WORKFLOW)
elif params.mode == "completion":
filters.append(App.mode == AppMode.COMPLETION)
elif params.mode == "chat":
filters.append(App.mode == AppMode.CHAT)
elif params.mode == "advanced-chat":
filters.append(App.mode == AppMode.ADVANCED_CHAT)
elif params.mode == "agent-chat":
filters.append(App.mode == AppMode.AGENT_CHAT)
elif params.mode == "agent":
filters.append(App.mode == AppMode.AGENT)
if params.status:
filters.append(App.status == params.status)
# OpenAPI surface visibility gate. Pushed into the query so
# `pagination.total` reflects only apps the openapi caller can
# actually reach — post-filtering by enable_api after the page
# arrives would make `total` page-dependent.
if params.openapi_visible:
filters.append(App.enable_api.is_(True))
if params.is_created_by_me:
filters.append(App.created_by == user_id)
if params.creator_ids:
filters.append(App.created_by.in_(params.creator_ids))
if params.name:
from libs.helper import escape_like_pattern
name = params.name[:30]
escaped_name = escape_like_pattern(name)
filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\"))
if params.tag_ids and len(params.tag_ids) > 0:
target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True)
if target_ids and len(target_ids) > 0:
filters.append(App.id.in_(target_ids))
else:
return None
order_by = self._build_app_list_order_by(params.sort_by)
app_models = db.paginate(
sa.select(App).where(*filters).order_by(App.created_at.desc()),
sa.select(App).where(*filters).order_by(order_by),
page=params.page,
per_page=params.limit,
error_out=False,
)
app_ids = [str(app.id) for app in app_models.items]
starred_app_ids = self.get_starred_app_ids(
db.session,
tenant_id=tenant_id,
account_id=user_id,
app_ids=app_ids,
)
for app in app_models.items:
app.is_starred = str(app.id) in starred_app_ids
return app_models
def get_paginate_starred_apps(
self, user_id: str, tenant_id: str, params: StarredAppListParams
) -> Pagination | None:
"""
Get apps starred by the current account with pagination, filters, and explicit sort order.
"""
filters = self._build_app_list_filters(user_id, tenant_id, params)
if not filters:
return None
order_by = self._build_app_list_order_by(params.sort_by)
app_models = db.paginate(
sa.select(App)
.join(
AppStar,
sa.and_(
AppStar.tenant_id == App.tenant_id,
AppStar.app_id == App.id,
AppStar.account_id == user_id,
),
)
.where(AppStar.tenant_id == tenant_id, *filters)
.order_by(order_by),
page=params.page,
per_page=params.limit,
error_out=False,
)
for app in app_models.items:
app.is_starred = True
return app_models
@staticmethod
def star_app(session: Session, *, app: App, account_id: str) -> None:
"""Create the account's app star if it does not already exist."""
existing_star = session.scalar(
select(AppStar)
.where(
AppStar.tenant_id == app.tenant_id,
AppStar.app_id == app.id,
AppStar.account_id == account_id,
)
.limit(1)
)
if existing_star:
return
session.add(AppStar(tenant_id=app.tenant_id, app_id=app.id, account_id=account_id))
@staticmethod
def unstar_app(session: Session, *, app: App, account_id: str) -> None:
"""Remove the account's app star if present."""
existing_star = session.scalar(
select(AppStar)
.where(
AppStar.tenant_id == app.tenant_id,
AppStar.app_id == app.id,
AppStar.account_id == account_id,
)
.limit(1)
)
if not existing_star:
return
session.delete(existing_star)
def create_app(self, tenant_id: str, params: CreateAppParams, account: Account) -> App:
"""
Create app
@@ -192,6 +192,27 @@ class SimpleProviderEntityResponse(BaseModel):
return self
class ProviderEntityResponse(BaseModel):
"""Runtime provider response with codegen-safe model pricing schemas."""
provider: str
provider_name: str = ""
label: I18nObject
description: I18nObject | None = None
icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
background: str | None = None
help: ProviderHelpEntity | None = None
supported_model_types: Sequence[ModelType]
configurate_methods: list[ConfigurateMethod]
models: list[AIModelEntityResponse] = []
provider_credential_schema: ProviderCredentialSchema | None = None
model_credential_schema: ModelCredentialSchema | None = None
position: dict[str, list[str]] | None = {}
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
class DefaultModelResponse(BaseModel):
"""
Default model entity.
@@ -1,18 +1,295 @@
"""Manage tenant plugin auto-upgrade strategies.
The storage is category-scoped: each tenant can have one strategy per plugin
category. Public mutation helpers require an explicit category so callers do
not accidentally overwrite every plugin type with one workspace-level policy.
"""
import logging
from dataclasses import dataclass
from hashlib import sha256
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from core.plugin.impl.plugin import PluginInstaller
from models.account import TenantPluginAutoUpgradeStrategy
logger = logging.getLogger(__name__)
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
PLUGIN_CATEGORIES = tuple(PluginCategory)
SECONDS_PER_DAY = 24 * 60 * 60
AUTO_UPGRADE_CHECK_SLOT_SECONDS = 15 * 60
AUTO_UPGRADE_CHECK_SLOT_COUNT = SECONDS_PER_DAY // AUTO_UPGRADE_CHECK_SLOT_SECONDS
@dataclass(frozen=True)
class PluginAutoUpgradeBackfillResult:
created_count: int
normalized: bool
class PluginAutoUpgradeService:
@staticmethod
def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None:
with session_factory.create_session() as session:
return session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.limit(1)
def default_strategy_setting_for_category(
category: PluginCategory,
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
if category == PluginCategory.MODEL:
return TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
return TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
@staticmethod
def default_upgrade_time_of_day(tenant_id: str) -> int:
"""Spread default checks across 15-minute aligned slots by tenant."""
hash_input = tenant_id.encode()
slot = int.from_bytes(sha256(hash_input).digest()[:8], "big") % AUTO_UPGRADE_CHECK_SLOT_COUNT
return slot * AUTO_UPGRADE_CHECK_SLOT_SECONDS
@staticmethod
def _coerce_category(category: object) -> PluginCategory | None:
"""Accept daemon enum/string categories and ignore unknown values."""
category_value = getattr(category, "value", category)
if category_value is None:
return None
try:
return PluginCategory(str(category_value))
except ValueError:
return None
@staticmethod
def _get_installed_plugin_categories(tenant_id: str) -> dict[str, PluginCategory]:
"""Build a plugin_id -> category map for splitting legacy include/exclude lists."""
installed_plugins = PluginInstaller().list_plugins(tenant_id)
plugin_categories: dict[str, PluginCategory] = {}
for plugin in installed_plugins:
plugin_category = PluginAutoUpgradeService._coerce_category(plugin.declaration.category)
if plugin_category is not None:
plugin_categories[plugin.plugin_id] = plugin_category
return plugin_categories
@staticmethod
def _filter_plugin_ids_for_category(
plugin_ids: list[str],
category: PluginCategory,
plugin_categories: dict[str, PluginCategory],
) -> list[str]:
return [plugin_id for plugin_id in plugin_ids if plugin_categories.get(plugin_id) == category]
@staticmethod
def _log_unknown_plugin_ids(
tenant_id: str,
field_name: str,
plugin_ids: list[str],
plugin_categories: dict[str, PluginCategory],
) -> None:
unknown_plugin_ids = [plugin_id for plugin_id in plugin_ids if plugin_id not in plugin_categories]
if not unknown_plugin_ids:
return
logger.warning(
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
"tenant_id=%s, field=%s, plugin_ids=%s",
tenant_id,
field_name,
unknown_plugin_ids,
)
@staticmethod
def _has_default_strategy(strategy: TenantPluginAutoUpgradeStrategy) -> bool:
return (
strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
and strategy.upgrade_time_of_day == 0
and strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
and not strategy.exclude_plugins
and not strategy.include_plugins
)
@staticmethod
def _strategy_setting_for_category(
source_strategy: TenantPluginAutoUpgradeStrategy,
category: PluginCategory,
source_has_default_strategy: bool,
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
# Only pure legacy defaults adopt the new model=latest default. User-edited
# strategies keep their original setting across all categories.
if source_has_default_strategy:
return PluginAutoUpgradeService.default_strategy_setting_for_category(category)
return source_strategy.strategy_setting
@staticmethod
def _upgrade_time_of_day_for_category(
tenant_id: str,
source_strategy: TenantPluginAutoUpgradeStrategy,
source_has_default_strategy: bool,
) -> int:
# Pure legacy defaults are spread by tenant so all default rows do not
# concentrate in the same scheduler window. User-edited schedules keep their time.
if source_has_default_strategy:
return PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
return source_strategy.upgrade_time_of_day
@staticmethod
def backfill_strategy_categories(
tenant_id: str,
) -> PluginAutoUpgradeBackfillResult:
"""Create missing category strategies and split include/exclude lists when needed.
The historical row is treated as the workspace-level source strategy.
New category rows copy it first, then plugin lists are narrowed by real
plugin category when the source strategy contains include/exclude IDs.
"""
with session_factory.create_session() as session, session.begin():
strategies = list(
session.scalars(
select(TenantPluginAutoUpgradeStrategy).where(
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id
)
).all()
)
if not strategies:
return PluginAutoUpgradeBackfillResult(created_count=0, normalized=False)
# Schema migration marks the historical workspace-level row as tool.
source_strategy = next(
(strategy for strategy in strategies if strategy.category == PluginCategory.TOOL),
strategies[0],
)
source_has_default_strategy = PluginAutoUpgradeService._has_default_strategy(source_strategy)
strategies_by_category = {strategy.category: strategy for strategy in strategies}
exclude_plugins = source_strategy.exclude_plugins
include_plugins = source_strategy.include_plugins
should_split_plugin_lists = bool(exclude_plugins or include_plugins)
# Query daemon only for tenants that actually customized plugin lists.
plugin_categories = (
PluginAutoUpgradeService._get_installed_plugin_categories(tenant_id)
if should_split_plugin_lists
else {}
)
if should_split_plugin_lists:
PluginAutoUpgradeService._log_unknown_plugin_ids(
tenant_id,
"exclude_plugins",
exclude_plugins,
plugin_categories,
)
PluginAutoUpgradeService._log_unknown_plugin_ids(
tenant_id,
"include_plugins",
include_plugins,
plugin_categories,
)
created_count = 0
for category in PLUGIN_CATEGORIES:
strategy = strategies_by_category.get(category)
if strategy is None:
# Start from the legacy workspace-level behavior before narrowing lists.
strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
category=category,
strategy_setting=PluginAutoUpgradeService._strategy_setting_for_category(
source_strategy, category, source_has_default_strategy
),
upgrade_time_of_day=PluginAutoUpgradeService._upgrade_time_of_day_for_category(
tenant_id, source_strategy, source_has_default_strategy
),
upgrade_mode=source_strategy.upgrade_mode,
exclude_plugins=source_strategy.exclude_plugins.copy(),
include_plugins=source_strategy.include_plugins.copy(),
)
session.add(strategy)
created_count += 1
elif source_has_default_strategy:
strategy.strategy_setting = PluginAutoUpgradeService.default_strategy_setting_for_category(
strategy.category
)
strategy.upgrade_time_of_day = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
if not should_split_plugin_lists:
continue
# Narrow include/exclude lists to the current category after all rows exist.
strategy.exclude_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category(
exclude_plugins,
strategy.category,
plugin_categories,
)
strategy.include_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category(
include_plugins,
strategy.category,
plugin_categories,
)
return PluginAutoUpgradeBackfillResult(created_count=created_count, normalized=should_split_plugin_lists)
@staticmethod
def _get_strategy(
session: Session,
tenant_id: str,
category: PluginCategory,
) -> TenantPluginAutoUpgradeStrategy | None:
return session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id,
TenantPluginAutoUpgradeStrategy.category == category,
)
.limit(1)
)
@staticmethod
def get_strategy(
tenant_id: str,
category: PluginCategory,
) -> TenantPluginAutoUpgradeStrategy | None:
with session_factory.create_session() as session:
return PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
@staticmethod
def get_strategies(tenant_id: str) -> list[TenantPluginAutoUpgradeStrategy]:
with session_factory.create_session() as session:
return list(
session.scalars(
select(TenantPluginAutoUpgradeStrategy).where(
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id
)
).all()
)
@staticmethod
def _change_strategy(
session: Session,
tenant_id: str,
category: PluginCategory,
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
upgrade_time_of_day: int,
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
) -> None:
exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
if not exist_strategy:
strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
category=category,
strategy_setting=strategy_setting,
upgrade_time_of_day=upgrade_time_of_day,
upgrade_mode=upgrade_mode,
exclude_plugins=exclude_plugins,
include_plugins=include_plugins,
)
session.add(strategy)
else:
exist_strategy.strategy_setting = strategy_setting
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
exist_strategy.upgrade_mode = upgrade_mode
exist_strategy.exclude_plugins = exclude_plugins
exist_strategy.include_plugins = include_plugins
@staticmethod
def change_strategy(
@@ -22,64 +299,72 @@ class PluginAutoUpgradeService:
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
category: PluginCategory,
) -> bool:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.limit(1)
PluginAutoUpgradeService._change_strategy(
session,
tenant_id=tenant_id,
category=category,
strategy_setting=strategy_setting,
upgrade_time_of_day=upgrade_time_of_day,
upgrade_mode=upgrade_mode,
exclude_plugins=exclude_plugins,
include_plugins=include_plugins,
)
if not exist_strategy:
strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
strategy_setting=strategy_setting,
upgrade_time_of_day=upgrade_time_of_day,
upgrade_mode=upgrade_mode,
exclude_plugins=exclude_plugins,
include_plugins=include_plugins,
)
session.add(strategy)
else:
exist_strategy.strategy_setting = strategy_setting
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
exist_strategy.upgrade_mode = upgrade_mode
exist_strategy.exclude_plugins = exclude_plugins
exist_strategy.include_plugins = include_plugins
return True
@staticmethod
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.limit(1)
def _exclude_plugin(
session: Session,
tenant_id: str,
category: PluginCategory,
plugin_id: str,
) -> None:
"""Remove one plugin from automatic updates for a single category strategy."""
exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
if not exist_strategy:
PluginAutoUpgradeService._change_strategy(
session,
tenant_id,
category,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
0,
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
[plugin_id],
[],
)
if not exist_strategy:
# create for this tenant
PluginAutoUpgradeService.change_strategy(
tenant_id,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
0,
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
[plugin_id],
[],
)
return True
else:
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
if plugin_id not in exist_strategy.exclude_plugins:
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
new_exclude_plugins.append(plugin_id)
exist_strategy.exclude_plugins = new_exclude_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
if plugin_id in exist_strategy.include_plugins:
new_include_plugins = exist_strategy.include_plugins.copy()
new_include_plugins.remove(plugin_id)
exist_strategy.include_plugins = new_include_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
exist_strategy.exclude_plugins = [plugin_id]
else:
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
# In exclude mode, disabling one plugin means adding it to exclude_plugins.
if plugin_id not in exist_strategy.exclude_plugins:
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
new_exclude_plugins.append(plugin_id)
exist_strategy.exclude_plugins = new_exclude_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
# In partial mode, disabling one plugin means removing it from include_plugins.
if plugin_id in exist_strategy.include_plugins:
new_include_plugins = exist_strategy.include_plugins.copy()
new_include_plugins.remove(plugin_id)
exist_strategy.include_plugins = new_include_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
# In all mode, switch to exclude mode so only this plugin is skipped.
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
exist_strategy.exclude_plugins = [plugin_id]
return True
@staticmethod
def exclude_plugin(
tenant_id: str,
plugin_id: str,
category: PluginCategory,
) -> bool:
with session_factory.create_session() as session, session.begin():
PluginAutoUpgradeService._exclude_plugin(
session,
tenant_id,
category,
plugin_id,
)
return True
@@ -1,4 +1,4 @@
from typing import Any, TypedDict, override
from typing import Any, NotRequired, TypedDict, override
from sqlalchemy import select
@@ -22,6 +22,7 @@ class RecommendedAppItemDict(TypedDict):
categories: list[str]
position: int
is_listed: bool
can_trial: NotRequired[bool]
class RecommendedAppsResultDict(TypedDict):
@@ -64,14 +65,47 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
:param language: language
:return:
"""
recommended_apps = db.session.scalars(
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == language)
).all()
recommended_apps = cls._fetch_listed_recommended_apps(language)
if len(recommended_apps) == 0:
recommended_apps = db.session.scalars(
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == languages[0])
).all()
recommended_apps = cls._fetch_listed_recommended_apps(languages[0])
return cls._format_recommended_apps(recommended_apps, language)
@classmethod
def fetch_learn_dify_apps_from_db(cls, language: str) -> RecommendedAppsResultDict:
"""
Fetch listed recommended apps explicitly marked for the Learn Dify section.
:param language: language
:return:
"""
recommended_apps = cls._fetch_listed_recommended_apps(language, is_learn_dify=True)
if len(recommended_apps) == 0 and language != languages[0]:
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], is_learn_dify=True)
return cls._format_recommended_apps(recommended_apps, language)
@classmethod
def _fetch_listed_recommended_apps(
cls, language: str, *, is_learn_dify: bool | None = None
) -> list[RecommendedApp]:
filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language]
if is_learn_dify is not None:
filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify))
return list(db.session.scalars(select(RecommendedApp).where(*filters)).all())
@classmethod
def _format_recommended_apps(
cls, recommended_apps: list[RecommendedApp], language: str
) -> RecommendedAppsResultDict:
"""
Serialize DB recommended app rows into the Explore list response shape.
:param recommended_apps: recommended app rows
:param language: language used for category ordering
:return:
"""
categories = set()
recommended_apps_result: list[RecommendedAppItemDict] = []
+23 -10
View File
@@ -6,6 +6,7 @@ from sqlalchemy.orm import scoped_session
from configs import dify_config
from models.model import AccountTrialAppRecord, TrialApp
from services.feature_service import FeatureService
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
@@ -31,13 +32,24 @@ class RecommendedAppService:
apps = result["recommended_apps"]
for app in apps:
app_id = app["app_id"]
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
if trial_app_model:
app["can_trial"] = True
else:
app["can_trial"] = False
app["can_trial"] = cls._can_trial_app(session, app_id)
return result
@classmethod
def get_learn_dify_apps(cls, session: scoped_session, language: str) -> dict[str, Any]:
"""
Get database-backed recommended apps marked as Learn Dify.
:param language: language
:return:
"""
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language)
if FeatureService.get_system_features().enable_trial_app:
for app in result["recommended_apps"]:
app["can_trial"] = cls._can_trial_app(session, app["app_id"])
return {"recommended_apps": result["recommended_apps"]}
@classmethod
def get_recommend_app_detail(cls, session: scoped_session, app_id: str) -> dict[str, Any] | None:
"""
@@ -52,11 +64,7 @@ class RecommendedAppService:
return None
if FeatureService.get_system_features().enable_trial_app:
app_id = result["id"]
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
if trial_app_model:
result["can_trial"] = True
else:
result["can_trial"] = False
result["can_trial"] = cls._can_trial_app(session, app_id)
return result
@classmethod
@@ -77,3 +85,8 @@ class RecommendedAppService:
else:
session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id))
session.commit()
@staticmethod
def _can_trial_app(session: scoped_session, app_id: str) -> bool:
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
return trial_app_model is not None
@@ -7,7 +7,7 @@ import click
from celery import shared_task
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin import PluginInstallation, PluginInstallationSource
from core.plugin.impl.plugin import PluginInstaller
from core.plugin.plugin_service import PluginService
from extensions.ext_redis import redis_client
@@ -15,6 +15,7 @@ from models.account import TenantPluginAutoUpgradeStrategy
logger = logging.getLogger(__name__)
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3
CACHE_REDIS_KEY_PREFIX = "plugin_autoupgrade_check_task:cached_plugin_snapshot:"
CACHE_REDIS_TTL = 60 * 60 # 1 hour
@@ -72,6 +73,25 @@ def marketplace_batch_fetch_plugin_manifests(
return result
def _normalize_category(category: PluginCategory | str | None) -> str | None:
if category is None:
return None
if isinstance(category, PluginCategory):
return category.value
return str(category)
def _plugin_matches_category(plugin: PluginInstallation, category: str | None) -> bool:
"""Return whether an installed plugin should be checked by a category strategy."""
if category is None:
return True
declaration = getattr(plugin, "declaration", None)
plugin_category = getattr(declaration, "category", None)
plugin_category_value = getattr(plugin_category, "value", plugin_category)
return plugin_category_value == category
@shared_task(queue="plugin")
def process_tenant_plugin_autoupgrade_check_task(
tenant_id: str,
@@ -80,13 +100,15 @@ def process_tenant_plugin_autoupgrade_check_task(
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
category: PluginCategory | str | None = None,
):
try:
manager = PluginInstaller()
category_value = _normalize_category(category)
click.echo(
click.style(
f"Checking upgradable plugin for tenant: {tenant_id}",
f"Checking upgradable plugin for tenant: {tenant_id}, category: {category_value or 'all'}",
fg="green",
)
)
@@ -102,7 +124,11 @@ def process_tenant_plugin_autoupgrade_check_task(
all_plugins = manager.list_plugins(tenant_id)
for plugin in all_plugins:
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id in include_plugins:
if (
plugin.source == PluginInstallationSource.Marketplace
and plugin.plugin_id in include_plugins
and _plugin_matches_category(plugin, category_value)
):
plugin_ids.append(
(
plugin.plugin_id,
@@ -117,7 +143,9 @@ def process_tenant_plugin_autoupgrade_check_task(
plugin_ids = [
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
for plugin in all_plugins
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id not in exclude_plugins
if plugin.source == PluginInstallationSource.Marketplace
and plugin.plugin_id not in exclude_plugins
and _plugin_matches_category(plugin, category_value)
]
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
all_plugins = manager.list_plugins(tenant_id)
@@ -125,6 +153,7 @@ def process_tenant_plugin_autoupgrade_check_task(
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
for plugin in all_plugins
if plugin.source == PluginInstallationSource.Marketplace
and _plugin_matches_category(plugin, category_value)
]
if not plugin_ids:
@@ -22,6 +22,7 @@ from models import (
AppDatasetJoin,
AppMCPServer,
AppModelConfig,
AppStar,
AppTrigger,
Conversation,
EndUser,
@@ -64,6 +65,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
_delete_app_mcp_servers(tenant_id, app_id)
_delete_app_api_tokens(tenant_id, app_id)
_delete_installed_apps(tenant_id, app_id)
_delete_app_stars(tenant_id, app_id)
_delete_recommended_apps(tenant_id, app_id)
_delete_app_annotation_data(tenant_id, app_id)
_delete_app_dataset_joins(tenant_id, app_id)
@@ -173,6 +175,18 @@ def _delete_installed_apps(tenant_id: str, app_id: str):
)
def _delete_app_stars(tenant_id: str, app_id: str):
def del_app_star(session, app_star_id: str):
session.execute(delete(AppStar).where(AppStar.id == app_star_id).execution_options(synchronize_session=False))
_delete_records(
"""select id from app_stars where tenant_id=:tenant_id and app_id=:app_id limit 1000""",
{"tenant_id": tenant_id, "app_id": app_id},
del_app_star,
"app star",
)
def _delete_recommended_apps(tenant_id: str, app_id: str):
def del_recommended_app(session, recommended_app_id: str):
session.execute(
@@ -7,6 +7,8 @@ from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermissi
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_permission_service import PluginPermissionService
PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
@pytest.fixture
def tenant(flask_req_ctx):
@@ -71,7 +73,7 @@ class TestPluginPermissionLifecycle:
class TestPluginAutoUpgradeLifecycle:
def test_get_returns_none_for_new_tenant(self, tenant):
assert PluginAutoUpgradeService.get_strategy(tenant) is None
assert PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) is None
def test_change_creates_row(self, tenant):
result = PluginAutoUpgradeService.change_strategy(
@@ -81,10 +83,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
assert result is True
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert strategy.upgrade_time_of_day == 3
@@ -97,6 +100,7 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.change_strategy(
tenant,
@@ -105,9 +109,10 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
exclude_plugins=[],
include_plugins=["plugin-a"],
category=PLUGIN_CATEGORY,
)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert strategy.upgrade_time_of_day == 12
@@ -115,9 +120,9 @@ class TestPluginAutoUpgradeLifecycle:
assert strategy.include_plugins == ["plugin-a"]
def test_exclude_plugin_creates_strategy_when_none_exists(self, tenant):
PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
assert "my-plugin" in strategy.exclude_plugins
@@ -130,10 +135,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=["existing"],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert "existing" in strategy.exclude_plugins
assert "new-plugin" in strategy.exclude_plugins
@@ -146,10 +152,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=["same-plugin"],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.exclude_plugins.count("same-plugin") == 1
@@ -161,10 +168,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
exclude_plugins=[],
include_plugins=["p1", "p2"],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "p1")
PluginAutoUpgradeService.exclude_plugin(tenant, "p1", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert "p1" not in strategy.include_plugins
assert "p2" in strategy.include_plugins
@@ -177,10 +185,11 @@ class TestPluginAutoUpgradeLifecycle:
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin")
PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin", PLUGIN_CATEGORY)
strategy = PluginAutoUpgradeService.get_strategy(tenant)
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
assert "excluded-plugin" in strategy.exclude_plugins
@@ -51,6 +51,7 @@ def _create_recommended_app(
categories: list[str] | None = None,
language: str = "en-US",
is_listed: bool = True,
is_learn_dify: bool = False,
position: int = 1,
) -> RecommendedApp:
rec = RecommendedApp(
@@ -62,6 +63,7 @@ def _create_recommended_app(
categories=[category] if categories is None else categories,
language=language,
is_listed=is_listed,
is_learn_dify=is_learn_dify,
position=position,
)
rec.id = str(uuid4())
@@ -205,6 +207,65 @@ class TestFetchRecommendedAppsFromDb:
app_ids = {r["app_id"] for r in result["recommended_apps"]}
assert app1.id not in app_ids
def test_fetch_learn_dify_apps_uses_flag_not_categories(
self,
flask_app_with_containers,
db_session_with_containers: Session,
):
tenant_id = str(uuid4())
learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
_create_site(db_session_with_containers, app_id=learn_dify_app.id)
_create_recommended_app(
db_session_with_containers,
app_id=learn_dify_app.id,
category="workflow",
categories=["Workflow"],
is_learn_dify=True,
)
category_only_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
_create_site(db_session_with_containers, app_id=category_only_app.id)
_create_recommended_app(
db_session_with_containers,
app_id=category_only_app.id,
category="Learn Dify",
categories=["Learn Dify"],
is_learn_dify=False,
)
db_session_with_containers.expire_all()
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("en-US")
app_ids = {r["app_id"] for r in result["recommended_apps"]}
assert learn_dify_app.id in app_ids
assert category_only_app.id not in app_ids
recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == learn_dify_app.id)
assert recommended_app["categories"] == ["Workflow"]
def test_fetch_learn_dify_apps_falls_back_to_default_language(
self,
flask_app_with_containers,
db_session_with_containers: Session,
):
tenant_id = str(uuid4())
learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
_create_site(db_session_with_containers, app_id=learn_dify_app.id)
_create_recommended_app(
db_session_with_containers,
app_id=learn_dify_app.id,
categories=["Workflow"],
is_learn_dify=True,
language="en-US",
)
db_session_with_containers.expire_all()
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("fr-FR")
app_ids = {r["app_id"] for r in result["recommended_apps"]}
assert learn_dify_app.id in app_ids
class TestFetchRecommendedAppDetailFromDb:
def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
@@ -1,6 +1,8 @@
from datetime import datetime
from unittest.mock import create_autospec, patch
import pytest
import sqlalchemy as sa
from faker import Faker
from pydantic import ValidationError
from sqlalchemy.orm import Session
@@ -245,6 +247,236 @@ class TestAppService:
assert app.tenant_id == tenant.id
assert app.mode == "chat"
def test_get_paginate_apps_sorts_by_modified_and_created_times(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app list sort options for modified time and creation time.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from services.app_service import AppListParams, AppService, CreateAppParams
app_service = AppService()
oldest_created = app_service.create_app(
tenant.id,
CreateAppParams(name="Oldest Created", mode="chat", icon_type="emoji", icon="1"),
account,
)
newest_modified = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Modified", mode="chat", icon_type="emoji", icon="2"),
account,
)
newest_created = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Created", mode="chat", icon_type="emoji", icon="3"),
account,
)
timestamp_by_app_id = {
oldest_created.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)),
newest_modified.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)),
newest_created.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)),
}
for app_id, (created_at, updated_at) in timestamp_by_app_id.items():
db_session_with_containers.execute(
sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at)
)
db_session_with_containers.commit()
last_modified_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")
)
assert last_modified_apps is not None
assert [app.name for app in last_modified_apps.items] == [
"Newest Modified",
"Newest Created",
"Oldest Created",
]
recently_created_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="recently_created")
)
assert recently_created_apps is not None
assert [app.name for app in recently_created_apps.items] == [
"Newest Created",
"Newest Modified",
"Oldest Created",
]
earliest_created_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created")
)
assert earliest_created_apps is not None
assert [app.name for app in earliest_created_apps.items] == [
"Oldest Created",
"Newest Modified",
"Newest Created",
]
def test_get_paginate_apps_marks_starred_apps(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app list marks apps starred by the current account.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from models import AppStar
from services.app_service import AppListParams, AppService, CreateAppParams
app_service = AppService()
starred_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Starred App", mode="chat", icon_type="emoji", icon="1"),
account,
)
unstarred_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="2"),
account,
)
app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id)
app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id)
db_session_with_containers.commit()
star_count = db_session_with_containers.scalar(
sa.select(sa.func.count()).select_from(AppStar).where(AppStar.app_id == starred_app.id)
)
assert star_count == 1
paginated_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")
)
assert paginated_apps is not None
starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items}
assert starred_by_app_id[starred_app.id] is True
assert starred_by_app_id[unstarred_app.id] is False
app_service.unstar_app(db_session_with_containers, app=starred_app, account_id=account.id)
db_session_with_containers.commit()
paginated_apps = app_service.get_paginate_apps(
account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")
)
assert paginated_apps is not None
starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items}
assert starred_by_app_id[starred_app.id] is False
assert starred_by_app_id[unstarred_app.id] is False
def test_get_paginate_starred_apps_returns_only_starred_apps_with_requested_sort(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test starred app list returns only starred apps ordered by requested app sort.
"""
fake = Faker()
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=generate_valid_password(fake),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
from services.app_service import AppService, CreateAppParams, StarredAppListParams
app_service = AppService()
oldest_created_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Oldest Created Starred App", mode="chat", icon_type="emoji", icon="1"),
account,
)
newest_modified_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Modified Starred App", mode="chat", icon_type="emoji", icon="2"),
account,
)
newest_created_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Newest Created Starred App", mode="chat", icon_type="emoji", icon="3"),
account,
)
unstarred_app = app_service.create_app(
tenant.id,
CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="4"),
account,
)
app_service.star_app(db_session_with_containers, app=oldest_created_app, account_id=account.id)
app_service.star_app(db_session_with_containers, app=newest_modified_app, account_id=account.id)
app_service.star_app(db_session_with_containers, app=newest_created_app, account_id=account.id)
timestamp_by_app_id = {
oldest_created_app.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)),
newest_modified_app.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)),
newest_created_app.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)),
unstarred_app.id: (datetime(2026, 1, 5, 10, 0, 0), datetime(2026, 1, 5, 10, 0, 0)),
}
for app_id, (created_at, updated_at) in timestamp_by_app_id.items():
db_session_with_containers.execute(
sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at)
)
db_session_with_containers.commit()
last_modified_apps = app_service.get_paginate_starred_apps(
account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat")
)
assert last_modified_apps is not None
assert [app.name for app in last_modified_apps.items] == [
"Newest Modified Starred App",
"Newest Created Starred App",
"Oldest Created Starred App",
]
assert all(app.is_starred for app in last_modified_apps.items)
assert unstarred_app.id not in {app.id for app in last_modified_apps.items}
recently_created_apps = app_service.get_paginate_starred_apps(
account.id,
tenant.id,
StarredAppListParams(page=1, limit=10, mode="chat", sort_by="recently_created"),
)
assert recently_created_apps is not None
assert [app.name for app in recently_created_apps.items] == [
"Newest Created Starred App",
"Newest Modified Starred App",
"Oldest Created Starred App",
]
earliest_created_apps = app_service.get_paginate_starred_apps(
account.id,
tenant.id,
StarredAppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created"),
)
assert earliest_created_apps is not None
assert [app.name for app in earliest_created_apps.items] == [
"Oldest Created Starred App",
"Newest Modified Starred App",
"Newest Created Starred App",
]
def test_get_paginate_apps_with_filters(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
@@ -263,6 +263,54 @@ class TestRecommendedAppServiceGetDetail:
mock_factory_class.get_recommend_app_factory.assert_called_with(mode)
# ── Pure logic tests: get_learn_dify_apps ──────────────────────────────
class TestRecommendedAppServiceGetLearnDifyApps:
def test_returns_database_learn_dify_apps_without_remote_factory(self, monkeypatch: pytest.MonkeyPatch) -> None:
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_database_retrieval = MagicMock()
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
"recommended_apps": [expected_app],
"categories": ["Workflow"],
}
monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SimpleNamespace(enable_trial_app=False)),
)
factory_mock = MagicMock()
monkeypatch.setattr(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory", factory_mock)
result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US")
assert result == {"recommended_apps": [expected_app]}
mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US")
factory_mock.assert_not_called()
def test_sets_can_trial_when_trial_feature_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_database_retrieval = MagicMock()
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
"recommended_apps": [app],
"categories": ["Workflow"],
}
monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SimpleNamespace(enable_trial_app=True)),
)
can_trial_mock = MagicMock(return_value=True)
monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock)
result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US")
assert result["recommended_apps"][0]["can_trial"] is True
can_trial_mock.assert_called_once_with(db.session, "app-1")
# ── Integration tests: trial app features (real DB) ────────────────────
@@ -79,6 +79,46 @@ class TestRecommendedAppListApi:
assert result == result_data
class TestLearnDifyAppListApi:
def test_get_with_language_param(self, app: Flask):
api = module.LearnDifyAppListApi()
method = unwrap(api.get)
result_data = {"recommended_apps": []}
with (
app.test_request_context("/", query_string={"language": "en-US"}),
patch.object(
module.RecommendedAppService,
"get_learn_dify_apps",
return_value=result_data,
) as service_mock,
):
result = method(api, make_account("fr-FR"))
service_mock.assert_called_once_with(ANY, "en-US")
assert result == result_data
def test_get_fallback_to_user_language(self, app: Flask):
api = module.LearnDifyAppListApi()
method = unwrap(api.get)
result_data = {"recommended_apps": []}
with (
app.test_request_context("/", query_string={"language": "invalid"}),
patch.object(
module.RecommendedAppService,
"get_learn_dify_apps",
return_value=result_data,
) as service_mock,
):
result = method(api, make_account("fr-FR"))
service_mock.assert_called_once_with(ANY, "fr-FR")
assert result == result_data
class TestRecommendedAppApi:
def test_get_success(self, app: Flask):
api = module.RecommendedAppApi()
@@ -144,3 +184,29 @@ class TestRecommendedAppResponseModels:
assert response["recommended_apps"][0]["app_id"] == "app-1"
assert response["recommended_apps"][0]["categories"] == ["cat", "other"]
assert response["categories"] == ["cat"]
def test_learn_dify_app_list_response_serialization(self):
response = module.LearnDifyAppListResponse.model_validate(
{
"recommended_apps": [
{
"app": {
"id": "app-1",
"name": "App",
"mode": "chat",
"icon": "icon.png",
"icon_type": "emoji",
"icon_background": "#fff",
},
"app_id": "app-1",
"description": "desc",
"categories": ["Workflow"],
"position": 1,
"is_listed": True,
}
],
}
).model_dump(mode="json")
assert response["recommended_apps"][0]["app_id"] == "app-1"
assert response["recommended_apps"][0]["categories"] == ["Workflow"]
@@ -1,5 +1,7 @@
import io
from datetime import datetime
from inspect import unwrap
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -10,12 +12,14 @@ from werkzeug.exceptions import Forbidden
from controllers.console.workspace.plugin import (
PluginAssetApi,
PluginAutoUpgradeExcludePluginApi,
PluginCategoryListApi,
PluginChangeAutoUpgradeApi,
PluginChangePermissionApi,
PluginChangePreferencesApi,
PluginDebuggingKeyApi,
PluginDeleteAllInstallTaskItemsApi,
PluginDeleteInstallTaskApi,
PluginDeleteInstallTaskItemApi,
PluginFetchAutoUpgradeApi,
PluginFetchDynamicSelectOptionsApi,
PluginFetchDynamicSelectOptionsWithCredentialsApi,
PluginFetchInstallTaskApi,
@@ -23,7 +27,6 @@ from controllers.console.workspace.plugin import (
PluginFetchManifestApi,
PluginFetchMarketplacePkgApi,
PluginFetchPermissionApi,
PluginFetchPreferencesApi,
PluginIconApi,
PluginInstallFromGithubApi,
PluginInstallFromMarketplaceApi,
@@ -43,6 +46,69 @@ from core.plugin.impl.exc import PluginDaemonClientSideError
from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
def _plugin_category_list_item(category: str = "tool") -> dict[str, Any]:
now = datetime(2023, 1, 1, 0, 0, 0)
return {
"id": "entity-1",
"created_at": now,
"updated_at": now,
"tenant_id": "t1",
"endpoints_setups": 0,
"endpoints_active": 0,
"runtime_type": "remote",
"source": "marketplace",
"meta": {},
"plugin_id": "test-author/test-plugin",
"plugin_unique_identifier": "test-author/test-plugin:1.0.0@checksum",
"version": "1.0.0",
"checksum": "checksum",
"name": "test-plugin",
"installation_id": "entity-1",
"declaration": {
"version": "1.0.0",
"author": "test-author",
"name": "test-plugin",
"description": {"en_US": "Test plugin"},
"icon": "icon.svg",
"label": {"en_US": "Test Plugin"},
"category": category,
"created_at": now,
"resource": {"memory": 268435456, "permission": None},
"plugins": {"tools": ["provider/test.yaml"]},
"meta": {"version": "1.0.0"},
"tool": {
"identity": {
"author": "test-author",
"name": "test-plugin",
"description": {"en_US": "Test plugin"},
"icon": "icon.svg",
"label": {"en_US": "Test Plugin"},
}
},
},
}
def _builtin_tool_provider_item() -> dict[str, Any]:
return {
"id": "builtin",
"author": "dify",
"name": "builtin",
"plugin_id": "",
"plugin_unique_identifier": "",
"description": {"en_US": "Builtin tool provider"},
"icon": "icon.svg",
"icon_dark": "",
"label": {"en_US": "Builtin"},
"type": "builtin",
"team_credentials": {},
"is_team_authorization": False,
"allow_delete": True,
"tools": [],
"labels": [],
}
def _account(role: TenantAccountRole = TenantAccountRole.OWNER) -> Account:
account = Account(name="Test User", email="u1@example.com")
account.id = "u1"
@@ -142,6 +208,83 @@ class TestPluginListApi:
mock_list_with_total.assert_called_once_with("t1", "u1", 1, 10)
class TestPluginCategoryListApi:
def test_plugin_category_list(self, app: Flask):
api = PluginCategoryListApi()
method = unwrap(api.get)
plugin_item = _plugin_category_list_item()
builtin_item = _builtin_tool_provider_item()
mock_list = MagicMock(list=[plugin_item], has_more=True)
with (
app.test_request_context("/?page=2&page_size=10"),
patch(
"controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list
) as list_mock,
patch(
"controllers.console.workspace.plugin._list_hardcoded_builtin_tool_providers",
return_value=[builtin_item],
) as builtin_mock,
):
result = method(api, "t1", "tool")
list_mock.assert_called_once()
assert list_mock.call_args.args[0] == "t1"
assert list_mock.call_args.args[1] == "tool"
assert list_mock.call_args.args[2] == 2
assert list_mock.call_args.args[3] == 10
assert result["plugins"][0]["id"] == "entity-1"
assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
assert result["builtin_tools"][0]["id"] == "builtin"
assert result["builtin_tools"][0]["type"] == "builtin"
assert result["has_more"] is True
assert "total" not in result
builtin_mock.assert_called_once_with("t1")
def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask):
api = PluginCategoryListApi()
method = unwrap(api.get)
mock_list = MagicMock(list=[_plugin_category_list_item(category="datasource")], has_more=False)
with (
app.test_request_context("/?page=1&page_size=10"),
patch("controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list),
patch("controllers.console.workspace.plugin._list_hardcoded_builtin_tool_providers") as builtin_mock,
):
result = method(api, "t1", "datasource")
assert result["plugins"][0]["id"] == "entity-1"
assert result["builtin_tools"] == []
assert result["has_more"] is False
builtin_mock.assert_not_called()
def test_invalid_category(self, app: Flask):
api = PluginCategoryListApi()
method = unwrap(api.get)
with (
app.test_request_context("/?page=1&page_size=10"),
):
result = method(api, "t1", "unknown")
assert result == ({"code": "invalid_param", "message": "invalid plugin category"}, 400)
def test_daemon_error(self, app: Flask):
api = PluginCategoryListApi()
method = unwrap(api.get)
with (
app.test_request_context("/?page=1&page_size=10"),
patch(
"controllers.console.workspace.plugin.PluginService.list_by_category",
side_effect=PluginDaemonClientSideError("error"),
),
):
result = method(api, "t1", "tool")
assert result == ({"code": "plugin_error", "message": "error"}, 400)
class TestPluginIconApi:
def test_plugin_icon(self, app: Flask):
api = PluginIconApi()
@@ -857,18 +1000,15 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi:
assert result == ({"code": "plugin_error", "message": "error"}, 400)
class TestPluginChangePreferencesApi:
class TestPluginChangeAutoUpgradeApi:
def test_success(self, app: Flask):
api = PluginChangePreferencesApi()
api = PluginChangeAutoUpgradeApi()
method = unwrap(api.post)
user = _account()
payload = {
"permission": {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
},
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value,
"auto_upgrade": {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
"upgrade_time_of_day": 0,
@@ -880,24 +1020,52 @@ class TestPluginChangePreferencesApi:
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=True),
patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True),
patch(
"controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True
) as change,
):
result = method(api, "t1", user)
assert result["success"] is True
change.assert_called_once()
def test_permission_fail(self, app: Flask):
api = PluginChangePreferencesApi()
def test_success_with_model_category_auto_upgrade(self, app: Flask):
api = PluginChangeAutoUpgradeApi()
method = unwrap(api.post)
user = _account()
payload = {
"permission": {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL.value,
"auto_upgrade": {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST,
"upgrade_time_of_day": 3600,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
"exclude_plugins": [],
"include_plugins": [],
},
}
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True
) as change,
):
result = method(api, "t1", user)
assert result["success"] is True
change.assert_called_once()
assert change.call_args.kwargs["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL
def test_auto_upgrade_fail(self, app: Flask):
api = PluginChangeAutoUpgradeApi()
method = unwrap(api.post)
user = MagicMock(is_admin_or_owner=True)
payload = {
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value,
"auto_upgrade": {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
"upgrade_time_of_day": 0,
@@ -909,24 +1077,20 @@ class TestPluginChangePreferencesApi:
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=False),
patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=False),
):
result = method(api, "t1", user)
assert result["success"] is False
class TestPluginFetchPreferencesApi:
class TestPluginFetchAutoUpgradeApi:
def test_success(self, app: Flask):
api = PluginFetchPreferencesApi()
api = PluginFetchAutoUpgradeApi()
method = unwrap(api.get)
permission = MagicMock(
install_permission=TenantPluginPermission.InstallPermission.EVERYONE,
debug_permission=TenantPluginPermission.DebugPermission.EVERYONE,
)
auto_upgrade = MagicMock(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
upgrade_time_of_day=1,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
@@ -935,18 +1099,16 @@ class TestPluginFetchPreferencesApi:
)
with (
app.test_request_context("/"),
app.test_request_context(f"/?category={TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}"),
patch(
"controllers.console.workspace.plugin.PluginPermissionService.get_permission", return_value=permission
),
patch(
"controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy", return_value=auto_upgrade
"controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy",
return_value=auto_upgrade,
),
):
result = method(api, "t1")
assert "permission" in result
assert "auto_upgrade" in result
assert result["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
assert result["auto_upgrade"]["upgrade_time_of_day"] == 1
class TestPluginAutoUpgradeExcludePluginApi:
@@ -954,7 +1116,7 @@ class TestPluginAutoUpgradeExcludePluginApi:
api = PluginAutoUpgradeExcludePluginApi()
method = unwrap(api.post)
payload = {"plugin_id": "p"}
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}
with (
app.test_request_context("/", json=payload),
@@ -968,7 +1130,7 @@ class TestPluginAutoUpgradeExcludePluginApi:
api = PluginAutoUpgradeExcludePluginApi()
method = unwrap(api.post)
payload = {"plugin_id": "p"}
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}
with (
app.test_request_context("/", json=payload),
@@ -53,6 +53,12 @@ def make_tenant(
return tenant
def make_membership(*, last_opened_at=None) -> MagicMock:
membership = MagicMock()
membership.last_opened_at = last_opened_at
return membership
def make_account_with_tenant(tenant: Tenant) -> Account:
account = make_account()
account._current_tenant = tenant
@@ -66,13 +72,17 @@ class TestTenantListApi:
tenant1 = make_tenant("t1", name="Tenant 1")
tenant2 = make_tenant("t2", name="Tenant 2")
last_opened_at = naive_utc_now()
user = make_account()
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[
(tenant1, make_membership(last_opened_at=last_opened_at)),
(tenant2, make_membership()),
],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
@@ -92,7 +102,9 @@ class TestTenantListApi:
assert len(result["workspaces"]) == 2
assert result["workspaces"][0]["current"] is True
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][0]["last_opened_at"] == int(last_opened_at.timestamp())
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
assert result["workspaces"][1]["last_opened_at"] is None
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_not_called()
@@ -116,8 +128,8 @@ class TestTenantListApi:
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
@@ -159,8 +171,8 @@ class TestTenantListApi:
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
@@ -198,8 +210,8 @@ class TestTenantListApi:
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant],
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant, make_membership())],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
@@ -226,8 +238,8 @@ class TestTenantListApi:
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
@@ -251,7 +263,7 @@ class TestTenantListApi:
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
@@ -1,5 +1,6 @@
"""OpenAPI JSON rendering tests for Flask-RESTX API blueprints."""
import json
from collections.abc import Iterator
import pytest
@@ -187,3 +188,39 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest
assert "payload" not in params
assert params["avatar"]["in"] == "query"
assert params["avatar"]["required"] is True
def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path):
from dev.generate_swagger_specs import generate_specs
written_paths = generate_specs(tmp_path)
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
operation = payload["paths"]["/workspaces/current/plugin/{category}/list"]["get"]
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
"#/components/schemas/"
)
schemas = payload["components"]["schemas"]
response_schema = schemas[response_ref]
assert response_schema["properties"]["plugins"]["items"]["$ref"] == (
"#/components/schemas/PluginCategoryInstalledPluginResponse"
)
assert response_schema["properties"]["builtin_tools"]["items"]["$ref"] == (
"#/components/schemas/PluginCategoryBuiltinToolProviderResponse"
)
installed_plugin_schema = schemas["PluginCategoryInstalledPluginResponse"]
for field in (
"plugin_unique_identifier",
"source",
"version",
"declaration",
"endpoints_active",
"endpoints_setups",
):
assert field in installed_plugin_schema["properties"]
builtin_tool_schema = schemas["PluginCategoryBuiltinToolProviderResponse"]
for field in ("plugin_unique_identifier", "team_credentials", "type", "tools"):
assert field in builtin_tool_schema["properties"]
@@ -33,6 +33,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTaskStartResponse,
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginReadmeResponse,
PluginVerification,
)
@@ -123,6 +124,26 @@ class TestPluginDiscovery:
assert call_args[1]["params"]["page_size"] == 5
assert result.total == 10
def test_list_plugins_by_category(self, plugin_installer, mock_plugin_entity):
"""Test category plugin listing without total."""
mock_response = PluginListWithoutTotalResponse(list=[mock_plugin_entity], has_more=True)
with patch.object(
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
) as mock_request:
result = plugin_installer.list_plugins_by_category(
"test-tenant", category=PluginCategory.Tool, page=2, page_size=10
)
mock_request.assert_called_once()
call_args = mock_request.call_args
assert call_args.args[1] == "plugin/test-tenant/management/tool/list"
assert call_args.args[2] is PluginListWithoutTotalResponse
assert call_args.kwargs["params"]["page"] == 2
assert call_args.kwargs["params"]["page_size"] == 10
assert result.list == [mock_plugin_entity]
assert result.has_more is True
def test_list_plugins_empty_result(self, plugin_installer):
"""Test plugin listing when no plugins are installed."""
# Arrange: Mock empty response
@@ -1,8 +1,10 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from models.account import TenantPluginAutoUpgradeStrategy
MODULE = "services.plugin.plugin_auto_upgrade_service"
PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
def _patched_session():
@@ -25,7 +27,7 @@ class TestGetStrategy:
with p1:
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.get_strategy("t1")
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY)
assert result is strategy
@@ -36,7 +38,7 @@ class TestGetStrategy:
with p1:
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.get_strategy("t1")
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY)
assert result is None
@@ -57,6 +59,7 @@ class TestChangeStrategy:
TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
[],
[],
category=PLUGIN_CATEGORY,
)
assert result is True
@@ -77,6 +80,7 @@ class TestChangeStrategy:
TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
["p1"],
["p2"],
category=PLUGIN_CATEGORY,
)
assert result is True
@@ -96,17 +100,19 @@ class TestExcludePlugin:
p1,
patch(f"{MODULE}.select"),
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls,
patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs,
):
strat_cls.StrategySetting.FIX_ONLY = "fix_only"
strat_cls.UpgradeMode.EXCLUDE = "exclude"
cs.return_value = True
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin("t1", "plugin-1")
result = PluginAutoUpgradeService.exclude_plugin(
"t1",
"plugin-1",
PLUGIN_CATEGORY,
)
assert result is True
cs.assert_called_once()
session.add.assert_called_once()
def test_appends_to_exclude_list_in_exclude_mode(self):
p1, session = _patched_session()
@@ -121,7 +127,7 @@ class TestExcludePlugin:
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new")
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY)
assert result is True
assert existing.exclude_plugins == ["p-existing", "p-new"]
@@ -139,7 +145,7 @@ class TestExcludePlugin:
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1")
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
assert result is True
assert existing.include_plugins == ["p2"]
@@ -156,7 +162,7 @@ class TestExcludePlugin:
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1")
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
assert result is True
assert existing.upgrade_mode == "exclude"
@@ -175,6 +181,101 @@ class TestExcludePlugin:
strat_cls.UpgradeMode.ALL = "all"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
PluginAutoUpgradeService.exclude_plugin("t1", "p1")
PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
assert existing.exclude_plugins == ["p1"]
class TestBackfillStrategyCategories:
def test_creates_default_missing_categories_without_fetching_daemon(self):
p1, session = _patched_session()
tool_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
session.scalars.return_value.all.return_value = [tool_strategy]
installer = MagicMock()
with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.backfill_strategy_categories("t1")
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 1
assert result.normalized is False
installer.list_plugins.assert_not_called()
assert tool_strategy.upgrade_time_of_day == expected_time
created_strategies = [call.args[0] for call in session.add.call_args_list]
model_strategy = next(
strategy
for strategy in created_strategies
if strategy.category == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL
)
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert model_strategy.upgrade_time_of_day == expected_time
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
assert default_time % (15 * 60) == 0
assert 0 <= default_time < 24 * 60 * 60
def test_creates_missing_categories_and_splits_known_plugins(self):
p1, session = _patched_session()
tool_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
include_plugins=["model-plugin", "tool-plugin"],
)
model_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
include_plugins=["model-plugin", "tool-plugin"],
)
session.scalars.return_value.all.return_value = [tool_strategy, model_strategy]
installed_plugins = [
SimpleNamespace(
plugin_id="tool-plugin",
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL),
),
SimpleNamespace(
plugin_id="model-plugin",
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL),
),
]
installer = MagicMock()
installer.list_plugins.return_value = installed_plugins
with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer), patch(f"{MODULE}.logger") as logger:
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.backfill_strategy_categories("t1")
assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2
assert result.normalized is True
assert session.add.call_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2
assert tool_strategy.exclude_plugins == ["tool-plugin"]
assert tool_strategy.include_plugins == ["tool-plugin"]
assert model_strategy.exclude_plugins == ["model-plugin"]
assert model_strategy.include_plugins == ["model-plugin"]
logger.warning.assert_called_once_with(
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
"tenant_id=%s, field=%s, plugin_ids=%s",
"t1",
"exclude_plugins",
["unknown-plugin"],
)
@@ -65,6 +65,7 @@ class TestAccountAssociatedDataFactory:
tenant_join.account_id = account_id
tenant_join.current = current
tenant_join.role = role
tenant_join.last_opened_at = kwargs.pop("last_opened_at", None)
for key, value in kwargs.items():
setattr(tenant_join, key, value)
return tenant_join
@@ -489,11 +490,13 @@ class TestAccountService:
# Mock datetime
with (
patch("services.account_service.datetime") as mock_datetime,
patch("services.account_service.naive_utc_now") as mock_naive_utc_now,
patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active,
):
mock_now = datetime.now()
mock_datetime.now.return_value = mock_now
mock_datetime.UTC = "UTC"
mock_naive_utc_now.return_value = mock_now
# Execute test
result = AccountService.load_user("user-123")
@@ -501,6 +504,7 @@ class TestAccountService:
# Verify results
assert result == mock_account
assert mock_available_tenant.current is True
assert mock_available_tenant.last_opened_at == mock_now
self._assert_database_operations_called(mock_db_dependencies["db"])
mock_refresh_last_active.assert_called_once_with(mock_account)
@@ -922,11 +926,16 @@ class TestTenantService:
# Mock scalar for the join query
mock_db.session.scalar.return_value = mock_tenant_join
# Execute test
TenantService.switch_tenant(mock_account, "tenant-456")
with patch("services.account_service.naive_utc_now") as mock_naive_utc_now:
mock_now = datetime(2026, 6, 5, 11, 0, 0)
mock_naive_utc_now.return_value = mock_now
# Execute test
TenantService.switch_tenant(mock_account, "tenant-456")
# Verify tenant was switched
assert mock_tenant_join.current is True
assert mock_tenant_join.last_opened_at == mock_now
self._assert_database_operations_called(mock_db)
def test_switch_tenant_no_tenant_id(self):
@@ -4,19 +4,25 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from models.account import TenantPluginAutoUpgradeStrategy
MODULE = "tasks.process_tenant_plugin_autoupgrade_check_task"
def _make_plugin(plugin_id: str, version: str, source=PluginInstallationSource.Marketplace):
def _make_plugin(
plugin_id: str,
version: str,
source=PluginInstallationSource.Marketplace,
category: PluginCategory = PluginCategory.Tool,
):
"""Build a minimal stand-in for a PluginInstallation entry returned by manager.list_plugins."""
return SimpleNamespace(
plugin_id=plugin_id,
version=version,
plugin_unique_identifier=f"{plugin_id}:{version}@deadbeef",
source=source,
declaration=SimpleNamespace(category=category),
)
@@ -39,6 +45,7 @@ def _run_task(
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
exclude_plugins=None,
include_plugins=None,
category=None,
):
"""
Execute the celery task synchronously with mocks for the plugin manager,
@@ -72,6 +79,7 @@ def _run_task(
upgrade_mode,
exclude_plugins or [],
include_plugins or [],
category,
)
return upgrade_mock, upgrade_calls
@@ -246,6 +254,26 @@ class TestUpgradeMode:
assert upgrade_mock.call_count == 1
assert calls[0][1] == plugins[0].plugin_unique_identifier
def test_category_strategy_only_upgrades_matching_category(self):
plugins = [
_make_plugin("acme/model-provider", "1.0.0", category=PluginCategory.Model),
_make_plugin("acme/tool-provider", "1.0.0", category=PluginCategory.Tool),
]
manifests = [
_make_manifest("acme/model-provider", "1.0.1"),
_make_manifest("acme/tool-provider", "1.0.1"),
]
upgrade_mock, calls = _run_task(
plugins=plugins,
manifests=manifests,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL,
)
upgrade_mock.assert_called_once()
assert calls[0][1] == plugins[0].plugin_unique_identifier
class TestErrorIsolation:
def test_one_plugin_failure_does_not_block_others(self):
@@ -4,6 +4,7 @@ import pytest
from libs.archive_storage import ArchiveStorageNotConfiguredError
from tasks.remove_app_and_related_data_task import (
_delete_app_stars,
_delete_app_workflow_archive_logs,
_delete_archived_workflow_run_files,
_delete_draft_variable_offload_data,
@@ -89,6 +90,27 @@ class TestDeleteWorkflowArchiveLogs:
mock_session.execute.assert_called_once()
class TestDeleteAppStars:
@patch("tasks.remove_app_and_related_data_task._delete_records")
def test_delete_app_stars_calls_delete_records(self, mock_delete_records):
tenant_id = "tenant-1"
app_id = "app-1"
_delete_app_stars(tenant_id, app_id)
mock_delete_records.assert_called_once()
query_sql, params, delete_func, name = mock_delete_records.call_args[0]
assert "app_stars" in query_sql
assert params == {"tenant_id": tenant_id, "app_id": app_id}
assert name == "app star"
mock_session = MagicMock()
delete_func(mock_session, "star-1")
mock_session.execute.assert_called_once()
class TestDeleteArchivedWorkflowRunFiles:
@patch("tasks.remove_app_and_related_data_task.get_archive_storage")
@patch("tasks.remove_app_and_related_data_task.logger")