mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
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:
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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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__])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
+42
@@ -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"])
|
||||
+26
@@ -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")
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
+61
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user