mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
Merge remote-tracking branch 'origin/main' into deploy/konwledge
# Conflicts: # api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py # api/tests/unit_tests/services/test_async_workflow_service.py # web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
This commit is contained in:
commit
e6fe2cd5d7
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@ -267,5 +267,9 @@
|
||||
/web/app/auth/refresh/route.ts @iamjoel @lyzno1
|
||||
/web/service/server.ts @iamjoel @lyzno1
|
||||
|
||||
# Frontend - Browser Mode Tests
|
||||
/web/app/**/*.browser.spec.ts @lyzno1
|
||||
/web/app/**/*.browser.spec.tsx @lyzno1
|
||||
|
||||
# Docker
|
||||
/docker/* @laipz8200
|
||||
|
||||
2
.github/workflows/accessibility-e2e.yml
vendored
2
.github/workflows/accessibility-e2e.yml
vendored
@ -36,7 +36,7 @@ concurrency:
|
||||
jobs:
|
||||
accessibility:
|
||||
name: WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} · ${{ inputs.page }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
|
||||
40
.github/workflows/web-tests.yml
vendored
40
.github/workflows/web-tests.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Run tests
|
||||
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
run: vp test run --project unit --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
browser-test:
|
||||
name: Web Browser Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
@ -67,7 +67,17 @@ jobs:
|
||||
run: vp exec playwright install --with-deps --only-shell chromium
|
||||
|
||||
- name: Run browser tests
|
||||
run: vp test run --config vitest.browser.config.ts --silent=passed-only
|
||||
run: vp test run --project browser --silent=passed-only
|
||||
|
||||
- name: Upload browser failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: browser-test-failures
|
||||
path: web/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
merge-reports:
|
||||
name: Merge Test Reports
|
||||
@ -111,7 +121,7 @@ jobs:
|
||||
|
||||
dify-ui-test:
|
||||
name: dify-ui Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@ -136,6 +146,16 @@ jobs:
|
||||
- name: Run dify-ui tests
|
||||
run: vp test run --project unit --coverage --silent=passed-only
|
||||
|
||||
- name: Upload dify-ui test failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dify-ui-test-failures
|
||||
path: packages/dify-ui/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
@ -147,7 +167,7 @@ jobs:
|
||||
|
||||
dify-ui-storybook-test:
|
||||
name: dify-ui Storybook Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
@ -169,3 +189,13 @@ jobs:
|
||||
|
||||
- name: Run dify-ui Storybook tests
|
||||
run: vp run test:storybook
|
||||
|
||||
- name: Upload dify-ui Storybook test failure artifacts
|
||||
if: ${{ failure() && !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dify-ui-storybook-test-failures
|
||||
path: packages/dify-ui/.vitest-browser/
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -268,6 +268,7 @@ scripts/stress-test/reports/
|
||||
.qoder/*
|
||||
.context/
|
||||
# Vitest local reports
|
||||
web/.vitest-browser/
|
||||
web/.vitest-reports/
|
||||
|
||||
# dify-agent-runtime
|
||||
|
||||
@ -709,6 +709,8 @@ AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
|
||||
# Client deadline for converting a Binding file to a ToolFile through the Agent backend.
|
||||
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
|
||||
|
||||
# KnowledgeFS (Dataset 2.0)
|
||||
KNOWLEDGE_FS_ENABLED=false
|
||||
|
||||
@ -93,6 +93,24 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-application-boundary]
|
||||
name = Account application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account_errors
|
||||
services.account_ports
|
||||
services.account_profile_service
|
||||
services.entities.account_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-definition-query-service-boundary]
|
||||
name = App definition query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@ -124,6 +142,24 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-app-runtime-query-service-boundary]
|
||||
name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_app_runtime_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.feature_service
|
||||
services.feature_service_gateway
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:feature-query-service-boundary]
|
||||
name = Feature query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@ -200,3 +236,43 @@ forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:tag-application-service-boundary]
|
||||
name = Tag application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.tag_application_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-query-service-boundary]
|
||||
name = Recommended app query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.feature_service
|
||||
services.recommended_app_catalog_gateway
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-catalog-gateway-boundary]
|
||||
name = Recommended app catalog gateway does not depend on Flask
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_catalog_gateway
|
||||
forbidden_modules =
|
||||
flask
|
||||
|
||||
@ -8,10 +8,21 @@ from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackend
|
||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
||||
|
||||
|
||||
def create_agent_backend_client(*, base_url: str, api_token: str | None = None, stream_timeout: float = 30) -> Client:
|
||||
def create_agent_backend_client(
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str | None = None,
|
||||
stream_timeout: float = 30,
|
||||
binding_file_download_timeout: float = 240,
|
||||
) -> Client:
|
||||
api_token = api_token.strip() if api_token else None
|
||||
headers = {"Authorization": f"Bearer {api_token}"} if api_token else None
|
||||
return Client(base_url=base_url, stream_timeout=stream_timeout, headers=headers)
|
||||
return Client(
|
||||
base_url=base_url,
|
||||
stream_timeout=stream_timeout,
|
||||
binding_file_download_timeout=binding_file_download_timeout,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def create_agent_backend_run_client(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from configs.extra.agent_backend_config import AgentBackendConfig
|
||||
from configs.extra.archive_config import ArchiveStorageConfig
|
||||
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
|
||||
from configs.extra.logstore_config import LogStoreConfig
|
||||
from configs.extra.notion_config import NotionConfig
|
||||
from configs.extra.sentry_config import SentryConfig
|
||||
from configs.extra.turnstile_config import TurnstileConfig
|
||||
@ -11,6 +12,7 @@ class ExtraServiceConfig(
|
||||
AgentBackendConfig,
|
||||
ArchiveStorageConfig,
|
||||
KnowledgeFSConfig,
|
||||
LogStoreConfig,
|
||||
NotionConfig,
|
||||
SentryConfig,
|
||||
TurnstileConfig,
|
||||
|
||||
@ -37,6 +37,11 @@ class AgentBackendConfig(BaseSettings):
|
||||
default=3,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS: PositiveFloat = Field(
|
||||
description="Client timeout for converting a Binding file to a ToolFile through the Agent backend.",
|
||||
default=240,
|
||||
)
|
||||
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "
|
||||
|
||||
11
api/configs/extra/logstore_config.py
Normal file
11
api/configs/extra/logstore_config.py
Normal file
@ -0,0 +1,11 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class LogStoreConfig(BaseSettings):
|
||||
"""Migration controls for repositories backed by Aliyun LogStore."""
|
||||
|
||||
LOGSTORE_DUAL_WRITE_ENABLED: bool = False
|
||||
|
||||
# Keep workflow graphs in LogStore by default. Deployments may disable this
|
||||
# while migrating large graph payloads to another persistence owner.
|
||||
LOGSTORE_ENABLE_PUT_GRAPH_FIELD: bool = True
|
||||
@ -36,6 +36,7 @@ _UNSTRUCTURED_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
"pptx",
|
||||
"xml",
|
||||
"epub",
|
||||
"odt",
|
||||
)
|
||||
)
|
||||
_DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
@ -53,6 +54,7 @@ _DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
|
||||
"csv",
|
||||
"vtt",
|
||||
"properties",
|
||||
"odt",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -166,6 +166,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 5,
|
||||
"privacy_policy": null
|
||||
@ -294,6 +295,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 4,
|
||||
"privacy_policy": null
|
||||
@ -326,6 +328,7 @@
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, A chatbot with a knowledge base. ",
|
||||
"is_learn_dify": true,
|
||||
"is_listed": true,
|
||||
"position": 4,
|
||||
"privacy_policy": null
|
||||
|
||||
@ -20,6 +20,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.app_access import resolve_app_access_filter
|
||||
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
|
||||
@ -53,6 +55,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.db.session_factory import session_factory
|
||||
from core.helper import encrypter
|
||||
from core.helper.trace_id_helper import get_external_trace_id
|
||||
from core.plugin.impl.exc import PluginInvokeError
|
||||
@ -95,7 +98,6 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_ref_service import WorkflowRefService
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -1303,7 +1305,7 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow, retirement_candidates = workflow_service.publish_workflow(
|
||||
workflow = workflow_service.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@ -1320,16 +1322,6 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app_model.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
@ -1620,10 +1612,11 @@ class WorkflowByIdApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@console_ns.response(204, "Workflow deleted successfully")
|
||||
def delete(self, app_model: App, workflow_id: str):
|
||||
def delete(self, current_user: Account, app_model: App, workflow_id: str):
|
||||
"""
|
||||
Delete workflow
|
||||
"""
|
||||
@ -1633,7 +1626,7 @@ class WorkflowByIdApi(Resource):
|
||||
# Create a session and manage the transaction
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
try:
|
||||
workflow_service.delete_workflow(
|
||||
retirement_candidates = workflow_service.delete_workflow(
|
||||
session=session,
|
||||
workflow_ref=workflow_ref,
|
||||
)
|
||||
@ -1644,6 +1637,11 @@ class WorkflowByIdApi(Resource):
|
||||
except ValueError as e:
|
||||
raise NotFound(str(e))
|
||||
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
return None, 204
|
||||
|
||||
|
||||
@ -1929,8 +1927,9 @@ class WorkflowOnlineUsersApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
def post(self, current_tenant_id: str, current_user: Account):
|
||||
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
app_ids = args.app_ids
|
||||
@ -1940,8 +1939,20 @@ class WorkflowOnlineUsersApi(Resource):
|
||||
if not app_ids:
|
||||
return {"data": []}
|
||||
|
||||
access_filter = None
|
||||
workflow_service = WorkflowService()
|
||||
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id, session=db.session())
|
||||
with session_factory.create_session() as session:
|
||||
if dify_config.RBAC_ENABLED:
|
||||
access_filter = resolve_app_access_filter(current_tenant_id, current_user.id, session=session)
|
||||
app_maintainers = workflow_service.get_tenant_app_maintainers(app_ids, current_tenant_id, session=session)
|
||||
|
||||
accessible_app_ids = set(app_maintainers)
|
||||
if access_filter is not None:
|
||||
accessible_app_ids = {
|
||||
app_id
|
||||
for app_id, maintainer in app_maintainers.items()
|
||||
if access_filter.is_app_accessible(app_id, maintainer, current_user.id)
|
||||
}
|
||||
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
|
||||
|
||||
users_json_by_app_id: dict[str, Any] = {}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"""Controller decorators for console app resources.
|
||||
|
||||
`get_app_model` still supports legacy handlers backed by Flask-SQLAlchemy's
|
||||
scoped session. Trial app handlers compose `get_app_model_with_trial` under
|
||||
`controllers.common.session.with_session` and always reuse that request session.
|
||||
scoped session. Preview handlers compose `get_previewable_app_model` under
|
||||
`controllers.common.session.with_session`; preview admission finishes before
|
||||
the request Session loads the accepted App.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
@ -16,16 +17,17 @@ from configs import dify_config
|
||||
from controllers.common.session import with_session
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models import App, AppMode, TrialApp
|
||||
from models import App, AppMode
|
||||
from models.agent import AgentScope
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
from services.app_service import AppService
|
||||
|
||||
__all__ = [
|
||||
"agent_manage_required_for_agent_app",
|
||||
"get_app_model",
|
||||
"get_app_model_with_trial",
|
||||
"get_previewable_app_model",
|
||||
"with_session",
|
||||
]
|
||||
|
||||
@ -48,12 +50,11 @@ def _load_app_model_from_scoped_session(app_id: str) -> App | None:
|
||||
return app_model
|
||||
|
||||
|
||||
def _load_app_model_with_trial(session: Session, app_id: str) -> App | None:
|
||||
"""Load a normal app through its trial registration without applying current-tenant scope."""
|
||||
app_model = session.scalar(
|
||||
select(App).join(TrialApp, TrialApp.app_id == App.id).where(App.id == app_id, App.status == "normal").limit(1)
|
||||
)
|
||||
return app_model
|
||||
def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
|
||||
"""Load a normal App after preview admission completes outside the request Session."""
|
||||
if not application_services().recommended_app_queries.is_previewable(app_id):
|
||||
return None
|
||||
return AppService.get_normal_app_by_id(app_id, session)
|
||||
|
||||
|
||||
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@ -183,7 +184,7 @@ def get_app_model[**P, R](
|
||||
|
||||
|
||||
@overload
|
||||
def get_app_model_with_trial[**P, R](
|
||||
def get_previewable_app_model[**P, R](
|
||||
view: Callable[P, R],
|
||||
*,
|
||||
mode: AppMode | list[AppMode] | None = None,
|
||||
@ -191,19 +192,25 @@ def get_app_model_with_trial[**P, R](
|
||||
|
||||
|
||||
@overload
|
||||
def get_app_model_with_trial[**P, R](
|
||||
def get_previewable_app_model[**P, R](
|
||||
view: None = None,
|
||||
*,
|
||||
mode: AppMode | list[AppMode] | None = None,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||
|
||||
|
||||
def get_app_model_with_trial[**P, R](
|
||||
def get_previewable_app_model[**P, R](
|
||||
view: Callable[P, R] | None = None,
|
||||
*,
|
||||
mode: AppMode | list[AppMode] | None = None,
|
||||
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Inject a trial-registered or recommended App using the Session supplied by `with_session`."""
|
||||
"""Inject an App authorized for read-only template preview.
|
||||
|
||||
Preview reads accept either an explicit TrialApp registration or membership
|
||||
in the recommended catalog. This does not grant trial execution, which is
|
||||
separately protected by TrialAppResource's feature, registration, and quota
|
||||
checks.
|
||||
"""
|
||||
|
||||
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view_func)
|
||||
@ -218,10 +225,8 @@ def get_app_model_with_trial[**P, R](
|
||||
|
||||
session = _get_injected_session(args)
|
||||
if session is None:
|
||||
raise RuntimeError("get_app_model_with_trial requires @with_session")
|
||||
app_model = _load_app_model_with_trial(session, app_id)
|
||||
if app_model is None:
|
||||
app_model = RecommendedAppService.get_app(app_id, session=session)
|
||||
raise RuntimeError("get_previewable_app_model requires @with_session")
|
||||
app_model = _load_previewable_app_model(session, app_id)
|
||||
|
||||
if not app_model:
|
||||
raise AppNotFoundError()
|
||||
|
||||
@ -6,11 +6,14 @@ from constants.languages import supported_language
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError, EmailDomainSuspendedError
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, dump_response, timezone
|
||||
from libs.login import current_account_with_tenant
|
||||
from libs.token import extract_access_token
|
||||
from services.account_activation_service import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.account_activation_service import (
|
||||
FrozenAccountError,
|
||||
InvalidInvitationError,
|
||||
@ -141,6 +144,8 @@ class ActivateApi(Resource):
|
||||
raise AlreadyActivateError() from None
|
||||
except InvitationAccountMismatchError:
|
||||
raise InvitationAccountMismatchHTTPError() from None
|
||||
except EmailDomainSuspendedRegistrationError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except FrozenAccountError:
|
||||
raise AccountInFreezeError() from None
|
||||
|
||||
|
||||
@ -24,9 +24,15 @@ from libs.password import valid_password
|
||||
from models import Account
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import AccountRegisterError, SeatsLimitExceededError
|
||||
from services.errors.account import (
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
|
||||
from ..error import AccountInFreezeError, EmailSendIpLimitError, SeatsLimitExceeded
|
||||
from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded
|
||||
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
|
||||
|
||||
|
||||
@ -99,10 +105,12 @@ class EmailRegisterSendEmailApi(Resource):
|
||||
if req_data.language is not None and req_data.language in languages:
|
||||
language = req_data.language
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
raise AccountInFreezeError()
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
|
||||
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
|
||||
@ -217,5 +225,7 @@ class EmailRegisterResetApi(Resource):
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
raise SeatsLimitExceeded()
|
||||
except AccountRegisterError:
|
||||
raise AccountInFreezeError()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
@ -34,6 +34,7 @@ from controllers.console.error import (
|
||||
AccountBannedError,
|
||||
AccountInFreezeError,
|
||||
AccountNotFound,
|
||||
EmailDomainSuspendedError,
|
||||
EmailSendIpLimitError,
|
||||
NotAllowedCreateWorkspace,
|
||||
SeatsLimitExceeded,
|
||||
@ -74,6 +75,9 @@ from services.errors.account import (
|
||||
RefreshTokenNotFoundError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.turnstile_service import (
|
||||
@ -149,11 +153,13 @@ class LoginApi(Resource):
|
||||
request_email = req_data.email
|
||||
normalized_email = request_email.lower()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
|
||||
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
|
||||
if is_login_error_rate_limit:
|
||||
@ -255,8 +261,10 @@ class ResetPasswordSendEmailApi(Resource):
|
||||
language = "en-US"
|
||||
try:
|
||||
account = _get_account_with_case_fallback(req_data.email)
|
||||
except AccountRegisterError:
|
||||
raise AccountInFreezeError()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
token = AccountService.send_reset_password_email(
|
||||
email=normalized_email,
|
||||
@ -297,8 +305,10 @@ class EmailCodeLoginSendEmailApi(Resource):
|
||||
language = "en-US"
|
||||
try:
|
||||
account = _get_account_with_case_fallback(req_data.email)
|
||||
except AccountRegisterError:
|
||||
raise AccountInFreezeError()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
if account is None:
|
||||
if FeatureService.get_system_features().is_allow_register:
|
||||
@ -377,9 +387,12 @@ class EmailCodeLoginApi(Resource):
|
||||
except Unauthorized as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED)
|
||||
raise AccountBannedError() from exc
|
||||
except AccountRegisterError:
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError() from exc
|
||||
if account:
|
||||
tenants = TenantService.get_join_tenants(account, session=db.session())
|
||||
if not tenants:
|
||||
@ -405,9 +418,12 @@ class EmailCodeLoginApi(Resource):
|
||||
raise NotAllowedCreateWorkspace()
|
||||
except SeatsLimitExceededError:
|
||||
raise SeatsLimitExceeded()
|
||||
except AccountRegisterError:
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError()
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError() from exc
|
||||
except WorkspacesLimitExceededError:
|
||||
raise WorkspacesLimitExceeded()
|
||||
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)
|
||||
|
||||
@ -12,6 +12,7 @@ from configs import dify_config
|
||||
from constants.languages import languages
|
||||
from controllers.common.fields import RedirectResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
|
||||
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@ -26,7 +27,14 @@ from libs.token import (
|
||||
from models import Account, AccountStatus
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
|
||||
from services.errors.account import (
|
||||
AccountNotFoundError,
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
@ -249,8 +257,10 @@ class OAuthCallback(Resource):
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Licensed seats limit exceeded.")
|
||||
except AccountRegisterError as e:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")
|
||||
except EmailDomainSuspendedRegistrationError:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={EmailDomainSuspendedError.description}")
|
||||
except AccountRegisterError as exc:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={exc.description}")
|
||||
|
||||
# Check account status
|
||||
if account.status == AccountStatus.BANNED:
|
||||
@ -309,15 +319,12 @@ def _generate_account(
|
||||
normalized_email = user_info.email.lower()
|
||||
oauth_new_user = True
|
||||
if not FeatureService.get_system_features().is_allow_register:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
|
||||
normalized_email
|
||||
):
|
||||
raise AccountRegisterError(
|
||||
description=(
|
||||
"This email account has been deleted within the past "
|
||||
"30 days and is temporarily unavailable for new account registration"
|
||||
)
|
||||
)
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedRegistrationError()
|
||||
raise AccountRegisterError(description=AccountInFreezeError.description or "")
|
||||
raise AccountRegisterError(description=("Invalid email or password"))
|
||||
account_name = user_info.name or "Dify"
|
||||
interface_language = _preferred_interface_language(language)
|
||||
|
||||
@ -732,10 +732,12 @@ class DatasetApi(Resource):
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
|
||||
@ -631,12 +631,16 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
|
||||
):
|
||||
# check dataset
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
# check document
|
||||
document_id_str = str(document_id)
|
||||
document = DocumentService.get_document(dataset_id_str, document_id_str, session=session)
|
||||
document_ref = DatasetRefService.create_document_ref_from_id(
|
||||
DatasetRefService.create_dataset_ref(dataset), document_id_str
|
||||
)
|
||||
document = DatasetRefService.get_document_by_ref(document_ref, session=session)
|
||||
if not document:
|
||||
raise NotFound("Document not found.")
|
||||
|
||||
|
||||
@ -336,6 +336,8 @@ class DatasourceAuth(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account, provider_id: str):
|
||||
|
||||
@ -61,6 +61,7 @@ from models import Account
|
||||
from models.dataset import Pipeline
|
||||
from models.model import EndUser
|
||||
from models.workflow import Workflow
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.dataset_service import DatasetService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
@ -770,8 +771,9 @@ class RagPipelineByIdApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
|
||||
@with_current_user
|
||||
@get_rag_pipeline
|
||||
def delete(self, pipeline: Pipeline, workflow_id: str):
|
||||
def delete(self, current_user: Account, pipeline: Pipeline, workflow_id: str):
|
||||
"""
|
||||
Delete a published workflow version that is not currently active on the pipeline.
|
||||
"""
|
||||
@ -783,7 +785,7 @@ class RagPipelineByIdApi(Resource):
|
||||
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
try:
|
||||
workflow_service.delete_workflow(
|
||||
retirement_candidates = workflow_service.delete_workflow(
|
||||
session=session,
|
||||
workflow_ref=workflow_ref,
|
||||
)
|
||||
@ -794,6 +796,11 @@ class RagPipelineByIdApi(Resource):
|
||||
except ValueError as e:
|
||||
raise NotFound(str(e))
|
||||
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=pipeline.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
return None, 204
|
||||
|
||||
|
||||
|
||||
@ -92,11 +92,17 @@ class AccountInFreezeError(BaseHTTPException):
|
||||
error_code = "account_in_freeze"
|
||||
code = 400
|
||||
description = (
|
||||
"This email account has been deleted within the past 30 days"
|
||||
"This email account has been deleted within the past 30 days "
|
||||
"and is temporarily unavailable for new account registration."
|
||||
)
|
||||
|
||||
|
||||
class EmailDomainSuspendedError(BaseHTTPException):
|
||||
error_code = "email_domain_suspended"
|
||||
code = 400
|
||||
description = "This email domain has been suspended."
|
||||
|
||||
|
||||
class EducationVerifyLimitError(BaseHTTPException):
|
||||
error_code = "education_verify_limit"
|
||||
description = "Rate limit exceeded"
|
||||
|
||||
@ -31,6 +31,12 @@ class AppAccessDeniedError(BaseHTTPException):
|
||||
code = 403
|
||||
|
||||
|
||||
class RecommendedAppNotFoundError(BaseHTTPException):
|
||||
error_code = "recommended_app_not_found"
|
||||
description = "Recommended app not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class TrialAppNotAllowed(BaseHTTPException):
|
||||
"""*403* `Trial App Not Allowed`
|
||||
|
||||
@ -51,3 +57,9 @@ class TrialAppLimitExceeded(BaseHTTPException):
|
||||
error_code = "trial_app_limit_exceeded"
|
||||
code = 403
|
||||
description = "The user has exceeded the trial app limit."
|
||||
|
||||
|
||||
class TrialAppFeatureDisabledError(BaseHTTPException):
|
||||
error_code = "trial_app_feature_disabled"
|
||||
code = 403
|
||||
description = "Trial app feature is not enabled."
|
||||
|
||||
@ -2,18 +2,18 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, computed_field, field_validator
|
||||
from pydantic import BaseModel, Field, computed_field, field_validator
|
||||
|
||||
from constants.languages import languages
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.explore.error import RecommendedAppNotFoundError
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, with_current_user
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import build_icon_url, dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
|
||||
|
||||
|
||||
class RecommendedAppsQuery(BaseModel):
|
||||
@ -79,10 +79,6 @@ class RecommendedAppDetailResponse(ResponseModel):
|
||||
can_trial: bool
|
||||
|
||||
|
||||
class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]):
|
||||
pass
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
RecommendedAppsQuery,
|
||||
@ -94,18 +90,9 @@ register_response_schema_models(
|
||||
RecommendedAppListResponse,
|
||||
LearnDifyAppListResponse,
|
||||
RecommendedAppDetailResponse,
|
||||
RecommendedAppDetailNullableResponse,
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
@ -115,12 +102,12 @@ class RecommendedAppListApi(Resource):
|
||||
@with_current_user
|
||||
@model_validate(RecommendedAppsQuery)
|
||||
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
|
||||
# language args
|
||||
language_prefix = _resolve_language(req_data.language, current_user)
|
||||
|
||||
return dump_response(
|
||||
RecommendedAppListResponse,
|
||||
RecommendedAppService.get_recommended_apps_and_categories(language_prefix, session=db.session()),
|
||||
application_services().recommended_app_queries.list_recommended(
|
||||
requested_language=req_data.language,
|
||||
interface_language=current_user.interface_language,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -133,19 +120,24 @@ class LearnDifyAppListApi(Resource):
|
||||
@with_current_user
|
||||
@model_validate(RecommendedAppsQuery)
|
||||
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
|
||||
language_prefix = _resolve_language(req_data.language, current_user)
|
||||
|
||||
return dump_response(
|
||||
LearnDifyAppListResponse,
|
||||
RecommendedAppService.get_learn_dify_apps(language_prefix, session=db.session()),
|
||||
application_services().recommended_app_queries.list_learn_dify(
|
||||
requested_language=req_data.language,
|
||||
interface_language=current_user.interface_language,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps/<uuid:app_id>")
|
||||
class RecommendedAppApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailNullableResponse.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
|
||||
@console_ns.response(404, "Recommended app not found")
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_id: UUID):
|
||||
result = RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
|
||||
return RecommendedAppDetailNullableResponse.model_validate(result).model_dump(mode="json")
|
||||
try:
|
||||
result = application_services().recommended_app_queries.get_detail(str(app_id))
|
||||
except RecommendedAppQueryNotFoundError:
|
||||
raise RecommendedAppNotFoundError() from None
|
||||
return dump_response(RecommendedAppDetailResponse, result)
|
||||
|
||||
@ -38,7 +38,7 @@ from controllers.console.app.error import (
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model_with_trial, with_session
|
||||
from controllers.console.app.wraps import get_previewable_app_model, with_session
|
||||
from controllers.console.explore.error import (
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
NotChatAppError,
|
||||
@ -96,7 +96,6 @@ from services.errors.message import (
|
||||
SuggestedQuestionsAfterAnswerDisabledError,
|
||||
)
|
||||
from services.message_service import MessageService
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -511,7 +510,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
streaming=True,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@ -589,7 +588,7 @@ class TrialChatApi(TrialAppResource):
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
streaming=True,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
@ -675,7 +674,7 @@ class TrialChatAudioApi(TrialAppResource):
|
||||
session=db.session(),
|
||||
end_user=None,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
return response
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
@ -736,7 +735,7 @@ class TrialChatTextApi(TrialAppResource):
|
||||
voice=voice,
|
||||
message_ref=message_ref,
|
||||
)
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
return response
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
@ -794,7 +793,7 @@ class TrialCompletionApi(TrialAppResource):
|
||||
streaming=streaming,
|
||||
)
|
||||
|
||||
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
|
||||
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
@ -824,7 +823,7 @@ class TrialSitApi(Resource):
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Retrieve app site info.
|
||||
|
||||
@ -848,7 +847,7 @@ class TrialAppParameterApi(Resource):
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Retrieve app parameters."""
|
||||
|
||||
@ -866,7 +865,7 @@ class TrialAppParameterApi(Resource):
|
||||
class AppApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Get app detail"""
|
||||
|
||||
@ -882,7 +881,7 @@ class AppApi(Resource):
|
||||
class AppWorkflowApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
"""Get workflow detail"""
|
||||
if not app_model.workflow_id:
|
||||
@ -902,7 +901,7 @@ class DatasetListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
|
||||
@with_session(write=False)
|
||||
@get_app_model_with_trial(None)
|
||||
@get_previewable_app_model(None)
|
||||
def get(self, session: Session, app_model):
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
|
||||
@ -2,19 +2,23 @@ from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Concatenate
|
||||
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.explore.error import AppAccessDeniedError, TrialAppLimitExceeded, TrialAppNotAllowed
|
||||
from controllers.console.explore.error import (
|
||||
AppAccessDeniedError,
|
||||
TrialAppFeatureDisabledError,
|
||||
TrialAppLimitExceeded,
|
||||
TrialAppNotAllowed,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
|
||||
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
|
||||
@ -107,8 +111,8 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N
|
||||
def trial_feature_enable[**P, R](view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
if not RecommendedAppService.is_trial_app_enabled():
|
||||
abort(403, "Trial app feature is not enabled.")
|
||||
if not application_services().recommended_app_queries.is_trial_enabled():
|
||||
raise TrialAppFeatureDisabledError()
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
@ -167,3 +167,9 @@ class IncludeSecretQuery(BaseModel):
|
||||
"""Query parameter for including secret variables in export."""
|
||||
|
||||
include_secret: str = Field(default="false", description="Whether to include secret variables")
|
||||
|
||||
|
||||
class SnippetExportQuery(IncludeSecretQuery):
|
||||
"""Query parameters for exporting a snippet workflow as DSL."""
|
||||
|
||||
workflow_id: str | None = Field(default=None, description="Specific published workflow version to export")
|
||||
|
||||
@ -57,12 +57,11 @@ from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
from services.snippet_service import SnippetService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -298,9 +297,8 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
tenant_id = snippet.tenant_id
|
||||
try:
|
||||
workflow, retirement_candidates = snippet_service.publish_workflow(
|
||||
workflow = snippet_service.publish_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account=current_user,
|
||||
@ -310,16 +308,6 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=current_user.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
@ -480,6 +468,39 @@ class SnippetWorkflowByIdApi(Resource):
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
@console_ns.doc("delete_snippet_workflow_by_id")
|
||||
@console_ns.doc(description="Delete a published snippet workflow version")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
|
||||
@console_ns.response(204, "Workflow deleted successfully")
|
||||
@console_ns.response(400, "Workflow is in use")
|
||||
@console_ns.response(404, "Workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def delete(self, snippet: CustomizedSnippet, workflow_id: str):
|
||||
"""Delete a published snippet workflow version."""
|
||||
snippet_service = _snippet_service()
|
||||
with _snippet_session_maker().begin() as session:
|
||||
try:
|
||||
snippet_service.delete_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
except WorkflowInUseError as e:
|
||||
raise BadRequest(str(e))
|
||||
except DraftWorkflowDeletionError as e:
|
||||
raise BadRequest(str(e))
|
||||
except ValueError as e:
|
||||
raise NotFound(str(e))
|
||||
|
||||
return None, 204
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
|
||||
class SnippetWorkflowRunsApi(Resource):
|
||||
|
||||
@ -3,37 +3,32 @@ from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import Forbidden
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Account
|
||||
from libs.login import current_account_with_tenant
|
||||
from machinery.context import RequestContext
|
||||
from models.enums import TagType
|
||||
from models.model import Tag
|
||||
from services.tag_service import (
|
||||
SaveTagPayload,
|
||||
TagBindingCreatePayload,
|
||||
TagBindingDeletePayload,
|
||||
TagService,
|
||||
UpdateTagPayload,
|
||||
from services.tag_application_service import (
|
||||
CreateTagInput,
|
||||
TagBindingInput,
|
||||
TagBindingTargetNotFoundError,
|
||||
TagNameConflictError,
|
||||
TagNotFoundError,
|
||||
UpdateTagInput,
|
||||
)
|
||||
|
||||
|
||||
@ -59,7 +54,7 @@ class TagBindingRemovePayload(BaseModel):
|
||||
|
||||
|
||||
class TagListQueryParam(BaseModel):
|
||||
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
|
||||
type: Literal["knowledge", "app", "snippet"] = Field(description="Tag type filter")
|
||||
keyword: str | None = Field(None, description="Search keyword")
|
||||
|
||||
|
||||
@ -101,143 +96,158 @@ register_schema_models(
|
||||
register_response_schema_models(console_ns, SimpleResultResponse, TagResponse, TagListResponse)
|
||||
|
||||
|
||||
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
|
||||
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None, context: RequestContext) -> None:
|
||||
if tag_type != TagType.SNIPPET:
|
||||
return
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
enforce_rbac_access(
|
||||
tenant_id=current_tenant_id,
|
||||
account_id=current_user.id,
|
||||
tenant_id=_workspace_id(context),
|
||||
account_id=context.account_id,
|
||||
resource_type=RBACResourceScope.WORKSPACE,
|
||||
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
|
||||
resource_required=False,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
|
||||
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str, context: RequestContext) -> None:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id).limit(1))
|
||||
_enforce_snippet_tag_rbac_if_needed(tag_type)
|
||||
tag_type = application_services().tags.get_tag_type(context, tag_id)
|
||||
_enforce_snippet_tag_rbac_if_needed(tag_type, context)
|
||||
|
||||
|
||||
def _workspace_id(context: RequestContext) -> str:
|
||||
if context.active_workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
return context.active_workspace_id
|
||||
|
||||
|
||||
def _require_tag_edit_permission(*, allow_dataset_editor: bool) -> None:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if current_user.has_edit_permission:
|
||||
return
|
||||
if allow_dataset_editor and current_user.is_dataset_editor:
|
||||
return
|
||||
raise Forbidden()
|
||||
|
||||
|
||||
@console_ns.route("/tags")
|
||||
class TagListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_account_admission()
|
||||
@console_ns.doc(params=query_params_from_model(TagListQueryParam))
|
||||
@console_ns.response(200, "Success", console_ns.models[TagListResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
@model_validate(TagListQueryParam)
|
||||
def get(self, req_data: TagListQueryParam, current_tenant_id: str):
|
||||
tags = TagService.get_tags(req_data.type, current_tenant_id, req_data.keyword, session=db.session())
|
||||
def get(self, req_data: TagListQueryParam, request_context: RequestContext):
|
||||
tags = application_services().tags.list_tags(request_context, req_data.type, req_data.keyword)
|
||||
|
||||
return dump_response(TagListResponse, tags), 200
|
||||
|
||||
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagBasePayload)
|
||||
def post(self, req_data: TagBasePayload, current_user: Account):
|
||||
def post(self, req_data: TagBasePayload, request_context: RequestContext):
|
||||
# Allow users with edit permission, or dataset editors (including dataset operators).
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
_require_tag_edit_permission(allow_dataset_editor=True)
|
||||
|
||||
_enforce_snippet_tag_rbac_if_needed(req_data.type)
|
||||
tag = TagService.save_tags(SaveTagPayload(name=req_data.name, type=req_data.type), db.session())
|
||||
_enforce_snippet_tag_rbac_if_needed(req_data.type, request_context)
|
||||
try:
|
||||
tag = application_services().tags.create_tag(
|
||||
request_context,
|
||||
CreateTagInput(name=req_data.name, type=req_data.type.value),
|
||||
)
|
||||
except TagNameConflictError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
return dump_response(TagResponse, {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}), 200
|
||||
return dump_response(TagResponse, tag), 200
|
||||
|
||||
|
||||
@console_ns.route("/tags/<uuid:tag_id>")
|
||||
class TagUpdateDeleteApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagUpdateRequestPayload)
|
||||
def patch(self, req_data: TagUpdateRequestPayload, current_user: Account, tag_id: UUID):
|
||||
def patch(self, req_data: TagUpdateRequestPayload, request_context: RequestContext, tag_id: UUID):
|
||||
tag_id_str = str(tag_id)
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
_require_tag_edit_permission(allow_dataset_editor=True)
|
||||
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=req_data.name), tag_id_str, db.session())
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
|
||||
try:
|
||||
tag = application_services().tags.update_tag(
|
||||
request_context,
|
||||
tag_id_str,
|
||||
UpdateTagInput(name=req_data.name),
|
||||
)
|
||||
except TagNameConflictError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
except TagNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session())
|
||||
return dump_response(TagResponse, tag), 200
|
||||
|
||||
return (
|
||||
dump_response(
|
||||
TagResponse,
|
||||
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count},
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@console_account_admission()
|
||||
@console_ns.response(204, "Tag deleted successfully")
|
||||
def delete(self, tag_id: UUID):
|
||||
def delete(self, request_context: RequestContext, tag_id: UUID):
|
||||
tag_id_str = str(tag_id)
|
||||
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
|
||||
TagService.delete_tag(tag_id_str, db.session())
|
||||
_require_tag_edit_permission(allow_dataset_editor=False)
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
|
||||
try:
|
||||
application_services().tags.delete_tag(request_context, tag_id_str)
|
||||
except TagNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
|
||||
return "", 204
|
||||
|
||||
|
||||
def _require_tag_binding_edit_permission(current_user: Account) -> None:
|
||||
def _require_tag_binding_edit_permission() -> None:
|
||||
"""
|
||||
Ensure the current account can edit tag bindings.
|
||||
|
||||
Tag binding operations are allowed for users who can edit resources (app/dataset) within the current tenant.
|
||||
"""
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
_require_tag_edit_permission(allow_dataset_editor=True)
|
||||
|
||||
|
||||
def _create_tag_bindings(current_user: Account, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
def _create_tag_bindings(context: RequestContext, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission()
|
||||
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.save_tag_binding(
|
||||
TagBindingCreatePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
target_id=payload.target_id,
|
||||
type=payload.type,
|
||||
),
|
||||
db.session(),
|
||||
)
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
|
||||
try:
|
||||
application_services().tags.create_bindings(
|
||||
context,
|
||||
TagBindingInput(
|
||||
tag_ids=tuple(payload.tag_ids),
|
||||
target_id=payload.target_id,
|
||||
type=payload.type.value,
|
||||
),
|
||||
)
|
||||
except TagBindingTargetNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
def _remove_tag_bindings(current_user: Account, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
def _remove_tag_bindings(context: RequestContext, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission()
|
||||
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
target_id=payload.target_id,
|
||||
type=payload.type,
|
||||
),
|
||||
db.session(),
|
||||
)
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
|
||||
try:
|
||||
application_services().tags.delete_bindings(
|
||||
context,
|
||||
TagBindingInput(
|
||||
tag_ids=tuple(payload.tag_ids),
|
||||
target_id=payload.target_id,
|
||||
type=payload.type.value,
|
||||
),
|
||||
)
|
||||
except TagBindingTargetNotFoundError as error:
|
||||
raise NotFound(str(error)) from None
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
@ -248,13 +258,10 @@ class TagBindingCollectionApi(Resource):
|
||||
@console_ns.doc("create_tag_binding")
|
||||
@console_ns.expect(console_ns.models[TagBindingPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagBindingPayload)
|
||||
def post(self, req_data: TagBindingPayload, current_user: Account):
|
||||
return _create_tag_bindings(current_user, req_data)
|
||||
def post(self, req_data: TagBindingPayload, request_context: RequestContext):
|
||||
return _create_tag_bindings(request_context, req_data)
|
||||
|
||||
|
||||
@console_ns.route("/tag-bindings/remove")
|
||||
@ -265,10 +272,7 @@ class TagBindingRemoveApi(Resource):
|
||||
@console_ns.doc(description="Remove one or more tag bindings from a target.")
|
||||
@console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@console_account_admission()
|
||||
@model_validate(TagBindingRemovePayload)
|
||||
def post(self, req_data: TagBindingRemovePayload, current_user: Account):
|
||||
return _remove_tag_bindings(current_user, req_data)
|
||||
def post(self, req_data: TagBindingRemovePayload, request_context: RequestContext):
|
||||
return _remove_tag_bindings(request_context, req_data)
|
||||
|
||||
@ -2,12 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import pytz
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
@ -28,7 +29,13 @@ from controllers.console.auth.error import (
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
)
|
||||
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.error import (
|
||||
AccountInFreezeError,
|
||||
AccountNotFound,
|
||||
EmailDomainSuspendedError,
|
||||
EmailSendIpLimitError,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.workspace.error import (
|
||||
AccountAlreadyInitedError,
|
||||
CurrentPasswordIncorrectError,
|
||||
@ -46,6 +53,7 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountResponse
|
||||
@ -53,12 +61,15 @@ from graphon.file import helpers as file_helpers
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
|
||||
from libs.login import login_required
|
||||
from machinery.context import RequestContext
|
||||
from models import Account, AccountIntegrate, InvitationCode
|
||||
from models.account import AccountStatus, InvitationCodeStatus
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from services import account_errors
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.account_entities import AccountProfileChanges
|
||||
from services.entities.auth_entities import (
|
||||
ChangeEmailNewEmailToken,
|
||||
ChangeEmailNewEmailVerifiedToken,
|
||||
@ -118,6 +129,42 @@ class AccountTimezonePayload(BaseModel):
|
||||
return timezone(value)
|
||||
|
||||
|
||||
class AccountProfilePatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: Annotated[str, Field(min_length=3, max_length=30)] | SkipJsonSchema[None] = None
|
||||
avatar: str | SkipJsonSchema[None] = None
|
||||
interface_language: str | SkipJsonSchema[None] = None
|
||||
interface_theme: Literal["light", "dark"] | SkipJsonSchema[None] = None
|
||||
timezone: str | SkipJsonSchema[None] = None
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def reject_null(cls, value: object) -> object:
|
||||
if value is None:
|
||||
raise ValueError("Account profile fields cannot be null")
|
||||
return value
|
||||
|
||||
@field_validator("interface_language")
|
||||
@classmethod
|
||||
def validate_language(cls, value: str) -> str:
|
||||
return supported_language(value)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
return timezone(value)
|
||||
|
||||
def to_changes(self) -> AccountProfileChanges:
|
||||
return AccountProfileChanges(
|
||||
name=self.name,
|
||||
avatar=self.avatar,
|
||||
interface_language=self.interface_language,
|
||||
interface_theme=self.interface_theme,
|
||||
timezone=self.timezone,
|
||||
)
|
||||
|
||||
|
||||
class AccountPasswordPayload(BaseModel):
|
||||
password: str | None = None
|
||||
new_password: str
|
||||
@ -183,6 +230,7 @@ register_schema_models(
|
||||
AccountInterfaceLanguagePayload,
|
||||
AccountInterfaceThemePayload,
|
||||
AccountTimezonePayload,
|
||||
AccountProfilePatchPayload,
|
||||
AccountPasswordPayload,
|
||||
AccountDeletePayload,
|
||||
AccountDeletionFeedbackPayload,
|
||||
@ -248,6 +296,14 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _update_account_profile(request_context: RequestContext, changes: AccountProfileChanges) -> dict[str, object]:
|
||||
try:
|
||||
account = application_services().accounts.profile.update(request_context, changes)
|
||||
except account_errors.AccountNotFoundError as error:
|
||||
raise AccountNotFound() from error
|
||||
return dump_response(AccountResponse, account)
|
||||
|
||||
|
||||
@console_ns.route("/account/init")
|
||||
class AccountInitApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
|
||||
@ -305,21 +361,28 @@ class AccountProfileApi(Resource):
|
||||
def get(self, current_user: Account):
|
||||
return dump_response(AccountResponse, current_user)
|
||||
|
||||
@console_ns.expect(console_ns.models[AccountProfilePatchPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
@model_validate(AccountProfilePatchPayload)
|
||||
def patch(self, args: AccountProfilePatchPayload, request_context: RequestContext):
|
||||
return _update_account_profile(request_context, args.to_changes())
|
||||
|
||||
|
||||
@console_ns.route("/account/name")
|
||||
class AccountNameApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_name_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountNamePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountNamePayload.model_validate(payload)
|
||||
updated_account = AccountService.update_account(current_user, session=db.session(), name=args.name)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
return _update_account_profile(request_context, AccountProfileChanges(name=args.name))
|
||||
|
||||
|
||||
@console_ns.route("/account/avatar")
|
||||
@ -350,73 +413,69 @@ class AccountAvatarApi(Resource):
|
||||
return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json")
|
||||
|
||||
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc("update_account_avatar_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountAvatarPayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, session=db.session(), avatar=args.avatar)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar))
|
||||
|
||||
|
||||
@console_ns.route("/account/interface-language")
|
||||
class AccountInterfaceLanguageApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_interface_language_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceLanguagePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(
|
||||
current_user, session=db.session(), interface_language=args.interface_language
|
||||
return _update_account_profile(
|
||||
request_context,
|
||||
AccountProfileChanges(interface_language=args.interface_language),
|
||||
)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
|
||||
|
||||
@console_ns.route("/account/interface-theme")
|
||||
class AccountInterfaceThemeApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_interface_theme_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceThemePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(
|
||||
current_user, session=db.session(), interface_theme=args.interface_theme
|
||||
return _update_account_profile(
|
||||
request_context,
|
||||
AccountProfileChanges(interface_theme=args.interface_theme),
|
||||
)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
|
||||
|
||||
@console_ns.route("/account/timezone")
|
||||
class AccountTimezoneApi(Resource):
|
||||
"""Deprecated compatibility route; use PATCH /account/profile."""
|
||||
|
||||
@console_ns.doc("update_account_timezone_deprecated")
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.expect(console_ns.models[AccountTimezonePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountTimezonePayload.model_validate(payload)
|
||||
|
||||
updated_account = AccountService.update_account(current_user, session=db.session(), timezone=args.timezone)
|
||||
|
||||
return dump_response(AccountResponse, updated_account)
|
||||
return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone))
|
||||
|
||||
|
||||
@console_ns.route("/account/password")
|
||||
@ -715,7 +774,10 @@ class ChangeEmailResetApi(Resource):
|
||||
args = ChangeEmailResetPayload.model_validate(payload)
|
||||
normalized_new_email = args.new_email.lower()
|
||||
|
||||
if AccountService.is_account_in_freeze(normalized_new_email):
|
||||
freeze_type = AccountService.get_account_freeze_type(normalized_new_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
|
||||
if not AccountService.check_email_unique(normalized_new_email, session=db.session()):
|
||||
@ -762,7 +824,10 @@ class CheckEmailUnique(Resource):
|
||||
payload = console_ns.payload or {}
|
||||
args = CheckEmailUniquePayload.model_validate(payload)
|
||||
normalized_email = args.email.lower()
|
||||
if AccountService.is_account_in_freeze(normalized_email):
|
||||
freeze_type = AccountService.get_account_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
if not AccountService.check_email_unique(normalized_email, session=db.session()):
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
@ -290,6 +290,8 @@ class EndpointListApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@ -318,6 +320,8 @@ class EndpointListForSinglePluginApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
|
||||
@ -17,7 +17,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.snippets.payloads import (
|
||||
CreateSnippetPayload,
|
||||
IncludeSecretQuery,
|
||||
SnippetExportQuery,
|
||||
SnippetImportPayload,
|
||||
SnippetListQuery,
|
||||
UpdateSnippetPayload,
|
||||
@ -89,7 +89,7 @@ register_schema_models(
|
||||
CreateSnippetPayload,
|
||||
UpdateSnippetPayload,
|
||||
SnippetImportPayload,
|
||||
IncludeSecretQuery,
|
||||
SnippetExportQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
@ -289,7 +289,7 @@ class CustomizedSnippetExportApi(Resource):
|
||||
@console_ns.doc("export_customized_snippet")
|
||||
@console_ns.doc(description="Export snippet configuration as DSL")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
|
||||
@console_ns.doc(params=query_params_from_model(IncludeSecretQuery))
|
||||
@console_ns.doc(params=query_params_from_model(SnippetExportQuery))
|
||||
@console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__])
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@ -312,11 +312,18 @@ class CustomizedSnippetExportApi(Resource):
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
# Get include_secret parameter
|
||||
query = IncludeSecretQuery.model_validate(request.args.to_dict())
|
||||
query = SnippetExportQuery.model_validate(request.args.to_dict())
|
||||
|
||||
with Session(db.engine) as session:
|
||||
export_service = SnippetDslService(session)
|
||||
result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
|
||||
try:
|
||||
result = export_service.export_snippet_dsl(
|
||||
snippet=snippet,
|
||||
include_secret=query.include_secret == "true",
|
||||
workflow_id=query.workflow_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
|
||||
# Set filename with .snippet extension
|
||||
filename = f"{snippet.name}.snippet"
|
||||
|
||||
@ -1296,6 +1296,8 @@ class ToolOAuthCustomClient(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, provider: str):
|
||||
|
||||
@ -4,6 +4,7 @@ from typing import cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope
|
||||
from controllers.openapi import openapi_ns
|
||||
@ -17,6 +18,7 @@ from models import Account, App
|
||||
from models.account import TenantAccountRole
|
||||
from services.app_dsl_service import AppDslService, Import
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
|
||||
|
||||
@ -53,18 +55,21 @@ class AppDslImportApi(Resource):
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
service = AppDslService(session)
|
||||
result = service.import_app(
|
||||
account=account,
|
||||
import_mode=body.mode,
|
||||
yaml_content=body.yaml_content,
|
||||
yaml_url=body.yaml_url,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
icon_type=body.icon_type,
|
||||
icon=body.icon,
|
||||
icon_background=body.icon_background,
|
||||
app_id=body.app_id,
|
||||
)
|
||||
try:
|
||||
result = service.import_app(
|
||||
account=account,
|
||||
import_mode=body.mode,
|
||||
yaml_content=body.yaml_content,
|
||||
yaml_url=body.yaml_url,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
icon_type=body.icon_type,
|
||||
icon=body.icon,
|
||||
icon_background=body.icon_background,
|
||||
app_id=body.app_id,
|
||||
)
|
||||
except NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
@ -108,7 +113,10 @@ class AppDslImportConfirmApi(Resource):
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
service = AppDslService(session)
|
||||
result = service.confirm_import(import_id=import_id, account=account)
|
||||
try:
|
||||
result = service.confirm_import(import_id=import_id, account=account)
|
||||
except NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
|
||||
@ -7,33 +7,29 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.common.fields import AccessModeResponse, Parameters
|
||||
from controllers.common.fields import AccessModeResponse, BooleanResultResponse, Parameters
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
WebAppAccessServiceUnavailableError,
|
||||
WebAppAuthRequiredError,
|
||||
WebAppNotFoundError,
|
||||
)
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import dump_response
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_webapp_passport
|
||||
from models.model import App, EndUser
|
||||
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.webapp_access_query_service import (
|
||||
WebAppAccessAppNotFoundError,
|
||||
WebAppAccessReferenceRequiredError,
|
||||
WebAppAccessUnavailableError,
|
||||
)
|
||||
from services.webapp_auth_service import WebAppAuthService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -64,7 +60,7 @@ register_response_schema_models(
|
||||
Parameters,
|
||||
AppMetaResponse,
|
||||
AccessModeResponse,
|
||||
fields.BooleanResultResponse,
|
||||
BooleanResultResponse,
|
||||
)
|
||||
|
||||
|
||||
@ -165,21 +161,23 @@ class AppWebAuthPermission(Resource):
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
500: "Internal Server Error",
|
||||
503: "Web App Access Service Unavailable",
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[fields.BooleanResultResponse.__name__])
|
||||
@web_ns.response(200, "Success", web_ns.models[BooleanResultResponse.__name__])
|
||||
def get(self):
|
||||
user_id = "visitor"
|
||||
app_code = request.headers.get(HEADER_NAME_APP_CODE)
|
||||
app_id = request.args.get("appId")
|
||||
if not app_id or not app_code:
|
||||
raise ValueError("appId must be provided")
|
||||
|
||||
require_permission_check = WebAppAuthService.is_app_require_permission_check(
|
||||
app_id=app_id, session=db.session()
|
||||
)
|
||||
if not require_permission_check:
|
||||
return {"result": True}
|
||||
webapp_access = application_services().webapp_access
|
||||
try:
|
||||
requires_permission_check = webapp_access.requires_permission_check(app_id)
|
||||
except WebAppAccessUnavailableError:
|
||||
raise WebAppAccessServiceUnavailableError() from None
|
||||
if not requires_permission_check:
|
||||
return dump_response(BooleanResultResponse, {"result": True})
|
||||
|
||||
try:
|
||||
tk = extract_webapp_passport(app_code, request)
|
||||
@ -188,16 +186,13 @@ class AppWebAuthPermission(Resource):
|
||||
decoded = PassportService().verify(tk)
|
||||
user_id = decoded.get("user_id", "visitor")
|
||||
except Unauthorized:
|
||||
raise
|
||||
raise WebAppAuthRequiredError() from None
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during auth verification")
|
||||
raise
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.webapp_auth.enabled:
|
||||
return {"result": True}
|
||||
|
||||
res = True
|
||||
if WebAppAuthService.is_app_require_permission_check(app_id=app_id, session=db.session()):
|
||||
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_id)
|
||||
return {"result": res}
|
||||
try:
|
||||
is_allowed = webapp_access.is_user_allowed(user_id=str(user_id), app_id=app_id)
|
||||
except WebAppAccessUnavailableError:
|
||||
raise WebAppAccessServiceUnavailableError() from None
|
||||
return dump_response(BooleanResultResponse, {"result": is_allowed})
|
||||
|
||||
@ -1,23 +1,19 @@
|
||||
from typing import Any, Self
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import build_icon_url
|
||||
from models.account import Tenant, TenantStatus
|
||||
from models.model import App, AppMode, EndUser, IconType, Site
|
||||
from libs.helper import build_icon_url, dump_response
|
||||
from models.account import Tenant
|
||||
from models.model import App, AppMode, EndUser, Site
|
||||
from services.entities.feature_entities import FeatureModel
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeUnavailableError
|
||||
|
||||
|
||||
class WebSiteResponse(ResponseModel):
|
||||
@ -128,17 +124,6 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None:
|
||||
"""Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere."""
|
||||
if site.icon_type != IconType.IMAGE or not site.icon:
|
||||
return None
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
|
||||
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
|
||||
):
|
||||
return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id)
|
||||
return build_icon_url(site.icon_type, site.icon)
|
||||
|
||||
|
||||
@web_ns.route("/site")
|
||||
class AppSiteApi(WebApiResource):
|
||||
@web_ns.doc("Get App Site Info")
|
||||
@ -156,25 +141,21 @@ class AppSiteApi(WebApiResource):
|
||||
@web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__])
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
"""Retrieve app site info."""
|
||||
# get site
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
try:
|
||||
bootstrap = application_services().web_app_runtime.get_bootstrap(app_model.id)
|
||||
except WebAppRuntimeUnavailableError:
|
||||
raise Forbidden() from None
|
||||
|
||||
if site is None:
|
||||
raise Forbidden()
|
||||
|
||||
tenant = app_model.tenant
|
||||
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden()
|
||||
|
||||
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
|
||||
|
||||
return WebAppSiteResponse.from_app_site(
|
||||
tenant=tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())),
|
||||
site=site,
|
||||
end_user_id=end_user.id,
|
||||
features=features,
|
||||
can_replace_logo=features.can_replace_logo,
|
||||
icon_url=_build_site_icon_url(site=site, tenant_id=tenant.id),
|
||||
).model_dump(mode="json")
|
||||
return dump_response(
|
||||
WebAppSiteResponse,
|
||||
{
|
||||
"app_id": bootstrap.app_id,
|
||||
"mode": bootstrap.mode,
|
||||
"end_user_id": end_user.id,
|
||||
"enable_site": bootstrap.enable_site,
|
||||
"site": bootstrap.site,
|
||||
"plan": bootstrap.plan,
|
||||
"can_replace_logo": bootstrap.can_replace_logo,
|
||||
"custom_config": bootstrap.custom_config,
|
||||
},
|
||||
)
|
||||
|
||||
@ -273,6 +273,17 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
with session_factory.create_session() as session:
|
||||
err = self.handle_error(event=event, session=session, message_id=self._message_id)
|
||||
session.commit()
|
||||
|
||||
if trace_manager:
|
||||
trace_manager.add_trace_task(
|
||||
TraceTask(
|
||||
TraceTaskName.MESSAGE_TRACE,
|
||||
conversation_id=self._conversation_id,
|
||||
message_id=self._message_id,
|
||||
trace_session_id=self._application_generate_entity.extras.get("trace_session_id"),
|
||||
)
|
||||
)
|
||||
|
||||
yield self.error_to_stream_response(err)
|
||||
break
|
||||
case QueueStopEvent() | QueueMessageEndEvent():
|
||||
|
||||
@ -16,10 +16,15 @@ from core.schemas.schema_manager import SchemaManager
|
||||
from enums import DeploymentEdition, WebAppAccessMode
|
||||
from extensions.ext_redis import RedisClientWrapper, redis_client
|
||||
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
|
||||
from repositories.account_repository import SQLAlchemyAccountRepository
|
||||
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
|
||||
from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository
|
||||
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
|
||||
from repositories.installation_state_repository import InstallationStateRepository
|
||||
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
|
||||
from repositories.tag_repository import TagRepository
|
||||
from repositories.trial_app_query_repository import TrialAppQueryRepository
|
||||
from repositories.trial_app_usage_repository import TrialAppUsageRepository
|
||||
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
|
||||
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
|
||||
from repositories.workspace_query_repository import WorkspaceQueryRepository
|
||||
@ -30,6 +35,7 @@ from services.account_activation_adapters import (
|
||||
RegisterServiceInvitationTokenStore,
|
||||
)
|
||||
from services.account_activation_service import AccountActivationService
|
||||
from services.account_profile_service import AccountProfileService
|
||||
from services.app_definition_query_service import AppDefinitionQueryService
|
||||
from services.auth.data_source_api_key_auth_gateways import (
|
||||
ProviderApiKeyAuthCredentialValidator,
|
||||
@ -42,10 +48,20 @@ from services.explore_banner_query_service import ExploreBannerQueryService
|
||||
from services.feature_query_service import FeatureQueryService
|
||||
from services.feature_service import FeatureService
|
||||
from services.feature_service_gateway import FeatureServiceGateway
|
||||
from services.file_service import FileService
|
||||
from services.init_validation_service import InitValidationService
|
||||
from services.recommended_app_catalog_gateway import (
|
||||
BuiltinRecommendedAppCatalogGateway,
|
||||
RecommendedAppCatalogRouter,
|
||||
RemoteRecommendedAppCatalogGateway,
|
||||
)
|
||||
from services.recommended_app_query_service import RecommendedAppQueryService
|
||||
from services.schema_definition_service import SchemaDefinitionService
|
||||
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
|
||||
from services.setup_service import SetupService
|
||||
from services.tag_application_service import TagApplicationService
|
||||
from services.trial_app_usage import TrialAppUsageRecorder
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
|
||||
from services.webapp_access_query_service import (
|
||||
WebAppAccessQueryService,
|
||||
WebAppAccessUnavailableError,
|
||||
@ -69,19 +85,36 @@ def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode:
|
||||
raise WebAppAccessUnavailableError from e
|
||||
|
||||
|
||||
def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
|
||||
try:
|
||||
return EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id)
|
||||
except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise WebAppAccessUnavailableError from e
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountServices:
|
||||
profile: AccountProfileService
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationServices:
|
||||
accounts: AccountServices
|
||||
account_activation: AccountActivationService
|
||||
app_definitions: AppDefinitionQueryService
|
||||
data_source_api_key_auth: DataSourceApiKeyAuthService
|
||||
webapp_access: WebAppAccessQueryService
|
||||
web_app_runtime: WebAppRuntimeQueryService
|
||||
explore_banner_queries: ExploreBannerQueryService
|
||||
schema_definitions: SchemaDefinitionService
|
||||
setup: SetupService
|
||||
feature_queries: FeatureQueryService
|
||||
init_validation: InitValidationService
|
||||
recommended_app_queries: RecommendedAppQueryService
|
||||
trial_app_usage: TrialAppUsageRecorder
|
||||
workspace_queries: WorkspaceQueryService
|
||||
workspace_member_queries: WorkspaceMemberQueryService
|
||||
tags: TagApplicationService
|
||||
|
||||
|
||||
def build_application_services(
|
||||
@ -93,7 +126,21 @@ def build_application_services(
|
||||
) -> ApplicationServices:
|
||||
installation_state = InstallationStateRepository(client=database_client)
|
||||
data_source_api_key_auth_bindings = SQLAlchemyDataSourceApiKeyAuthBindingRepository(session_factory=database_client)
|
||||
app_definition_repository = AppDefinitionQueryRepository(session_factory=database_client)
|
||||
feature_gateway = FeatureServiceGateway()
|
||||
trial_app_enabled = FeatureService.is_trial_app_enabled()
|
||||
database_catalog = DatabaseRecommendedAppCatalogRepository(session_factory=database_client, redis=redis)
|
||||
builtin_catalog = BuiltinRecommendedAppCatalogGateway()
|
||||
remote_catalog = RemoteRecommendedAppCatalogGateway()
|
||||
recommended_app_catalog = RecommendedAppCatalogRouter(
|
||||
remote=remote_catalog,
|
||||
database=database_catalog,
|
||||
builtin=builtin_catalog,
|
||||
)
|
||||
return ApplicationServices(
|
||||
accounts=AccountServices(
|
||||
profile=AccountProfileService(accounts=SQLAlchemyAccountRepository(database_client)),
|
||||
),
|
||||
account_activation=AccountActivationService(
|
||||
tokens=RegisterServiceInvitationTokenStore(),
|
||||
accounts=SQLAlchemyAccountActivationRepository(database_client),
|
||||
@ -106,7 +153,7 @@ def build_application_services(
|
||||
),
|
||||
),
|
||||
app_definitions=AppDefinitionQueryService(
|
||||
definitions=AppDefinitionQueryRepository(session_factory=database_client),
|
||||
definitions=app_definition_repository,
|
||||
builtin_icon_url_prefix=(
|
||||
dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
|
||||
),
|
||||
@ -120,6 +167,13 @@ def build_application_services(
|
||||
access=WebAppAccessQueryRepository(session_factory=database_client),
|
||||
webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(),
|
||||
access_mode_for_app=_get_enterprise_webapp_access_mode,
|
||||
is_user_allowed_for_app=_is_user_allowed_to_access_webapp,
|
||||
),
|
||||
web_app_runtime=WebAppRuntimeQueryService(
|
||||
runtime=app_definition_repository,
|
||||
file_service=FileService(database_client),
|
||||
workspace_features=feature_gateway.get_workspace_features,
|
||||
files_url=dify_config.FILES_URL,
|
||||
),
|
||||
explore_banner_queries=ExploreBannerQueryService(
|
||||
banners=ExploreBannerQueryRepository(client=database_client),
|
||||
@ -133,7 +187,7 @@ def build_application_services(
|
||||
setup_required=deployment_edition != DeploymentEdition.CLOUD,
|
||||
),
|
||||
feature_queries=FeatureQueryService(
|
||||
features=FeatureServiceGateway(),
|
||||
features=feature_gateway,
|
||||
trial_models=FeatureService.get_trial_models(),
|
||||
app_dsl_version=CURRENT_APP_DSL_VERSION,
|
||||
),
|
||||
@ -142,6 +196,12 @@ def build_application_services(
|
||||
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
|
||||
expected_password=initialization_password,
|
||||
),
|
||||
recommended_app_queries=RecommendedAppQueryService(
|
||||
catalog=recommended_app_catalog,
|
||||
trial_apps=TrialAppQueryRepository(session_factory=database_client),
|
||||
trial_enabled=trial_app_enabled,
|
||||
),
|
||||
trial_app_usage=TrialAppUsageRepository(session_factory=database_client),
|
||||
workspace_queries=WorkspaceQueryService(
|
||||
workspaces=WorkspaceQueryRepository(
|
||||
client=database_client,
|
||||
@ -154,6 +214,9 @@ def build_application_services(
|
||||
),
|
||||
roles=DeploymentWorkspaceMemberRoleResolver(),
|
||||
),
|
||||
tags=TagApplicationService(
|
||||
tags=TagRepository(session_factory=database_client),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ WorkflowNodeExecutionModel operations using Aliyun SLS LogStore.
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, override
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@ -17,6 +17,7 @@ from extensions.logstore.aliyun_logstore import AliyunLogStore
|
||||
from extensions.logstore.repositories import safe_float, safe_int
|
||||
from extensions.logstore.sql_escape import escape_identifier, escape_logstore_query_value
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
|
||||
from models.enums import CreatorUserRole
|
||||
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
|
||||
from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
|
||||
@ -86,28 +87,29 @@ def _dict_to_workflow_node_execution_model(data: dict[str, Any]) -> WorkflowNode
|
||||
model.execution_metadata = data.get("execution_metadata")
|
||||
|
||||
# Handle datetime fields
|
||||
# Every branch must yield naive UTC, matching what the database path stores.
|
||||
created_at = data.get("created_at")
|
||||
match created_at:
|
||||
case None:
|
||||
# Provide default created_at if missing
|
||||
model.created_at = datetime.now()
|
||||
model.created_at = naive_utc_now()
|
||||
case str():
|
||||
model.created_at = datetime.fromisoformat(created_at)
|
||||
model.created_at = ensure_naive_utc(datetime.fromisoformat(created_at))
|
||||
case int() | float():
|
||||
model.created_at = datetime.fromtimestamp(created_at)
|
||||
model.created_at = datetime.fromtimestamp(created_at, tz=UTC).replace(tzinfo=None)
|
||||
case _:
|
||||
model.created_at = created_at
|
||||
model.created_at = ensure_naive_utc(created_at)
|
||||
|
||||
finished_at = data.get("finished_at")
|
||||
match finished_at:
|
||||
case None:
|
||||
...
|
||||
case str():
|
||||
model.finished_at = datetime.fromisoformat(finished_at)
|
||||
model.finished_at = ensure_naive_utc(datetime.fromisoformat(finished_at))
|
||||
case int() | float():
|
||||
model.finished_at = datetime.fromtimestamp(finished_at)
|
||||
model.finished_at = datetime.fromtimestamp(finished_at, tz=UTC).replace(tzinfo=None)
|
||||
case _:
|
||||
model.finished_at = finished_at
|
||||
model.finished_at = ensure_naive_utc(finished_at)
|
||||
|
||||
return model
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast, override
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@ -26,6 +26,7 @@ from extensions.logstore.aliyun_logstore import AliyunLogStore
|
||||
from extensions.logstore.repositories import safe_float, safe_int
|
||||
from extensions.logstore.sql_escape import escape_identifier, escape_logstore_query_value, escape_sql_string
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowRun, WorkflowType
|
||||
@ -104,28 +105,30 @@ def _dict_to_workflow_run(data: dict[str, Any]) -> WorkflowRun:
|
||||
model.error = data.get("error_message") or data.get("error")
|
||||
|
||||
# Handle datetime fields
|
||||
# Every branch must yield naive UTC, matching what the database path stores.
|
||||
# Mixing naive local time and aware values here breaks the elapsed_time subtraction below.
|
||||
started_at = data.get("started_at") or data.get("created_at")
|
||||
if started_at:
|
||||
match started_at:
|
||||
case str():
|
||||
model.created_at = datetime.fromisoformat(started_at)
|
||||
model.created_at = ensure_naive_utc(datetime.fromisoformat(started_at))
|
||||
case int() | float():
|
||||
model.created_at = datetime.fromtimestamp(started_at)
|
||||
model.created_at = datetime.fromtimestamp(started_at, tz=UTC).replace(tzinfo=None)
|
||||
case _:
|
||||
model.created_at = started_at
|
||||
model.created_at = ensure_naive_utc(started_at)
|
||||
else:
|
||||
# Provide default created_at if missing
|
||||
model.created_at = datetime.now()
|
||||
model.created_at = naive_utc_now()
|
||||
|
||||
finished_at = data.get("finished_at")
|
||||
if finished_at:
|
||||
match finished_at:
|
||||
case str():
|
||||
model.finished_at = datetime.fromisoformat(finished_at)
|
||||
model.finished_at = ensure_naive_utc(datetime.fromisoformat(finished_at))
|
||||
case int() | float():
|
||||
model.finished_at = datetime.fromtimestamp(finished_at)
|
||||
model.finished_at = datetime.fromtimestamp(finished_at, tz=UTC).replace(tzinfo=None)
|
||||
case _:
|
||||
model.finished_at = finished_at
|
||||
model.finished_at = ensure_naive_utc(finished_at)
|
||||
|
||||
# Compute elapsed_time from started_at and finished_at
|
||||
# LogStore doesn't store elapsed_time, it's computed in WorkflowExecution domain entity
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.repositories.factory import WorkflowExecutionRepository
|
||||
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
|
||||
from extensions.logstore.aliyun_logstore import AliyunLogStore
|
||||
@ -71,14 +71,12 @@ class LogstoreWorkflowExecutionRepository(WorkflowExecutionRepository):
|
||||
triggered_from=triggered_from,
|
||||
)
|
||||
|
||||
# Control flag for dual-write (write to both LogStore and SQL database)
|
||||
# Set to True to enable dual-write for safe migration, False to use LogStore only
|
||||
self._enable_dual_write = os.environ.get("LOGSTORE_DUAL_WRITE_ENABLED", "false").lower() == "true"
|
||||
self._enable_dual_write = dify_config.LOGSTORE_DUAL_WRITE_ENABLED
|
||||
|
||||
# Control flag for whether to write the `graph` field to LogStore.
|
||||
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
|
||||
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
|
||||
self._enable_put_graph_field = os.environ.get("LOGSTORE_ENABLE_PUT_GRAPH_FIELD", "true").lower() == "true"
|
||||
self._enable_put_graph_field = dify_config.LOGSTORE_ENABLE_PUT_GRAPH_FIELD
|
||||
|
||||
def _to_logstore_model(self, domain_model: WorkflowExecution) -> list[tuple[str, str]]:
|
||||
"""
|
||||
|
||||
@ -7,7 +7,6 @@ using Aliyun SLS LogStore with append-only writes and version control.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
@ -16,6 +15,7 @@ from typing import Any, override
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.ops.utils import JSON_DICT_ADAPTER
|
||||
from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
|
||||
from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository
|
||||
@ -152,9 +152,9 @@ class LogstoreWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
|
||||
triggered_from=triggered_from,
|
||||
)
|
||||
|
||||
# Control flag for dual-write (write to both LogStore and SQL database)
|
||||
# Set to True to enable dual-write for safe migration, False to use LogStore only
|
||||
self._enable_dual_write = os.environ.get("LOGSTORE_DUAL_WRITE_ENABLED", "false").lower() == "true"
|
||||
# Keep the migration switch on the typed application config so callers
|
||||
# and tests share the same validated source.
|
||||
self._enable_dual_write = dify_config.LOGSTORE_DUAL_WRITE_ENABLED
|
||||
|
||||
def _to_logstore_model(self, domain_model: WorkflowNodeExecution) -> Sequence[tuple[str, str]]:
|
||||
logger.debug(
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
"""clean legacy agent soul files
|
||||
|
||||
Revision ID: fbdfcf5f5a6e
|
||||
Revises: 89919253ca7a
|
||||
Create Date: 2026-08-20 09:38:36.827807
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "fbdfcf5f5a6e"
|
||||
down_revision = "89919253ca7a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _remove_legacy_files(table_name: str) -> None:
|
||||
dialect_name = op.get_context().dialect.name
|
||||
if dialect_name == "postgresql":
|
||||
op.execute(
|
||||
f"""UPDATE {table_name}
|
||||
SET config_snapshot = (config_snapshot::jsonb - 'files')::text
|
||||
WHERE config_snapshot::jsonb ? 'files'"""
|
||||
)
|
||||
return
|
||||
if dialect_name == "mysql":
|
||||
op.execute(
|
||||
f"""UPDATE {table_name}
|
||||
SET config_snapshot = JSON_REMOVE(config_snapshot, '$.files')
|
||||
WHERE JSON_CONTAINS_PATH(config_snapshot, 'one', '$.files')"""
|
||||
)
|
||||
return
|
||||
if dialect_name == "sqlite":
|
||||
op.execute(
|
||||
f"""UPDATE {table_name}
|
||||
SET config_snapshot = json_remove(config_snapshot, '$.files')
|
||||
WHERE json_type(config_snapshot, '$.files') IS NOT NULL"""
|
||||
)
|
||||
return
|
||||
raise RuntimeError(f"unsupported database dialect: {dialect_name}")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The Agent Drive removal migration skipped its Python row rewrite when
|
||||
# migrations were emitted as offline SQL. Run the cleanup again as native
|
||||
# SQL so every deployment removes the retired AgentSoulConfig.files field.
|
||||
_remove_legacy_files("agent_config_snapshots")
|
||||
_remove_legacy_files("agent_config_drafts")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The retired files catalog cannot be reconstructed after Agent Drive data
|
||||
# has been removed.
|
||||
pass
|
||||
@ -27,7 +27,12 @@ Get account avatar url
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AvatarUrlResponse](#avatarurlresponse)<br> |
|
||||
|
||||
### [POST] /account/avatar
|
||||
### ~~[POST] /account/avatar~~
|
||||
|
||||
***DEPRECATED***
|
||||
|
||||
Deprecated. Use PATCH /account/profile instead.
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
@ -187,7 +192,12 @@ Get account avatar url
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AccountIntegrateListResponse](#accountintegratelistresponse)<br> |
|
||||
|
||||
### [POST] /account/interface-language
|
||||
### ~~[POST] /account/interface-language~~
|
||||
|
||||
***DEPRECATED***
|
||||
|
||||
Deprecated. Use PATCH /account/profile instead.
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
@ -200,7 +210,12 @@ Get account avatar url
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
|
||||
|
||||
### [POST] /account/interface-theme
|
||||
### ~~[POST] /account/interface-theme~~
|
||||
|
||||
***DEPRECATED***
|
||||
|
||||
Deprecated. Use PATCH /account/profile instead.
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
@ -213,7 +228,12 @@ Get account avatar url
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
|
||||
|
||||
### [POST] /account/name
|
||||
### ~~[POST] /account/name~~
|
||||
|
||||
***DEPRECATED***
|
||||
|
||||
Deprecated. Use PATCH /account/profile instead.
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
@ -246,7 +266,25 @@ Get account avatar url
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
|
||||
|
||||
### [POST] /account/timezone
|
||||
### [PATCH] /account/profile
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AccountProfilePatchPayload](#accountprofilepatchpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
|
||||
|
||||
### ~~[POST] /account/timezone~~
|
||||
|
||||
***DEPRECATED***
|
||||
|
||||
Deprecated. Use PATCH /account/profile instead.
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
@ -6526,7 +6564,8 @@ Check if dataset is in use
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [RecommendedAppDetailNullableResponse](#recommendedappdetailnullableresponse)<br> |
|
||||
| 200 | Success | **application/json**: [RecommendedAppDetailResponse](#recommendedappdetailresponse)<br> |
|
||||
| 404 | Recommended app not found | |
|
||||
|
||||
### [GET] /features
|
||||
**Get feature configuration for current tenant**
|
||||
@ -11070,6 +11109,24 @@ Reset a draft workflow variable to its default value (snippet scope)
|
||||
| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)<br> |
|
||||
| 400 | No draft workflow found | |
|
||||
|
||||
### [DELETE] /snippets/{snippet_id}/workflows/{workflow_id}
|
||||
**Delete a published snippet workflow version**
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| snippet_id | path | Snippet ID | Yes | string (uuid) |
|
||||
| workflow_id | path | Workflow ID | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 204 | Workflow deleted successfully |
|
||||
| 400 | Workflow is in use |
|
||||
| 404 | Workflow not found |
|
||||
|
||||
### [PATCH] /snippets/{snippet_id}/workflows/{workflow_id}
|
||||
**Update a published snippet workflow version's display metadata**
|
||||
|
||||
@ -11185,7 +11242,7 @@ Remove one or more tag bindings from a target.
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| keyword | query | Search keyword | No | string |
|
||||
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
|
||||
| type | query | Tag type filter | Yes | string, <br>**Available values:** "app", "knowledge", "snippet" |
|
||||
|
||||
#### Responses
|
||||
|
||||
@ -11850,6 +11907,7 @@ Export snippet configuration as DSL
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| snippet_id | path | Snippet ID to export | Yes | string (uuid) |
|
||||
| include_secret | query | Whether to include secret variables | No | string, <br>**Default:** false |
|
||||
| workflow_id | query | Specific published workflow version to export | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
@ -14749,6 +14807,16 @@ Model class for AI model.
|
||||
| password | string | | No |
|
||||
| repeat_new_password | string | | Yes |
|
||||
|
||||
#### AccountProfilePatchPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| avatar | string | | No |
|
||||
| interface_language | string | | No |
|
||||
| interface_theme | string, <br>**Available values:** "dark", "light" | *Enum:* `"dark"`, `"light"` | No |
|
||||
| name | string | | No |
|
||||
| timezone | string | | No |
|
||||
|
||||
#### AccountResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -20130,11 +20198,9 @@ How Dify forwards the end-user's identity to an MCP server.
|
||||
|
||||
#### IncludeSecretQuery
|
||||
|
||||
Query parameter for including secret variables in export.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| include_secret | string, <br>**Default:** false | Whether to include secret variables | No |
|
||||
| include_secret | string, <br>**Default:** false | Whether to include secret values in the exported DSL | No |
|
||||
|
||||
#### IndexingEstimate
|
||||
|
||||
@ -24903,12 +24969,6 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
|
||||
| permission_keys | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### RecommendedAppDetailNullableResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| RecommendedAppDetailNullableResponse | [RecommendedAppDetailResponse](#recommendedappdetailresponse) | | |
|
||||
|
||||
#### RecommendedAppDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -25558,6 +25618,15 @@ Payload for syncing snippet draft workflow.
|
||||
| hash | string | | No |
|
||||
| input_fields | [ object ] | | No |
|
||||
|
||||
#### SnippetExportQuery
|
||||
|
||||
Query parameters for exporting a snippet workflow as DSL.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| include_secret | string, <br>**Default:** false | Whether to include secret variables | No |
|
||||
| workflow_id | string | Specific published workflow version to export | No |
|
||||
|
||||
#### SnippetImportPayload
|
||||
|
||||
Payload for importing snippet from DSL.
|
||||
@ -25992,7 +26061,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword | string | Search keyword | No |
|
||||
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
|
||||
| type | string, <br>**Available values:** "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `"app"`, `"knowledge"`, `"snippet"` | Yes |
|
||||
|
||||
#### TagListResponse
|
||||
|
||||
|
||||
@ -847,6 +847,7 @@ Check if user has permission to access a web application.
|
||||
| 400 | Bad Request | |
|
||||
| 401 | Unauthorized | |
|
||||
| 500 | Internal Server Error | |
|
||||
| 503 | Web App Access Service Unavailable | |
|
||||
|
||||
### [POST] /workflows/run
|
||||
**Run workflow**
|
||||
|
||||
@ -6,7 +6,7 @@ requires-python = "~=3.12.0"
|
||||
dependencies = [
|
||||
# Legacy: mature and widely deployed
|
||||
"bleach>=6.4.0,<7.0.0",
|
||||
"boto3>=1.43.56,<2.0.0",
|
||||
"boto3>=1.43.71,<2.0.0",
|
||||
"celery>=5.6.3,<6.0.0",
|
||||
"croniter>=6.2.2,<7.0.0",
|
||||
"dify-agent",
|
||||
@ -31,7 +31,7 @@ dependencies = [
|
||||
"flask-migrate>=4.1.0,<5.0.0",
|
||||
"flask-orjson>=2.0.0,<3.0.0",
|
||||
"flask-restx>=1.3.2,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.160.0,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.164.0,<2.0.0",
|
||||
"httpx[socks]==0.28.1",
|
||||
"opentelemetry-distro==0.65b0",
|
||||
"opentelemetry-instrumentation-celery==0.65b0",
|
||||
@ -207,7 +207,7 @@ storage = [
|
||||
"bce-python-sdk==0.9.76",
|
||||
"cos-python-sdk-v5>=1.9.44,<2.0.0",
|
||||
"esdk-obs-python>=3.26.6,<4.0.0",
|
||||
"google-cloud-storage>=3.13.0,<4.0.0",
|
||||
"google-cloud-storage>=3.13.1,<4.0.0",
|
||||
"opendal==0.46.0",
|
||||
"oss2>=2.19.1,<3.0.0",
|
||||
"supabase>=2.31.0,<3.0.0",
|
||||
|
||||
59
api/repositories/account_repository.py
Normal file
59
api/repositories/account_repository.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""SQLAlchemy implementation of the account persistence port."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Account
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
|
||||
|
||||
|
||||
class SQLAlchemyAccountRepository(AccountRepository):
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def get(self, account_id: str) -> AccountSnapshot | None:
|
||||
with self._session_factory() as session:
|
||||
account = session.get(Account, account_id)
|
||||
return self._to_snapshot(account) if account is not None else None
|
||||
|
||||
@override
|
||||
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None:
|
||||
with self._session_factory.begin() as session:
|
||||
account = session.get(Account, account_id)
|
||||
if account is None:
|
||||
return None
|
||||
|
||||
if changes.name is not None:
|
||||
account.name = changes.name
|
||||
if changes.avatar is not None:
|
||||
account.avatar = changes.avatar
|
||||
if changes.interface_language is not None:
|
||||
account.interface_language = changes.interface_language
|
||||
if changes.interface_theme is not None:
|
||||
account.interface_theme = changes.interface_theme
|
||||
if changes.timezone is not None:
|
||||
account.timezone = changes.timezone
|
||||
|
||||
session.flush()
|
||||
return self._to_snapshot(account)
|
||||
|
||||
@staticmethod
|
||||
def _to_snapshot(account: Account) -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id=account.id,
|
||||
name=account.name,
|
||||
email=account.email,
|
||||
avatar=account.avatar,
|
||||
is_password_set=account.is_password_set,
|
||||
interface_language=account.interface_language,
|
||||
interface_theme=account.interface_theme,
|
||||
timezone=account.timezone,
|
||||
last_login_at=account.last_login_at,
|
||||
last_login_ip=account.last_login_ip,
|
||||
status=account.status.value,
|
||||
initialized_at=account.initialized_at,
|
||||
created_at=account.created_at,
|
||||
)
|
||||
@ -9,6 +9,7 @@ from core.agent.publish_visibility import agent_has_workflow_callable_active_sna
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from models.account import Tenant, TenantStatus
|
||||
from models.agent import AgentConfigSnapshot
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config
|
||||
@ -21,6 +22,27 @@ from services.app_definition_query_service import (
|
||||
AppSiteConfiguration,
|
||||
AppToolIconSource,
|
||||
)
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeRecord
|
||||
|
||||
|
||||
def _map_site_configuration(site: Site) -> AppSiteConfiguration:
|
||||
return AppSiteConfiguration(
|
||||
title=site.title,
|
||||
chat_color_theme=site.chat_color_theme,
|
||||
chat_color_theme_inverted=site.chat_color_theme_inverted,
|
||||
icon_type=site.icon_type.value if site.icon_type is not None else None,
|
||||
icon=site.icon,
|
||||
icon_background=site.icon_background,
|
||||
description=site.description,
|
||||
copyright=site.copyright,
|
||||
privacy_policy=site.privacy_policy,
|
||||
input_placeholder=site.input_placeholder,
|
||||
custom_disclaimer=site.custom_disclaimer,
|
||||
default_language=site.default_language,
|
||||
prompt_public=site.prompt_public,
|
||||
show_workflow_steps=site.show_workflow_steps,
|
||||
use_icon_as_answer_icon=site.use_icon_as_answer_icon,
|
||||
)
|
||||
|
||||
|
||||
def _get_public_agent_parameter_config(app: App, *, session: Session) -> AppParameterConfig:
|
||||
@ -153,21 +175,41 @@ class AppDefinitionQueryRepository(AppDefinitionQuery):
|
||||
if site is None:
|
||||
return None
|
||||
|
||||
return AppSiteConfiguration(
|
||||
title=site.title,
|
||||
chat_color_theme=site.chat_color_theme,
|
||||
chat_color_theme_inverted=site.chat_color_theme_inverted,
|
||||
icon_type=site.icon_type.value if site.icon_type is not None else None,
|
||||
icon=site.icon,
|
||||
icon_background=site.icon_background,
|
||||
description=site.description,
|
||||
copyright=site.copyright,
|
||||
privacy_policy=site.privacy_policy,
|
||||
input_placeholder=site.input_placeholder,
|
||||
custom_disclaimer=site.custom_disclaimer,
|
||||
default_language=site.default_language,
|
||||
show_workflow_steps=site.show_workflow_steps,
|
||||
use_icon_as_answer_icon=site.use_icon_as_answer_icon,
|
||||
return _map_site_configuration(site)
|
||||
|
||||
def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None:
|
||||
with self._session_factory() as session:
|
||||
app = session.get(App, app_id)
|
||||
if app is None:
|
||||
return None
|
||||
|
||||
site = session.scalar(select(Site).where(Site.app_id == app_id).limit(1))
|
||||
if site is None:
|
||||
return None
|
||||
|
||||
tenant = session.get(Tenant, app.tenant_id)
|
||||
if tenant is None:
|
||||
return None
|
||||
|
||||
app_id = app.id
|
||||
tenant_id = app.tenant_id
|
||||
enable_site = app.enable_site
|
||||
site_configuration = _map_site_configuration(site)
|
||||
plan = tenant.plan
|
||||
tenant_status = tenant.status.value
|
||||
tenant_custom_config_json = tenant.custom_config
|
||||
mode = AppMode.value_of(app.mode).value
|
||||
if tenant.status != TenantStatus.ARCHIVE:
|
||||
mode = AppMode.value_of(app.mode_compatible_with_agent_with_session(session=session)).value
|
||||
return WebAppRuntimeRecord(
|
||||
app_id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
mode=mode,
|
||||
enable_site=enable_site,
|
||||
site=site_configuration,
|
||||
plan=plan,
|
||||
tenant_status=tenant_status,
|
||||
tenant_custom_config_json=tenant_custom_config_json,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
191
api/repositories/recommended_app_catalog_repository.py
Normal file
191
api/repositories/recommended_app_catalog_repository.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Database-backed recommended app catalog adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import cast, override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from constants.languages import languages
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from models.model import App, RecommendedApp
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.recommended_app_query_service import (
|
||||
RecommendedAppCatalogPage,
|
||||
RecommendedAppCatalogQuery,
|
||||
RecommendedAppDetailRecord,
|
||||
RecommendedAppInfoRecord,
|
||||
RecommendedAppRecord,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keep the legacy "explore" Redis key: Explore was the former UI name for this recommended-app surface.
|
||||
_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order"
|
||||
|
||||
|
||||
class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
|
||||
def __init__(self, session_factory: sessionmaker[Session], *, redis: RedisClientWrapper) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._redis = redis
|
||||
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
with self._session_factory() as session:
|
||||
recommended_apps = self._list_rows(language, session=session)
|
||||
if not recommended_apps:
|
||||
recommended_apps = self._list_rows(languages[0], session=session)
|
||||
records, categories = self._map_rows(recommended_apps, session=session)
|
||||
return RecommendedAppCatalogPage(
|
||||
recommended_apps=records,
|
||||
categories=tuple(self._order_categories(categories, language)),
|
||||
)
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
with self._session_factory() as session:
|
||||
recommended_apps = self._list_rows(language, session=session, is_learn_dify=True)
|
||||
if not recommended_apps and language != languages[0]:
|
||||
recommended_apps = self._list_rows(languages[0], session=session, is_learn_dify=True)
|
||||
records, _ = self._map_rows(recommended_apps, session=session)
|
||||
return RecommendedAppCatalogPage(recommended_apps=records, categories=())
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
with self._session_factory() as session:
|
||||
return self._get_detail(app_id, session=session)
|
||||
|
||||
@override
|
||||
def contains(self, app_id: str) -> bool:
|
||||
with self._session_factory() as session:
|
||||
return (
|
||||
session.scalar(
|
||||
select(RecommendedApp.app_id)
|
||||
.join(App, App.id == RecommendedApp.app_id)
|
||||
.where(
|
||||
RecommendedApp.app_id == app_id,
|
||||
RecommendedApp.is_listed.is_(True),
|
||||
App.is_public.is_(True),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
def _order_categories(self, categories: set[str], language: str) -> list[str]:
|
||||
try:
|
||||
raw_categories = self._redis.get(f"{_CATEGORY_ORDER_KEY_PREFIX}:{language}")
|
||||
except Exception:
|
||||
logger.exception("Failed to read recommended app category order from Redis.")
|
||||
return sorted(categories)
|
||||
|
||||
if not raw_categories:
|
||||
return sorted(categories)
|
||||
if isinstance(raw_categories, bytes):
|
||||
raw_categories = raw_categories.decode("utf-8")
|
||||
|
||||
try:
|
||||
configured_order = json.loads(raw_categories)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.warning("Invalid recommended app category order payload for language %s.", language)
|
||||
return sorted(categories)
|
||||
|
||||
if not isinstance(configured_order, list):
|
||||
return sorted(categories)
|
||||
|
||||
string_order = [category for category in configured_order if isinstance(category, str)]
|
||||
return string_order or sorted(categories)
|
||||
|
||||
@staticmethod
|
||||
def _list_rows(
|
||||
language: str,
|
||||
*,
|
||||
session: Session,
|
||||
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(session.scalars(select(RecommendedApp).where(*filters)).all())
|
||||
|
||||
@classmethod
|
||||
def _map_rows(
|
||||
cls,
|
||||
recommended_apps: Sequence[RecommendedApp],
|
||||
*,
|
||||
session: Session,
|
||||
) -> tuple[tuple[RecommendedAppRecord, ...], set[str]]:
|
||||
categories: set[str] = set()
|
||||
records: list[RecommendedAppRecord] = []
|
||||
for recommended_app in recommended_apps:
|
||||
app = session.get(App, recommended_app.app_id)
|
||||
if app is None or not app.is_public:
|
||||
continue
|
||||
|
||||
site = app.site_with_session(session=session)
|
||||
if site is None:
|
||||
continue
|
||||
|
||||
app_categories = cls._as_string_tuple(recommended_app.categories or (), field="categories")
|
||||
records.append(
|
||||
RecommendedAppRecord(
|
||||
app=RecommendedAppInfoRecord(
|
||||
id=app.id,
|
||||
name=app.name,
|
||||
mode=app.mode.value,
|
||||
icon=cast(str | None, app.icon),
|
||||
icon_type=app.icon_type.value if app.icon_type is not None else None,
|
||||
icon_background=app.icon_background,
|
||||
),
|
||||
app_id=recommended_app.app_id,
|
||||
description=cast(str | None, site.description),
|
||||
copyright=cast(str | None, site.copyright),
|
||||
privacy_policy=cast(str | None, site.privacy_policy),
|
||||
custom_disclaimer=cast(str | None, site.custom_disclaimer),
|
||||
categories=app_categories,
|
||||
position=recommended_app.position,
|
||||
is_listed=recommended_app.is_listed,
|
||||
)
|
||||
)
|
||||
categories.update(app_categories)
|
||||
|
||||
return tuple(records), categories
|
||||
|
||||
@staticmethod
|
||||
def _get_detail(app_id: str, *, session: Session) -> RecommendedAppDetailRecord | None:
|
||||
recommended_app = session.scalar(
|
||||
select(RecommendedApp)
|
||||
.where(
|
||||
RecommendedApp.is_listed.is_(True),
|
||||
RecommendedApp.app_id == app_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if recommended_app is None:
|
||||
return None
|
||||
|
||||
app = session.get(App, app_id)
|
||||
if app is None or not app.is_public:
|
||||
return None
|
||||
|
||||
return RecommendedAppDetailRecord(
|
||||
id=app.id,
|
||||
name=app.name,
|
||||
icon=cast(str | None, app.icon),
|
||||
icon_background=app.icon_background,
|
||||
mode=app.mode.value,
|
||||
export_data=AppDslService.export_dsl(app_model=app, session=session),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
||||
raise TypeError(f"{field} must be a sequence of strings")
|
||||
items: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
raise TypeError(f"{field} must contain only strings")
|
||||
items.append(item)
|
||||
return tuple(items)
|
||||
214
api/repositories/tag_repository.py
Normal file
214
api/repositories/tag_repository.py
Normal file
@ -0,0 +1,214 @@
|
||||
"""SQLAlchemy persistence adapter for Console tag management."""
|
||||
|
||||
import uuid
|
||||
from typing import override
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from libs.helper import escape_like_pattern
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.tag_application_service import (
|
||||
CreateTagInput,
|
||||
InvalidTagBindingTypeError,
|
||||
TagBindingInput,
|
||||
TagBindingTargetNotFoundError,
|
||||
TagNameConflictError,
|
||||
TagNotFoundError,
|
||||
TagStore,
|
||||
TagSummary,
|
||||
UpdateTagInput,
|
||||
)
|
||||
|
||||
|
||||
class TagRepository(TagStore):
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def list_tags(self, workspace_id: str, tag_type: str, keyword: str | None) -> tuple[TagSummary, ...]:
|
||||
stmt = (
|
||||
select(Tag.id, Tag.name, Tag.type, func.count(TagBinding.id))
|
||||
.outerjoin(
|
||||
TagBinding,
|
||||
sa.and_(TagBinding.tag_id == Tag.id, TagBinding.tenant_id == workspace_id),
|
||||
)
|
||||
.where(Tag.type == tag_type, Tag.tenant_id == workspace_id)
|
||||
)
|
||||
if keyword:
|
||||
escaped_keyword = escape_like_pattern(keyword)
|
||||
stmt = stmt.where(Tag.name.ilike(f"%{escaped_keyword}%", escape="\\"))
|
||||
stmt = stmt.group_by(Tag.id, Tag.name, Tag.type, Tag.created_at).order_by(Tag.created_at.desc())
|
||||
|
||||
with self._session_factory() as session:
|
||||
return tuple(
|
||||
TagSummary(
|
||||
id=tag_id,
|
||||
name=name,
|
||||
type=tag_kind.value,
|
||||
binding_count=binding_count,
|
||||
)
|
||||
for tag_id, name, tag_kind, binding_count in session.execute(stmt).all()
|
||||
)
|
||||
|
||||
@override
|
||||
def get_tag_type(self, workspace_id: str, tag_id: str) -> str | None:
|
||||
with self._session_factory() as session:
|
||||
tag_type = session.scalar(select(Tag.type).where(Tag.id == tag_id, Tag.tenant_id == workspace_id).limit(1))
|
||||
return tag_type.value if tag_type is not None else None
|
||||
|
||||
@override
|
||||
def create_tag(self, workspace_id: str, actor_id: str, tag: CreateTagInput) -> TagSummary:
|
||||
with self._session_factory.begin() as session:
|
||||
existing = session.scalar(
|
||||
select(Tag.id).where(Tag.name == tag.name, Tag.tenant_id == workspace_id, Tag.type == tag.type).limit(1)
|
||||
)
|
||||
if existing is not None:
|
||||
raise TagNameConflictError
|
||||
|
||||
model = Tag(
|
||||
name=tag.name,
|
||||
type=TagType(tag.type),
|
||||
created_by=actor_id,
|
||||
tenant_id=workspace_id,
|
||||
)
|
||||
model.id = str(uuid.uuid4())
|
||||
session.add(model)
|
||||
session.flush()
|
||||
return self._summary(model, binding_count=0)
|
||||
|
||||
@override
|
||||
def update_tag(self, workspace_id: str, tag_id: str, tag: UpdateTagInput) -> TagSummary:
|
||||
with self._session_factory.begin() as session:
|
||||
model = session.scalar(select(Tag).where(Tag.id == tag_id, Tag.tenant_id == workspace_id).limit(1))
|
||||
if model is None:
|
||||
raise TagNotFoundError
|
||||
|
||||
if tag.name != model.name:
|
||||
existing = session.scalar(
|
||||
select(Tag.id)
|
||||
.where(
|
||||
Tag.name == tag.name,
|
||||
Tag.tenant_id == workspace_id,
|
||||
Tag.type == model.type,
|
||||
Tag.id != tag_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if existing is not None:
|
||||
raise TagNameConflictError
|
||||
model.name = tag.name
|
||||
|
||||
binding_count = (
|
||||
session.scalar(
|
||||
select(func.count(TagBinding.id)).where(
|
||||
TagBinding.tag_id == tag_id,
|
||||
TagBinding.tenant_id == workspace_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return self._summary(model, binding_count=binding_count)
|
||||
|
||||
@override
|
||||
def delete_tag(self, workspace_id: str, tag_id: str) -> None:
|
||||
with self._session_factory.begin() as session:
|
||||
model = session.scalar(select(Tag).where(Tag.id == tag_id, Tag.tenant_id == workspace_id).limit(1))
|
||||
if model is None:
|
||||
raise TagNotFoundError
|
||||
|
||||
session.execute(
|
||||
delete(TagBinding).where(
|
||||
TagBinding.tag_id == tag_id,
|
||||
TagBinding.tenant_id == workspace_id,
|
||||
)
|
||||
)
|
||||
session.delete(model)
|
||||
|
||||
@override
|
||||
def create_bindings(self, workspace_id: str, actor_id: str, binding: TagBindingInput) -> None:
|
||||
with self._session_factory.begin() as session:
|
||||
self._ensure_target_exists(session, workspace_id, binding)
|
||||
requested_tag_ids = tuple(dict.fromkeys(binding.tag_ids))
|
||||
if not requested_tag_ids:
|
||||
return
|
||||
|
||||
valid_tag_ids = tuple(
|
||||
session.scalars(
|
||||
select(Tag.id).where(
|
||||
Tag.id.in_(requested_tag_ids),
|
||||
Tag.tenant_id == workspace_id,
|
||||
Tag.type == binding.type,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if not valid_tag_ids:
|
||||
return
|
||||
|
||||
existing_tag_ids = set(
|
||||
session.scalars(
|
||||
select(TagBinding.tag_id).where(
|
||||
TagBinding.tag_id.in_(valid_tag_ids),
|
||||
TagBinding.target_id == binding.target_id,
|
||||
TagBinding.tenant_id == workspace_id,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
session.add_all(
|
||||
TagBinding(
|
||||
tag_id=tag_id,
|
||||
target_id=binding.target_id,
|
||||
tenant_id=workspace_id,
|
||||
created_by=actor_id,
|
||||
)
|
||||
for tag_id in valid_tag_ids
|
||||
if tag_id not in existing_tag_ids
|
||||
)
|
||||
|
||||
@override
|
||||
def delete_bindings(self, workspace_id: str, binding: TagBindingInput) -> None:
|
||||
with self._session_factory.begin() as session:
|
||||
self._ensure_target_exists(session, workspace_id, binding)
|
||||
session.execute(
|
||||
delete(TagBinding).where(
|
||||
TagBinding.target_id == binding.target_id,
|
||||
TagBinding.tag_id.in_(binding.tag_ids),
|
||||
TagBinding.tenant_id == workspace_id,
|
||||
TagBinding.tag_id.in_(
|
||||
select(Tag.id).where(
|
||||
Tag.tenant_id == workspace_id,
|
||||
Tag.type == binding.type,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _summary(tag: Tag, *, binding_count: int) -> TagSummary:
|
||||
return TagSummary(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
type=tag.type.value,
|
||||
binding_count=binding_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_target_exists(session: Session, workspace_id: str, binding: TagBindingInput) -> None:
|
||||
if binding.type == "knowledge":
|
||||
stmt = select(Dataset.id).where(Dataset.tenant_id == workspace_id, Dataset.id == binding.target_id)
|
||||
elif binding.type == "app":
|
||||
stmt = select(App.id).where(App.tenant_id == workspace_id, App.id == binding.target_id)
|
||||
elif binding.type == "snippet":
|
||||
stmt = select(CustomizedSnippet.id).where(
|
||||
CustomizedSnippet.tenant_id == workspace_id,
|
||||
CustomizedSnippet.id == binding.target_id,
|
||||
)
|
||||
else:
|
||||
raise InvalidTagBindingTypeError
|
||||
|
||||
if session.scalar(stmt.limit(1)) is None:
|
||||
raise TagBindingTargetNotFoundError(binding.type)
|
||||
23
api/repositories/trial_app_query_repository.py
Normal file
23
api/repositories/trial_app_query_repository.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Database repository for recommended app trial eligibility."""
|
||||
|
||||
from collections.abc import Sequence, Set
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.model import TrialApp
|
||||
from services.recommended_app_query_service import TrialAppQuery
|
||||
|
||||
|
||||
class TrialAppQueryRepository(TrialAppQuery):
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]:
|
||||
if not app_ids:
|
||||
return frozenset()
|
||||
|
||||
with self._session_factory() as session:
|
||||
return frozenset(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())
|
||||
28
api/repositories/trial_app_usage_repository.py
Normal file
28
api/repositories/trial_app_usage_repository.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Database repository for recommended trial app usage."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.model import AccountTrialAppRecord
|
||||
from services.trial_app_usage import TrialAppUsageRecorder
|
||||
|
||||
|
||||
class TrialAppUsageRepository(TrialAppUsageRecorder):
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def record(self, *, app_id: str, account_id: str) -> None:
|
||||
"""Increment usage without committing the caller's request transaction."""
|
||||
with self._session_factory() as session, session.begin():
|
||||
record = session.scalar(
|
||||
select(AccountTrialAppRecord)
|
||||
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
|
||||
.limit(1)
|
||||
)
|
||||
if record is None:
|
||||
session.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=1))
|
||||
else:
|
||||
record.count += 1
|
||||
@ -54,8 +54,10 @@ class BillingAccountActivationEligibility(AccountActivationEligibility):
|
||||
self._enabled = enabled
|
||||
|
||||
@override
|
||||
def is_frozen(self, email: str) -> bool:
|
||||
return self._enabled and BillingService.is_email_in_freeze(email)
|
||||
def get_freeze_type(self, email: str) -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
return BillingService.get_email_freeze_type(email)
|
||||
|
||||
|
||||
class BillingWorkspaceMembershipCache(WorkspaceMembershipCache):
|
||||
|
||||
@ -41,7 +41,7 @@ class WorkspaceInvitePolicy(Protocol):
|
||||
|
||||
|
||||
class AccountActivationEligibility(Protocol):
|
||||
def is_frozen(self, email: str) -> bool: ...
|
||||
def get_freeze_type(self, email: str) -> str | None: ...
|
||||
|
||||
|
||||
class WorkspaceMembershipCache(Protocol):
|
||||
@ -60,6 +60,10 @@ class FrozenAccountError(Exception):
|
||||
"""The invited account is temporarily ineligible for activation."""
|
||||
|
||||
|
||||
class EmailDomainSuspendedError(Exception):
|
||||
"""The invited account uses a suspended email domain."""
|
||||
|
||||
|
||||
class AccountActivationService:
|
||||
def __init__(
|
||||
self,
|
||||
@ -101,7 +105,10 @@ class AccountActivationService:
|
||||
if authenticated_account_id is not None and authenticated_account_id != invitation.account_id:
|
||||
raise InvitationAccountMismatchError
|
||||
|
||||
if self._eligibility.is_frozen(invitation.account_email):
|
||||
freeze_type = self._eligibility.get_freeze_type(invitation.account_email)
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError
|
||||
if freeze_type:
|
||||
raise FrozenAccountError
|
||||
|
||||
setup = self._resolve_setup(invitation, command)
|
||||
|
||||
9
api/services/account_errors.py
Normal file
9
api/services/account_errors.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Framework-neutral errors shared by account application services."""
|
||||
|
||||
|
||||
class AccountApplicationError(Exception):
|
||||
"""Base class for failures owned by account application services."""
|
||||
|
||||
|
||||
class AccountNotFoundError(AccountApplicationError):
|
||||
"""The admitted account no longer exists."""
|
||||
11
api/services/account_ports.py
Normal file
11
api/services/account_ports.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Persistence ports used by account application services."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
|
||||
|
||||
|
||||
class AccountRepository(Protocol):
|
||||
def get(self, account_id: str) -> AccountSnapshot | None: ...
|
||||
|
||||
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ...
|
||||
26
api/services/account_profile_service.py
Normal file
26
api/services/account_profile_service.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Application service for reading and updating the current account profile."""
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
|
||||
|
||||
|
||||
class AccountProfileService:
|
||||
def __init__(self, *, accounts: AccountRepository) -> None:
|
||||
self._accounts = accounts
|
||||
|
||||
def get(self, context: RequestContext) -> AccountSnapshot:
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise AccountNotFoundError
|
||||
return account
|
||||
|
||||
def update(self, context: RequestContext, changes: AccountProfileChanges) -> AccountSnapshot:
|
||||
if changes.has_changes():
|
||||
account = self._accounts.update_profile(context.account_id, changes)
|
||||
else:
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise AccountNotFoundError
|
||||
return account
|
||||
@ -67,6 +67,7 @@ from services.errors.account import (
|
||||
AccountRegisterError,
|
||||
CannotOperateSelfError,
|
||||
CurrentPasswordIncorrectError,
|
||||
EmailDomainSuspendedError,
|
||||
InvalidActionError,
|
||||
LinkAccountIntegrateError,
|
||||
MemberNotInTenantError,
|
||||
@ -470,6 +471,9 @@ class AccountService:
|
||||
raise SeatsLimitExceededError("licensed seats limit exceeded")
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
|
||||
freeze_type = BillingService.get_email_freeze_type(email) or "freeze"
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountRegisterError(
|
||||
description=(
|
||||
"This email account has been deleted within the past "
|
||||
@ -1070,6 +1074,9 @@ class AccountService:
|
||||
@classmethod
|
||||
def get_user_through_email(cls, email: str, *, session: Session):
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
|
||||
freeze_type = BillingService.get_email_freeze_type(email) or "freeze"
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountRegisterError(
|
||||
description=(
|
||||
"This email account has been deleted within the past "
|
||||
@ -1088,9 +1095,13 @@ class AccountService:
|
||||
|
||||
@classmethod
|
||||
def is_account_in_freeze(cls, email: str) -> bool:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
|
||||
return True
|
||||
return False
|
||||
return cls.get_account_freeze_type(email) is not None
|
||||
|
||||
@classmethod
|
||||
def get_account_freeze_type(cls, email: str):
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
return None
|
||||
return BillingService.get_email_freeze_type(email)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
|
||||
@ -279,16 +279,11 @@ class AgentComposerService:
|
||||
)
|
||||
state["validation"] = cls.collect_validation_findings(payload=payload)
|
||||
session.commit()
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account_id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
|
||||
103
api/services/agent/deletion_service.py
Normal file
103
api/services/agent/deletion_service.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Hard-delete archived Agent aggregates after external resources are collected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigRevision,
|
||||
AgentConfigSnapshot,
|
||||
AgentDebugConversation,
|
||||
AgentHomeSnapshot,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
|
||||
|
||||
class AgentDeletionInvariantError(RuntimeError):
|
||||
"""An archived Agent no longer satisfies the hard-deletion contract."""
|
||||
|
||||
|
||||
class AgentDeletionService:
|
||||
"""Delete archived Agent aggregates after their external resources are gone.
|
||||
|
||||
The aggregate includes Agent-owned configuration, debug, Home, and Workspace
|
||||
Binding rows. Workflow-owned binding soft references are outside the
|
||||
aggregate and may remain dangling after deletion.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def purge_archived_agents(cls, *, tenant_id: str, agent_ids: Iterable[str]) -> None:
|
||||
"""Idempotently hard-delete eligible archived Agent aggregates.
|
||||
|
||||
Missing targets are a no-op. Every stored target must be ``ARCHIVED``,
|
||||
have no ACTIVE Workspace Binding or Home Snapshot, and all dependent rows
|
||||
and Agents are deleted and committed in one transaction; an exception
|
||||
before commit leaves the transaction to roll back without a partial
|
||||
aggregate deletion.
|
||||
"""
|
||||
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
agents = session.scalars(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id.in_(candidates))).all()
|
||||
if not agents:
|
||||
return
|
||||
|
||||
stored_ids = [agent.id for agent in agents]
|
||||
non_archived_ids = [agent.id for agent in agents if agent.status != AgentStatus.ARCHIVED]
|
||||
if non_archived_ids:
|
||||
raise AgentDeletionInvariantError(
|
||||
f"Agents must be ARCHIVED before deletion: {', '.join(non_archived_ids)}"
|
||||
)
|
||||
|
||||
active_binding_id = session.scalar(
|
||||
select(AgentWorkspaceBinding.id)
|
||||
.where(
|
||||
AgentWorkspaceBinding.tenant_id == tenant_id,
|
||||
AgentWorkspaceBinding.agent_id.in_(stored_ids),
|
||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if active_binding_id is not None:
|
||||
raise AgentDeletionInvariantError(f"Agent aggregate still has ACTIVE Binding {active_binding_id}")
|
||||
|
||||
active_home_id = session.scalar(
|
||||
select(AgentHomeSnapshot.id)
|
||||
.where(
|
||||
AgentHomeSnapshot.tenant_id == tenant_id,
|
||||
AgentHomeSnapshot.agent_id.in_(stored_ids),
|
||||
AgentHomeSnapshot.status == AgentWorkingResourceStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if active_home_id is not None:
|
||||
raise AgentDeletionInvariantError(f"Agent aggregate still has ACTIVE Home Snapshot {active_home_id}")
|
||||
|
||||
for model in (
|
||||
AgentDebugConversation,
|
||||
AgentConfigRevision,
|
||||
AgentConfigDraft,
|
||||
AgentConfigSnapshot,
|
||||
AgentHomeSnapshot,
|
||||
AgentWorkspaceBinding,
|
||||
):
|
||||
session.execute(
|
||||
delete(model).where(
|
||||
model.tenant_id == tenant_id,
|
||||
model.agent_id.in_(stored_ids),
|
||||
)
|
||||
)
|
||||
session.execute(delete(Agent).where(Agent.tenant_id == tenant_id, Agent.id.in_(stored_ids)))
|
||||
session.commit()
|
||||
|
||||
|
||||
__all__ = ["AgentDeletionInvariantError", "AgentDeletionService"]
|
||||
@ -15,7 +15,6 @@ from libs.uuid_utils import uuidv7
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentHomeSnapshot,
|
||||
AgentStatus,
|
||||
@ -108,13 +107,13 @@ class AgentHomeSnapshotService:
|
||||
select(AgentHomeSnapshot).where(
|
||||
AgentHomeSnapshot.tenant_id == tenant_id,
|
||||
AgentHomeSnapshot.agent_id == agent_id,
|
||||
AgentHomeSnapshot.status == AgentWorkingResourceStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
now = naive_utc_now()
|
||||
for row in rows:
|
||||
row.status = AgentWorkingResourceStatus.RETIRED
|
||||
row.retired_at = now
|
||||
if row.status == AgentWorkingResourceStatus.ACTIVE:
|
||||
row.status = AgentWorkingResourceStatus.RETIRED
|
||||
row.retired_at = now
|
||||
return [row.id for row in rows]
|
||||
|
||||
@classmethod
|
||||
@ -129,13 +128,6 @@ class AgentHomeSnapshotService:
|
||||
)
|
||||
if snapshot is None:
|
||||
return
|
||||
referenced = session.scalar(
|
||||
select(AgentConfigDraft.id).where(AgentConfigDraft.home_snapshot_id == home_snapshot_id).limit(1)
|
||||
) or session.scalar(
|
||||
select(AgentConfigSnapshot.id).where(AgentConfigSnapshot.home_snapshot_id == home_snapshot_id).limit(1)
|
||||
)
|
||||
if referenced is not None:
|
||||
return
|
||||
snapshot_ref = snapshot.snapshot_ref
|
||||
cls.delete(snapshot_ref=snapshot_ref)
|
||||
with session_factory.create_session() as session:
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
@ -15,20 +15,22 @@ from models.agent import (
|
||||
AgentScope,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspace,
|
||||
AgentWorkspaceBinding,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.enums import AppStatus
|
||||
from models.model import App
|
||||
from models.model import App, AppMode
|
||||
from models.workflow import Workflow
|
||||
from services.agent.home_snapshot_service import AgentHomeSnapshotService
|
||||
from services.agent.workspace_service import AgentWorkspaceService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowAgentRetirementService:
|
||||
"""Archive workflow-only Agents once no effective binding owns them."""
|
||||
"""Delete workflow-only Agent aggregates after their last Workflow owner is gone."""
|
||||
|
||||
@classmethod
|
||||
def retire_unowned(
|
||||
@ -37,13 +39,25 @@ class WorkflowAgentRetirementService:
|
||||
tenant_id: str,
|
||||
agent_ids: Iterable[str],
|
||||
account_id: str | None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Re-check ownership, archive orphans, and commit their resource retirement."""
|
||||
) -> None:
|
||||
"""Retire unowned workflow-only Agents in an independent transaction.
|
||||
|
||||
This method returns ``None``. It archives orphan Agents, retires their
|
||||
working resources, and deletes their hidden Apps before committing. It
|
||||
then publishes every hidden-App cleanup before publishing the Agent
|
||||
resource collector; database and task-publication errors propagate.
|
||||
|
||||
Archived Agents, missing hidden App rows, and already-retired resources
|
||||
remain cleanup candidates. A retry can therefore publish duplicate
|
||||
cleanup tasks, which are expected to be idempotent.
|
||||
"""
|
||||
|
||||
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
|
||||
if not candidates:
|
||||
return [], []
|
||||
return
|
||||
backing_app_ids: list[str] = []
|
||||
retired_bindings: list[str] = []
|
||||
retired_workspaces: list[str] = []
|
||||
retired_snapshots: list[str] = []
|
||||
try:
|
||||
with session_factory.create_session() as session:
|
||||
@ -53,22 +67,43 @@ class WorkflowAgentRetirementService:
|
||||
agent_ids=candidates,
|
||||
account_id=account_id,
|
||||
)
|
||||
retired_agents = session.scalars(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id.in_(retired_agent_ids),
|
||||
)
|
||||
).all()
|
||||
backing_app_ids = sorted({agent.backing_app_id for agent in retired_agents if agent.backing_app_id})
|
||||
for app_id in backing_app_ids:
|
||||
AgentWorkspaceService.retire_all_for_app(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
)
|
||||
retired_workspaces.extend(
|
||||
session.scalars(
|
||||
select(AgentWorkspace.id).where(
|
||||
AgentWorkspace.tenant_id == tenant_id,
|
||||
AgentWorkspace.app_id == app_id,
|
||||
AgentWorkspace.status == AgentWorkingResourceStatus.RETIRED,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for agent_id in retired_agent_ids:
|
||||
bindings = session.scalars(
|
||||
select(AgentWorkspaceBinding).where(
|
||||
AgentWorkspaceBinding.tenant_id == tenant_id,
|
||||
AgentWorkspaceBinding.agent_id == agent_id,
|
||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
for binding in bindings:
|
||||
binding_id = AgentWorkspaceService.retire_binding(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
binding_id=binding.id,
|
||||
)
|
||||
if binding_id is not None:
|
||||
retired_bindings.append(binding_id)
|
||||
if binding.status == AgentWorkingResourceStatus.ACTIVE:
|
||||
AgentWorkspaceService.retire_binding(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
binding_id=binding.id,
|
||||
)
|
||||
retired_bindings.append(binding.id)
|
||||
retired_snapshots.extend(
|
||||
AgentHomeSnapshotService.retire_all_for_agent(
|
||||
session=session,
|
||||
@ -76,6 +111,14 @@ class WorkflowAgentRetirementService:
|
||||
agent_id=agent_id,
|
||||
)
|
||||
)
|
||||
if backing_app_ids:
|
||||
session.execute(
|
||||
delete(App).where(
|
||||
App.tenant_id == tenant_id,
|
||||
App.id.in_(backing_app_ids),
|
||||
App.mode == AppMode.AGENT,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
@ -85,8 +128,24 @@ class WorkflowAgentRetirementService:
|
||||
"agent_ids": candidates,
|
||||
},
|
||||
)
|
||||
return [], []
|
||||
return retired_bindings, retired_snapshots
|
||||
raise
|
||||
|
||||
for app_id in backing_app_ids:
|
||||
try:
|
||||
remove_app_and_related_data_task.delay(tenant_id=tenant_id, app_id=app_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to enqueue hidden Agent App cleanup",
|
||||
extra={"tenant_id": tenant_id, "app_id": app_id},
|
||||
)
|
||||
raise
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=tenant_id,
|
||||
workspace_ids=retired_workspaces,
|
||||
binding_ids=retired_bindings,
|
||||
home_snapshot_ids=retired_snapshots,
|
||||
purge_agent_ids=retired_agent_ids,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def archive_unowned(
|
||||
@ -97,7 +156,7 @@ class WorkflowAgentRetirementService:
|
||||
agent_ids: Iterable[str],
|
||||
account_id: str | None,
|
||||
) -> list[str]:
|
||||
"""Archive active orphans and return every orphan eligible for Home cleanup."""
|
||||
"""Archive active orphans and return complete aggregate purge candidates."""
|
||||
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
|
||||
if not candidates:
|
||||
return []
|
||||
@ -109,7 +168,7 @@ class WorkflowAgentRetirementService:
|
||||
Agent.status.in_((AgentStatus.ACTIVE, AgentStatus.ARCHIVED)),
|
||||
)
|
||||
).all()
|
||||
effective_agent_ids = cls._effective_agent_ids(
|
||||
retained_agent_ids = cls.retained_agent_ids(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=[agent.id for agent in agents],
|
||||
@ -117,7 +176,7 @@ class WorkflowAgentRetirementService:
|
||||
now = naive_utc_now()
|
||||
cleanup_candidates: list[str] = []
|
||||
for agent in agents:
|
||||
if agent.id in effective_agent_ids:
|
||||
if agent.id in retained_agent_ids:
|
||||
continue
|
||||
if agent.status == AgentStatus.ACTIVE:
|
||||
agent.status = AgentStatus.ARCHIVED
|
||||
@ -130,33 +189,32 @@ class WorkflowAgentRetirementService:
|
||||
return cleanup_candidates
|
||||
|
||||
@staticmethod
|
||||
def _effective_agent_ids(
|
||||
def retained_agent_ids(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_ids: list[str],
|
||||
) -> set[str]:
|
||||
"""Return Agents that still have an exact persisted Workflow owner.
|
||||
|
||||
The owner key is tenant, App, Workflow, and Workflow version. Draft and
|
||||
every published version, whether current or historical, count equally;
|
||||
the App's current-Workflow pointer is not part of ownership.
|
||||
"""
|
||||
if not agent_ids:
|
||||
return set()
|
||||
values = session.scalars(
|
||||
select(WorkflowAgentNodeBinding.agent_id)
|
||||
.join(
|
||||
Workflow,
|
||||
Workflow.id == WorkflowAgentNodeBinding.workflow_id,
|
||||
(Workflow.tenant_id == WorkflowAgentNodeBinding.tenant_id)
|
||||
& (Workflow.app_id == WorkflowAgentNodeBinding.app_id)
|
||||
& (Workflow.id == WorkflowAgentNodeBinding.workflow_id)
|
||||
& (Workflow.version == WorkflowAgentNodeBinding.workflow_version),
|
||||
)
|
||||
.join(App, App.id == WorkflowAgentNodeBinding.app_id)
|
||||
.where(
|
||||
WorkflowAgentNodeBinding.tenant_id == tenant_id,
|
||||
WorkflowAgentNodeBinding.agent_id.in_(agent_ids),
|
||||
Workflow.tenant_id == tenant_id,
|
||||
Workflow.app_id == WorkflowAgentNodeBinding.app_id,
|
||||
Workflow.version == WorkflowAgentNodeBinding.workflow_version,
|
||||
App.tenant_id == tenant_id,
|
||||
App.status == AppStatus.NORMAL,
|
||||
or_(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
App.workflow_id == Workflow.id,
|
||||
),
|
||||
)
|
||||
.distinct()
|
||||
).all()
|
||||
|
||||
@ -23,8 +23,6 @@ from models.agent import (
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
@ -42,7 +40,6 @@ from services.agent.errors import (
|
||||
AgentNotFoundError,
|
||||
AgentVersionNotFoundError,
|
||||
)
|
||||
from services.agent.home_snapshot_service import AgentHomeSnapshotService
|
||||
from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService, WorkspaceOwnerScope
|
||||
from services.app_service import AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
@ -1261,41 +1258,6 @@ class AgentRosterService:
|
||||
raise AgentNameConflictError() from exc
|
||||
return self.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent_id)
|
||||
|
||||
def archive_roster_agent(self, *, tenant_id: str, agent_id: str, account_id: str) -> None:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
retired_binding_ids: list[str] = []
|
||||
if agent.status != AgentStatus.ARCHIVED:
|
||||
agent.status = AgentStatus.ARCHIVED
|
||||
agent.archived_by = account_id
|
||||
agent.archived_at = naive_utc_now()
|
||||
agent.updated_by = account_id
|
||||
bindings = self._session.scalars(
|
||||
select(AgentWorkspaceBinding).where(
|
||||
AgentWorkspaceBinding.tenant_id == tenant_id,
|
||||
AgentWorkspaceBinding.agent_id == agent_id,
|
||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
for binding in bindings:
|
||||
retired_id = AgentWorkspaceService.retire_binding(
|
||||
session=self._session,
|
||||
tenant_id=tenant_id,
|
||||
binding_id=binding.id,
|
||||
)
|
||||
if retired_id is not None:
|
||||
retired_binding_ids.append(retired_id)
|
||||
retired_snapshot_ids = AgentHomeSnapshotService.retire_all_for_agent(
|
||||
session=self._session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
self._session.commit()
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=tenant_id,
|
||||
binding_ids=retired_binding_ids,
|
||||
home_snapshot_ids=retired_snapshot_ids,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
|
||||
if agent.source == AgentSource.AGENT_APP or (
|
||||
|
||||
@ -24,7 +24,6 @@ from models.agent_config_entities import (
|
||||
WorkflowNodeJobConfig,
|
||||
WorkflowPreviousNodeOutputRef,
|
||||
)
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.prompt_mentions import (
|
||||
@ -575,32 +574,18 @@ class WorkflowAgentPublishService:
|
||||
session: Session,
|
||||
draft_workflow: Workflow,
|
||||
published_workflow: Workflow,
|
||||
) -> set[str]:
|
||||
current_workflow_id = session.scalar(
|
||||
select(App.workflow_id).where(
|
||||
App.tenant_id == draft_workflow.tenant_id,
|
||||
App.id == draft_workflow.app_id,
|
||||
)
|
||||
)
|
||||
retirement_candidates: set[str] = set()
|
||||
if current_workflow_id:
|
||||
retirement_candidates = {
|
||||
agent_id
|
||||
for agent_id in session.scalars(
|
||||
select(WorkflowAgentNodeBinding.agent_id).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id,
|
||||
WorkflowAgentNodeBinding.app_id == draft_workflow.app_id,
|
||||
WorkflowAgentNodeBinding.workflow_id == current_workflow_id,
|
||||
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
|
||||
)
|
||||
).all()
|
||||
if agent_id
|
||||
}
|
||||
) -> None:
|
||||
"""Copy all draft Roster and inline bindings to a published version.
|
||||
|
||||
Only copied inline bindings add owners for workflow-only Agents.
|
||||
Publishing does not release existing draft or historical inline owners,
|
||||
produces no retirement candidates, and returns ``None``.
|
||||
"""
|
||||
node_ids = {
|
||||
node_id for node_id, _node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict)
|
||||
}
|
||||
if not node_ids:
|
||||
return retirement_candidates
|
||||
return
|
||||
|
||||
bindings = session.scalars(
|
||||
select(WorkflowAgentNodeBinding).where(
|
||||
@ -612,25 +597,17 @@ class WorkflowAgentPublishService:
|
||||
)
|
||||
).all()
|
||||
if not bindings:
|
||||
return retirement_candidates
|
||||
|
||||
agents_by_id = {
|
||||
agent.id: agent
|
||||
for agent in session.scalars(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == draft_workflow.tenant_id,
|
||||
Agent.id.in_({binding.agent_id for binding in bindings if binding.agent_id}),
|
||||
)
|
||||
).all()
|
||||
}
|
||||
return
|
||||
|
||||
for binding in bindings:
|
||||
agent = agents_by_id.get(binding.agent_id) if binding.agent_id else None
|
||||
current_snapshot_id = (
|
||||
agent.active_config_snapshot_id
|
||||
if agent is not None and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
|
||||
else binding.current_snapshot_id
|
||||
)
|
||||
current_snapshot_id = binding.current_snapshot_id
|
||||
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT and binding.agent_id:
|
||||
_, current_snapshot_id = cls._resolve_roster_agent_graph_binding(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
node_id=binding.node_id,
|
||||
agent_id=binding.agent_id,
|
||||
)
|
||||
copied = WorkflowAgentNodeBinding(
|
||||
tenant_id=binding.tenant_id,
|
||||
app_id=binding.app_id,
|
||||
@ -645,7 +622,6 @@ class WorkflowAgentPublishService:
|
||||
updated_by=binding.updated_by,
|
||||
)
|
||||
session.add(copied)
|
||||
return retirement_candidates
|
||||
|
||||
@classmethod
|
||||
def restore_agent_node_bindings_to_draft(
|
||||
@ -671,9 +647,6 @@ class WorkflowAgentPublishService:
|
||||
for binding in existing
|
||||
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and binding.agent_id
|
||||
}
|
||||
for binding in existing:
|
||||
session.delete(binding)
|
||||
|
||||
source_bindings = session.scalars(
|
||||
select(WorkflowAgentNodeBinding).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == source_workflow.tenant_id,
|
||||
@ -682,6 +655,19 @@ class WorkflowAgentPublishService:
|
||||
WorkflowAgentNodeBinding.workflow_version == source_workflow.version,
|
||||
)
|
||||
).all()
|
||||
for source in source_bindings:
|
||||
if source.binding_type == WorkflowAgentBindingType.ROSTER_AGENT and source.agent_id:
|
||||
cls._resolve_roster_agent_graph_binding(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
node_id=source.node_id,
|
||||
agent_id=source.agent_id,
|
||||
)
|
||||
|
||||
for binding in existing:
|
||||
session.delete(binding)
|
||||
session.flush()
|
||||
|
||||
for source in source_bindings:
|
||||
agent_id = source.agent_id
|
||||
snapshot_id = source.current_snapshot_id
|
||||
|
||||
@ -13,7 +13,7 @@ from dataclasses import dataclass
|
||||
|
||||
from dify_agent.client import Client
|
||||
from dify_agent.protocol import CreateExecutionBindingRequest, DestroyExecutionBindingRequest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from clients.agent_backend.factory import create_agent_backend_client
|
||||
@ -364,46 +364,68 @@ class AgentWorkspaceService:
|
||||
.order_by(AgentWorkspaceBinding.created_at)
|
||||
).all()
|
||||
if not bindings:
|
||||
logger.error(
|
||||
"RETIRED Workspace has no Binding available for physical collection",
|
||||
extra={"tenant_id": tenant_id, "workspace_id": workspace_id},
|
||||
raise AgentWorkspaceError(
|
||||
f"RETIRED Workspace has no RETIRED Binding: tenant_id={tenant_id}, workspace_id={workspace_id}"
|
||||
)
|
||||
return
|
||||
anchor = bindings[0]
|
||||
remaining_ids = [binding.id for binding in bindings[1:]]
|
||||
remaining = [(binding.id, binding.backend_binding_ref) for binding in bindings[1:]]
|
||||
workspace_ref = workspace.backend_workspace_ref
|
||||
binding_ref = anchor.backend_binding_ref
|
||||
anchor_id = anchor.id
|
||||
|
||||
failures: list[str] = []
|
||||
first_error: Exception | None = None
|
||||
with cls._client() as client:
|
||||
client.destroy_execution_binding_sync(
|
||||
DestroyExecutionBindingRequest(
|
||||
binding_ref=binding_ref,
|
||||
workspace_ref=workspace_ref,
|
||||
destroy_workspace=True,
|
||||
targets = [(anchor_id, binding_ref, workspace_ref, True)] + [
|
||||
(binding_id, backend_binding_ref, None, False) for binding_id, backend_binding_ref in remaining
|
||||
]
|
||||
for binding_id, backend_binding_ref, target_workspace_ref, destroy_workspace in targets:
|
||||
try:
|
||||
client.destroy_execution_binding_sync(
|
||||
DestroyExecutionBindingRequest(
|
||||
binding_ref=backend_binding_ref,
|
||||
workspace_ref=target_workspace_ref,
|
||||
destroy_workspace=destroy_workspace,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
failures.append(binding_id)
|
||||
if first_error is None:
|
||||
first_error = exc
|
||||
logger.exception(
|
||||
"Failed to destroy retired Agent Workspace Binding",
|
||||
extra={
|
||||
"tenant_id": tenant_id,
|
||||
"workspace_id": workspace_id,
|
||||
"binding_id": binding_id,
|
||||
"destroy_workspace": destroy_workspace,
|
||||
},
|
||||
)
|
||||
if failures:
|
||||
if len(failures) == 1 and first_error is not None:
|
||||
raise first_error
|
||||
raise AgentWorkspaceError(
|
||||
f"Failed to destroy {len(failures)} RETIRED Workspace Binding(s): {', '.join(failures)}"
|
||||
) from first_error
|
||||
|
||||
binding_ids = [anchor_id, *(binding_id for binding_id, _binding_ref in remaining)]
|
||||
with session_factory.create_session() as session:
|
||||
session.execute(
|
||||
delete(AgentWorkspaceBinding).where(
|
||||
AgentWorkspaceBinding.id.in_(binding_ids),
|
||||
AgentWorkspaceBinding.tenant_id == tenant_id,
|
||||
AgentWorkspaceBinding.workspace_id == workspace_id,
|
||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
|
||||
)
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
stored_workspace = session.scalar(
|
||||
select(AgentWorkspace).where(
|
||||
session.execute(
|
||||
delete(AgentWorkspace).where(
|
||||
AgentWorkspace.id == workspace_id,
|
||||
AgentWorkspace.tenant_id == tenant_id,
|
||||
AgentWorkspace.status == AgentWorkingResourceStatus.RETIRED,
|
||||
)
|
||||
)
|
||||
stored_anchor = session.scalar(
|
||||
select(AgentWorkspaceBinding).where(
|
||||
AgentWorkspaceBinding.id == anchor_id,
|
||||
AgentWorkspaceBinding.tenant_id == tenant_id,
|
||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
|
||||
)
|
||||
)
|
||||
if stored_workspace is not None:
|
||||
session.delete(stored_workspace)
|
||||
if stored_anchor is not None:
|
||||
session.delete(stored_anchor)
|
||||
session.commit()
|
||||
for remaining_id in remaining_ids:
|
||||
cls.collect_retired_binding(tenant_id=tenant_id, binding_id=remaining_id)
|
||||
|
||||
@staticmethod
|
||||
def validate_binding_generation(
|
||||
|
||||
@ -476,6 +476,7 @@ def _default_client_factory() -> Client:
|
||||
return create_agent_backend_client(
|
||||
base_url=base_url,
|
||||
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
|
||||
binding_file_download_timeout=dify_config.AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ class AppSiteConfiguration(NamedTuple):
|
||||
input_placeholder: str | None
|
||||
custom_disclaimer: str | None
|
||||
default_language: str
|
||||
prompt_public: bool
|
||||
show_workflow_steps: bool
|
||||
use_icon_as_answer_icon: bool
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.file import remote_fetcher
|
||||
from core.plugin.entities.plugin import PluginDependency
|
||||
from core.rbac import RBACPermission
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from core.trigger.constants import (
|
||||
TRIGGER_PLUGIN_NODE_TYPE,
|
||||
TRIGGER_SCHEDULE_NODE_TYPE,
|
||||
@ -65,7 +65,6 @@ from services.errors.app import WorkflowNotFoundError
|
||||
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableService
|
||||
from services.workflow_service import WorkflowService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -228,9 +227,7 @@ class AppDslService:
|
||||
# If app_id is provided, check if it exists
|
||||
app = None
|
||||
if app_id:
|
||||
stmt = select(App).where(App.id == app_id, App.tenant_id == account.current_tenant_id)
|
||||
app = self._session.scalar(stmt)
|
||||
|
||||
app = self._load_app_for_overwrite(account, app_id)
|
||||
if not app:
|
||||
return Import(
|
||||
id=import_id,
|
||||
@ -368,8 +365,13 @@ class AppDslService:
|
||||
|
||||
app = None
|
||||
if pending_data.app_id:
|
||||
stmt = select(App).where(App.id == pending_data.app_id, App.tenant_id == account.current_tenant_id)
|
||||
app = self._session.scalar(stmt)
|
||||
app = self._load_app_for_overwrite(account, pending_data.app_id)
|
||||
if not app:
|
||||
return Import(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="App not found",
|
||||
)
|
||||
|
||||
# Create or update app
|
||||
app = self._create_or_update_app(
|
||||
@ -430,6 +432,31 @@ class AppDslService:
|
||||
leaked_dependencies=leaked_dependencies,
|
||||
)
|
||||
|
||||
def _load_app_for_overwrite(self, account: Account, app_id: str) -> App | None:
|
||||
if account.current_tenant_id is None:
|
||||
raise ValueError("Current tenant is not set")
|
||||
if dify_config.RBAC_ENABLED and self._session.in_transaction():
|
||||
raise RuntimeError("App overwrite authorization requires a session without an active transaction")
|
||||
rbac_allowed = not dify_config.RBAC_ENABLED or RBACService.CheckAccess.check(
|
||||
account.current_tenant_id,
|
||||
account.id,
|
||||
scene=RBACPermission.APP_IMPORT_EXPORT_DSL,
|
||||
resource_type=RBACResourceScope.APP,
|
||||
resource_id=app_id,
|
||||
)
|
||||
app = self._session.scalar(
|
||||
select(App)
|
||||
.where(
|
||||
App.id == app_id,
|
||||
App.tenant_id == account.current_tenant_id,
|
||||
App.status == "normal",
|
||||
)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
if app is not None and not rbac_allowed and app.maintainer != account.id:
|
||||
raise NoPermissionError("You do not have permission to overwrite this app")
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def _ensure_agent_manage_permission(account: Account) -> None:
|
||||
"""Importing an Agent DSL creates a roster Agent, which requires ``agent.manage``."""
|
||||
@ -601,16 +628,11 @@ class AppDslService:
|
||||
draft_workflow=draft_workflow,
|
||||
)
|
||||
self._session.commit()
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
case AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
|
||||
# Initialize model config
|
||||
model_config = data.get("model_config")
|
||||
|
||||
@ -7,7 +7,7 @@ from typing import Any, Literal, NotRequired, TypedDict, cast, override
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import ColumnElement, select
|
||||
from sqlalchemy import ColumnElement, delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@ -36,6 +36,8 @@ from models.agent import (
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspaceBinding,
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.model import App, AppMode, AppModelConfig, IconType, Site, load_annotation_reply_config
|
||||
from models.workflow import Workflow
|
||||
@ -287,6 +289,13 @@ class AppService:
|
||||
) -> App | None:
|
||||
return session.get(App, app_id)
|
||||
|
||||
@staticmethod
|
||||
def get_normal_app_by_id(
|
||||
app_id: str,
|
||||
session: Session,
|
||||
) -> App | None:
|
||||
return session.scalar(select(App).where(App.id == app_id, App.status == "normal").limit(1))
|
||||
|
||||
@staticmethod
|
||||
def get_visible_app_by_id(
|
||||
app_id: str,
|
||||
@ -741,7 +750,7 @@ class AppService:
|
||||
role: NotRequired[str | None]
|
||||
|
||||
@staticmethod
|
||||
def _get_backing_agent_for_update(app: App, *, session: Session) -> Agent | None:
|
||||
def _get_backing_agent(app: App, *, session: Session) -> Agent | None:
|
||||
if app.mode != AppMode.AGENT:
|
||||
return None
|
||||
return session.scalar(
|
||||
@ -784,7 +793,7 @@ class AppService:
|
||||
Role omission is intentional: ``role=None`` preserves the backing
|
||||
Agent's current role, while ``role=""`` explicitly clears it.
|
||||
"""
|
||||
agent = self._get_backing_agent_for_update(app, session=session)
|
||||
agent = self._get_backing_agent(app, session=session)
|
||||
if agent is None:
|
||||
return
|
||||
|
||||
@ -988,21 +997,49 @@ class AppService:
|
||||
return app
|
||||
|
||||
def delete_app(self, app: App, *, session: Session) -> None:
|
||||
"""
|
||||
Delete app
|
||||
:param app: App instance
|
||||
"""Delete an App and commit the passed session.
|
||||
|
||||
The transaction releases all of a Workflow App's binding owners across
|
||||
draft and published versions, archives a backing Roster Agent, retires
|
||||
its resources, and deletes the App. Deleting a Roster Agent's backing
|
||||
App does not remove bindings owned by external Workflows.
|
||||
|
||||
After commit, the main App cleanup is published first, followed by
|
||||
workflow-only Agent retirement and the Roster resource collector. Any
|
||||
publication failure propagates.
|
||||
"""
|
||||
app_was_deleted.send(app)
|
||||
|
||||
backing_agent = self._get_backing_agent_for_update(app, session=session)
|
||||
workflow_agent_ids = session.scalars(
|
||||
select(Agent.id).where(
|
||||
Agent.tenant_id == app.tenant_id,
|
||||
Agent.app_id == app.id,
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
backing_agent = self._get_backing_agent(app, session=session)
|
||||
workflow_agent_ids = set(
|
||||
session.scalars(
|
||||
select(Agent.id).where(
|
||||
Agent.tenant_id == app.tenant_id,
|
||||
Agent.app_id == app.id,
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if app.mode in (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT):
|
||||
workflow_agent_ids.update(
|
||||
agent_id
|
||||
for agent_id in session.scalars(
|
||||
select(WorkflowAgentNodeBinding.agent_id).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
|
||||
WorkflowAgentNodeBinding.app_id == app.id,
|
||||
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
|
||||
WorkflowAgentNodeBinding.agent_id.is_not(None),
|
||||
)
|
||||
).all()
|
||||
if agent_id
|
||||
)
|
||||
session.execute(
|
||||
delete(WorkflowAgentNodeBinding).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
|
||||
WorkflowAgentNodeBinding.app_id == app.id,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
account_id = current_user.id if current_user else None
|
||||
if backing_agent is not None:
|
||||
now = naive_utc_now()
|
||||
@ -1019,17 +1056,16 @@ class AppService:
|
||||
select(AgentWorkspaceBinding).where(
|
||||
AgentWorkspaceBinding.tenant_id == app.tenant_id,
|
||||
AgentWorkspaceBinding.agent_id == backing_agent.id,
|
||||
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
for binding in bindings:
|
||||
binding_id = AgentWorkspaceService.retire_binding(
|
||||
session=session,
|
||||
tenant_id=app.tenant_id,
|
||||
binding_id=binding.id,
|
||||
)
|
||||
if binding_id is not None:
|
||||
retired_binding_ids.append(binding_id)
|
||||
if binding.status == AgentWorkingResourceStatus.ACTIVE:
|
||||
AgentWorkspaceService.retire_binding(
|
||||
session=session,
|
||||
tenant_id=app.tenant_id,
|
||||
binding_id=binding.id,
|
||||
)
|
||||
retired_binding_ids.append(binding.id)
|
||||
retired_snapshot_ids = AgentHomeSnapshotService.retire_all_for_agent(
|
||||
session=session,
|
||||
tenant_id=app.tenant_id,
|
||||
@ -1044,7 +1080,16 @@ class AppService:
|
||||
session.delete(app)
|
||||
session.commit()
|
||||
|
||||
workflow_binding_ids, workflow_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
try:
|
||||
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to enqueue App cleanup",
|
||||
extra={"tenant_id": app.tenant_id, "app_id": app.id},
|
||||
)
|
||||
raise
|
||||
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app.tenant_id,
|
||||
agent_ids=workflow_agent_ids,
|
||||
account_id=account_id,
|
||||
@ -1052,8 +1097,9 @@ class AppService:
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app.tenant_id,
|
||||
workspace_ids=retired_workspace_ids,
|
||||
binding_ids=[*retired_binding_ids, *workflow_binding_ids],
|
||||
home_snapshot_ids=[*retired_snapshot_ids, *workflow_snapshot_ids],
|
||||
binding_ids=retired_binding_ids,
|
||||
home_snapshot_ids=retired_snapshot_ids,
|
||||
purge_agent_ids=[backing_agent.id] if backing_agent is not None else [],
|
||||
)
|
||||
|
||||
# clean up web app settings
|
||||
@ -1063,9 +1109,6 @@ class AppService:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
BillingService.clean_billing_info_cache(app.tenant_id)
|
||||
|
||||
# Trigger asynchronous deletion of app and related data
|
||||
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
|
||||
|
||||
@staticmethod
|
||||
def get_app_code_by_id(app_id: str, *, session: Session) -> str:
|
||||
"""
|
||||
|
||||
@ -28,6 +28,9 @@ _http_client: httpx.Client = get_pooled_http_client(
|
||||
)
|
||||
|
||||
|
||||
EmailFreezeType = Literal["freeze", "email_domain_suspended"]
|
||||
|
||||
|
||||
class SubscriptionPlan(TypedDict):
|
||||
"""Tenant subscriptionplan information."""
|
||||
|
||||
@ -479,13 +482,26 @@ class BillingService:
|
||||
return cls._send_request("DELETE", "/account", params=params)
|
||||
|
||||
@classmethod
|
||||
def is_email_in_freeze(cls, email: str) -> bool:
|
||||
def get_email_freeze_type(cls, email: str) -> EmailFreezeType | None:
|
||||
params = {"email": email}
|
||||
try:
|
||||
response = cls._send_request("GET", "/account/in-freeze", params=params)
|
||||
return bool(response.get("data", False))
|
||||
if not response.get("data", False):
|
||||
return None
|
||||
|
||||
freeze_type = response.get("freeze_type") or response.get("freezeType")
|
||||
if freeze_type in ("freeze", "email_domain_suspended"):
|
||||
return freeze_type
|
||||
|
||||
# Keep compatibility with older billing services that only return
|
||||
# the boolean `data` field.
|
||||
return "freeze"
|
||||
except Exception:
|
||||
return False
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def is_email_in_freeze(cls, email: str) -> bool:
|
||||
return cls.get_email_freeze_type(email) is not None
|
||||
|
||||
@classmethod
|
||||
def update_account_deletion_feedback(cls, email: str, feedback: str):
|
||||
|
||||
@ -18,6 +18,7 @@ import yaml
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfiguration
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
|
||||
from extensions.ext_database import db
|
||||
@ -26,7 +27,6 @@ from models import Account, ApiToken, Tenant, TenantAccountJoin, TenantAccountRo
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import App
|
||||
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
|
||||
from services.data_migration.entities import (
|
||||
@ -48,7 +48,6 @@ from services.tools.api_tools_manage_service import ApiToolManageService
|
||||
from services.tools.mcp_tools_manage_service import MCPToolManageService
|
||||
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
|
||||
from services.workflow_service import WorkflowService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -326,11 +325,14 @@ class MigrationImportService:
|
||||
) -> str:
|
||||
import_service = AppDslService(session)
|
||||
if existing_app is not None:
|
||||
existing_app_id = existing_app.id
|
||||
if dify_config.RBAC_ENABLED:
|
||||
session.commit()
|
||||
import_result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode="yaml-content",
|
||||
yaml_content=dsl_content,
|
||||
app_id=existing_app.id,
|
||||
app_id=existing_app_id,
|
||||
)
|
||||
else:
|
||||
import_app_id = app_id if self._should_preserve_source_app_id(options) else None
|
||||
@ -713,7 +715,7 @@ class MigrationImportService:
|
||||
raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}")
|
||||
if account_in_session is None:
|
||||
raise MigrationDataError(f"Operator account not found: {account.id}")
|
||||
workflow, retirement_candidates = workflow_service.publish_workflow(
|
||||
workflow = workflow_service.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_in_session,
|
||||
account=account_in_session,
|
||||
@ -723,16 +725,6 @@ class MigrationImportService:
|
||||
app_in_session.workflow_id = workflow.id
|
||||
app_in_session.updated_by = account.id
|
||||
app_in_session.updated_at = naive_utc_now()
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=target.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
|
||||
def _import_mcp_tools(
|
||||
self,
|
||||
|
||||
42
api/services/entities/account_entities.py
Normal file
42
api/services/entities/account_entities.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Framework-neutral contracts for Console account use cases."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountSnapshot:
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
avatar: str | None
|
||||
is_password_set: bool
|
||||
interface_language: str | None
|
||||
interface_theme: str | None
|
||||
timezone: str | None
|
||||
last_login_at: datetime | None
|
||||
last_login_ip: str | None
|
||||
status: str
|
||||
initialized_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountProfileChanges:
|
||||
name: str | None = None
|
||||
avatar: str | None = None
|
||||
interface_language: str | None = None
|
||||
interface_theme: str | None = None
|
||||
timezone: str | None = None
|
||||
|
||||
def has_changes(self) -> bool:
|
||||
return any(
|
||||
value is not None
|
||||
for value in (
|
||||
self.name,
|
||||
self.avatar,
|
||||
self.interface_language,
|
||||
self.interface_theme,
|
||||
self.timezone,
|
||||
)
|
||||
)
|
||||
@ -9,6 +9,11 @@ class AccountRegisterError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class EmailDomainSuspendedError(AccountRegisterError):
|
||||
def __init__(self, description: str = "This email domain has been suspended."):
|
||||
super().__init__(description)
|
||||
|
||||
|
||||
class AccountLoginError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
@ -163,6 +163,10 @@ class FeatureService:
|
||||
def is_webapp_auth_enabled() -> bool:
|
||||
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
|
||||
|
||||
@staticmethod
|
||||
def is_trial_app_enabled() -> bool:
|
||||
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
|
||||
|
||||
@classmethod
|
||||
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel):
|
||||
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
|
||||
|
||||
@ -20,6 +20,7 @@ from constants import (
|
||||
VIDEO_EXTENSIONS,
|
||||
)
|
||||
from core.rag.extractor.extract_processor import ExtractProcessor
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_storage import storage
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from graphon.file import helpers as file_helpers
|
||||
@ -179,6 +180,13 @@ class FileService:
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
def get_icon_url(self, file_id: str, tenant_id: str) -> str:
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
|
||||
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
|
||||
):
|
||||
return self.get_file_presigned_url(file_id=file_id, tenant_id=tenant_id)
|
||||
return file_helpers.get_signed_file_url(upload_file_id=file_id)
|
||||
|
||||
def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) -> UploadFile:
|
||||
if len(text_name) > 200:
|
||||
text_name = text_name[:200]
|
||||
|
||||
@ -301,7 +301,11 @@ class RagPipelineService:
|
||||
return workflow
|
||||
|
||||
def get_published_workflow_by_id(self, pipeline: Pipeline, workflow_id: str) -> Workflow | None:
|
||||
"""Fetch a published workflow snapshot by ID for restore operations."""
|
||||
"""Fetch and lock a published Workflow snapshot for restoration.
|
||||
|
||||
The source lock is held until the service transaction ends, preventing
|
||||
concurrent deletion while restore copies its Workflow snapshot fields.
|
||||
"""
|
||||
workflow = self._session.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
@ -310,6 +314,7 @@ class RagPipelineService:
|
||||
Workflow.id == workflow_id,
|
||||
)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
if workflow and workflow.version == Workflow.VERSION_DRAFT:
|
||||
raise IsDraftWorkflowError("source workflow must be published")
|
||||
@ -419,7 +424,8 @@ class RagPipelineService:
|
||||
|
||||
Pipelines reuse the shared draft-restore field copy helper, but still own
|
||||
the pipeline-specific flush/link step that wires a newly created draft
|
||||
back onto ``pipeline.workflow_id``.
|
||||
back onto ``pipeline.workflow_id``. The source version remains locked
|
||||
through snapshot-field copy and commit.
|
||||
"""
|
||||
source_workflow = self.get_published_workflow_by_id(pipeline=pipeline, workflow_id=workflow_id)
|
||||
if not source_workflow:
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
import json
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from typing import Any, override
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
|
||||
class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"""
|
||||
Retrieval recommended app from buildin, the location is constants/recommended_apps.json
|
||||
"""
|
||||
|
||||
builtin_data: dict[str, Any] | None = None
|
||||
|
||||
@override
|
||||
def get_type(self) -> str:
|
||||
return RecommendAppType.BUILDIN
|
||||
|
||||
@override
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
|
||||
del session
|
||||
result = self.fetch_recommended_apps_from_builtin(language)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session):
|
||||
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session):
|
||||
del session
|
||||
result = self.fetch_recommended_app_detail_from_builtin(app_id)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _get_builtin_data(cls):
|
||||
"""
|
||||
Get builtin data.
|
||||
:return:
|
||||
"""
|
||||
if cls.builtin_data:
|
||||
return cls.builtin_data
|
||||
|
||||
root_path = current_app.root_path
|
||||
cls.builtin_data = json.loads(
|
||||
Path(path.join(root_path, "constants", "recommended_apps.json")).read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
return cls.builtin_data or {}
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_apps_from_builtin(cls, language: str):
|
||||
"""
|
||||
Fetch recommended apps from builtin.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
|
||||
return builtin_data.get("recommended_apps", {}).get(language, {})
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_app_detail_from_builtin(cls, app_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Fetch recommended app detail from builtin.
|
||||
:param app_id: App ID
|
||||
:return:
|
||||
"""
|
||||
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
|
||||
return builtin_data.get("app_details", {}).get(app_id)
|
||||
@ -1,49 +0,0 @@
|
||||
"""Apply Redis-backed category ordering for DB-backed Explore apps."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Collection
|
||||
from typing import Any
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order"
|
||||
|
||||
|
||||
def _category_order_key(language: str) -> str:
|
||||
return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}"
|
||||
|
||||
|
||||
def get_explore_app_category_order(language: str) -> list[str]:
|
||||
try:
|
||||
raw_categories = redis_client.get(_category_order_key(language))
|
||||
except Exception:
|
||||
logger.exception("Failed to read explore app category order from Redis.")
|
||||
return []
|
||||
|
||||
if not raw_categories:
|
||||
return []
|
||||
|
||||
if isinstance(raw_categories, bytes):
|
||||
raw_categories = raw_categories.decode("utf-8")
|
||||
|
||||
try:
|
||||
categories: Any = json.loads(raw_categories)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.warning("Invalid explore app category order payload for language %s.", language)
|
||||
return []
|
||||
|
||||
if not isinstance(categories, list):
|
||||
return []
|
||||
|
||||
return [category for category in categories if isinstance(category, str)]
|
||||
|
||||
|
||||
def order_categories(categories: Collection[str], language: str) -> list[str]:
|
||||
configured_order = get_explore_app_category_order(language)
|
||||
if configured_order:
|
||||
return configured_order
|
||||
|
||||
return sorted(categories)
|
||||
@ -1,175 +0,0 @@
|
||||
from typing import Any, NotRequired, TypedDict, override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants.languages import languages
|
||||
from models.model import App, RecommendedApp
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.recommend_app.category_order import order_categories
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
|
||||
class RecommendedAppItemDict(TypedDict):
|
||||
id: str
|
||||
app: App | None
|
||||
app_id: str
|
||||
description: Any
|
||||
copyright: Any
|
||||
privacy_policy: Any
|
||||
custom_disclaimer: str
|
||||
categories: list[str]
|
||||
position: int
|
||||
is_listed: bool
|
||||
can_trial: NotRequired[bool]
|
||||
|
||||
|
||||
class RecommendedAppsResultDict(TypedDict):
|
||||
recommended_apps: list[RecommendedAppItemDict]
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class RecommendedAppDetailDict(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
icon: Any
|
||||
icon_background: str | None
|
||||
mode: str
|
||||
export_data: str
|
||||
|
||||
|
||||
class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"""
|
||||
Retrieval recommended app from database
|
||||
"""
|
||||
|
||||
@override
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
result = self.fetch_recommended_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
result = self.fetch_learn_dify_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
|
||||
result = self.fetch_recommended_app_detail_from_db(app_id, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_type(self) -> str:
|
||||
return RecommendAppType.DATABASE
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
"""
|
||||
Fetch recommended apps from db.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session)
|
||||
|
||||
if len(recommended_apps) == 0:
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session)
|
||||
|
||||
return cls._format_recommended_apps(recommended_apps, language)
|
||||
|
||||
@classmethod
|
||||
def fetch_learn_dify_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
"""
|
||||
Fetch listed recommended apps explicitly marked for the Learn Dify section.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session, is_learn_dify=True)
|
||||
|
||||
if len(recommended_apps) == 0 and language != languages[0]:
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session, is_learn_dify=True)
|
||||
|
||||
return cls._format_recommended_apps(recommended_apps, language)
|
||||
|
||||
@classmethod
|
||||
def _fetch_listed_recommended_apps(
|
||||
cls, language: str, *, session: Session, 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(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] = []
|
||||
for recommended_app in recommended_apps:
|
||||
app = recommended_app.app
|
||||
if not app or not app.is_public:
|
||||
continue
|
||||
|
||||
site = app.site
|
||||
if not site:
|
||||
continue
|
||||
|
||||
app_categories = recommended_app.categories or []
|
||||
recommended_app_result: RecommendedAppItemDict = {
|
||||
"id": recommended_app.id,
|
||||
"app": recommended_app.app,
|
||||
"app_id": recommended_app.app_id,
|
||||
"description": site.description,
|
||||
"copyright": site.copyright,
|
||||
"privacy_policy": site.privacy_policy,
|
||||
"custom_disclaimer": site.custom_disclaimer,
|
||||
"categories": app_categories,
|
||||
"position": recommended_app.position,
|
||||
"is_listed": recommended_app.is_listed,
|
||||
}
|
||||
recommended_apps_result.append(recommended_app_result)
|
||||
|
||||
categories.update(app_categories)
|
||||
|
||||
return RecommendedAppsResultDict(
|
||||
recommended_apps=recommended_apps_result,
|
||||
categories=order_categories(categories, language),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_app_detail_from_db(cls, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
|
||||
"""
|
||||
Fetch recommended app detail from db.
|
||||
:param app_id: App ID
|
||||
:return:
|
||||
"""
|
||||
# is in public recommended list
|
||||
recommended_app = session.scalar(
|
||||
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.app_id == app_id).limit(1)
|
||||
)
|
||||
|
||||
if not recommended_app:
|
||||
return None
|
||||
|
||||
# get app detail
|
||||
app_model = session.get(App, app_id)
|
||||
if not app_model or not app_model.is_public:
|
||||
return None
|
||||
|
||||
return RecommendedAppDetailDict(
|
||||
id=app_model.id,
|
||||
name=app_model.name,
|
||||
icon=app_model.icon,
|
||||
icon_background=app_model.icon_background,
|
||||
mode=app_model.mode,
|
||||
export_data=AppDslService.export_dsl(app_model=app_model, session=session),
|
||||
)
|
||||
@ -1,15 +0,0 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class RecommendAppRetrievalBase(Protocol):
|
||||
"""Interface for recommend app retrieval."""
|
||||
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> Any: ...
|
||||
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session) -> Any: ...
|
||||
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> Any: ...
|
||||
|
||||
def get_type(self) -> str: ...
|
||||
@ -1,23 +0,0 @@
|
||||
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
|
||||
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval
|
||||
|
||||
|
||||
class RecommendAppRetrievalFactory:
|
||||
@staticmethod
|
||||
def get_recommend_app_factory(mode: str) -> type[RecommendAppRetrievalBase]:
|
||||
match mode:
|
||||
case RecommendAppType.REMOTE:
|
||||
return RemoteRecommendAppRetrieval
|
||||
case RecommendAppType.DATABASE:
|
||||
return DatabaseRecommendAppRetrieval
|
||||
case RecommendAppType.BUILDIN:
|
||||
return BuildInRecommendAppRetrieval
|
||||
case _:
|
||||
raise ValueError(f"invalid fetch recommended apps mode: {mode}")
|
||||
|
||||
@staticmethod
|
||||
def get_buildin_recommend_app_retrieval():
|
||||
return BuildInRecommendAppRetrieval
|
||||
@ -1,7 +0,0 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RecommendAppType(StrEnum):
|
||||
REMOTE = "remote"
|
||||
BUILDIN = "builtin"
|
||||
DATABASE = "db"
|
||||
@ -1,173 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, override
|
||||
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
from flask import has_request_context, request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
|
||||
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REMOTE_FETCH_CACHE_MAXSIZE = 64
|
||||
_remote_fetch_cache: TTLCache[tuple[str, str], dict[str, Any]] | None = None
|
||||
_remote_fetch_cache_ttl: int | None = None
|
||||
_remote_fetch_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _current_origin_headers() -> dict[str, str]:
|
||||
origin = request.headers.get("Origin") if has_request_context() else None
|
||||
if origin:
|
||||
return {"Origin": origin}
|
||||
|
||||
console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "")
|
||||
if not isinstance(console_web_url, str) or not console_web_url:
|
||||
return {}
|
||||
return {"Origin": console_web_url}
|
||||
|
||||
|
||||
def _remote_fetch_cache_key(url: str, headers: dict[str, str]) -> tuple[str, str]:
|
||||
return url, headers.get("Origin", "")
|
||||
|
||||
|
||||
def _hosted_fetch_cache_ttl() -> int:
|
||||
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
|
||||
if isinstance(ttl, int) and not isinstance(ttl, bool):
|
||||
return ttl
|
||||
return 600
|
||||
|
||||
|
||||
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], dict[str, Any]] | None:
|
||||
ttl = _hosted_fetch_cache_ttl()
|
||||
if ttl <= 0:
|
||||
return None
|
||||
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
with _remote_fetch_cache_lock:
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
|
||||
_remote_fetch_cache_ttl = ttl
|
||||
return _remote_fetch_cache
|
||||
|
||||
|
||||
def clear_remote_fetch_cache() -> None:
|
||||
"""Reset the in-memory remote fetch cache (used by tests)."""
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
with _remote_fetch_cache_lock:
|
||||
_remote_fetch_cache = None
|
||||
_remote_fetch_cache_ttl = None
|
||||
|
||||
|
||||
def _fetch_remote_payload(url: str) -> tuple[int, dict[str, Any] | None]:
|
||||
headers = _current_origin_headers()
|
||||
cache_key = _remote_fetch_cache_key(url, headers)
|
||||
cache = _get_remote_fetch_cache()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return 200, cached
|
||||
|
||||
response = httpx.get(url, headers=headers, timeout=httpx.Timeout(10.0, connect=3.0))
|
||||
status_code = response.status_code
|
||||
if status_code != 200:
|
||||
return status_code, None
|
||||
|
||||
result: dict[str, Any] = response.json()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cache[cache_key] = result
|
||||
return status_code, result
|
||||
|
||||
|
||||
class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"""
|
||||
Retrieval recommended app from dify official.
|
||||
|
||||
The remote `/apps` payload is already curated for display, including category order.
|
||||
Keep the response order intact so Explore matches the template service.
|
||||
"""
|
||||
|
||||
@override
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session):
|
||||
del session
|
||||
try:
|
||||
result = self.fetch_recommended_app_detail_from_dify_official(app_id)
|
||||
except Exception as e:
|
||||
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", e)
|
||||
result = BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin(app_id)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
|
||||
del session
|
||||
try:
|
||||
result = self.fetch_recommended_apps_from_dify_official(language)
|
||||
except Exception as e:
|
||||
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", e)
|
||||
result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session):
|
||||
try:
|
||||
result = self.fetch_learn_dify_apps_from_dify_official(language)
|
||||
except Exception as e:
|
||||
logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e)
|
||||
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_type(self) -> str:
|
||||
return RecommendAppType.REMOTE
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_app_detail_from_dify_official(cls, app_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Fetch recommended app detail from dify official.
|
||||
:param app_id: App ID
|
||||
:return:
|
||||
"""
|
||||
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
|
||||
url = f"{domain}/apps/{app_id}"
|
||||
status_code, data = _fetch_remote_payload(url)
|
||||
if status_code != 200:
|
||||
return None
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_apps_from_dify_official(cls, language: str):
|
||||
"""
|
||||
Fetch recommended apps from dify official.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
|
||||
url = f"{domain}/apps?language={language}"
|
||||
status_code, result = _fetch_remote_payload(url)
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def fetch_learn_dify_apps_from_dify_official(cls, language: str):
|
||||
"""
|
||||
Fetch Learn Dify apps from dify official.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
|
||||
url = f"{domain}/apps/learn-dify?language={language}"
|
||||
status_code, result = _fetch_remote_payload(url)
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
|
||||
|
||||
return result
|
||||
376
api/services/recommended_app_catalog_gateway.py
Normal file
376
api/services/recommended_app_catalog_gateway.py
Normal file
@ -0,0 +1,376 @@
|
||||
"""Typed remote and built-in adapters for the recommended app catalog."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import cast, override
|
||||
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
|
||||
from configs import dify_config
|
||||
from services.recommended_app_query_service import (
|
||||
RecommendedAppCatalogPage,
|
||||
RecommendedAppCatalogQuery,
|
||||
RecommendedAppDetailRecord,
|
||||
RecommendedAppInfoRecord,
|
||||
RecommendedAppRecord,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUILTIN_FALLBACK_LANGUAGE = "en-US"
|
||||
_BUILTIN_CATALOG_PATH = Path(__file__).resolve().parents[1] / "constants" / "recommended_apps.json"
|
||||
_REMOTE_FETCH_CACHE_MAXSIZE = 64
|
||||
_remote_fetch_cache: TTLCache[tuple[str, str], object] | None = None
|
||||
_remote_fetch_cache_ttl: int | None = None
|
||||
_remote_fetch_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _hosted_fetch_cache_ttl() -> int:
|
||||
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
|
||||
if isinstance(ttl, int) and not isinstance(ttl, bool):
|
||||
return ttl
|
||||
return 600
|
||||
|
||||
|
||||
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], object] | None:
|
||||
ttl = _hosted_fetch_cache_ttl()
|
||||
if ttl <= 0:
|
||||
return None
|
||||
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
with _remote_fetch_cache_lock:
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
|
||||
_remote_fetch_cache_ttl = ttl
|
||||
return _remote_fetch_cache
|
||||
|
||||
|
||||
def clear_remote_fetch_cache() -> None:
|
||||
"""Reset the in-memory remote fetch cache (used by tests)."""
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
with _remote_fetch_cache_lock:
|
||||
_remote_fetch_cache = None
|
||||
_remote_fetch_cache_ttl = None
|
||||
|
||||
|
||||
class _RecommendedAppSourceUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BuiltinRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
|
||||
def __init__(self) -> None:
|
||||
self._data: Mapping[str, object] | None = None
|
||||
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
return _map_recommended_page(self._raw_page(language))
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
return _map_learn_dify_page(self._raw_learn_dify_page(language))
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
detail = self._raw_detail(app_id)
|
||||
if detail is None:
|
||||
return None
|
||||
return _map_detail(_as_mapping(detail, field="recommended app detail"))
|
||||
|
||||
@override
|
||||
def contains(self, app_id: str) -> bool:
|
||||
return self._raw_detail(app_id) is not None
|
||||
|
||||
def _raw_page(self, language: str) -> Mapping[str, object]:
|
||||
pages = _as_mapping(self._get_data().get("recommended_apps", {}), field="recommended_apps")
|
||||
return _as_mapping(pages.get(language, {}), field="recommended app page")
|
||||
|
||||
def _raw_learn_dify_page(self, language: str) -> Mapping[str, object]:
|
||||
apps = self._raw_learn_dify_apps(language)
|
||||
if not apps and language != _BUILTIN_FALLBACK_LANGUAGE:
|
||||
apps = self._raw_learn_dify_apps(_BUILTIN_FALLBACK_LANGUAGE)
|
||||
return {"recommended_apps": apps}
|
||||
|
||||
def _raw_learn_dify_apps(self, language: str) -> tuple[object, ...]:
|
||||
page = self._raw_page(language)
|
||||
return tuple(
|
||||
app
|
||||
for app in _as_sequence(page.get("recommended_apps", ()), field="apps")
|
||||
if _as_mapping(app, field="recommended app").get("is_learn_dify") is True
|
||||
)
|
||||
|
||||
def _raw_detail(self, app_id: str) -> object | None:
|
||||
details = _as_mapping(self._get_data().get("app_details", {}), field="app_details")
|
||||
return details.get(app_id)
|
||||
|
||||
def _get_data(self) -> Mapping[str, object]:
|
||||
if self._data is None:
|
||||
loaded = json.loads(_BUILTIN_CATALOG_PATH.read_text(encoding="utf-8"))
|
||||
self._data = _as_mapping(loaded, field="built-in recommended app catalog")
|
||||
return self._data
|
||||
|
||||
|
||||
class RemoteRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
result = self._fetch(lambda: self._fetch_page(language))
|
||||
return _map_recommended_page(_as_mapping(result, field="recommended app page"))
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
result = self._fetch(lambda: self._fetch_learn_dify_page(language))
|
||||
return _map_learn_dify_page(_as_mapping(result, field="Learn Dify app page"))
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
detail = self._fetch(lambda: self._fetch_detail(app_id))
|
||||
if detail is None:
|
||||
return None
|
||||
return _map_detail(_as_mapping(detail, field="recommended app detail"))
|
||||
|
||||
@override
|
||||
def contains(self, app_id: str) -> bool:
|
||||
detail = self._fetch(lambda: self._fetch_detail(app_id))
|
||||
return detail is not None
|
||||
|
||||
def _fetch_detail(self, app_id: str) -> object | None:
|
||||
status_code, detail = self._get_payload(f"/apps/{app_id}")
|
||||
if status_code != 200:
|
||||
# Preserve the legacy detail contract: only request or decoding
|
||||
# failures use the bundled fallback; HTTP responses are authoritative.
|
||||
return None
|
||||
return detail
|
||||
|
||||
def _fetch_page(self, language: str) -> object:
|
||||
status_code, page = self._get_payload(f"/apps?language={language}")
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
|
||||
return page
|
||||
|
||||
def _fetch_learn_dify_page(self, language: str) -> object:
|
||||
status_code, page = self._get_payload(f"/apps/learn-dify?language={language}")
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def _get_payload(path: str) -> tuple[int, object]:
|
||||
origin = dify_config.CONSOLE_WEB_URL
|
||||
|
||||
url = f"{dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN}{path}"
|
||||
headers = {"Origin": origin} if origin else {}
|
||||
cache_key = (url, origin)
|
||||
cache = _get_remote_fetch_cache()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return 200, cached
|
||||
|
||||
response = httpx.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(10.0, connect=3.0),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return response.status_code, None
|
||||
|
||||
result = response.json()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cache[cache_key] = result
|
||||
return response.status_code, result
|
||||
|
||||
@staticmethod
|
||||
def _fetch[T](fetch: Callable[[], T]) -> T:
|
||||
try:
|
||||
return fetch()
|
||||
except Exception as error:
|
||||
raise _RecommendedAppSourceUnavailableError(str(error)) from error
|
||||
|
||||
|
||||
class RecommendedAppCatalogRouter(RecommendedAppCatalogQuery):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
remote: RecommendedAppCatalogQuery,
|
||||
database: RecommendedAppCatalogQuery,
|
||||
builtin: RecommendedAppCatalogQuery,
|
||||
) -> None:
|
||||
self._remote = remote
|
||||
self._builtin = builtin
|
||||
self._sources: dict[str, RecommendedAppCatalogQuery] = {
|
||||
"remote": remote,
|
||||
"db": database,
|
||||
"builtin": builtin,
|
||||
}
|
||||
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
page = source.list_recommended(language)
|
||||
else:
|
||||
try:
|
||||
page = self._remote.list_recommended(language)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", error)
|
||||
page = self._builtin.list_recommended(language)
|
||||
|
||||
if not page.recommended_apps:
|
||||
return self._builtin.list_recommended(_BUILTIN_FALLBACK_LANGUAGE)
|
||||
return page
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
return source.list_learn_dify(language)
|
||||
try:
|
||||
return self._remote.list_learn_dify(language)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch learn dify apps from dify official failed: %s, switch to built-in.", error)
|
||||
return self._builtin.list_learn_dify(language)
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
return source.get_detail(app_id)
|
||||
try:
|
||||
return self._remote.get_detail(app_id)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
|
||||
return self._builtin.get_detail(app_id)
|
||||
|
||||
@override
|
||||
def contains(self, app_id: str) -> bool:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
return source.contains(app_id)
|
||||
try:
|
||||
return self._remote.contains(app_id)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
|
||||
return self._builtin.contains(app_id)
|
||||
|
||||
def _source(self) -> RecommendedAppCatalogQuery:
|
||||
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
|
||||
try:
|
||||
return self._sources[mode]
|
||||
except KeyError:
|
||||
raise ValueError(f"invalid fetch recommended apps mode: {mode}") from None
|
||||
|
||||
|
||||
def _map_recommended_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
if not source.get("recommended_apps"):
|
||||
return RecommendedAppCatalogPage(recommended_apps=(), categories=())
|
||||
return _map_page(source)
|
||||
|
||||
|
||||
def _map_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
return RecommendedAppCatalogPage(
|
||||
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
|
||||
categories=_as_string_tuple(source["categories"], field="categories"),
|
||||
)
|
||||
|
||||
|
||||
def _map_learn_dify_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
return RecommendedAppCatalogPage(
|
||||
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
|
||||
categories=(),
|
||||
)
|
||||
|
||||
|
||||
def _map_app(source: object) -> RecommendedAppRecord:
|
||||
source = _as_mapping(source, field="recommended app")
|
||||
app_id = source["app_id"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("app_id must be a string")
|
||||
|
||||
app_source = source.get("app")
|
||||
if app_source is not None:
|
||||
app_source = _as_mapping(app_source, field="app")
|
||||
|
||||
return RecommendedAppRecord(
|
||||
app=_map_app_info(app_source),
|
||||
app_id=app_id,
|
||||
description=cast(str | None, source.get("description")),
|
||||
copyright=cast(str | None, source.get("copyright")),
|
||||
privacy_policy=cast(str | None, source.get("privacy_policy")),
|
||||
custom_disclaimer=cast(str | None, source.get("custom_disclaimer")),
|
||||
categories=_as_string_tuple(source.get("categories", ()), field="categories"),
|
||||
position=cast(int | None, source.get("position")),
|
||||
is_listed=cast(bool | None, source.get("is_listed")),
|
||||
)
|
||||
|
||||
|
||||
def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoRecord | None:
|
||||
if source is None:
|
||||
return None
|
||||
app_id = source["id"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("app.id must be a string")
|
||||
return RecommendedAppInfoRecord(
|
||||
id=app_id,
|
||||
name=cast(str | None, source.get("name")),
|
||||
mode=_enum_string(source.get("mode"), field="app.mode"),
|
||||
icon=cast(str | None, source.get("icon")),
|
||||
icon_type=_enum_string(source.get("icon_type"), field="app.icon_type"),
|
||||
icon_background=cast(str | None, source.get("icon_background")),
|
||||
)
|
||||
|
||||
|
||||
def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
|
||||
app_id = source["id"]
|
||||
name = source["name"]
|
||||
export_data = source["export_data"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("id must be a string")
|
||||
if not isinstance(name, str):
|
||||
raise TypeError("name must be a string")
|
||||
if not isinstance(export_data, str):
|
||||
raise TypeError("export_data must be a string")
|
||||
|
||||
mode = _enum_string(source["mode"], field="mode")
|
||||
if mode is None:
|
||||
raise TypeError("mode must be a string or string enum")
|
||||
return RecommendedAppDetailRecord(
|
||||
id=app_id,
|
||||
name=name,
|
||||
icon=cast(str | None, source.get("icon")),
|
||||
icon_background=cast(str | None, source.get("icon_background")),
|
||||
mode=mode,
|
||||
export_data=export_data,
|
||||
)
|
||||
|
||||
|
||||
def _as_mapping(value: object, *, field: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise TypeError(f"{field} must be a mapping")
|
||||
return cast(Mapping[str, object], value)
|
||||
|
||||
|
||||
def _as_sequence(value: object, *, field: str) -> Sequence[object]:
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
||||
raise TypeError(f"{field} must be a sequence")
|
||||
return cast(Sequence[object], value)
|
||||
|
||||
|
||||
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
|
||||
values = _as_sequence(value, field=field)
|
||||
if not all(isinstance(item, str) for item in values):
|
||||
raise TypeError(f"{field} must contain only strings")
|
||||
return cast(tuple[str, ...], tuple(values))
|
||||
|
||||
|
||||
def _enum_string(value: object, *, field: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return str(value)
|
||||
raise TypeError(f"{field} must be a string or string enum")
|
||||
186
api/services/recommended_app_query_service.py
Normal file
186
api/services/recommended_app_query_service.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""Application service for querying the recommended app catalog."""
|
||||
|
||||
from collections.abc import Sequence, Set
|
||||
from typing import NamedTuple, Protocol
|
||||
|
||||
from constants.languages import languages
|
||||
|
||||
|
||||
class RecommendedAppInfoRecord(NamedTuple):
|
||||
id: str
|
||||
name: str | None
|
||||
mode: str | None
|
||||
icon: str | None
|
||||
icon_type: str | None
|
||||
icon_background: str | None
|
||||
|
||||
|
||||
class RecommendedAppRecord(NamedTuple):
|
||||
app: RecommendedAppInfoRecord | None
|
||||
app_id: str
|
||||
description: str | None
|
||||
copyright: str | None
|
||||
privacy_policy: str | None
|
||||
custom_disclaimer: str | None
|
||||
categories: tuple[str, ...]
|
||||
position: int | None
|
||||
is_listed: bool | None
|
||||
|
||||
|
||||
class RecommendedAppCatalogPage(NamedTuple):
|
||||
recommended_apps: tuple[RecommendedAppRecord, ...]
|
||||
categories: tuple[str, ...]
|
||||
|
||||
|
||||
class RecommendedAppDetailRecord(NamedTuple):
|
||||
id: str
|
||||
name: str
|
||||
icon: str | None
|
||||
icon_background: str | None
|
||||
mode: str
|
||||
export_data: str
|
||||
|
||||
|
||||
class RecommendedAppCatalogQuery(Protocol):
|
||||
"""Read from the recommended-app catalog."""
|
||||
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage: ...
|
||||
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: ...
|
||||
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: ...
|
||||
|
||||
def contains(self, app_id: str) -> bool: ...
|
||||
|
||||
|
||||
class TrialAppQuery(Protocol):
|
||||
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]: ...
|
||||
|
||||
|
||||
class RecommendedAppSummary(NamedTuple):
|
||||
app: RecommendedAppInfoRecord | None
|
||||
app_id: str
|
||||
description: str | None
|
||||
copyright: str | None
|
||||
privacy_policy: str | None
|
||||
custom_disclaimer: str | None
|
||||
categories: tuple[str, ...]
|
||||
position: int | None
|
||||
is_listed: bool | None
|
||||
can_trial: bool
|
||||
|
||||
|
||||
class RecommendedAppListResult(NamedTuple):
|
||||
recommended_apps: tuple[RecommendedAppSummary, ...]
|
||||
categories: tuple[str, ...]
|
||||
|
||||
|
||||
class LearnDifyAppListResult(NamedTuple):
|
||||
recommended_apps: tuple[RecommendedAppSummary, ...]
|
||||
|
||||
|
||||
class RecommendedAppDetailSummary(NamedTuple):
|
||||
id: str
|
||||
name: str
|
||||
icon: str | None
|
||||
icon_background: str | None
|
||||
mode: str
|
||||
export_data: str
|
||||
can_trial: bool
|
||||
|
||||
|
||||
class RecommendedAppNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RecommendedAppQueryService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
catalog: RecommendedAppCatalogQuery,
|
||||
trial_apps: TrialAppQuery,
|
||||
trial_enabled: bool,
|
||||
) -> None:
|
||||
self._catalog = catalog
|
||||
self._trial_apps = trial_apps
|
||||
self._trial_enabled = trial_enabled
|
||||
|
||||
def is_trial_enabled(self) -> bool:
|
||||
return self._trial_enabled
|
||||
|
||||
def is_previewable(self, app_id: str) -> bool:
|
||||
if app_id in self._trial_apps.existing_ids((app_id,)):
|
||||
return True
|
||||
return self._catalog.contains(app_id)
|
||||
|
||||
def list_recommended(
|
||||
self,
|
||||
*,
|
||||
requested_language: str | None,
|
||||
interface_language: str | None,
|
||||
) -> RecommendedAppListResult:
|
||||
language = self._resolve_language(requested_language, interface_language)
|
||||
page = self._catalog.list_recommended(language)
|
||||
|
||||
return RecommendedAppListResult(
|
||||
recommended_apps=self._with_trial_status(page.recommended_apps),
|
||||
categories=page.categories,
|
||||
)
|
||||
|
||||
def list_learn_dify(
|
||||
self,
|
||||
*,
|
||||
requested_language: str | None,
|
||||
interface_language: str | None,
|
||||
) -> LearnDifyAppListResult:
|
||||
language = self._resolve_language(requested_language, interface_language)
|
||||
page = self._catalog.list_learn_dify(language)
|
||||
return LearnDifyAppListResult(recommended_apps=self._with_trial_status(page.recommended_apps))
|
||||
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailSummary:
|
||||
detail = self._catalog.get_detail(app_id)
|
||||
if detail is None:
|
||||
raise RecommendedAppNotFoundError
|
||||
|
||||
can_trial = False
|
||||
if self._trial_enabled:
|
||||
can_trial = detail.id in self._trial_apps.existing_ids((detail.id,))
|
||||
|
||||
return RecommendedAppDetailSummary(
|
||||
id=detail.id,
|
||||
name=detail.name,
|
||||
icon=detail.icon,
|
||||
icon_background=detail.icon_background,
|
||||
mode=detail.mode,
|
||||
export_data=detail.export_data,
|
||||
can_trial=can_trial,
|
||||
)
|
||||
|
||||
def _with_trial_status(self, apps: Sequence[RecommendedAppRecord]) -> tuple[RecommendedAppSummary, ...]:
|
||||
trial_app_ids: Set[str] = set()
|
||||
if self._trial_enabled:
|
||||
trial_app_ids = self._trial_apps.existing_ids([app.app_id for app in apps])
|
||||
|
||||
return tuple(
|
||||
RecommendedAppSummary(
|
||||
app=app.app,
|
||||
app_id=app.app_id,
|
||||
description=app.description,
|
||||
copyright=app.copyright,
|
||||
privacy_policy=app.privacy_policy,
|
||||
custom_disclaimer=app.custom_disclaimer,
|
||||
categories=app.categories,
|
||||
position=app.position,
|
||||
is_listed=app.is_listed,
|
||||
can_trial=app.app_id in trial_app_ids,
|
||||
)
|
||||
for app in apps
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_language(requested_language: str | None, interface_language: str | None) -> str:
|
||||
if requested_language and requested_language in languages:
|
||||
return requested_language
|
||||
if interface_language:
|
||||
return interface_language
|
||||
return languages[0]
|
||||
@ -1,119 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from enums import DeploymentEdition
|
||||
from models.model import AccountTrialAppRecord, App, TrialApp
|
||||
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
|
||||
|
||||
|
||||
class RecommendedAppService:
|
||||
"""Own recommended app retrieval and Cloud-only trial eligibility."""
|
||||
|
||||
@staticmethod
|
||||
def is_trial_app_enabled() -> bool:
|
||||
"""Return whether trial execution is enabled for this deployment."""
|
||||
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
|
||||
|
||||
@classmethod
|
||||
def get_app(cls, app_id: str, *, session: Session) -> App | None:
|
||||
"""Return a normal app only when it belongs to the recommended catalog."""
|
||||
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
|
||||
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
|
||||
recommended_app_detail = retrieval_instance.get_recommend_app_detail(app_id, session=session)
|
||||
if recommended_app_detail is None:
|
||||
return None
|
||||
|
||||
return session.scalar(select(App).where(App.id == app_id, App.status == "normal").limit(1))
|
||||
|
||||
@classmethod
|
||||
def get_recommended_apps_and_categories(cls, language: str, *, session: Session):
|
||||
"""
|
||||
Get recommended apps and categories.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
|
||||
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
|
||||
result = retrieval_instance.get_recommended_apps_and_categories(language, session=session)
|
||||
if not result.get("recommended_apps"):
|
||||
result = (
|
||||
RecommendAppRetrievalFactory.get_buildin_recommend_app_retrieval().fetch_recommended_apps_from_builtin(
|
||||
"en-US"
|
||||
)
|
||||
)
|
||||
|
||||
apps = result["recommended_apps"]
|
||||
trial_app_ids = (
|
||||
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
|
||||
)
|
||||
for app in apps:
|
||||
app["can_trial"] = app["app_id"] in trial_app_ids
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_learn_dify_apps(cls, language: str, *, session: Session) -> dict[str, Any]:
|
||||
"""
|
||||
Get recommended apps marked for the Learn Dify section.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
|
||||
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
|
||||
result = retrieval_instance.get_learn_dify_apps(language, session=session)
|
||||
|
||||
apps = result["recommended_apps"]
|
||||
trial_app_ids = (
|
||||
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
|
||||
)
|
||||
for app in apps:
|
||||
app["can_trial"] = app["app_id"] in trial_app_ids
|
||||
|
||||
return {"recommended_apps": apps}
|
||||
|
||||
@classmethod
|
||||
def get_recommend_app_detail(cls, app_id: str, *, session: Session) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get recommend app detail.
|
||||
:param app_id: app id
|
||||
:return:
|
||||
"""
|
||||
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
|
||||
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
|
||||
result: dict[str, Any] | None = retrieval_instance.get_recommend_app_detail(app_id, session=session)
|
||||
if result is None:
|
||||
return None
|
||||
result["can_trial"] = cls.is_trial_app_enabled() and cls._can_trial_app(session, result["id"])
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def add_trial_app_record(cls, app_id: str, account_id: str, *, session: Session):
|
||||
"""
|
||||
Add trial app record.
|
||||
:param app_id: app id
|
||||
:return:
|
||||
"""
|
||||
account_trial_app_record = session.scalar(
|
||||
select(AccountTrialAppRecord)
|
||||
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
|
||||
.limit(1)
|
||||
)
|
||||
if account_trial_app_record:
|
||||
account_trial_app_record.count += 1
|
||||
session.commit()
|
||||
else:
|
||||
session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id))
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _can_trial_app(session: 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
|
||||
|
||||
@staticmethod
|
||||
def _get_trial_app_ids(session: Session, app_ids: list[str]) -> set[str]:
|
||||
if not app_ids:
|
||||
return set()
|
||||
return set(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())
|
||||
@ -32,7 +32,6 @@ from services.entities.dsl_entities import (
|
||||
)
|
||||
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
||||
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -491,29 +490,34 @@ class SnippetDslService:
|
||||
|
||||
self._session.commit()
|
||||
if workflow_data:
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=snippet.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=snippet.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return snippet
|
||||
|
||||
def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str:
|
||||
def export_snippet_dsl(
|
||||
self, snippet: CustomizedSnippet, include_secret: bool = False, workflow_id: str | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Export snippet as DSL
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param include_secret: Whether include secret variable
|
||||
:param workflow_id: Optional published workflow version to export; defaults to the draft workflow
|
||||
:return: YAML string
|
||||
"""
|
||||
snippet_service = self._snippet_service()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
workflow = (
|
||||
snippet_service.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id)
|
||||
if workflow_id
|
||||
else snippet_service.get_draft_workflow(snippet=snippet)
|
||||
)
|
||||
if not workflow:
|
||||
raise ValueError("Missing draft workflow configuration, please check.")
|
||||
workflow_description = (
|
||||
f"published workflow {workflow_id}" if workflow_id else "draft workflow configuration"
|
||||
)
|
||||
raise ValueError(f"Missing {workflow_description}, please check.")
|
||||
|
||||
icon_info = snippet.icon_info or {}
|
||||
export_data = {
|
||||
|
||||
@ -19,10 +19,11 @@ from models.agent import (
|
||||
Agent,
|
||||
AgentScope,
|
||||
AgentStatus,
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.enums import WorkflowRunTriggeredFrom
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from models.model import UploadFile
|
||||
from models.snippet import CustomizedSnippet, SnippetType
|
||||
from models.tools import WorkflowToolProvider
|
||||
from models.workflow import (
|
||||
@ -39,13 +40,13 @@ from models.workflow import (
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
from services.tag_service import TagService
|
||||
from services.workflow_node_execution_trace_service import (
|
||||
WorkflowNodeExecutionTrace,
|
||||
assemble_workflow_node_execution_traces,
|
||||
)
|
||||
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -360,51 +361,51 @@ class SnippetService:
|
||||
snippet: CustomizedSnippet,
|
||||
account_id: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a snippet.
|
||||
"""Stage Snippet deletion in the caller's transaction.
|
||||
|
||||
Workflow rows and all of their binding owners are deleted in that
|
||||
transaction. A single ``after_commit`` callback performs Agent
|
||||
retirement, so rollback does not trigger cleanup.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: Snippet to delete
|
||||
:return: True if deleted successfully
|
||||
"""
|
||||
SnippetService._delete_draft_variable_files(session=session, snippet=snippet)
|
||||
owned_agents = session.scalars(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == snippet.tenant_id,
|
||||
Agent.app_id == snippet.id,
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
Agent.source.in_(WORKFLOW_ONLY_AGENT_SOURCES),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
backing_app_ids = {agent.backing_app_id for agent in owned_agents if agent.backing_app_id}
|
||||
for agent in owned_agents:
|
||||
agent.status = AgentStatus.ARCHIVED
|
||||
agent.archived_by = account_id
|
||||
agent.archived_at = now
|
||||
agent.updated_by = account_id or agent.updated_by
|
||||
agent.updated_at = now
|
||||
|
||||
if backing_app_ids:
|
||||
session.execute(
|
||||
delete(App)
|
||||
.where(
|
||||
App.tenant_id == snippet.tenant_id,
|
||||
App.id.in_(backing_app_ids),
|
||||
App.mode == AppMode.AGENT,
|
||||
candidate_agent_ids = {
|
||||
agent_id
|
||||
for agent_id in session.scalars(
|
||||
select(WorkflowAgentNodeBinding.agent_id).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == snippet.tenant_id,
|
||||
WorkflowAgentNodeBinding.app_id == snippet.id,
|
||||
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
|
||||
WorkflowAgentNodeBinding.agent_id.is_not(None),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
).all()
|
||||
if agent_id
|
||||
}
|
||||
candidate_agent_ids.update(
|
||||
session.scalars(
|
||||
select(Agent.id).where(
|
||||
Agent.tenant_id == snippet.tenant_id,
|
||||
Agent.app_id == snippet.id,
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
Agent.source.in_(WORKFLOW_ONLY_AGENT_SOURCES),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if candidate_agent_ids:
|
||||
tenant_id = snippet.tenant_id
|
||||
|
||||
def cleanup_backing_apps(_session: Session) -> None:
|
||||
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
|
||||
def collect_agent_resources(_session: Session) -> None:
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=candidate_agent_ids,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
for app_id in backing_app_ids:
|
||||
remove_app_and_related_data_task.delay(tenant_id=tenant_id, app_id=app_id)
|
||||
|
||||
event.listen(session, "after_commit", cleanup_backing_apps, once=True)
|
||||
event.listen(session, "after_commit", collect_agent_resources, once=True)
|
||||
|
||||
session.execute(
|
||||
delete(WorkflowAgentNodeBinding)
|
||||
@ -620,16 +621,11 @@ class SnippetService:
|
||||
)
|
||||
self._commit_if_owned(session)
|
||||
if self._session is None:
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=snippet.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=snippet.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return workflow
|
||||
|
||||
def restore_published_workflow_to_draft(
|
||||
@ -679,16 +675,11 @@ class SnippetService:
|
||||
)
|
||||
self._commit_if_owned(session)
|
||||
if self._session is None:
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=snippet.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=snippet.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
return draft_workflow
|
||||
|
||||
def publish_workflow(
|
||||
@ -697,7 +688,7 @@ class SnippetService:
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
account: Account,
|
||||
) -> tuple[Workflow, set[str]]:
|
||||
) -> Workflow:
|
||||
"""
|
||||
Publish the draft workflow as a new version.
|
||||
|
||||
@ -748,7 +739,7 @@ class SnippetService:
|
||||
kind=WorkflowKind.SNIPPET.value,
|
||||
)
|
||||
session.add(workflow)
|
||||
retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
|
||||
WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
published_workflow=workflow,
|
||||
@ -761,7 +752,7 @@ class SnippetService:
|
||||
snippet.updated_by = account.id
|
||||
session.add(snippet)
|
||||
|
||||
return workflow, retirement_candidates
|
||||
return workflow
|
||||
|
||||
def get_all_published_workflows(
|
||||
self,
|
||||
@ -842,6 +833,53 @@ class SnippetService:
|
||||
session.add(workflow)
|
||||
return workflow
|
||||
|
||||
def delete_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
workflow_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a published snippet workflow version.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow_id: Workflow ID
|
||||
:return: True if successful
|
||||
:raises: ValueError if workflow not found
|
||||
:raises: WorkflowInUseError if workflow is the snippet's active version or published as a tool
|
||||
:raises: DraftWorkflowDeletionError if workflow is a draft version
|
||||
"""
|
||||
stmt = select(Workflow).where(
|
||||
Workflow.id == workflow_id,
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
)
|
||||
workflow = session.scalar(stmt)
|
||||
if not workflow:
|
||||
raise ValueError(f"Workflow with ID {workflow_id} not found")
|
||||
|
||||
if workflow.version == Workflow.VERSION_DRAFT:
|
||||
raise DraftWorkflowDeletionError("Cannot delete draft workflow versions")
|
||||
|
||||
if snippet.workflow_id == workflow.id:
|
||||
raise WorkflowInUseError(f"Cannot delete workflow that is currently in use by snippet '{snippet.id}'")
|
||||
|
||||
tool_provider = session.scalar(
|
||||
select(WorkflowToolProvider).where(
|
||||
WorkflowToolProvider.tenant_id == snippet.tenant_id,
|
||||
WorkflowToolProvider.app_id == snippet.id,
|
||||
WorkflowToolProvider.version == workflow.version,
|
||||
)
|
||||
)
|
||||
if tool_provider:
|
||||
raise WorkflowInUseError("Cannot delete workflow that is published as a tool")
|
||||
|
||||
session.delete(workflow)
|
||||
return True
|
||||
|
||||
# --- Default Block Configs ---
|
||||
|
||||
def get_default_block_configs(self) -> list[dict]:
|
||||
|
||||
103
api/services/tag_application_service.py
Normal file
103
api/services/tag_application_service.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Application boundary for Console tag management."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal, NamedTuple, Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
|
||||
type TagKind = Literal["knowledge", "app", "snippet"]
|
||||
|
||||
|
||||
class TagSummary(NamedTuple):
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
binding_count: int
|
||||
|
||||
|
||||
class CreateTagInput(NamedTuple):
|
||||
name: str
|
||||
type: TagKind
|
||||
|
||||
|
||||
class UpdateTagInput(NamedTuple):
|
||||
name: str
|
||||
|
||||
|
||||
class TagBindingInput(NamedTuple):
|
||||
tag_ids: tuple[str, ...]
|
||||
target_id: str
|
||||
type: TagKind
|
||||
|
||||
|
||||
class TagStore(Protocol):
|
||||
def list_tags(self, workspace_id: str, tag_type: str, keyword: str | None) -> Sequence[TagSummary]: ...
|
||||
|
||||
def get_tag_type(self, workspace_id: str, tag_id: str) -> str | None: ...
|
||||
|
||||
def create_tag(self, workspace_id: str, actor_id: str, tag: CreateTagInput) -> TagSummary: ...
|
||||
|
||||
def update_tag(self, workspace_id: str, tag_id: str, tag: UpdateTagInput) -> TagSummary: ...
|
||||
|
||||
def delete_tag(self, workspace_id: str, tag_id: str) -> None: ...
|
||||
|
||||
def create_bindings(self, workspace_id: str, actor_id: str, binding: TagBindingInput) -> None: ...
|
||||
|
||||
def delete_bindings(self, workspace_id: str, binding: TagBindingInput) -> None: ...
|
||||
|
||||
|
||||
class TagApplicationError(Exception):
|
||||
"""Base class for framework-neutral tag failures."""
|
||||
|
||||
|
||||
class TagNotFoundError(TagApplicationError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Tag not found")
|
||||
|
||||
|
||||
class TagNameConflictError(TagApplicationError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Tag name already exists")
|
||||
|
||||
|
||||
class TagBindingTargetNotFoundError(TagApplicationError):
|
||||
def __init__(self, target_type: TagKind) -> None:
|
||||
target_name = {"knowledge": "Dataset", "app": "App", "snippet": "Snippet"}[target_type]
|
||||
super().__init__(f"{target_name} not found")
|
||||
|
||||
|
||||
class InvalidTagBindingTypeError(TagApplicationError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Invalid binding type")
|
||||
|
||||
|
||||
class TagApplicationService:
|
||||
def __init__(self, *, tags: TagStore) -> None:
|
||||
self._tags = tags
|
||||
|
||||
def list_tags(self, context: RequestContext, tag_type: str, keyword: str | None = None) -> tuple[TagSummary, ...]:
|
||||
return tuple(self._tags.list_tags(self._workspace_id(context), tag_type, keyword))
|
||||
|
||||
def get_tag_type(self, context: RequestContext, tag_id: str) -> str | None:
|
||||
return self._tags.get_tag_type(self._workspace_id(context), tag_id)
|
||||
|
||||
def create_tag(self, context: RequestContext, tag: CreateTagInput) -> TagSummary:
|
||||
return self._tags.create_tag(self._workspace_id(context), context.account_id, tag)
|
||||
|
||||
def update_tag(self, context: RequestContext, tag_id: str, tag: UpdateTagInput) -> TagSummary:
|
||||
return self._tags.update_tag(self._workspace_id(context), tag_id, tag)
|
||||
|
||||
def delete_tag(self, context: RequestContext, tag_id: str) -> None:
|
||||
self._tags.delete_tag(self._workspace_id(context), tag_id)
|
||||
|
||||
def create_bindings(self, context: RequestContext, binding: TagBindingInput) -> None:
|
||||
self._tags.create_bindings(self._workspace_id(context), context.account_id, binding)
|
||||
|
||||
def delete_bindings(self, context: RequestContext, binding: TagBindingInput) -> None:
|
||||
self._tags.delete_bindings(self._workspace_id(context), binding)
|
||||
|
||||
@staticmethod
|
||||
def _workspace_id(context: RequestContext) -> str:
|
||||
if context.active_workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
return context.active_workspace_id
|
||||
7
api/services/trial_app_usage.py
Normal file
7
api/services/trial_app_usage.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Port for recording recommended trial app usage."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class TrialAppUsageRecorder(Protocol):
|
||||
def record(self, *, app_id: str, account_id: str) -> None: ...
|
||||
102
api/services/web_app_runtime_query_service.py
Normal file
102
api/services/web_app_runtime_query_service.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Application service for building the public Web app runtime bootstrap."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import NamedTuple, Protocol, cast
|
||||
|
||||
from services.app_definition_query_service import AppSiteConfiguration
|
||||
from services.entities.feature_entities import FeatureModel
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
class WebAppRuntimeRecord(NamedTuple):
|
||||
app_id: str
|
||||
tenant_id: str
|
||||
mode: str
|
||||
enable_site: bool
|
||||
site: AppSiteConfiguration
|
||||
plan: str
|
||||
tenant_status: str
|
||||
# Keep this lazy: workspaces without custom branding never parsed this legacy field.
|
||||
tenant_custom_config_json: str | None
|
||||
|
||||
|
||||
class WebAppBootstrap(NamedTuple):
|
||||
app_id: str
|
||||
mode: str
|
||||
enable_site: bool
|
||||
site: Mapping[str, str | bool | None]
|
||||
plan: str
|
||||
can_replace_logo: bool
|
||||
custom_config: Mapping[str, str | bool | None] | None
|
||||
|
||||
|
||||
class WebAppRuntimeQuery(Protocol):
|
||||
def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: ...
|
||||
|
||||
|
||||
class WebAppRuntimeUnavailableError(ValueError):
|
||||
"""Raised when the admitted Web app can no longer be bootstrapped."""
|
||||
|
||||
|
||||
_ARCHIVED_TENANT_STATUS = "archive"
|
||||
|
||||
|
||||
class WebAppRuntimeQueryService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
runtime: WebAppRuntimeQuery,
|
||||
file_service: FileService,
|
||||
workspace_features: Callable[[str], FeatureModel],
|
||||
files_url: str,
|
||||
) -> None:
|
||||
self._runtime = runtime
|
||||
self._file_service = file_service
|
||||
self._workspace_features = workspace_features
|
||||
self._files_url = files_url
|
||||
|
||||
def get_bootstrap(self, app_id: str) -> WebAppBootstrap:
|
||||
record = self._runtime.get_runtime_record(app_id)
|
||||
if record is None or record.tenant_status == _ARCHIVED_TENANT_STATUS:
|
||||
raise WebAppRuntimeUnavailableError("Site not found")
|
||||
|
||||
features = self._workspace_features(record.tenant_id)
|
||||
site_icon_url = (
|
||||
self._file_service.get_icon_url(record.site.icon, record.tenant_id)
|
||||
if record.site.icon_type == "image" and record.site.icon
|
||||
else None
|
||||
)
|
||||
|
||||
site = cast(dict[str, str | bool | None], record.site._asdict())
|
||||
site["icon_url"] = site_icon_url
|
||||
if features.billing.enabled and not features.webapp_copyright_enabled:
|
||||
site["copyright"] = None
|
||||
site["input_placeholder"] = None
|
||||
|
||||
custom_config = None
|
||||
if features.can_replace_logo:
|
||||
tenant_custom_config = (
|
||||
cast(Mapping[str, str | bool | None], json.loads(record.tenant_custom_config_json))
|
||||
if record.tenant_custom_config_json
|
||||
else {}
|
||||
)
|
||||
replace_webapp_logo = (
|
||||
f"{self._files_url}/files/workspaces/{record.tenant_id}/webapp-logo"
|
||||
if tenant_custom_config.get("replace_webapp_logo")
|
||||
else None
|
||||
)
|
||||
custom_config = {
|
||||
"remove_webapp_brand": tenant_custom_config.get("remove_webapp_brand", False),
|
||||
"replace_webapp_logo": replace_webapp_logo,
|
||||
}
|
||||
|
||||
return WebAppBootstrap(
|
||||
app_id=record.app_id,
|
||||
mode=record.mode,
|
||||
enable_site=record.enable_site,
|
||||
site=site,
|
||||
plan=record.plan,
|
||||
can_replace_logo=features.can_replace_logo,
|
||||
custom_config=custom_config,
|
||||
)
|
||||
@ -5,6 +5,8 @@ from typing import Protocol
|
||||
|
||||
from enums import WebAppAccessMode
|
||||
|
||||
_PERMISSION_CHECK_MODES = frozenset({WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL})
|
||||
|
||||
|
||||
class WebAppAccessQuery(Protocol):
|
||||
def find_app_id_by_code(self, app_code: str) -> str | None: ...
|
||||
@ -29,10 +31,12 @@ class WebAppAccessQueryService:
|
||||
access: WebAppAccessQuery,
|
||||
webapp_auth_enabled: bool,
|
||||
access_mode_for_app: Callable[[str], WebAppAccessMode],
|
||||
is_user_allowed_for_app: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
self._access = access
|
||||
self._webapp_auth_enabled = webapp_auth_enabled
|
||||
self._access_mode_for_app = access_mode_for_app
|
||||
self._is_user_allowed_for_app = is_user_allowed_for_app
|
||||
|
||||
def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode:
|
||||
if not self._webapp_auth_enabled:
|
||||
@ -47,3 +51,12 @@ class WebAppAccessQueryService:
|
||||
raise WebAppAccessReferenceRequiredError("appId or appCode must be provided")
|
||||
|
||||
return self._access_mode_for_app(app_id)
|
||||
|
||||
def requires_permission_check(self, app_id: str) -> bool:
|
||||
return self._access_mode_for_app(app_id) in _PERMISSION_CHECK_MODES
|
||||
|
||||
def is_user_allowed(self, *, user_id: str, app_id: str) -> bool:
|
||||
if not self._webapp_auth_enabled:
|
||||
return True
|
||||
|
||||
return self._is_user_allowed_for_app(user_id, app_id)
|
||||
|
||||
@ -12,9 +12,12 @@ from socketio.exceptions import TimeoutError as SocketIOTimeoutError # type: ig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from models.account import Account
|
||||
from models.model import App
|
||||
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository, WorkflowSessionInfo
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -112,7 +115,7 @@ class WorkflowCollaborationService:
|
||||
if not user_id or not tenant_id:
|
||||
return None
|
||||
|
||||
if not self._can_access_workflow(workflow_id, str(tenant_id), session=session):
|
||||
if not self._can_access_workflow(workflow_id, str(tenant_id), str(user_id), session=session):
|
||||
logger.warning(
|
||||
"Workflow collaboration join rejected: workflow_id=%s tenant_id=%s user_id=%s sid=%s",
|
||||
workflow_id,
|
||||
@ -148,10 +151,27 @@ class WorkflowCollaborationService:
|
||||
|
||||
return str(user_id), is_leader
|
||||
|
||||
def _can_access_workflow(self, workflow_id: str, tenant_id: str, *, session: Session) -> bool:
|
||||
"""Check room access without relying on Flask's app-context-bound scoped session."""
|
||||
app_id = session.scalar(select(App.id).where(App.id == workflow_id, App.tenant_id == tenant_id).limit(1))
|
||||
return app_id is not None
|
||||
def _can_access_workflow(self, workflow_id: str, tenant_id: str, user_id: str, *, session: Session) -> bool:
|
||||
"""Check tenant and app permission without relying on Flask's scoped session."""
|
||||
with session.begin():
|
||||
app = session.execute(
|
||||
select(App.id, App.maintainer).where(
|
||||
App.id == workflow_id, App.tenant_id == tenant_id, App.status == "normal"
|
||||
)
|
||||
).one_or_none()
|
||||
if app is None:
|
||||
return False
|
||||
|
||||
app_id, maintainer = app
|
||||
if not dify_config.RBAC_ENABLED or maintainer == user_id:
|
||||
return True
|
||||
return RBACService.CheckAccess.check(
|
||||
tenant_id,
|
||||
user_id,
|
||||
scene=RBACPermission.APP_EDIT,
|
||||
resource_type=RBACResourceScope.APP,
|
||||
resource_id=app_id,
|
||||
)
|
||||
|
||||
def disconnect_session(self, sid: str) -> None:
|
||||
mapping = self._repository.get_sid_mapping(sid)
|
||||
|
||||
@ -80,6 +80,7 @@ from graphon.variables.input_entities import VariableEntityType
|
||||
from graphon.variables.variables import Variable
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account
|
||||
from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding
|
||||
from models.human_input import HumanInputFormRecipient, RecipientType
|
||||
from models.model import App, AppMode
|
||||
from models.tools import WorkflowToolProvider
|
||||
@ -93,7 +94,6 @@ from services.errors.app import (
|
||||
WorkflowHashNotEqualError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -280,14 +280,21 @@ class WorkflowService:
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
def get_published_workflow_by_id(self, app_model: App, workflow_id: str, *, session: Session) -> Workflow | None:
|
||||
"""
|
||||
fetch published workflow by workflow_id
|
||||
def get_published_workflow_by_id(
|
||||
self,
|
||||
app_model: App,
|
||||
workflow_id: str,
|
||||
*,
|
||||
session: Session,
|
||||
for_update: bool = False,
|
||||
) -> Workflow | None:
|
||||
"""Fetch a published workflow by ID in the caller's transaction.
|
||||
|
||||
Reuses the caller's active session so workflow reads stay in the same
|
||||
transaction as the surrounding request or task.
|
||||
With ``for_update=True``, the source version stays locked until that
|
||||
transaction ends. Restore uses the lock while copying Agent bindings so
|
||||
a concurrent delete cannot release the same owner.
|
||||
"""
|
||||
workflow = session.scalar(
|
||||
stmt = (
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
@ -296,6 +303,9 @@ class WorkflowService:
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
workflow = session.scalar(stmt)
|
||||
if not workflow:
|
||||
return None
|
||||
if workflow.version == Workflow.VERSION_DRAFT:
|
||||
@ -328,15 +338,17 @@ class WorkflowService:
|
||||
|
||||
return workflow
|
||||
|
||||
def get_accessible_app_ids(self, app_ids: Sequence[str], tenant_id: str, *, session: Session) -> set[str]:
|
||||
"""
|
||||
Return app IDs that belong to the given tenant.
|
||||
"""
|
||||
def get_tenant_app_maintainers(
|
||||
self, app_ids: Sequence[str], tenant_id: str, *, session: Session
|
||||
) -> dict[str, str | None]:
|
||||
"""Return requested normal apps and their maintainers within a tenant."""
|
||||
if not app_ids:
|
||||
return set()
|
||||
return {}
|
||||
|
||||
stmt = select(App.id).where(App.id.in_(app_ids), App.tenant_id == tenant_id)
|
||||
return {str(app_id) for app_id in session.scalars(stmt).all()}
|
||||
stmt = select(App.id, App.maintainer).where(
|
||||
App.id.in_(app_ids), App.tenant_id == tenant_id, App.status == "normal"
|
||||
)
|
||||
return {str(app_id): maintainer for app_id, maintainer in session.execute(stmt)}
|
||||
|
||||
def get_all_published_workflow(
|
||||
self,
|
||||
@ -491,16 +503,11 @@ class WorkflowService:
|
||||
# commit db session changes
|
||||
if commit:
|
||||
session.commit()
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app_model.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
|
||||
# trigger app workflow events
|
||||
if commit:
|
||||
@ -624,7 +631,10 @@ class WorkflowService:
|
||||
published workflow so the normal draft sync flow stays stateless.
|
||||
"""
|
||||
source_workflow = self.get_published_workflow_by_id(
|
||||
app_model=app_model, workflow_id=workflow_id, session=session
|
||||
app_model=app_model,
|
||||
workflow_id=workflow_id,
|
||||
session=session,
|
||||
for_update=True,
|
||||
)
|
||||
if not source_workflow:
|
||||
raise WorkflowNotFoundError("Workflow not found.")
|
||||
@ -656,16 +666,11 @@ class WorkflowService:
|
||||
)
|
||||
|
||||
session.commit()
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_ids=retirement_candidates,
|
||||
account_id=account.id,
|
||||
)
|
||||
enqueue_agent_resource_collection(
|
||||
tenant_id=app_model.tenant_id,
|
||||
binding_ids=binding_ids,
|
||||
home_snapshot_ids=home_snapshot_ids,
|
||||
)
|
||||
app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=draft_workflow)
|
||||
|
||||
return draft_workflow
|
||||
@ -678,7 +683,7 @@ class WorkflowService:
|
||||
account: Account,
|
||||
marked_name: str = "",
|
||||
marked_comment: str = "",
|
||||
) -> tuple[Workflow, set[str]]:
|
||||
) -> Workflow:
|
||||
draft_workflow_stmt = select(Workflow).where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
@ -752,7 +757,7 @@ class WorkflowService:
|
||||
|
||||
# commit db session changes
|
||||
session.add(workflow)
|
||||
retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
|
||||
WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
published_workflow=workflow,
|
||||
@ -766,7 +771,7 @@ class WorkflowService:
|
||||
)
|
||||
|
||||
# return new workflow
|
||||
return workflow, retirement_candidates
|
||||
return workflow
|
||||
|
||||
def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None:
|
||||
"""
|
||||
@ -1901,21 +1906,29 @@ class WorkflowService:
|
||||
|
||||
return workflow
|
||||
|
||||
def delete_workflow(self, *, session: Session, workflow_ref: WorkflowRef) -> bool:
|
||||
"""
|
||||
Delete a workflow
|
||||
def delete_workflow(self, *, session: Session, workflow_ref: WorkflowRef) -> list[str]:
|
||||
"""Stage a published Workflow and its binding owners for deletion.
|
||||
|
||||
The exact owner key is tenant, App, Workflow, and Workflow version. The
|
||||
Workflow row lock serializes source-version reads and restoration with
|
||||
deletion. The caller must commit successfully before retiring the
|
||||
returned, sorted and deduplicated inline Agent candidates.
|
||||
|
||||
:param session: SQLAlchemy database session
|
||||
:param workflow_ref: Owner-bound workflow reference
|
||||
:return: True if successful
|
||||
:return: Inline Agent IDs whose owner binding is staged for deletion
|
||||
:raises: ValueError if workflow not found
|
||||
:raises: WorkflowInUseError if workflow is in use
|
||||
:raises: DraftWorkflowDeletionError if workflow is a draft version
|
||||
"""
|
||||
stmt = select(Workflow).where(
|
||||
Workflow.id == workflow_ref.workflow_id,
|
||||
Workflow.tenant_id == workflow_ref.tenant_id,
|
||||
Workflow.app_id == workflow_ref.owner_id,
|
||||
stmt = (
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.id == workflow_ref.workflow_id,
|
||||
Workflow.tenant_id == workflow_ref.tenant_id,
|
||||
Workflow.app_id == workflow_ref.owner_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
workflow = session.scalar(stmt)
|
||||
|
||||
@ -1947,8 +1960,25 @@ class WorkflowService:
|
||||
# Cannot delete a workflow that's published as a tool
|
||||
raise WorkflowInUseError("Cannot delete workflow that is published as a tool")
|
||||
|
||||
bindings = session.scalars(
|
||||
select(WorkflowAgentNodeBinding).where(
|
||||
WorkflowAgentNodeBinding.tenant_id == workflow.tenant_id,
|
||||
WorkflowAgentNodeBinding.app_id == workflow.app_id,
|
||||
WorkflowAgentNodeBinding.workflow_id == workflow.id,
|
||||
WorkflowAgentNodeBinding.workflow_version == workflow.version,
|
||||
)
|
||||
).all()
|
||||
retirement_candidates = sorted(
|
||||
{
|
||||
binding.agent_id
|
||||
for binding in bindings
|
||||
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and binding.agent_id
|
||||
}
|
||||
)
|
||||
for binding in bindings:
|
||||
session.delete(binding)
|
||||
session.delete(workflow)
|
||||
return True
|
||||
return retirement_candidates
|
||||
|
||||
|
||||
def _setup_variable_pool(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user