Compare commits

...
Author SHA1 Message Date
QuantumGhost 3b45199611 refactor(ci): inject no-new-getattr base revision explicitly
Assisted-by: Codex:GPT-5.4
2026-07-07 10:57:48 +08:00
QuantumGhost 03a20d17fa fix(ci): handle merge-group fallback for getattr guard
Prefer the PR base SHA when available, but fall back to merge-base against origin/main when the local main branch does not exist in CI checkouts. Also add regression coverage for detached HEAD checkouts without a local main ref.

Assisted-by: Codex:GPT-5.4
2026-07-07 10:21:12 +08:00
EvanYao826andQuantumGhost 2c518c03f6 fix(ci): make no-new-getattr guard stable in shallow PR checkouts
The check_no_new_getattr guard could fail when CI checked out a shallow
synthetic PR merge commit, then fetched a newer main tip with --depth=1.
git merge-base failed because the PR base commit wasn't reachable from
the shallow-fetched main ref.

Fix:
- Use GITHUB_BASE_SHA (set by GitHub Actions for PR events) as the stable
  base commit reference instead of relying on git merge-base against a
  moving, shallow-fetched main branch.
- Remove the now-unnecessary git fetch/bind steps from style.yml.
- Add unit tests for the GITHUB_BASE_SHA fallback in resolve_ci_base.

Fixes #38325
2026-07-07 09:59:27 +08:00
5aae5c15b3 refactor(api): migrate web auth endpoints to BaseModel (#37961)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-07-06 07:53:21 +00:00
Asuka MinatoandGitHub b1724073fc test: example use sqlite3 as unittest backend (#38159) 2026-07-06 07:31:51 +00:00
chaririGitHubWH-2099autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Asuka Minato
904fadde20 refactor(api): remove member field compatibility (#37966)
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-07-06 07:00:43 +00:00
chaririGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Asuka Minato
77ae583b44 refactor(api): migrate dataset rag pipeline endpoints to BaseModel (#37958)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-07-06 07:00:04 +00:00
chaririandGitHub c3b1508712 refactor(api): migrate workspace account endpoints to BaseModel (#37954) 2026-07-06 06:57:40 +00:00
yyhandGitHub 8208b786ee docs(dify-ui): clarify radio composition stories (#38456) 2026-07-06 06:40:17 +00:00
d9c99daf29 fix(api): isolate side-effect session writes in multimodal and RAG handlers (#38210)
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
2026-07-06 05:17:12 +00:00
KVOJJJinandGitHub 93eb6d32b5 fix(web): update snippet placeholder icon color (#38445) 2026-07-06 05:10:36 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
de5af6c2da build(deps): bump the github-actions-dependencies group across 1 directory with 12 updates (#38430)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 04:39:10 +00:00
i晟andGitHub 586c8de1a0 refactor: drop redundant len(tag_ids)==0 check in get_target_ids_by_tag_ids (#38447) 2026-07-06 04:35:58 +00:00
131 changed files with 3143 additions and 2421 deletions
+6 -6
View File
@@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -91,13 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -142,13 +142,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@@ -73,12 +73,12 @@ jobs:
dify-agent/pyproject.toml
dify-agent/uv.lock
- if: github.event_name != 'merge_group'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
+6 -6
View File
@@ -97,7 +97,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@@ -107,7 +107,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env[matrix.image_name_env] }}
@@ -159,10 +159,10 @@ jobs:
file: "docker/local-sandbox/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Validate Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
push: false
context: ${{ matrix.build_context }}
@@ -197,14 +197,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |
+6 -6
View File
@@ -79,7 +79,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -170,7 +170,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -233,7 +233,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -295,7 +295,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -351,7 +351,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+4 -4
View File
@@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
@@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
+2 -2
View File
@@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker Image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
push: false
context: ${{ matrix.context }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
+8 -2
View File
@@ -46,13 +46,17 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |
api:
- 'api/**'
- 'scripts/check_no_new_getattr.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
- '.github/workflows/api-tests.yml'
- 'docker/.env.example'
- 'docker/envs/middleware.env.example'
@@ -380,6 +384,8 @@ jobs:
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
uses: ./.github/workflows/style.yml
with:
base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
vdb-tests-run:
name: Run VDB Tests
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
@@ -21,10 +21,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
+14 -17
View File
@@ -2,6 +2,10 @@ name: Style check
on:
workflow_call:
inputs:
base-rev:
required: true
type: string
concurrency:
group: style-${{ github.head_ref || github.run_id }}
@@ -19,7 +23,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -33,10 +37,11 @@ jobs:
scripts/check_no_new_getattr.py
scripts/ast_grep_rules/no_new_getattr.yml
.github/workflows/style.yml
.github/workflows/main-ci.yml
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: false
python-version: "3.12"
@@ -54,17 +59,9 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --project api --dev python api/dev/lint_response_contracts.py --fail-on-mismatch
- name: Fetch merge target ref for getattr guard
if: steps.changed-files.outputs.any_changed == 'true'
run: git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main
- name: Bind merge target branch for getattr guard
if: steps.changed-files.outputs.any_changed == 'true'
run: git show-ref --verify --quiet refs/heads/main || git branch main origin/main
- name: Run No New Getattr Guard
if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --project api python scripts/check_no_new_getattr.py --mode ci --merge-target main
run: uv run --project api python scripts/check_no_new_getattr.py --base-rev "${{ inputs.base-rev }}"
- name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true'
@@ -88,7 +85,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -139,7 +136,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -168,7 +165,7 @@ jobs:
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
@@ -185,7 +182,7 @@ jobs:
- name: Save ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
@@ -196,7 +193,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -214,7 +211,7 @@ jobs:
.editorconfig
- name: Super-linter
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
if: steps.changed-files.outputs.any_changed == 'true'
env:
BASH_SEVERITY: warning
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+2 -2
View File
@@ -16,7 +16,7 @@ from core.plugin.plugin_service import PluginService
from core.tools.utils.system_encryption import encrypt_system_params
from extensions.ext_database import db
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy
from models.account import TenantPluginAutoUpgradeCategory, TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
@@ -406,7 +406,7 @@ def migrate_data_for_plugin():
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
category_count = len(TenantPluginAutoUpgradeCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
+29 -16
View File
@@ -9,7 +9,12 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from constants.languages import get_valid_language
from controllers.common.fields import SimpleResultDataResponse, SimpleResultOptionalDataResponse, SimpleResultResponse
from controllers.common.fields import (
SimpleResultDataResponse,
SimpleResultMessageResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
@@ -87,6 +92,7 @@ register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPay
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
SimpleResultMessageResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
@@ -154,16 +160,19 @@ class LoginApi(Resource):
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
raise WorkspacesLimitExceeded()
else:
return {
"result": "fail",
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
}
return SimpleResultOptionalDataResponse(
result="fail",
data="workspace not found, please contact system admin to invite you to join in a workspace",
).model_dump(mode="json")
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
# Create response with cookies instead of returning tokens in body
response = make_response({"result": "success"})
# response-contract:ignore cookie-bearing Flask response
response = make_response(
SimpleResultOptionalDataResponse(result="success").model_dump(mode="json", exclude_none=True)
)
set_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
@@ -178,12 +187,11 @@ class LogoutApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user
def post(self, account: Account):
if isinstance(account, flask_login.AnonymousUserMixin):
response = make_response({"result": "success"})
else:
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
if not isinstance(account, flask_login.AnonymousUserMixin):
AccountService.logout(account=account)
flask_login.logout_user()
response = make_response({"result": "success"})
# Clear cookies on logout
clear_access_token_from_cookie(response)
@@ -219,7 +227,7 @@ class ResetPasswordSendEmailApi(Resource):
is_allow_register=FeatureService.get_system_features().is_allow_register,
)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/email-code-login")
@@ -252,7 +260,7 @@ class EmailCodeLoginSendEmailApi(Resource):
else:
token = AccountService.send_email_code_login_email(account=account, language=language)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/email-code-login/validity")
@@ -326,7 +334,8 @@ class EmailCodeLoginApi(Resource):
AccountService.reset_login_error_rate_limit(user_email)
# Create response with cookies instead of returning tokens in body
response = make_response({"result": "success"})
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
# Set HTTP-only secure cookies for tokens
@@ -338,18 +347,22 @@ class EmailCodeLoginApi(Resource):
@console_ns.route("/refresh-token")
class RefreshTokenApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(401, "Unauthorized", console_ns.models[SimpleResultMessageResponse.__name__])
def post(self):
# Get refresh token from cookie instead of request body
refresh_token = extract_refresh_token(request)
if not refresh_token:
return {"result": "fail", "message": "No refresh token provided"}, 401
return SimpleResultMessageResponse(result="fail", message="No refresh token provided").model_dump(
mode="json"
), 401
try:
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session)
# Create response with new cookies
response = make_response({"result": "success"})
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
# Update cookies with new tokens
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
@@ -357,7 +370,7 @@ class RefreshTokenApi(Resource):
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
return response
except Exception as e:
return {"result": "fail", "message": str(e)}, 401
return SimpleResultMessageResponse(result="fail", message=str(e)).model_dump(mode="json"), 401
def _get_account_with_case_fallback(email: str):
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.fields import RedirectResponse, SimpleResultResponse
from controllers.common.fields import SimpleResultResponse
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.wraps import (
@@ -19,11 +19,13 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.entities.provider_entities import ProviderConfig
from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse
from core.plugin.impl.oauth import OAuthHandler
from core.tools.entities.common_entities import I18nObject
from fields.base import ResponseModel
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.provider_ids import DatasourceProviderID
@@ -33,7 +35,9 @@ from services.plugin.oauth_service import OAuthProxyService
class DatasourceCredentialPayload(BaseModel):
name: str | None = Field(default=None, max_length=100)
credentials: dict[str, Any]
credentials: dict[str, Any] = Field(
description="Plugin-defined credential parameters. The schema is declared by the datasource provider."
)
class DatasourceCredentialDeletePayload(BaseModel):
@@ -43,11 +47,17 @@ class DatasourceCredentialDeletePayload(BaseModel):
class DatasourceCredentialUpdatePayload(BaseModel):
credential_id: str
name: str | None = Field(default=None, max_length=100)
credentials: dict[str, Any] | None = Field(default=None)
credentials: dict[str, Any] | None = Field(
default=None,
description="Plugin-defined credential parameters. The schema is declared by the datasource provider.",
)
class DatasourceCustomClientPayload(BaseModel):
client_params: dict[str, Any] | None = Field(default=None)
client_params: dict[str, Any] | None = Field(
default=None,
description="Plugin-defined OAuth client parameters. The schema is declared by the datasource provider.",
)
enable_oauth_custom_client: bool | None = None
@@ -71,8 +81,48 @@ class DatasourceOAuthCallbackQuery(BaseModel):
context_id: str | None = Field(default=None, description="OAuth proxy context ID")
class DatasourceCredentialsResponse(ResponseModel):
result: Any
class DatasourceCredentialResponse(ResponseModel):
credential: dict[str, Any] = Field(
description="Obfuscated plugin-defined credential parameters from the datasource provider."
)
type: str
name: str
avatar_url: str | None
id: str
is_default: bool
class DatasourceCredentialListResponse(ResponseModel):
result: list[DatasourceCredentialResponse]
class DatasourceOAuthSchemaResponse(ResponseModel):
client_schema: list[ProviderConfig]
credentials_schema: list[ProviderConfig]
oauth_custom_client_params: dict[str, Any] | None = Field(
description="Masked plugin-defined OAuth client parameters, when configured for the tenant."
)
is_oauth_custom_client_enabled: bool
is_system_oauth_params_exists: bool
redirect_uri: str
class DatasourceProviderAuthResponse(ResponseModel):
author: str
provider: str
plugin_id: str
plugin_unique_identifier: str
icon: str
name: str
label: I18nObject
description: I18nObject
credential_schema: list[ProviderConfig]
oauth_schema: DatasourceOAuthSchemaResponse | None
credentials_list: list[DatasourceCredentialResponse]
class DatasourceProviderAuthListResponse(ResponseModel):
result: list[DatasourceProviderAuthResponse]
register_schema_models(
@@ -88,9 +138,9 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
DatasourceCredentialsResponse,
DatasourceCredentialListResponse,
DatasourceProviderAuthListResponse,
PluginOAuthAuthorizationUrlResponse,
RedirectResponse,
SimpleResultResponse,
)
@@ -100,7 +150,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthAuthorizationQuery))
@console_ns.response(
200,
"Authorization URL retrieved successfully",
"Datasource OAuth authorization URL generated successfully",
console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__],
)
@setup_required
@@ -140,7 +190,8 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
redirect_uri=redirect_uri,
system_credentials=oauth_config,
)
response = make_response(jsonable_encoder(authorization_url_response))
# response-contract:ignore cookie-bearing Flask response
response = make_response(dump_response(PluginOAuthAuthorizationUrlResponse, authorization_url_response))
response.set_cookie(
"context_id",
context_id,
@@ -154,11 +205,8 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.route("/oauth/plugin/<path:provider_id>/datasource/callback")
class DatasourceOAuthCallback(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthCallbackQuery))
@console_ns.response(
302,
"Redirect to console OAuth callback page",
console_ns.models[RedirectResponse.__name__],
)
# response-contract:ignore redirect response
@console_ns.response(302, "Redirect to OAuth callback page")
@setup_required
def get(self, provider_id: str):
context_id = request.cookies.get("context_id") or request.args.get("context_id")
@@ -217,7 +265,9 @@ class DatasourceOAuthCallback(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>")
class DatasourceAuth(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(
200, "Datasource credential created successfully", console_ns.models[SimpleResultResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@@ -238,12 +288,16 @@ class DatasourceAuth(Resource):
)
except CredentialsValidateFailedError as ex:
raise ValueError(str(ex))
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.response(
200,
"Datasource credentials retrieved successfully",
console_ns.models[DatasourceCredentialListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account, provider_id: str):
@@ -256,7 +310,7 @@ class DatasourceAuth(Resource):
plugin_id=datasource_provider_id.plugin_id,
user=user,
)
return {"result": datasources}, 200
return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/delete")
@@ -282,13 +336,15 @@ class DatasourceAuthDeleteApi(Resource):
provider=provider_name,
plugin_id=plugin_id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update")
class DatasourceAuthUpdateApi(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialUpdatePayload.__name__])
@console_ns.response(201, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(
201, "Datasource credential updated successfully", console_ns.models[SimpleResultResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@@ -308,12 +364,16 @@ class DatasourceAuthUpdateApi(Resource):
credentials=payload.credentials or {},
name=payload.name,
)
return {"result": "success"}, 201
return SimpleResultResponse(result="success").model_dump(mode="json"), 201
@console_ns.route("/auth/plugin/datasource/list")
class DatasourceAuthListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@console_ns.response(
200,
"Datasource credentials retrieved successfully",
console_ns.models[DatasourceProviderAuthListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -321,12 +381,16 @@ class DatasourceAuthListApi(Resource):
def get(self, current_tenant_id: str):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_all_datasource_credentials(tenant_id=current_tenant_id)
return {"result": jsonable_encoder(datasources)}, 200
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@console_ns.route("/auth/plugin/datasource/default-list")
class DatasourceHardCodeAuthListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@console_ns.response(
200,
"Default datasource credentials retrieved successfully",
console_ns.models[DatasourceProviderAuthListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@@ -334,13 +398,15 @@ class DatasourceHardCodeAuthListApi(Resource):
def get(self, current_tenant_id: str):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_hard_code_datasource_credentials(tenant_id=current_tenant_id)
return {"result": jsonable_encoder(datasources)}, 200
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/custom-client")
class DatasourceAuthOauthCustomClient(Resource):
@console_ns.expect(console_ns.models[DatasourceCustomClientPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(
200, "Datasource OAuth custom client saved successfully", console_ns.models[SimpleResultResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@@ -357,7 +423,7 @@ class DatasourceAuthOauthCustomClient(Resource):
client_params=payload.client_params or {},
enabled=payload.enable_oauth_custom_client or False,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@setup_required
@login_required
@@ -371,7 +437,7 @@ class DatasourceAuthOauthCustomClient(Resource):
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/default")
@@ -393,7 +459,7 @@ class DatasourceAuthDefaultApi(Resource):
datasource_provider_id=datasource_provider_id,
credential_id=payload.id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update-name")
@@ -416,4 +482,4 @@ class DatasourceUpdateProviderNameApi(Resource):
name=payload.name,
credential_id=payload.credential_id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@@ -3,9 +3,9 @@ from typing import Any
from flask_restx import ( # type: ignore
Resource, # type: ignore
)
from pydantic import BaseModel, RootModel
from pydantic import BaseModel
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
@@ -21,18 +21,13 @@ class Parser(BaseModel):
credential_id: str | None = None
class DataSourceContentPreviewResponse(RootModel[Any]):
root: Any
register_schema_models(console_ns, Parser)
register_response_schema_models(console_ns, DataSourceContentPreviewResponse)
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/datasource/nodes/<string:node_id>/preview")
class DataSourceContentPreviewApi(Resource):
@console_ns.expect(console_ns.models[Parser.__name__])
@console_ns.response(200, "Success", console_ns.models[DataSourceContentPreviewResponse.__name__])
@console_ns.response(200, "Success")
@setup_required
@login_required
@account_initialization_required
+6 -5
View File
@@ -13,7 +13,6 @@ import services
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
)
from controllers.common.fields import Parameters as ParametersResponse
@@ -391,7 +390,6 @@ register_response_schema_models(
ParametersResponse,
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
SiteResponse,
SuggestedQuestionsResponse,
@@ -406,7 +404,7 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppWorkflowRunApi(TrialAppResource):
@trial_feature_enable
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@@ -434,6 +432,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@@ -478,7 +477,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
@@ -513,6 +512,7 @@ class TrialChatApi(TrialAppResource):
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -680,7 +680,7 @@ class TrialChatTextApi(TrialAppResource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
@@ -710,6 +710,7 @@ class TrialCompletionApi(TrialAppResource):
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -8,7 +8,7 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models.account import TenantPluginPermission
from models.account import TenantPluginDebugPermission, TenantPluginInstallPermission, TenantPluginPermission
def plugin_permission_required(
@@ -40,22 +40,22 @@ def plugin_permission_required(
if install_required:
match permission.install_permission:
case TenantPluginPermission.InstallPermission.NOBODY:
case TenantPluginInstallPermission.NOBODY:
raise Forbidden()
case TenantPluginPermission.InstallPermission.ADMINS:
case TenantPluginInstallPermission.ADMINS:
if not user.is_admin_or_owner:
raise Forbidden()
case TenantPluginPermission.InstallPermission.EVERYONE:
case TenantPluginInstallPermission.EVERYONE:
pass
if debug_required:
match permission.debug_permission:
case TenantPluginPermission.DebugPermission.NOBODY:
case TenantPluginDebugPermission.NOBODY:
raise Forbidden()
case TenantPluginPermission.DebugPermission.ADMINS:
case TenantPluginDebugPermission.ADMINS:
if not user.is_admin_or_owner:
raise Forbidden()
case TenantPluginPermission.DebugPermission.EVERYONE:
case TenantPluginDebugPermission.EVERYONE:
pass
return view(*args, **kwargs)
+74 -84
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from http import HTTPStatus
from typing import Literal
import pytz
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
@@ -47,7 +48,7 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import Account as AccountResponse
from fields.member_fields import AccountResponse
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
@@ -194,10 +195,6 @@ register_schema_models(
)
def _serialize_account(account) -> dict[str, Any]:
return AccountResponse.model_validate(account, from_attributes=True).model_dump(mode="json")
class AccountIntegrateResponse(ResponseModel):
provider: str
created_at: int | None = None
@@ -236,23 +233,15 @@ class EducationAutocompleteResponse(ResponseModel):
has_next: bool | None = None
class EducationActivateResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(
console_ns,
AccountIntegrateResponse,
AccountIntegrateListResponse,
EducationVerifyResponse,
EducationStatusResponse,
EducationAutocompleteResponse,
)
register_response_schema_models(
console_ns,
AccountResponse,
AccountIntegrateResponse,
AccountIntegrateListResponse,
AvatarUrlResponse,
EducationActivateResponse,
EducationVerifyResponse,
EducationStatusResponse,
EducationAutocompleteResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
@@ -262,7 +251,7 @@ register_response_schema_models(
@console_ns.route("/account/init")
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@with_current_user
@@ -302,7 +291,7 @@ class AccountInitApi(Resource):
account.initialized_at = naive_utc_now()
db.session.commit()
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/profile")
@@ -310,11 +299,11 @@ class AccountProfileApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@enterprise_license_required
@with_current_user
def get(self, current_user: Account):
return _serialize_account(current_user)
return dump_response(AccountResponse, current_user)
@console_ns.route("/account/name")
@@ -323,14 +312,14 @@ class AccountNameApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session, name=args.name)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/avatar")
@@ -338,7 +327,7 @@ class AccountAvatarApi(Resource):
@console_ns.doc("get_account_avatar")
@console_ns.doc(description="Get account avatar url")
@console_ns.doc(params=query_params_from_model(AccountAvatarQuery))
@console_ns.response(200, "Success", console_ns.models[AvatarUrlResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AvatarUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -349,7 +338,7 @@ class AccountAvatarApi(Resource):
avatar = args.avatar
if avatar.startswith(("http://", "https://")):
return dump_response(AvatarUrlResponse, {"avatar_url": avatar})
return AvatarUrlResponse(avatar_url=avatar).model_dump(mode="json")
upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1))
if upload_file is None:
@@ -362,13 +351,13 @@ class AccountAvatarApi(Resource):
raise NotFound("Avatar file not found")
avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
return dump_response(AvatarUrlResponse, {"avatar_url": avatar_url})
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.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -376,7 +365,7 @@ class AccountAvatarApi(Resource):
updated_account = AccountService.update_account(current_user, session=db.session, avatar=args.avatar)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/interface-language")
@@ -385,7 +374,7 @@ class AccountInterfaceLanguageApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -395,7 +384,7 @@ class AccountInterfaceLanguageApi(Resource):
current_user, session=db.session, interface_language=args.interface_language
)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/interface-theme")
@@ -404,7 +393,7 @@ class AccountInterfaceThemeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -414,7 +403,7 @@ class AccountInterfaceThemeApi(Resource):
current_user, session=db.session, interface_theme=args.interface_theme
)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/timezone")
@@ -423,7 +412,7 @@ class AccountTimezoneApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -431,7 +420,7 @@ class AccountTimezoneApi(Resource):
updated_account = AccountService.update_account(current_user, session=db.session, timezone=args.timezone)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/password")
@@ -440,7 +429,7 @@ class AccountPasswordApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -452,7 +441,7 @@ class AccountPasswordApi(Resource):
except ServiceCurrentPasswordIncorrectError:
raise CurrentPasswordIncorrectError()
return _serialize_account(current_user)
return dump_response(AccountResponse, current_user)
@console_ns.route("/account/integrates")
@@ -460,7 +449,7 @@ class AccountIntegrateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
@with_current_user
def get(self, account: Account):
account_integrates = db.session.scalars(
@@ -471,33 +460,29 @@ class AccountIntegrateApi(Resource):
oauth_base_path = "/console/api/oauth/login"
providers = ["github", "google"]
integrate_data = []
integrate_data: list[AccountIntegrateResponse] = []
for provider in providers:
existing_integrate = next((ai for ai in account_integrates if ai.provider == provider), None)
if existing_integrate:
integrate_data.append(
{
"id": existing_integrate.id,
"provider": provider,
"created_at": existing_integrate.created_at,
"is_bound": True,
"link": None,
}
AccountIntegrateResponse(
provider=provider,
created_at=to_timestamp(existing_integrate.created_at),
is_bound=True,
link=None,
)
)
else:
integrate_data.append(
{
"id": None,
"provider": provider,
"created_at": None,
"is_bound": False,
"link": f"{base_url}{oauth_base_path}/{provider}",
}
AccountIntegrateResponse(
provider=provider,
created_at=None,
is_bound=False,
link=f"{base_url}{oauth_base_path}/{provider}",
)
)
return AccountIntegrateListResponse(
data=[AccountIntegrateResponse.model_validate(item) for item in integrate_data]
).model_dump(mode="json")
return AccountIntegrateListResponse(data=integrate_data).model_dump(mode="json")
@console_ns.route("/account/delete/verify")
@@ -505,19 +490,19 @@ class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@with_current_user
def get(self, account: Account):
token, code = AccountService.generate_account_deletion_verification_code(account)
AccountService.send_account_deletion_verification_email(account, code)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/account/delete")
class AccountDeleteApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -531,13 +516,13 @@ class AccountDeleteApi(Resource):
AccountService.delete_account(account)
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/delete/feedback")
class AccountDeleteUpdateFeedbackApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
@@ -545,7 +530,7 @@ class AccountDeleteUpdateFeedbackApi(Resource):
BillingService.update_account_deletion_feedback(args.email, args.feedback)
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/education/verify")
@@ -555,18 +540,19 @@ class EducationVerifyApi(Resource):
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(200, "Success", console_ns.models[EducationVerifyResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
@with_current_user
def get(self, account: Account):
return EducationVerifyResponse.model_validate(
BillingService.EducationIdentity.verify(account.id, account.email) or {}
).model_dump(mode="json")
return dump_response(
EducationVerifyResponse, BillingService.EducationIdentity.verify(account.id, account.email) or {}
)
@console_ns.route("/account/education")
class EducationApi(Resource):
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[EducationActivateResponse.__name__])
# response-contract:ignore billing-service activation payload; TODO: model education activation result.
@console_ns.response(HTTPStatus.OK, "Success")
@setup_required
@login_required
@account_initialization_required
@@ -577,21 +563,22 @@ class EducationApi(Resource):
payload = console_ns.payload or {}
args = EducationActivatePayload.model_validate(payload)
return BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
return result
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(200, "Success", console_ns.models[EducationStatusResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
@with_current_user
def get(self, account: Account):
res = BillingService.EducationIdentity.status(account.id) or {}
# convert expire_at to UTC timestamp from isoformat
if res and "expire_at" in res:
res["expire_at"] = datetime.fromisoformat(res["expire_at"]).astimezone(pytz.utc)
return EducationStatusResponse.model_validate(res).model_dump(mode="json")
return dump_response(EducationStatusResponse, res)
@console_ns.route("/account/education/autocomplete")
@@ -602,20 +589,21 @@ class EducationAutoCompleteApi(Resource):
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(200, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
def get(self):
payload = request.args.to_dict(flat=True)
args = EducationAutocompleteQuery.model_validate(payload)
return EducationAutocompleteResponse.model_validate(
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {}
).model_dump(mode="json")
return dump_response(
EducationAutocompleteResponse,
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {},
)
@console_ns.route("/account/change-email")
class ChangeEmailSendEmailApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@enable_change_email
@setup_required
@login_required
@@ -669,13 +657,13 @@ class ChangeEmailSendEmailApi(Resource):
language=language,
phase=send_phase,
)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/account/change-email/validity")
class ChangeEmailCheckApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@enable_change_email
@setup_required
@login_required
@@ -716,7 +704,9 @@ class ChangeEmailCheckApi(Resource):
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
AccountService.reset_change_email_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
return VerificationTokenResponse(is_valid=True, email=normalized_token_email, token=new_token).model_dump(
mode="json"
)
@console_ns.route("/account/change-email/reset")
@@ -726,7 +716,7 @@ class ChangeEmailResetApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -768,13 +758,13 @@ class ChangeEmailResetApi(Resource):
email=normalized_new_email,
)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/change-email/check-email-unique")
class CheckEmailUnique(Resource):
@console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
@@ -784,4 +774,4 @@ class CheckEmailUnique(Resource):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session):
raise EmailAlreadyInUseError()
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
+193 -133
View File
@@ -6,13 +6,17 @@ verb-based aliases stay available as deprecated resources so OpenAPI metadata
marks only the legacy paths as deprecated.
"""
from datetime import datetime
from enum import StrEnum
from http import HTTPStatus
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.common.fields import SuccessResponse
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.wraps import (
RBACPermission,
@@ -24,8 +28,14 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user_id,
)
from core.entities.parameter_entities import (
AppSelectorScope,
ModelSelectorScope,
ToolSelectorScope,
)
from core.entities.provider_entities import ProviderConfigType
from core.plugin.impl.exc import PluginPermissionDeniedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from fields.base import ResponseModel
from libs.login import login_required
from services.plugin.endpoint_service import EndpointService
@@ -40,14 +50,17 @@ class EndpointIdPayload(BaseModel):
endpoint_id: str
class EndpointUpdatePayload(BaseModel):
class EndpointSettingsPayload(BaseModel):
settings: dict[str, Any]
name: str = Field(min_length=1)
class LegacyEndpointUpdatePayload(EndpointIdPayload):
settings: dict[str, Any]
name: str = Field(min_length=1)
class EndpointUpdatePayload(EndpointSettingsPayload):
pass
class LegacyEndpointUpdatePayload(EndpointIdPayload, EndpointSettingsPayload):
pass
class EndpointListQuery(BaseModel):
@@ -59,98 +72,158 @@ class EndpointListForPluginQuery(EndpointListQuery):
plugin_id: str
class EndpointCreateResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderConfigScope(StrEnum):
ALL = AppSelectorScope.ALL.value
CHAT = AppSelectorScope.CHAT.value
WORKFLOW = AppSelectorScope.WORKFLOW.value
COMPLETION = AppSelectorScope.COMPLETION.value
LLM = ModelSelectorScope.LLM.value
TEXT_EMBEDDING = ModelSelectorScope.TEXT_EMBEDDING.value
RERANK = ModelSelectorScope.RERANK.value
TTS = ModelSelectorScope.TTS.value
SPEECH2TEXT = ModelSelectorScope.SPEECH2TEXT.value
MODERATION = ModelSelectorScope.MODERATION.value
VISION = ModelSelectorScope.VISION.value
CUSTOM = ToolSelectorScope.CUSTOM.value
BUILTIN = ToolSelectorScope.BUILTIN.value
class EndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
class EndpointProviderConfigI18nResponse(ResponseModel):
en_US: str
zh_Hans: str | None = None
pt_BR: str | None = None
ja_JP: str | None = None
class PluginEndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
class EndpointProviderConfigOptionResponse(ResponseModel):
value: str
label: EndpointProviderConfigI18nResponse
class EndpointDeleteResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderConfigResponse(ResponseModel):
type: ProviderConfigType
name: str
scope: EndpointProviderConfigScope | None = None
required: bool = False
default: int | str | float | bool | None = None
options: list[EndpointProviderConfigOptionResponse] | None = None
multiple: bool = False
label: EndpointProviderConfigI18nResponse | None = None
help: EndpointProviderConfigI18nResponse | None = None
url: str | None = None
placeholder: EndpointProviderConfigI18nResponse | None = None
class EndpointUpdateResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointDeclarationResponse(ResponseModel):
path: str
method: str
hidden: bool = False
class EndpointEnableResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderDeclarationResponse(ResponseModel):
settings: list[EndpointProviderConfigResponse] = Field(default_factory=list)
endpoints: list[EndpointDeclarationResponse] | None = Field(default_factory=list)
class EndpointDisableResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointListItemResponse(ResponseModel):
id: str
created_at: datetime
updated_at: datetime
tenant_id: str
plugin_id: str
settings: dict[str, Any]
expired_at: datetime
declaration: EndpointProviderDeclarationResponse = Field(default_factory=EndpointProviderDeclarationResponse)
name: str
enabled: bool
url: str
hook_id: str
class EndpointListResponse(ResponseModel):
endpoints: list[EndpointListItemResponse] = Field(description="Endpoint information")
register_schema_models(
console_ns,
EndpointCreatePayload,
EndpointIdPayload,
EndpointSettingsPayload,
EndpointUpdatePayload,
LegacyEndpointUpdatePayload,
EndpointListQuery,
EndpointListForPluginQuery,
EndpointCreateResponse,
)
register_response_schema_models(
console_ns,
SuccessResponse,
EndpointProviderConfigOptionResponse,
EndpointProviderConfigResponse,
EndpointDeclarationResponse,
EndpointProviderDeclarationResponse,
EndpointListItemResponse,
EndpointListResponse,
PluginEndpointListResponse,
EndpointDeleteResponse,
EndpointUpdateResponse,
EndpointEnableResponse,
EndpointDisableResponse,
)
def _create_endpoint(tenant_id: str, user_id: str) -> dict[str, bool]:
def _create_endpoint(tenant_id: str, user_id: str) -> bool:
"""Create a plugin endpoint for the injected workspace and user."""
args = EndpointCreatePayload.model_validate(console_ns.payload)
try:
return {
"success": EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user_id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
}
return EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user_id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]:
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
"""Update a plugin endpoint identified by the canonical path parameter."""
args = EndpointUpdatePayload.model_validate(console_ns.payload)
return {
"success": EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
}
return EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]:
def _legacy_update_endpoint(tenant_id: str, user_id: str) -> bool:
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=args.endpoint_id,
name=args.name,
settings=args.settings,
)
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
"""Delete a plugin endpoint identified by the canonical path parameter."""
return {
"success": EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
)
}
return EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
)
def _delete_endpoint_from_payload(tenant_id: str, user_id: str) -> bool:
args = EndpointIdPayload.model_validate(console_ns.payload)
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
def _set_endpoint_enabled(tenant_id: str, user_id: str, *, enabled: bool) -> bool:
args = EndpointIdPayload.model_validate(console_ns.payload)
action = EndpointService.enable_endpoint if enabled else EndpointService.disable_endpoint
return action(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints")
@@ -161,11 +234,11 @@ class EndpointCollectionApi(Resource):
@console_ns.doc(description="Create a new plugin endpoint")
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint created successfully",
console_ns.models[EndpointCreateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -174,7 +247,7 @@ class EndpointCollectionApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return _create_endpoint(tenant_id=tenant_id, user_id=user_id)
return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/create")
@@ -190,11 +263,11 @@ class DeprecatedEndpointCreateApi(Resource):
)
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint created successfully",
console_ns.models[EndpointCreateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -203,7 +276,7 @@ class DeprecatedEndpointCreateApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return _create_endpoint(tenant_id=tenant_id, user_id=user_id)
return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/list")
@@ -212,7 +285,7 @@ class EndpointListApi(Resource):
@console_ns.doc(description="List plugin endpoints with pagination")
@console_ns.doc(params=query_params_from_model(EndpointListQuery))
@console_ns.response(
200,
HTTPStatus.OK,
"Success",
console_ns.models[EndpointListResponse.__name__],
)
@@ -224,20 +297,15 @@ class EndpointListApi(Resource):
def get(self, tenant_id: str, user_id: str):
args = EndpointListQuery.model_validate(request.args.to_dict(flat=True))
page = args.page
page_size = args.page_size
return jsonable_encoder(
{
"endpoints": EndpointService.list_endpoints(
tenant_id=tenant_id,
user_id=user_id,
page=page,
page_size=page_size,
)
}
endpoints = EndpointService.list_endpoints(
tenant_id=tenant_id,
user_id=user_id,
page=args.page,
page_size=args.page_size,
)
return EndpointListResponse(endpoints=endpoints).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/list/plugin")
class EndpointListForSinglePluginApi(Resource):
@@ -245,9 +313,9 @@ class EndpointListForSinglePluginApi(Resource):
@console_ns.doc(description="List endpoints for a specific plugin")
@console_ns.doc(params=query_params_from_model(EndpointListForPluginQuery))
@console_ns.response(
200,
HTTPStatus.OK,
"Success",
console_ns.models[PluginEndpointListResponse.__name__],
console_ns.models[EndpointListResponse.__name__],
)
@setup_required
@login_required
@@ -257,22 +325,16 @@ class EndpointListForSinglePluginApi(Resource):
def get(self, tenant_id: str, user_id: str):
args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True))
page = args.page
page_size = args.page_size
plugin_id = args.plugin_id
return jsonable_encoder(
{
"endpoints": EndpointService.list_endpoints_for_single_plugin(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=plugin_id,
page=page,
page_size=page_size,
)
}
endpoints = EndpointService.list_endpoints_for_single_plugin(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=args.plugin_id,
page=args.page,
page_size=args.page_size,
)
return EndpointListResponse(endpoints=endpoints).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/<string:id>")
class EndpointItemApi(Resource):
@@ -282,11 +344,11 @@ class EndpointItemApi(Resource):
@console_ns.doc(description="Delete a plugin endpoint")
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint deleted successfully",
console_ns.models[EndpointDeleteResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -295,18 +357,20 @@ class EndpointItemApi(Resource):
@with_current_user_id
@with_current_tenant_id
def delete(self, tenant_id: str, user_id: str, id: str):
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
return SuccessResponse(
success=_delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
).model_dump(mode="json")
@console_ns.doc("update_endpoint")
@console_ns.doc(description="Update a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint updated successfully",
console_ns.models[EndpointUpdateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -315,7 +379,9 @@ class EndpointItemApi(Resource):
@with_current_user_id
@with_current_tenant_id
def patch(self, tenant_id: str, user_id: str, id: str):
return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
return SuccessResponse(
success=_update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/delete")
@@ -332,11 +398,11 @@ class DeprecatedEndpointDeleteApi(Resource):
)
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint deleted successfully",
console_ns.models[EndpointDeleteResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -345,8 +411,9 @@ class DeprecatedEndpointDeleteApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = EndpointIdPayload.model_validate(console_ns.payload)
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
return SuccessResponse(success=_delete_endpoint_from_payload(tenant_id=tenant_id, user_id=user_id)).model_dump(
mode="json"
)
@console_ns.route("/workspaces/current/endpoints/update")
@@ -363,11 +430,11 @@ class DeprecatedEndpointUpdateApi(Resource):
)
@console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint updated successfully",
console_ns.models[EndpointUpdateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -376,8 +443,9 @@ class DeprecatedEndpointUpdateApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
return SuccessResponse(success=_legacy_update_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(
mode="json"
)
@console_ns.route("/workspaces/current/endpoints/enable")
@@ -386,11 +454,11 @@ class EndpointEnableApi(Resource):
@console_ns.doc(description="Enable a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint enabled successfully",
console_ns.models[EndpointEnableResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -399,13 +467,9 @@ class EndpointEnableApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.enable_endpoint(
tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id
)
}
return SuccessResponse(
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=True)
).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/disable")
@@ -414,11 +478,11 @@ class EndpointDisableApi(Resource):
@console_ns.doc(description="Disable a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint disabled successfully",
console_ns.models[EndpointDisableResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -427,10 +491,6 @@ class EndpointDisableApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.disable_endpoint(
tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id
)
}
return SuccessResponse(
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=False)
).model_dump(mode="json")
+71 -70
View File
@@ -1,10 +1,12 @@
from http import HTTPStatus
from urllib import parse
from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from werkzeug.exceptions import NotFound
import services
from configs import dify_config
@@ -30,8 +32,8 @@ from controllers.console.wraps import (
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from fields.member_fields import AccountWithRole, AccountWithRoleList
from libs.helper import extract_remote_ip
from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse
from libs.helper import dump_response, extract_remote_ip
from libs.login import current_account_with_tenant, login_required
from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
@@ -45,6 +47,11 @@ class MemberInvitePayload(BaseModel):
role: str
language: str | None = None
@field_validator("emails")
@classmethod
def normalize_emails(cls, emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
class MemberRoleUpdatePayload(BaseModel):
role: str
@@ -70,14 +77,14 @@ class MemberInviteResultResponse(ResponseModel):
message: str | None = None
class MemberInviteResponse(ResponseModel):
class MemberActionResponse(ResponseModel):
result: str
invitation_results: list[MemberInviteResultResponse]
tenant_id: str
class MemberActionTenantResponse(ResponseModel):
class MemberInviteResponse(ResponseModel):
result: str
invitation_results: list[MemberInviteResultResponse]
tenant_id: str
@@ -92,13 +99,14 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
AccountWithRole,
AccountWithRoleList,
AccountWithRoleResponse,
AccountWithRoleListResponse,
MemberActionResponse,
MemberInviteResponse,
MemberInviteResultResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
MemberInviteResponse,
MemberActionTenantResponse,
)
@@ -124,10 +132,6 @@ def _normalize_enum_value(value: object) -> str:
return str(normalized) if normalized is not None else ""
def _normalize_invitee_emails(emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
@@ -179,7 +183,7 @@ class MemberListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@with_current_user
def get(self, current_user: Account | None = None):
if current_user is None:
@@ -216,9 +220,7 @@ class MemberListApi(Resource):
}
)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
return dump_response(AccountWithRoleListResponse, {"accounts": serialized_members}), HTTPStatus.OK
@console_ns.route("/workspaces/current/members/invite-email")
@@ -226,7 +228,7 @@ class MemberInviteEmailApi(Resource):
"""Invite a new member by email."""
@console_ns.expect(console_ns.models[MemberInvitePayload.__name__])
@console_ns.response(201, "Success", console_ns.models[MemberInviteResponse.__name__])
@console_ns.response(HTTPStatus.CREATED, "Success", console_ns.models[MemberInviteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -235,26 +237,26 @@ class MemberInviteEmailApi(Resource):
payload = console_ns.payload or {}
args = MemberInvitePayload.model_validate(payload)
invitee_emails = _normalize_invitee_emails(args.emails)
invitee_emails = args.emails
invitee_role = args.role
interface_language = args.language
if not dify_config.RBAC_ENABLED:
if not TenantAccountRole.is_valid_role(invitee_role):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
inviter = current_user
if not inviter.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(invitee_role, inviter.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
# Check workspace permission for member invitations
from libs.workspace_permission import check_workspace_member_invite_permission
check_workspace_member_invite_permission(inviter.current_tenant.id)
invitation_results = []
invitation_results: list[MemberInviteResultResponse] = []
console_web_url = dify_config.CONSOLE_WEB_URL
tenant_id = inviter.current_tenant.id
@@ -277,63 +279,65 @@ class MemberInviteEmailApi(Resource):
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
{
"status": "success",
"email": invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
MemberInviteResultResponse(
status="success",
email=invitee_email,
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
)
)
except AccountAlreadyInTenantError:
invitation_results.append(
{
"status": "already_member",
"email": invitee_email,
"message": "Account already in workspace.",
}
MemberInviteResultResponse(
status="already_member",
email=invitee_email,
message="Account already in workspace.",
)
)
except Exception as e:
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
invitation_results.append(
MemberInviteResultResponse(status="failed", email=invitee_email, message=str(e))
)
return {
"result": "success",
"invitation_results": invitation_results,
"tenant_id": str(inviter.current_tenant.id) if inviter.current_tenant else "",
}, 201
return MemberInviteResponse(
result="success",
invitation_results=invitation_results,
tenant_id=inviter.current_tenant.id if inviter.current_tenant else "",
).model_dump(mode="json"), HTTPStatus.CREATED
@console_ns.route("/workspaces/current/members/<uuid:member_id>")
class MemberCancelInviteApi(Resource):
"""Cancel an invitation by member id."""
@console_ns.response(200, "Success", console_ns.models[MemberActionTenantResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[MemberActionResponse.__name__])
@with_current_user
def delete(self, current_user: Account, member_id: UUID):
if not current_user.current_tenant:
raise ValueError("No current tenant")
member = db.session.get(Account, str(member_id))
if member is None:
abort(404)
abort(HTTPStatus.NOT_FOUND)
else:
try:
TenantService.remove_member_from_tenant(
current_user.current_tenant, member, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, 403
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, 404
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except Exception as e:
raise ValueError(str(e))
return {
"result": "success",
"tenant_id": str(current_user.current_tenant.id) if current_user.current_tenant else "",
}, 200
return MemberActionResponse(
result="success",
tenant_id=current_user.current_tenant.id if current_user.current_tenant else "",
).model_dump(mode="json"), HTTPStatus.OK
@console_ns.route("/workspaces/current/members/<uuid:member_id>/update-role")
@@ -341,7 +345,7 @@ class MemberUpdateRoleApi(Resource):
"""Update member role."""
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -352,14 +356,14 @@ class MemberUpdateRoleApi(Resource):
new_role = args.role
if not TenantAccountRole.is_valid_role(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(new_role, current_user.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
member = db.session.get(Account, str(member_id))
if not member:
abort(404)
abort(HTTPStatus.NOT_FOUND)
try:
assert member is not None, "Member not found"
@@ -367,17 +371,17 @@ class MemberUpdateRoleApi(Resource):
current_user.current_tenant, member, new_role, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, 403
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, 404
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except services.errors.account.RoleAlreadyAssignedError as e:
return {"code": "role-already-assigned", "message": str(e)}, 400
return {"code": "role-already-assigned", "message": str(e)}, HTTPStatus.BAD_REQUEST
except Exception as e:
raise ValueError(str(e))
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/workspaces/current/dataset-operators")
@@ -387,15 +391,13 @@ class DatasetOperatorMemberListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@with_current_user
def get(self, current_user: Account):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
return dump_response(AccountWithRoleListResponse, {"accounts": members}), HTTPStatus.OK
@console_ns.route("/workspaces/current/members/send-owner-transfer-confirm-email")
@@ -403,7 +405,7 @@ class SendOwnerTransferEmailApi(Resource):
"""Send owner transfer email."""
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -435,13 +437,13 @@ class SendOwnerTransferEmailApi(Resource):
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/workspaces/current/members/owner-transfer-check")
class OwnerTransferCheckApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -480,13 +482,13 @@ class OwnerTransferCheckApi(Resource):
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={})
AccountService.reset_owner_transfer_error_rate_limit(user_email)
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json")
@console_ns.route("/workspaces/current/members/<uuid:member_id>/owner-transfer")
class OwnerTransfer(Resource):
@console_ns.expect(console_ns.models[OwnerTransferPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -516,8 +518,7 @@ class OwnerTransfer(Resource):
member = db.session.get(Account, str(member_id))
if not member:
abort(404)
return # Never reached, but helps type checker
raise NotFound()
if not current_user.current_tenant:
raise ValueError("No current tenant")
@@ -546,4 +547,4 @@ class OwnerTransfer(Resource):
except Exception as e:
raise ValueError(str(e))
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
+34 -28
View File
@@ -42,7 +42,15 @@ from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.account import (
Account,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginDebugPermission,
TenantPluginInstallPermission,
)
from models.provider_ids import ToolProviderID
from services.entities.model_provider_entities import ProviderEntityResponse
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
@@ -52,9 +60,9 @@ from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
strategy_setting: TenantPluginAutoUpgradeStrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
upgrade_mode: TenantPluginAutoUpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
@@ -127,8 +135,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
install_permission: TenantPluginInstallPermission = TenantPluginInstallPermission.EVERYONE
debug_permission: TenantPluginDebugPermission = TenantPluginDebugPermission.EVERYONE
class ParserDynamicOptions(BaseModel):
@@ -150,16 +158,14 @@ class ParserDynamicOptionsWithCredentials(BaseModel):
class PluginPermissionSettingsPayload(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
install_permission: TenantPluginInstallPermission = TenantPluginInstallPermission.EVERYONE
debug_permission: TenantPluginDebugPermission = TenantPluginDebugPermission.EVERYONE
class PluginAutoUpgradeSettingsPayload(BaseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting = (
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
)
strategy_setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY
upgrade_time_of_day: int = 0
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
upgrade_mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE
exclude_plugins: list[str] = Field(default_factory=list)
include_plugins: list[str] = Field(default_factory=list)
@@ -170,15 +176,15 @@ class PluginAutoUpgradeChangeResponse(ResponseModel):
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
strategy_setting: TenantPluginAutoUpgradeStrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
upgrade_mode: TenantPluginAutoUpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
@@ -209,19 +215,19 @@ class PluginDeclarationResponse(ResponseModel):
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
class ParserReadme(BaseModel):
@@ -339,8 +345,8 @@ class PluginTaskResponse(ResponseModel):
class PluginPermissionResponse(ResponseModel):
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
install_permission: TenantPluginInstallPermission
debug_permission: TenantPluginDebugPermission
class PluginDynamicOptionsResponse(ResponseModel):
@@ -408,22 +414,22 @@ register_response_schema_models(
register_enum_models(
console_ns,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
TenantPluginDebugPermission,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginInstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
category: TenantPluginAutoUpgradeCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"upgrade_mode": TenantPluginAutoUpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
@@ -987,8 +993,8 @@ class PluginFetchPermissionApi(Resource):
if not permission:
return jsonable_encoder(
{
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
"install_permission": TenantPluginInstallPermission.EVERYONE,
"debug_permission": TenantPluginDebugPermission.EVERYONE,
}
)
+16 -14
View File
@@ -4,7 +4,7 @@ from typing import Any
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource, marshal
from flask_restx import Resource
from pydantic import Field as PydanticField
from pydantic import field_validator
from sqlalchemy.orm import Session, sessionmaker
@@ -37,8 +37,7 @@ from controllers.console.wraps import (
from core.plugin.entities.plugin import PluginDependency
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.snippet_fields import snippet_fields, snippet_list_fields
from libs.helper import to_timestamp
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account
from models.snippet import SnippetType
@@ -198,13 +197,16 @@ class CustomizedSnippetsApi(Resource):
tag_ids=query.tag_ids,
)
return {
"data": marshal(snippets, snippet_list_fields),
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": has_more,
}, 200
return dump_response(
SnippetPaginationResponse,
{
"data": snippets,
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": has_more,
},
), 200
@console_ns.doc("create_customized_snippet")
@console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__))
@@ -245,7 +247,7 @@ class CustomizedSnippetsApi(Resource):
except ValueError as e:
return {"message": str(e)}, 400
return marshal(snippet, snippet_fields), 201
return dump_response(SnippetResponse, snippet), 201
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>")
@@ -268,7 +270,7 @@ class CustomizedSnippetDetailApi(Resource):
if not snippet:
raise NotFound("Snippet not found")
return marshal(snippet, snippet_fields), 200
return dump_response(SnippetResponse, snippet), 200
@console_ns.doc("update_customized_snippet")
@console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__))
@@ -317,7 +319,7 @@ class CustomizedSnippetDetailApi(Resource):
except ValueError as e:
return {"message": str(e)}, 400
return marshal(snippet, snippet_fields), 200
return dump_response(SnippetResponse, snippet), 200
@console_ns.doc("delete_customized_snippet")
@console_ns.response(204, "Snippet deleted successfully")
@@ -533,4 +535,4 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
session.commit()
session.refresh(snippet)
return {"result": "success", "use_count": snippet.use_count}, 200
return SnippetUseCountResponse(result="success", use_count=snippet.use_count).model_dump(mode="json"), 200
+59 -73
View File
@@ -1,8 +1,9 @@
import logging
from datetime import datetime
from http import HTTPStatus
from flask import request
from flask_restx import Resource, fields, marshal
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Unauthorized
@@ -16,7 +17,12 @@ from controllers.common.errors import (
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.admin import admin_required
from controllers.console.error import AccountNotLinkTenantError
@@ -31,7 +37,7 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
@@ -133,7 +139,7 @@ class WorkspaceListItemResponse(ResponseModel):
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value):
def _normalize_enum_like(cls, value):
if value is None:
return None
if isinstance(value, str):
@@ -146,7 +152,7 @@ class WorkspaceListItemResponse(ResponseModel):
return to_timestamp(value)
class WorkspaceListResponse(ResponseModel):
class WorkspacePaginationResponse(ResponseModel):
data: list[WorkspaceListItemResponse]
has_more: bool
limit: int
@@ -159,7 +165,7 @@ class SwitchWorkspaceResponse(ResponseModel):
new_tenant: TenantInfoResponse
class WorkspaceMutationResponse(ResponseModel):
class WorkspaceTenantResultResponse(ResponseModel):
result: str
tenant: TenantInfoResponse
@@ -174,6 +180,16 @@ class WorkspacePermissionResponse(ResponseModel):
allow_owner_transfer: bool
WORKSPACE_LOGO_UPLOAD_PARAMS = {
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "Workspace web app logo file. Only SVG and PNG files are supported.",
}
}
register_schema_models(
console_ns,
WorkspaceListQuery,
@@ -184,53 +200,21 @@ register_schema_models(
register_response_schema_models(
console_ns,
TenantInfoResponse,
TenantListItemResponse,
TenantListResponse,
WorkspaceListResponse,
SwitchWorkspaceResponse,
WorkspaceMutationResponse,
WorkspaceLogoUploadResponse,
WorkspaceCustomConfigResponse,
WorkspaceListItemResponse,
WorkspacePaginationResponse,
SwitchWorkspaceResponse,
WorkspaceTenantResultResponse,
WorkspaceLogoUploadResponse,
WorkspacePermissionResponse,
)
provider_fields = {
"provider_name": fields.String,
"provider_type": fields.String,
"is_valid": fields.Boolean,
"token_is_set": fields.Boolean,
}
tenant_fields = {
"id": fields.String,
"name": fields.String,
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"role": fields.String,
"in_trial": fields.Boolean,
"trial_end_reason": fields.String,
"custom_config": fields.Raw(attribute="custom_config"),
"trial_credits": fields.Integer,
"trial_credits_used": fields.Integer,
"next_credit_reset_date": fields.Integer,
}
tenants_fields = {
"id": fields.String,
"name": fields.String,
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
workspace_fields = {"id": fields.String, "name": fields.String, "status": fields.String, "created_at": TimestampField}
@console_ns.route("/workspaces")
class TenantListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TenantListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -281,18 +265,17 @@ class TenantListApi(Resource):
tenant_dicts.append(tenant_dict)
return {"workspaces": marshal(tenant_dicts, tenants_fields)}, 200
return dump_response(TenantListResponse, {"workspaces": tenant_dicts}), HTTPStatus.OK
@console_ns.route("/all-workspaces")
class WorkspaceListApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceListQuery))
@console_ns.response(200, "Success", console_ns.models[WorkspaceListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePaginationResponse.__name__])
@setup_required
@admin_required
def get(self):
payload = request.args.to_dict(flat=True)
args = WorkspaceListQuery.model_validate(payload)
args = query_params_from_request(WorkspaceListQuery)
stmt = select(Tenant).order_by(Tenant.created_at.desc())
tenants = paginate_query(stmt, page=args.page, per_page=args.limit)
@@ -301,13 +284,9 @@ class WorkspaceListApi(Resource):
if tenants.has_next:
has_more = True
return {
"data": marshal(tenants.items, workspace_fields),
"has_more": has_more,
"limit": args.limit,
"page": args.page,
"total": tenants.total,
}, 200
return WorkspacePaginationResponse(
data=tenants.items, has_more=has_more, limit=args.limit, page=args.page, total=tenants.total or 0
).model_dump(mode="json"), HTTPStatus.OK
@console_ns.route("/workspaces/current", endpoint="workspaces_current")
@@ -316,7 +295,7 @@ class TenantApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[TenantInfoResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantInfoResponse.__name__])
@with_current_user
def post(self, current_user: Account):
if request.path == "/info":
@@ -336,13 +315,13 @@ class TenantApi(Resource):
else:
raise Unauthorized("workspace is archived")
return dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant)), 200
return dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant)), HTTPStatus.OK
@console_ns.route("/workspaces/switch")
class SwitchWorkspaceApi(Resource):
@console_ns.expect(console_ns.models[SwitchWorkspacePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -351,7 +330,7 @@ class SwitchWorkspaceApi(Resource):
payload = console_ns.payload or {}
args = SwitchWorkspacePayload.model_validate(payload)
# check if tenant_id is valid, 403 if not
# Check whether the tenant_id belongs to the current account.
try:
TenantService.switch_tenant(current_user, args.tenant_id, session=db.session)
except Exception:
@@ -361,13 +340,15 @@ class SwitchWorkspaceApi(Resource):
if new_tenant is None:
raise ValueError("Tenant not found")
return {"result": "success", "new_tenant": marshal(WorkspaceService.get_tenant_info(new_tenant), tenant_fields)}
return SwitchWorkspaceResponse(
result="success", new_tenant=WorkspaceService.get_tenant_info(new_tenant)
).model_dump(mode="json")
@console_ns.route("/workspaces/custom-config")
class CustomConfigWorkspaceApi(Resource):
@console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -390,12 +371,15 @@ class CustomConfigWorkspaceApi(Resource):
tenant.custom_config_dict = custom_config_dict
db.session.commit()
return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant)
).model_dump(mode="json")
@console_ns.route("/workspaces/custom-config/webapp-logo/upload")
class WebappLogoWorkspaceApi(Resource):
@console_ns.response(201, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
@console_ns.doc(consumes=["multipart/form-data"], params=WORKSPACE_LOGO_UPLOAD_PARAMS)
@console_ns.response(HTTPStatus.CREATED, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -431,13 +415,13 @@ class WebappLogoWorkspaceApi(Resource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return {"id": upload_file.id}, 201
return WorkspaceLogoUploadResponse(id=upload_file.id).model_dump(mode="json"), HTTPStatus.CREATED
@console_ns.route("/workspaces/info")
class WorkspaceInfoApi(Resource):
@console_ns.expect(console_ns.models[WorkspaceInfoPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -453,14 +437,16 @@ class WorkspaceInfoApi(Resource):
tenant.name = args.name
db.session.commit()
return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant)
).model_dump(mode="json")
@console_ns.route("/workspaces/current/permission")
class WorkspacePermissionApi(Resource):
"""Get workspace permissions for the current workspace."""
@console_ns.response(200, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -477,8 +463,8 @@ class WorkspacePermissionApi(Resource):
# Get workspace permissions from enterprise service
permission = EnterpriseService.WorkspacePermissionService.get_permission(current_tenant_id)
return {
"workspace_id": permission.workspace_id,
"allow_member_invite": permission.allow_member_invite,
"allow_owner_transfer": permission.allow_owner_transfer,
}, 200
return WorkspacePermissionResponse(
workspace_id=permission.workspace_id,
allow_member_invite=permission.allow_member_invite,
allow_owner_transfer=permission.allow_owner_transfer,
).model_dump(mode="json"), HTTPStatus.OK
@@ -1,9 +1,10 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any
from uuid import UUID
from flask import request
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
@@ -13,24 +14,20 @@ from controllers.common.errors import FilenameNotExistsError, NoFileUploadedErro
from controllers.common.fields import GeneratedAppResponse
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_model,
register_schema_models,
)
from controllers.console.app.wraps import with_session
from controllers.service_api import service_api_ns
from controllers.service_api.dataset.error import PipelineRunError
from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file
from controllers.service_api.schema import (
event_stream_response,
json_or_event_stream_response,
multipart_file_params,
)
from controllers.service_api.schema import event_stream_response, json_or_event_stream_response, multipart_file_params
from controllers.service_api.wraps import DatasetApiResource
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from fields.base import ResponseModel
from libs import helper
from libs.helper import dump_response
from libs.login import current_user
from models import Account
from models.dataset import Dataset, Pipeline
@@ -84,7 +81,7 @@ class DatasourcePluginResponse(ResponseModel):
datasource_type: str | None = None
title: str | None = None
user_input_variables: list[dict[str, Any]] = Field(default_factory=list)
credentials: list[DatasourceCredentialInfoResponse]
credentials: list[DatasourceCredentialInfoResponse] = Field(default_factory=list)
class DatasourcePluginListResponse(RootModel[list[DatasourcePluginResponse]]):
@@ -100,14 +97,22 @@ class PipelineUploadFileResponse(ResponseModel):
created_by: str
created_at: str | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | str | None) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return value
register_schema_model(service_api_ns, DatasourceNodeRunPayload)
register_schema_model(service_api_ns, DatasourcePluginsQuery)
register_schema_model(service_api_ns, PipelineRunApiEntity)
register_schema_models(service_api_ns, DatasourcePluginsQuery)
register_response_schema_models(
service_api_ns,
DatasourceCredentialInfoResponse,
DatasourcePluginResponse,
DatasourcePluginListResponse,
GeneratedAppResponse,
PipelineUploadFileResponse,
)
@@ -119,8 +124,8 @@ class DatasourcePluginsApi(DatasetApiResource):
@service_api_ns.doc(
summary="List Datasource Plugins",
description=(
"List the datasource nodes configured in the knowledge pipeline. Each node includes the "
"plugin it uses plus the metadata needed to run it."
"List the datasource nodes configured in the knowledge pipeline. Each node includes the plugin it uses "
"plus the metadata needed to run it."
),
tags=["Knowledge Pipeline"],
responses={
@@ -152,14 +157,13 @@ class DatasourcePluginsApi(DatasetApiResource):
if not dataset:
raise NotFound("Dataset not found.")
# Get query parameter to determine published or draft
is_published: bool = request.args.get("is_published", default=True, type=bool)
query = query_params_from_request(DatasourcePluginsQuery)
rag_pipeline_service: RagPipelineService = RagPipelineService()
datasource_plugins: list[dict[Any, Any]] = rag_pipeline_service.get_datasource_plugins(
tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=is_published
tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=query.is_published
)
return datasource_plugins, 200
return dump_response(DatasourcePluginListResponse, datasource_plugins), 200
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run")
@@ -169,8 +173,8 @@ class DatasourceNodeRunApi(DatasetApiResource):
@service_api_ns.doc(
summary="Run Datasource Node",
description=(
"Execute a single datasource node within the knowledge pipeline. Returns a streaming "
"response with the node execution results."
"Execute a single datasource node within the knowledge pipeline. Returns a streaming response with the "
"node execution results."
),
tags=["Knowledge Pipeline"],
responses={
@@ -189,11 +193,6 @@ class DatasourceNodeRunApi(DatasetApiResource):
}
)
@service_api_ns.expect(service_api_ns.models[DatasourceNodeRunPayload.__name__])
@service_api_ns.response(
200,
"Datasource node run successfully",
service_api_ns.models[GeneratedAppResponse.__name__],
)
def post(self, tenant_id: str, dataset_id: UUID, node_id: str):
"""Resource for getting datasource plugins."""
dataset_id_str = str(dataset_id)
@@ -210,10 +209,11 @@ class DatasourceNodeRunApi(DatasetApiResource):
datasource_node_run_api_entity = DatasourceNodeRunApiEntity.model_validate(
{
**payload.model_dump(exclude_none=True),
"pipeline_id": str(pipeline.id),
"pipeline_id": pipeline.id,
"node_id": node_id,
}
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(
PipelineGenerator.convert_to_event_stream(
rag_pipeline_service.run_datasource_workflow_node(
@@ -236,8 +236,8 @@ class PipelineRunApi(DatasetApiResource):
@service_api_ns.doc(
summary="Run Pipeline",
description=(
"Execute the full knowledge pipeline for a knowledge base. Supports both streaming and "
"blocking response modes."
"Execute the full knowledge pipeline for a knowledge base. Supports both streaming and blocking response "
"modes."
),
tags=["Knowledge Pipeline"],
responses={
@@ -293,6 +293,7 @@ class PipelineRunApi(DatasetApiResource):
streaming=payload.response_mode == "streaming",
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except Exception as ex:
raise PipelineRunError(description=str(ex))
@@ -368,4 +369,4 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return serialize_upload_file(upload_file), 201
return dump_response(PipelineUploadFileResponse, upload_file), 201
@@ -1,32 +0,0 @@
"""
Serialization helpers for Service API knowledge pipeline endpoints.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from models.model import UploadFile
class UploadFileDict(TypedDict):
id: str
name: str
size: int
extension: str
mime_type: str | None
created_by: str
created_at: str | None
def serialize_upload_file(upload_file: UploadFile) -> UploadFileDict:
return {
"id": upload_file.id,
"name": upload_file.name,
"size": upload_file.size,
"extension": upload_file.extension,
"mime_type": upload_file.mime_type,
"created_by": upload_file.created_by,
"created_at": upload_file.created_at.isoformat() if upload_file.created_at else None,
}
+16 -18
View File
@@ -9,6 +9,7 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from controllers.common.fields import (
AccessTokenData,
AccessTokenResultResponse,
LoginStatusResponse,
SimpleResultDataResponse,
@@ -115,9 +116,10 @@ class LoginApi(Resource):
raise AuthenticationFailedError()
token = WebAppAuthService.login(account=account)
response = make_response({"result": "success", "data": {"access_token": token}})
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
return response
return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
mode="json"
)
# this api helps frontend to check whether user is authenticated
@@ -136,14 +138,12 @@ class LoginStatusApi(Resource):
)
@web_ns.response(200, "Login status", web_ns.models[LoginStatusResponse.__name__])
def get(self):
app_code = request.args.get("app_code")
user_id = request.args.get("user_id")
query = LoginStatusQuery.model_validate(request.args.to_dict(flat=True))
app_code = query.app_code
user_id = query.user_id
token = extract_webapp_access_token(request)
if not app_code:
return {
"logged_in": bool(token),
"app_logged_in": False,
}
return LoginStatusResponse(logged_in=bool(token), app_logged_in=False).model_dump(mode="json")
app_id = AppService.get_app_id_by_code(app_code)
is_public = not dify_config.ENTERPRISE_ENABLED or not WebAppAuthService.is_app_require_permission_check(
app_id=app_id
@@ -165,10 +165,7 @@ class LoginStatusApi(Resource):
except Exception:
app_logged_in = False
return {
"logged_in": user_logged_in,
"app_logged_in": app_logged_in,
}
return LoginStatusResponse(logged_in=user_logged_in, app_logged_in=app_logged_in).model_dump(mode="json")
@web_ns.route("/logout")
@@ -183,7 +180,8 @@ class LogoutApi(Resource):
)
@web_ns.response(200, "Logout successful", web_ns.models[SimpleResultResponse.__name__])
def post(self):
response = make_response({"result": "success"})
# response-contract:ignore hand-crafted response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
# enterprise SSO sets same site to None in https deployment
# so we need to logout by calling api
clear_webapp_access_token_from_cookie(response, samesite="None")
@@ -216,9 +214,8 @@ class EmailCodeLoginSendEmailApi(Resource):
account = WebAppAuthService.get_user_through_email(payload.email)
if account is None:
raise AuthenticationFailedError()
else:
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
return {"result": "success", "data": token}
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@web_ns.route("/email-code-login/validity")
@@ -277,9 +274,10 @@ class EmailCodeLoginApi(Resource):
token = WebAppAuthService.login(account=account)
AccountService.reset_login_error_rate_limit(user_email)
response = make_response({"result": "success", "data": {"access_token": token}})
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
return response
return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
mode="json"
)
def _log_web_login_failure(*, email: str, reason: LoginFailureReason) -> None:
+19 -24
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from flask import make_response, request
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import func, select
@@ -10,11 +10,12 @@ from werkzeug.exceptions import NotFound, Unauthorized
from configs import dify_config
from constants import HEADER_NAME_APP_CODE
from controllers.common.fields import AccessTokenData
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 WebAppAuthRequiredError
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_access_token
from models.enums import EndUserType
@@ -28,7 +29,13 @@ class PassportQuery(BaseModel):
register_schema_models(web_ns, PassportQuery)
register_response_schema_models(web_ns, AccessTokenData)
class PassportAccessTokenResponse(ResponseModel):
access_token: str
register_response_schema_models(web_ns, PassportAccessTokenResponse)
@web_ns.route("/passport")
@@ -45,7 +52,7 @@ class PassportResource(Resource):
404: "Application or user not found",
}
)
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[AccessTokenData.__name__])
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[PassportAccessTokenResponse.__name__])
def get(self):
system_features = FeatureService.get_system_features()
app_code = request.headers.get(HEADER_NAME_APP_CODE)
@@ -59,8 +66,11 @@ class PassportResource(Resource):
if app_auth_type != WebAppAuthType.PUBLIC:
if not enterprise_user_decoded:
raise WebAppAuthRequiredError()
return exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
return dump_response(
PassportAccessTokenResponse,
exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
),
)
# get site from db and check if it is normal
@@ -110,12 +120,7 @@ class PassportResource(Resource):
tk = PassportService().issue(payload)
response = make_response(
{
"access_token": tk,
}
)
return response
return dump_response(PassportAccessTokenResponse, {"access_token": tk})
def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None:
@@ -206,12 +211,7 @@ def exchange_token_for_existing_web_user(
"exp": exp,
}
token: str = PassportService().issue(payload)
resp = make_response(
{
"access_token": token,
}
)
return resp
return {"access_token": token}
def _exchange_for_public_app_token(app_model, site, token_decoded):
@@ -244,12 +244,7 @@ def _exchange_for_public_app_token(app_model, site, token_decoded):
tk = PassportService().issue(payload)
resp = make_response(
{
"access_token": tk,
}
)
return resp
return {"access_token": tk}
def generate_session_id():
@@ -61,10 +61,17 @@ _sensitive_word_avoidance_adapter: TypeAdapter[SensitiveWordAvoidanceConfig] = T
def _normalize_raw(raw: Any) -> Any:
if isinstance(raw, dict):
if raw.get("enabled") is None:
enabled = raw.get("enabled")
if enabled is None:
raw = {**raw, "enabled": False}
elif raw.get("enabled") is True and raw.get("config") is None:
raw = {**raw, "config": {}}
elif enabled is True:
if raw.get("config") is None:
raw = {**raw, "config": {}}
else:
# enabled is False or any falsy value —
# drop extra fields (type, config) so they don't
# violate SensitiveWordAvoidanceDisabledConfig.extra="forbid"
raw = {"enabled": False}
return raw
+7 -4
View File
@@ -5,6 +5,8 @@ from collections.abc import Generator, Mapping, Sequence
from mimetypes import guess_extension
from typing import TYPE_CHECKING, Any, Union
from sqlalchemy.orm import sessionmaker
from core.app.app_config.entities import ExternalDataVariableEntity, PromptTemplateEntity
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
@@ -423,7 +425,9 @@ class AppRunner:
_logger.exception("Failed to save image file")
return
# Create MessageFile record
# Create MessageFile record.
# Use an independent session so this side-effect write does not
# commit or close the caller's request-scoped session.
message_file = MessageFile(
message_id=message_id,
type=FileType.IMAGE,
@@ -437,9 +441,8 @@ class AppRunner:
created_by=user_id,
)
db.session.add(message_file)
db.session.commit()
db.session.refresh(message_file)
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add(message_file)
# Publish QueueMessageFileEvent
queue_manager.publish(
@@ -2,7 +2,7 @@ import logging
from collections.abc import Sequence
from sqlalchemy import select, update
from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -10,6 +10,7 @@ from core.app.entities.queue_entities import QueueRetrieverResourcesEvent
from core.rag.entities import RetrievalSourceMetadata
from core.rag.index_processor.constant.index_type import IndexStructureType
from core.rag.models.document import Document
from extensions.ext_database import db
from models.dataset import ChildChunk, DatasetQuery, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.enums import CreatorUserRole, DatasetQuerySource
@@ -46,47 +47,52 @@ class DatasetIndexToolCallbackHandler:
created_by=self._user_id,
)
session.add(dataset_query)
session.commit()
# Use an independent session so this audit-log side effect does
# not commit or close the caller's request-scoped session.
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as independent_session:
independent_session.add(dataset_query)
def on_tool_end(self, documents: list[Document], session: scoped_session):
"""Handle tool end."""
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
)
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = session.scalar(child_chunk_stmt)
if child_chunk:
session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
# Use an independent session so hit-count updates do not
# interfere with the caller's request-scoped session.
with Session(db.engine, expire_on_commit=False) as independent_session:
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = independent_session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
)
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = independent_session.scalar(child_chunk_stmt)
if child_chunk:
independent_session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
)
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
# add hit count to document segment
session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
# add hit count to document segment
independent_session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
session.commit()
independent_session.commit()
# TODO(-LAN-): Improve type check
def return_retriever_resource_info(self, resource: Sequence[RetrievalSourceMetadata]):
@@ -3,7 +3,7 @@ from typing import Any
from core.datasource.__base.datasource_plugin import DatasourcePlugin
from core.datasource.entities.datasource_entities import DatasourceProviderEntityWithPlugin, DatasourceProviderType
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.plugin.impl.tool import PluginToolManager
from core.tools.errors import ToolProviderCredentialValidationError
@@ -78,11 +78,11 @@ class DatasourcePluginProviderController(ABC):
if not credential_schema.required and credentials[credential_name] is None:
continue
if credential_schema.type in {ProviderConfig.Type.SECRET_INPUT, ProviderConfig.Type.TEXT_INPUT}:
if credential_schema.type in {ProviderConfigType.SECRET_INPUT, ProviderConfigType.TEXT_INPUT}:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
elif credential_schema.type == ProviderConfig.Type.SELECT:
elif credential_schema.type == ProviderConfigType.SELECT:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
@@ -107,9 +107,9 @@ class DatasourcePluginProviderController(ABC):
default_value = credential_schema.default
# parse default value into the correct type
if credential_schema.type in {
ProviderConfig.Type.SECRET_INPUT,
ProviderConfig.Type.TEXT_INPUT,
ProviderConfig.Type.SELECT,
ProviderConfigType.SECRET_INPUT,
ProviderConfigType.TEXT_INPUT,
ProviderConfigType.SELECT,
}:
default_value = str(default_value)
+2 -2
View File
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from pydantic import BaseModel
from configs import dify_config
from core.entities.provider_entities import BasicProviderConfig
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.helper import encrypter
from core.helper.provider_cache import NoOpProviderCredentialCache
from core.mcp.types import OAuthClientInformation, OAuthClientMetadata, OAuthTokens
@@ -315,7 +315,7 @@ class MCPProviderEntity(BaseModel):
return data
# Create dynamic config only for encrypted fields
config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in encrypted_fields]
config = [BasicProviderConfig(type=ProviderConfigType.SECRET_INPUT, name=key) for key in encrypted_fields]
encrypter_instance, _ = create_provider_encrypter(
tenant_id=self.tenant_id,
+24 -23
View File
@@ -165,34 +165,35 @@ class ModelSettings(BaseModel):
model_config = ConfigDict(protected_namespaces=())
class ProviderConfigType(StrEnum):
SECRET_INPUT = CommonParameterType.SECRET_INPUT
TEXT_INPUT = CommonParameterType.TEXT_INPUT
SELECT = CommonParameterType.SELECT
BOOLEAN = CommonParameterType.BOOLEAN
APP_SELECTOR = CommonParameterType.APP_SELECTOR
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR
TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR
@classmethod
def value_of(cls, value: str) -> ProviderConfigType:
"""
Get value of given mode.
:param value: mode value
:return: mode
"""
for mode in cls:
if mode.value == value:
return mode
raise ValueError(f"invalid mode value {value}")
class BasicProviderConfig(BaseModel):
"""
Base model class for common provider settings like credentials
"""
class Type(StrEnum):
SECRET_INPUT = CommonParameterType.SECRET_INPUT
TEXT_INPUT = CommonParameterType.TEXT_INPUT
SELECT = CommonParameterType.SELECT
BOOLEAN = CommonParameterType.BOOLEAN
APP_SELECTOR = CommonParameterType.APP_SELECTOR
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR
TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR
@classmethod
def value_of(cls, value: str) -> ProviderConfig.Type:
"""
Get value of given mode.
:param value: mode value
:return: mode
"""
for mode in cls:
if mode.value == value:
return mode
raise ValueError(f"invalid mode value {value}")
type: Type = Field(..., description="The type of the credentials")
type: ProviderConfigType = Field(..., description="The type of the credentials")
name: str = Field(..., description="The name of the credentials")
+4 -4
View File
@@ -3,7 +3,7 @@ from collections.abc import Mapping
from copy import deepcopy
from typing import Any, Protocol
from core.entities.provider_entities import BasicProviderConfig
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.helper import encrypter
@@ -60,7 +60,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field.type == ProviderConfigType.SECRET_INPUT:
if field_name in data:
encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "")
data[field_name] = encrypted
@@ -81,7 +81,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field.type == ProviderConfigType.SECRET_INPUT:
if field_name in data:
if len(data[field_name]) > 6:
data[field_name] = (
@@ -112,7 +112,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field.type == ProviderConfigType.SECRET_INPUT:
if field_name in data:
with contextlib.suppress(Exception):
# if the value is None or empty string, skip decrypt
+7 -6
View File
@@ -5,12 +5,13 @@ from pydantic import BaseModel
from core.plugin.entities.plugin import PluginDeclaration, PluginInstallationSource
class PluginBundleDependency(BaseModel):
class Type(StrEnum):
Github = PluginInstallationSource.Github.value
Marketplace = PluginInstallationSource.Marketplace.value
Package = PluginInstallationSource.Package.value
class PluginBundleDependencyType(StrEnum):
Github = PluginInstallationSource.Github.value
Marketplace = PluginInstallationSource.Marketplace.value
Package = PluginInstallationSource.Package.value
class PluginBundleDependency(BaseModel):
class Github(BaseModel):
repo_address: str
repo: str
@@ -26,5 +27,5 @@ class PluginBundleDependency(BaseModel):
unique_identifier: str
manifest: PluginDeclaration
type: Type
type: PluginBundleDependencyType
value: Github | Marketplace | Package
+1
View File
@@ -32,6 +32,7 @@ class EndpointEntity(BasePluginEntity):
entity of an endpoint
"""
# TODO: Confirm daemon masks secret-input settings before endpoint list responses expose them.
settings: dict[str, Any]
tenant_id: str
plugin_id: str
+5 -4
View File
@@ -57,11 +57,12 @@ class MCPServerParameterType(StrEnum):
OBJECT = auto()
class PluginParameterAutoGenerate(BaseModel):
class Type(StrEnum):
PROMPT_INSTRUCTION = auto()
class PluginParameterAutoGenerateType(StrEnum):
PROMPT_INSTRUCTION = auto()
type: Type
class PluginParameterAutoGenerate(BaseModel):
type: PluginParameterAutoGenerateType
class PluginParameterTemplate(BaseModel):
+7 -6
View File
@@ -166,12 +166,13 @@ class PluginEntity(PluginInstallation):
return self
class PluginDependency(BaseModel):
class Type(StrEnum):
Github = PluginInstallationSource.Github
Marketplace = PluginInstallationSource.Marketplace
Package = PluginInstallationSource.Package
class PluginDependencyType(StrEnum):
Github = PluginInstallationSource.Github
Marketplace = PluginInstallationSource.Marketplace
Package = PluginInstallationSource.Package
class PluginDependency(BaseModel):
class Github(BaseModel):
repo: str
version: str
@@ -194,7 +195,7 @@ class PluginDependency(BaseModel):
plugin_unique_identifier: str
version: str | None = None
type: Type
type: PluginDependencyType
value: Github | Marketplace | Package
current_identifier: str | None = None
+6 -6
View File
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Any
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import (
ToolProviderEntity,
@@ -71,11 +71,11 @@ class ToolProviderController[ToolProviderEntityT: ToolProviderEntity, ToolProvid
if not credential_schema.required and credentials[credential_name] is None:
continue
if credential_schema.type in {ProviderConfig.Type.SECRET_INPUT, ProviderConfig.Type.TEXT_INPUT}:
if credential_schema.type in {ProviderConfigType.SECRET_INPUT, ProviderConfigType.TEXT_INPUT}:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
elif credential_schema.type == ProviderConfig.Type.SELECT:
elif credential_schema.type == ProviderConfigType.SELECT:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
@@ -100,9 +100,9 @@ class ToolProviderController[ToolProviderEntityT: ToolProviderEntity, ToolProvid
default_value = credential_schema.default
# parse default value into the correct type
if credential_schema.type in {
ProviderConfig.Type.SECRET_INPUT,
ProviderConfig.Type.TEXT_INPUT,
ProviderConfig.Type.SELECT,
ProviderConfigType.SECRET_INPUT,
ProviderConfigType.TEXT_INPUT,
ProviderConfigType.SELECT,
}:
default_value = str(default_value)
+7 -7
View File
@@ -5,7 +5,7 @@ from typing import override
from pydantic import Field
from sqlalchemy import select
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.custom_tool.tool import ApiTool
@@ -41,7 +41,7 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
ProviderConfig(
name="auth_type",
required=True,
type=ProviderConfig.Type.SELECT,
type=ProviderConfigType.SELECT,
options=[
ProviderConfig.Option(value="none", label=I18nObject(en_US="None", zh_Hans="")),
ProviderConfig.Option(value="api_key_header", label=I18nObject(en_US="Header", zh_Hans="请求头")),
@@ -60,20 +60,20 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
name="api_key_header",
required=False,
default="Authorization",
type=ProviderConfig.Type.TEXT_INPUT,
type=ProviderConfigType.TEXT_INPUT,
help=I18nObject(en_US="The header name of the api key", zh_Hans="携带 api key 的 header 名称"),
),
ProviderConfig(
name="api_key_value",
required=True,
type=ProviderConfig.Type.SECRET_INPUT,
type=ProviderConfigType.SECRET_INPUT,
help=I18nObject(en_US="The api key", zh_Hans="api key 的值"),
),
ProviderConfig(
name="api_key_header_prefix",
required=False,
default="basic",
type=ProviderConfig.Type.SELECT,
type=ProviderConfigType.SELECT,
help=I18nObject(en_US="The prefix of the api key header", zh_Hans="api key header 的前缀"),
options=[
ProviderConfig.Option(value="basic", label=I18nObject(en_US="Basic", zh_Hans="Basic")),
@@ -89,7 +89,7 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
name="api_key_query_param",
required=False,
default="key",
type=ProviderConfig.Type.TEXT_INPUT,
type=ProviderConfigType.TEXT_INPUT,
help=I18nObject(
en_US="The query parameter name of the api key", zh_Hans="携带 api key 的查询参数名称"
),
@@ -97,7 +97,7 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
ProviderConfig(
name="api_key_value",
required=True,
type=ProviderConfig.Type.SECRET_INPUT,
type=ProviderConfigType.SECRET_INPUT,
help=I18nObject(en_US="The api key", zh_Hans="api key 的值"),
),
]
+2 -2
View File
@@ -1,7 +1,7 @@
from collections.abc import Mapping
from typing import Union, override
from core.entities.provider_entities import BasicProviderConfig, ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.helper.provider_cache import ProviderCredentialsCache
from core.helper.provider_encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter
from core.plugin.entities.plugin_daemon import CredentialType
@@ -142,7 +142,7 @@ def masked_credentials(
if not config:
masked_credentials[key] = value
continue
if config.type == BasicProviderConfig.Type.SECRET_INPUT:
if config.type == ProviderConfigType.SECRET_INPUT:
if len(value) <= 4:
masked_credentials[key] = "*" * len(value)
else:
-271
View File
@@ -1,271 +0,0 @@
import json
from typing import override
from flask_restx import fields
from fields.workflow_fields import workflow_partial_fields
from libs.helper import AppIconUrlField, TimestampField
class JsonStringField(fields.Raw):
@override
def format(self, value):
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
return value
class OpaqueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
class StringListRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "array", "items": {"type": "string"}}
class ObjectListRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "array", "items": {"type": "object"}}
app_detail_kernel_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
}
related_app_list = {
"data": fields.List(fields.Nested(app_detail_kernel_fields)),
"total": fields.Integer,
}
model_config_fields = {
"opening_statement": fields.String,
"suggested_questions": StringListRawField(attribute="suggested_questions_list"),
"suggested_questions_after_answer": OpaqueRawField(attribute="suggested_questions_after_answer_dict"),
"speech_to_text": OpaqueRawField(attribute="speech_to_text_dict"),
"text_to_speech": OpaqueRawField(attribute="text_to_speech_dict"),
"retriever_resource": OpaqueRawField(attribute="retriever_resource_dict"),
"annotation_reply": OpaqueRawField(attribute="annotation_reply_dict"),
"more_like_this": OpaqueRawField(attribute="more_like_this_dict"),
"sensitive_word_avoidance": OpaqueRawField(attribute="sensitive_word_avoidance_dict"),
"external_data_tools": ObjectListRawField(attribute="external_data_tools_list"),
"model": OpaqueRawField(attribute="model_dict"),
"user_input_form": ObjectListRawField(attribute="user_input_form_list"),
"dataset_query_variable": fields.String,
"pre_prompt": fields.String,
"agent_mode": OpaqueRawField(attribute="agent_mode_dict"),
"prompt_type": fields.String,
"chat_prompt_config": OpaqueRawField(attribute="chat_prompt_config_dict"),
"completion_prompt_config": OpaqueRawField(attribute="completion_prompt_config_dict"),
"dataset_configs": OpaqueRawField(attribute="dataset_configs_dict"),
"file_upload": OpaqueRawField(attribute="file_upload_dict"),
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String}
app_detail_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon": fields.String,
"icon_background": fields.String,
"enable_site": fields.Boolean,
"enable_api": fields.Boolean,
"model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_fields, allow_null=True),
"tracing": OpaqueRawField,
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_fields)),
"permission_keys": fields.List(fields.String()),
}
prompt_config_fields = {
"prompt_template": fields.String,
}
model_config_partial_fields = {
"model": OpaqueRawField(attribute="model_dict"),
"pre_prompt": fields.String,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
app_partial_fields = {
"id": fields.String,
"name": fields.String,
"max_active_requests": OpaqueRawField(),
"description": fields.String(attribute="desc_or_prompt"),
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"model_config": fields.Nested(model_config_partial_fields, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_fields, allow_null=True),
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"tags": fields.List(fields.Nested(tag_fields)),
"access_mode": fields.String,
"create_user_name": fields.String,
"author_name": fields.String,
"has_draft_trigger": fields.Boolean,
"permission_keys": fields.List(fields.String()),
}
app_pagination_fields = {
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(app_partial_fields), attribute="items"),
}
template_fields = {
"name": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"description": fields.String,
"mode": fields.String,
"model_config": fields.Nested(model_config_fields),
}
template_list_fields = {
"data": fields.List(fields.Nested(template_fields)),
}
site_fields = {
"access_token": fields.String(attribute="code"),
"code": fields.String,
"title": fields.String,
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"description": fields.String,
"default_language": fields.String,
"chat_color_theme": fields.String,
"chat_color_theme_inverted": fields.Boolean,
"customize_domain": fields.String,
"copyright": fields.String,
"privacy_policy": fields.String,
"custom_disclaimer": fields.String,
"customize_token_strategy": fields.String,
"prompt_public": fields.Boolean,
"app_base_url": fields.String,
"show_workflow_steps": fields.Boolean,
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
deleted_tool_fields = {
"type": fields.String,
"tool_name": fields.String,
"provider_id": fields.String,
}
app_detail_fields_with_site = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"enable_site": fields.Boolean,
"enable_api": fields.Boolean,
"model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_fields, allow_null=True),
"api_base_url": fields.String,
"use_icon_as_answer_icon": fields.Boolean,
"max_active_requests": fields.Integer,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"deleted_tools": fields.List(fields.Nested(deleted_tool_fields)),
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_fields)),
"permission_keys": fields.List(fields.String()),
"site": fields.Nested(site_fields),
}
app_site_fields = {
"app_id": fields.String,
"access_token": fields.String(attribute="code"),
"code": fields.String,
"title": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"description": fields.String,
"default_language": fields.String,
"customize_domain": fields.String,
"copyright": fields.String,
"privacy_policy": fields.String,
"custom_disclaimer": fields.String,
"customize_token_strategy": fields.String,
"prompt_public": fields.Boolean,
"show_workflow_steps": fields.Boolean,
"use_icon_as_answer_icon": fields.Boolean,
}
leaked_dependency_fields = {"type": fields.String, "value": OpaqueRawField, "current_identifier": fields.String}
app_import_fields = {
"id": fields.String,
"status": fields.String,
"app_id": fields.String,
"app_mode": fields.String,
"current_dsl_version": fields.String,
"imported_dsl_version": fields.String,
"error": fields.String,
}
app_import_check_dependencies_fields = {
"leaked_dependencies": fields.List(fields.Nested(leaked_dependency_fields)),
}
app_server_fields = {
"id": fields.String,
"name": fields.String,
"server_code": fields.String,
"description": fields.String,
"status": fields.String,
"parameters": JsonStringField,
"created_at": TimestampField,
"updated_at": TimestampField,
}
-7
View File
@@ -2,18 +2,11 @@ from __future__ import annotations
from datetime import datetime
from flask_restx import fields
from pydantic import Field, computed_field, field_validator
from fields.base import ResponseModel
from libs.helper import build_avatar_url, to_timestamp
simple_account_fields = {
"id": fields.String,
"name": fields.String,
"email": fields.String,
}
class SimpleAccountResponse(ResponseModel):
id: str
+65 -52
View File
@@ -1,61 +1,74 @@
from typing import override
from datetime import datetime
from typing import Any
from flask_restx import fields
from pydantic import Field, field_validator
from fields.member_fields import simple_account_fields
from libs.helper import TimestampField
from fields.base import ResponseModel
from fields.member_fields import SimpleAccountResponse
from libs.helper import to_timestamp
from models.snippet import SnippetType
class OpaqueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
class SnippetTagResponse(ResponseModel):
id: str
name: str
type: str
tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String}
class SnippetListItemResponse(ResponseModel):
id: str
name: str
description: str | None
type: SnippetType
version: int
use_count: int
is_published: bool
icon_info: dict[str, Any] | None
tags: list[SnippetTagResponse]
created_by: str | None
author_name: str | None
created_at: int
updated_by: str | None
updated_at: int
# Snippet list item fields (lightweight for list display)
snippet_list_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"type": fields.String,
"version": fields.Integer,
"use_count": fields.Integer,
"is_published": fields.Boolean,
"icon_info": OpaqueRawField,
"tags": fields.List(fields.Nested(tag_fields)),
"created_by": fields.String,
"author_name": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int:
timestamp = to_timestamp(value)
if timestamp is None:
raise ValueError("timestamp is required")
return timestamp
# Full snippet fields (includes creator info and graph data)
snippet_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"type": fields.String,
"version": fields.Integer,
"use_count": fields.Integer,
"is_published": fields.Boolean,
"icon_info": OpaqueRawField,
"graph": OpaqueRawField(attribute="graph_dict"),
"input_fields": OpaqueRawField(attribute="input_fields_list"),
"tags": fields.List(fields.Nested(tag_fields)),
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True),
"created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
"updated_at": TimestampField,
}
# Pagination response fields
snippet_pagination_fields = {
"data": fields.List(fields.Nested(snippet_list_fields)),
"page": fields.Integer,
"limit": fields.Integer,
"total": fields.Integer,
"has_more": fields.Boolean,
}
class SnippetResponse(ResponseModel):
id: str
name: str
description: str | None
type: SnippetType
version: int
use_count: int
is_published: bool
icon_info: dict[str, Any] | None
graph: dict[str, Any] = Field(validation_alias="graph_dict")
input_fields: list[dict[str, Any]] = Field(validation_alias="input_fields_list")
tags: list[SnippetTagResponse]
created_by: SimpleAccountResponse | None = Field(validation_alias="created_by_account")
created_at: int
updated_by: SimpleAccountResponse | None = Field(validation_alias="updated_by_account")
updated_at: int
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int:
timestamp = to_timestamp(value)
if timestamp is None:
raise ValueError("timestamp is required")
return timestamp
class SnippetPaginationResponse(ResponseModel):
data: list[SnippetListItemResponse]
page: int
limit: int
total: int
has_more: bool
-129
View File
@@ -1,129 +0,0 @@
from typing import override
from flask_restx import fields
from core.helper import encrypter
from fields.member_fields import simple_account_fields
from graphon.variables import SecretVariable, SegmentType, VariableBase
from libs.helper import TimestampField
from ._value_type_serializer import serialize_value_type
ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER, SegmentType.SECRET)
class OpaqueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
class JsonValueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {
"anyOf": [
{"type": "string"},
{"type": "integer"},
{"type": "number"},
{"type": "boolean"},
{"type": "object", "additionalProperties": True},
{"type": "array", "items": {}},
{"type": "null"},
]
}
class EnvironmentVariableField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
@override
def format(self, value):
# Mask secret variables values in environment_variables
if isinstance(value, SecretVariable):
return {
"id": value.id,
"name": value.name,
"value": encrypter.full_mask_token(),
"value_type": value.value_type.value,
"description": value.description,
}
if isinstance(value, VariableBase):
return {
"id": value.id,
"name": value.name,
"value": value.value,
"value_type": str(value.value_type.exposed_type()),
"description": value.description,
}
if isinstance(value, dict):
value_type_str = value.get("value_type")
if not isinstance(value_type_str, str):
raise TypeError(
f"unexpected type for value_type field, value={value_type_str}, type={type(value_type_str)}"
)
value_type = SegmentType(value_type_str).exposed_type()
if value_type not in ENVIRONMENT_VARIABLE_SUPPORTED_TYPES:
raise ValueError(f"Unsupported environment variable value type: {value_type}")
return value
conversation_variable_fields = {
"id": fields.String,
"name": fields.String,
"value_type": fields.String(attribute=serialize_value_type),
"value": JsonValueRawField,
"description": fields.String,
}
pipeline_variable_fields = {
"label": fields.String,
"variable": fields.String,
"type": fields.String,
"belong_to_node_id": fields.String,
"max_length": fields.Integer,
"required": fields.Boolean,
"unit": fields.String,
"default_value": JsonValueRawField,
"options": fields.List(fields.String),
"placeholder": fields.String,
"tooltips": fields.String,
"allowed_file_types": fields.List(fields.String),
"allow_file_extension": fields.List(fields.String),
"allow_file_upload_methods": fields.List(fields.String),
}
workflow_fields = {
"id": fields.String,
"graph": OpaqueRawField(attribute="graph_dict"),
"features": OpaqueRawField(attribute="features_dict"),
"hash": fields.String(attribute="unique_hash"),
"version": fields.String,
"marked_name": fields.String,
"marked_comment": fields.String,
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
"created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
"updated_at": TimestampField,
"tool_published": fields.Boolean,
"environment_variables": fields.List(EnvironmentVariableField()),
"conversation_variables": fields.List(fields.Nested(conversation_variable_fields)),
"rag_pipeline_variables": fields.List(fields.Nested(pipeline_variable_fields)),
}
workflow_partial_fields = {
"id": fields.String,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
workflow_pagination_fields = {
"items": fields.List(fields.Nested(workflow_fields), attribute="items"),
"page": fields.Integer,
"limit": fields.Integer(attribute="limit"),
"has_more": fields.Boolean(attribute="has_more"),
}
+52 -41
View File
@@ -369,17 +369,19 @@ class InvitationCode(TypeBase):
)
class TenantPluginInstallPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
class TenantPluginDebugPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
class TenantPluginPermission(TypeBase):
class InstallPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
class DebugPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
__tablename__ = "account_plugin_permissions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="account_plugin_permission_pkey"),
@@ -390,36 +392,42 @@ class TenantPluginPermission(TypeBase):
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
install_permission: Mapped[InstallPermission] = mapped_column(
EnumText(InstallPermission, length=16),
install_permission: Mapped[TenantPluginInstallPermission] = mapped_column(
EnumText(TenantPluginInstallPermission, length=16),
nullable=False,
server_default="everyone",
default=InstallPermission.EVERYONE,
default=TenantPluginInstallPermission.EVERYONE,
)
debug_permission: Mapped[DebugPermission] = mapped_column(
EnumText(DebugPermission, length=16), nullable=False, server_default="noone", default=DebugPermission.NOBODY
debug_permission: Mapped[TenantPluginDebugPermission] = mapped_column(
EnumText(TenantPluginDebugPermission, length=16),
nullable=False,
server_default="noone",
default=TenantPluginDebugPermission.NOBODY,
)
class TenantPluginAutoUpgradeCategory(enum.StrEnum):
TOOL = "tool"
MODEL = "model"
EXTENSION = "extension"
AGENT_STRATEGY = "agent-strategy"
DATASOURCE = "datasource"
TRIGGER = "trigger"
class TenantPluginAutoUpgradeStrategySetting(enum.StrEnum):
DISABLED = "disabled"
FIX_ONLY = "fix_only"
LATEST = "latest"
class TenantPluginAutoUpgradeMode(enum.StrEnum):
ALL = "all"
PARTIAL = "partial"
EXCLUDE = "exclude"
class TenantPluginAutoUpgradeStrategy(TypeBase):
class PluginCategory(enum.StrEnum):
TOOL = "tool"
MODEL = "model"
EXTENSION = "extension"
AGENT_STRATEGY = "agent-strategy"
DATASOURCE = "datasource"
TRIGGER = "trigger"
class StrategySetting(enum.StrEnum):
DISABLED = "disabled"
FIX_ONLY = "fix_only"
LATEST = "latest"
class UpgradeMode(enum.StrEnum):
ALL = "all"
PARTIAL = "partial"
EXCLUDE = "exclude"
__tablename__ = "tenant_plugin_auto_upgrade_strategies"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"),
@@ -431,20 +439,23 @@ class TenantPluginAutoUpgradeStrategy(TypeBase):
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
category: Mapped[PluginCategory] = mapped_column(
EnumText(PluginCategory, length=32),
category: Mapped[TenantPluginAutoUpgradeCategory] = mapped_column(
EnumText(TenantPluginAutoUpgradeCategory, length=32),
nullable=False,
server_default="tool",
default=PluginCategory.TOOL,
default=TenantPluginAutoUpgradeCategory.TOOL,
)
strategy_setting: Mapped[StrategySetting] = mapped_column(
EnumText(StrategySetting, length=16),
strategy_setting: Mapped[TenantPluginAutoUpgradeStrategySetting] = mapped_column(
EnumText(TenantPluginAutoUpgradeStrategySetting, length=16),
nullable=False,
server_default="fix_only",
default=StrategySetting.FIX_ONLY,
default=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
)
upgrade_mode: Mapped[UpgradeMode] = mapped_column(
EnumText(UpgradeMode, length=16), nullable=False, server_default="exclude", default=UpgradeMode.EXCLUDE
upgrade_mode: Mapped[TenantPluginAutoUpgradeMode] = mapped_column(
EnumText(TenantPluginAutoUpgradeMode, length=16),
nullable=False,
server_default="exclude",
default=TenantPluginAutoUpgradeMode.EXCLUDE,
)
exclude_plugins: Mapped[list[str]] = mapped_column(sa.JSON, nullable=False, default_factory=list)
include_plugins: Mapped[list[str]] = mapped_column(sa.JSON, nullable=False, default_factory=list)
+252 -166
View File
@@ -141,9 +141,9 @@ Get account avatar url
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [EducationActivateResponse](#educationactivateresponse)<br> |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### [GET] /account/education/autocomplete
#### Parameters
@@ -1411,7 +1411,7 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceListResponse](#workspacelistresponse)<br> |
| 200 | Success | **application/json**: [WorkspacePaginationResponse](#workspacepaginationresponse)<br> |
### [GET] /api-based-extension
Get all API-based extensions for current tenant
@@ -5106,14 +5106,14 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)<br> |
| 200 | Default datasource credentials retrieved successfully | **application/json**: [DatasourceProviderAuthListResponse](#datasourceproviderauthlistresponse)<br> |
### [GET] /auth/plugin/datasource/list
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)<br> |
| 200 | Datasource credentials retrieved successfully | **application/json**: [DatasourceProviderAuthListResponse](#datasourceproviderauthlistresponse)<br> |
### [GET] /auth/plugin/datasource/{provider_id}
#### Parameters
@@ -5126,7 +5126,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)<br> |
| 200 | Datasource credentials retrieved successfully | **application/json**: [DatasourceCredentialListResponse](#datasourcecredentiallistresponse)<br> |
### [POST] /auth/plugin/datasource/{provider_id}
#### Parameters
@@ -5145,7 +5145,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 200 | Datasource credential created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [DELETE] /auth/plugin/datasource/{provider_id}/custom-client
#### Parameters
@@ -5177,7 +5177,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 200 | Datasource OAuth custom client saved successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [POST] /auth/plugin/datasource/{provider_id}/default
#### Parameters
@@ -5234,7 +5234,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 201 | Datasource credential updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [POST] /auth/plugin/datasource/{provider_id}/update-name
#### Parameters
@@ -7638,9 +7638,9 @@ Initiate OAuth login process
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)<br> |
| Code | Description |
| ---- | ----------- |
| 302 | Redirect to OAuth callback page |
### [GET] /oauth/plugin/{provider_id}/datasource/get-authorization-url
#### Parameters
@@ -7654,7 +7654,7 @@ Initiate OAuth login process
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Authorization URL retrieved successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)<br> |
| 200 | Datasource OAuth authorization URL generated successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)<br> |
### [GET] /oauth/plugin/{provider}/tool/authorization-url
#### Parameters
@@ -8477,9 +8477,9 @@ Initiate OAuth login process
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [DataSourceContentPreviewResponse](#datasourcecontentpreviewresponse)<br> |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### [POST] /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run
**Run rag pipeline datasource**
@@ -8617,6 +8617,7 @@ Initiate OAuth login process
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 401 | Unauthorized | **application/json**: [SimpleResultMessageResponse](#simpleresultmessageresponse)<br> |
### [POST] /remote-files/upload
#### Request Body
@@ -9432,9 +9433,9 @@ Bedrock retrieval test (internal use only)
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br> |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### [POST] /trial-apps/{app_id}/completion-messages
#### Parameters
@@ -9451,9 +9452,9 @@ Bedrock retrieval test (internal use only)
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br> |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### [GET] /trial-apps/{app_id}/datasets
#### Parameters
@@ -9568,9 +9569,9 @@ Returns the site configuration for the application including theme, icons, and t
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br> |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### [POST] /trial-apps/{app_id}/workflows/tasks/{task_id}/stop
**Stop workflow task**
@@ -9983,7 +9984,7 @@ Create a new plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)<br> |
| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### ~~[POST] /workspaces/current/endpoints/create~~
@@ -10002,7 +10003,7 @@ Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/cur
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)<br> |
| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### ~~[POST] /workspaces/current/endpoints/delete~~
@@ -10021,7 +10022,7 @@ Deprecated legacy alias for deleting a plugin endpoint. Use DELETE /workspaces/c
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)<br> |
| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### [POST] /workspaces/current/endpoints/disable
@@ -10037,7 +10038,7 @@ Disable a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint disabled successfully | **application/json**: [EndpointDisableResponse](#endpointdisableresponse)<br> |
| 200 | Endpoint disabled successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### [POST] /workspaces/current/endpoints/enable
@@ -10053,7 +10054,7 @@ Enable a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint enabled successfully | **application/json**: [EndpointEnableResponse](#endpointenableresponse)<br> |
| 200 | Endpoint enabled successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### [GET] /workspaces/current/endpoints/list
@@ -10087,7 +10088,7 @@ List endpoints for a specific plugin
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [PluginEndpointListResponse](#pluginendpointlistresponse)<br> |
| 200 | Success | **application/json**: [EndpointListResponse](#endpointlistresponse)<br> |
### ~~[POST] /workspaces/current/endpoints/update~~
@@ -10105,7 +10106,7 @@ Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/cu
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)<br> |
| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### [DELETE] /workspaces/current/endpoints/{id}
@@ -10121,7 +10122,7 @@ Delete a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)<br> |
| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### [PATCH] /workspaces/current/endpoints/{id}
@@ -10143,7 +10144,7 @@ Update a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)<br> |
| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 403 | Admin privileges required | |
### [GET] /workspaces/current/members
@@ -10203,7 +10204,7 @@ Update a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [MemberActionTenantResponse](#memberactiontenantresponse)<br> |
| 200 | Success | **application/json**: [MemberActionResponse](#memberactionresponse)<br> |
### [POST] /workspaces/current/members/{member_id}/owner-transfer
#### Parameters
@@ -12471,9 +12472,15 @@ Returns permission flags that control workspace features like member invitations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)<br> |
| 200 | Success | **application/json**: [WorkspaceTenantResultResponse](#workspacetenantresultresponse)<br> |
### [POST] /workspaces/custom-config/webapp-logo/upload
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
@@ -12491,7 +12498,7 @@ Returns permission flags that control workspace features like member invitations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)<br> |
| 200 | Success | **application/json**: [WorkspaceTenantResultResponse](#workspacetenantresultresponse)<br> |
### [POST] /workspaces/switch
#### Request Body
@@ -16103,12 +16110,6 @@ Model class for provider custom model configuration.
| ---- | ---- | ----------- | -------- |
| info_list | [InfoList](#infolist) | | Yes |
#### DataSourceContentPreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DataSourceContentPreviewResponse | | | |
#### DataSourceIntegrateIconResponse
| Name | Type | Description | Required |
@@ -16629,32 +16630,43 @@ Model class for provider custom model configuration.
| ---- | ---- | ----------- | -------- |
| credential_id | string | | Yes |
#### DatasourceCredentialListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | [ [DatasourceCredentialResponse](#datasourcecredentialresponse) ] | | Yes |
#### DatasourceCredentialPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credentials | object | | Yes |
| credentials | object | Plugin-defined credential parameters. The schema is declared by the datasource provider. | Yes |
| name | string | | No |
#### DatasourceCredentialResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| avatar_url | string | | Yes |
| credential | object | Obfuscated plugin-defined credential parameters from the datasource provider. | Yes |
| id | string | | Yes |
| is_default | boolean | | Yes |
| name | string | | Yes |
| type | string | | Yes |
#### DatasourceCredentialUpdatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credential_id | string | | Yes |
| credentials | object | | No |
| credentials | object | Plugin-defined credential parameters. The schema is declared by the datasource provider. | No |
| name | string | | No |
#### DatasourceCredentialsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | | | Yes |
#### DatasourceCustomClientPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| client_params | object | | No |
| client_params | object | Plugin-defined OAuth client parameters. The schema is declared by the datasource provider. | No |
| enable_oauth_custom_client | boolean | | No |
#### DatasourceDefaultPayload
@@ -16686,6 +16698,39 @@ Model class for provider custom model configuration.
| error | string | Error message from OAuth provider | No |
| state | string | OAuth state parameter | No |
#### DatasourceOAuthSchemaResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| client_schema | [ [ProviderConfig](#providerconfig) ] | | Yes |
| credentials_schema | [ [ProviderConfig](#providerconfig) ] | | Yes |
| is_oauth_custom_client_enabled | boolean | | Yes |
| is_system_oauth_params_exists | boolean | | Yes |
| oauth_custom_client_params | object | Masked plugin-defined OAuth client parameters, when configured for the tenant. | Yes |
| redirect_uri | string | | Yes |
#### DatasourceProviderAuthListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | [ [DatasourceProviderAuthResponse](#datasourceproviderauthresponse) ] | | Yes |
#### DatasourceProviderAuthResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | Yes |
| credential_schema | [ [ProviderConfig](#providerconfig) ] | | Yes |
| credentials_list | [ [DatasourceCredentialResponse](#datasourcecredentialresponse) ] | | Yes |
| description | [I18nObject](#i18nobject) | | Yes |
| icon | string | | Yes |
| label | [I18nObject](#i18nobject) | | Yes |
| name | string | | Yes |
| oauth_schema | [DatasourceOAuthSchemaResponse](#datasourceoauthschemaresponse) | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| provider | string | | Yes |
#### DatasourceUpdateNamePayload
| Name | Type | Description | Required |
@@ -16702,12 +16747,6 @@ Model class for provider custom model configuration.
| start_node_id | string | | Yes |
| start_node_title | string | | Yes |
#### DebugPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DebugPermission | string | | |
#### DeclaredArrayItem
Per-item shape for an ``array``-typed declared output.
@@ -17034,12 +17073,6 @@ Request payload for bulk downloading documents as a zip archive.
| role | string | | Yes |
| token | string | | Yes |
#### EducationActivateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| EducationActivateResponse | object | | |
#### EducationAutocompleteQuery
| Name | Type | Description | Required |
@@ -17149,29 +17182,13 @@ Request payload for bulk downloading documents as a zip archive.
| plugin_unique_identifier | string | | Yes |
| settings | object | | Yes |
#### EndpointCreateResponse
#### EndpointDeclarationResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EndpointDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EndpointDisableResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EndpointEnableResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
| hidden | boolean | | No |
| method | string | | Yes |
| path | string | | Yes |
#### EndpointIdPayload
@@ -17187,6 +17204,23 @@ Request payload for bulk downloading documents as a zip archive.
| page_size | integer | | Yes |
| plugin_id | string | | Yes |
#### EndpointListItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | dateTime | | Yes |
| declaration | [EndpointProviderDeclarationResponse](#endpointproviderdeclarationresponse) | | No |
| enabled | boolean | | Yes |
| expired_at | dateTime | | Yes |
| hook_id | string | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| plugin_id | string | | Yes |
| settings | object | | Yes |
| tenant_id | string | | Yes |
| updated_at | dateTime | | Yes |
| url | string | | Yes |
#### EndpointListQuery
| Name | Type | Description | Required |
@@ -17198,7 +17232,59 @@ Request payload for bulk downloading documents as a zip archive.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| endpoints | [ object ] | Endpoint information | Yes |
| endpoints | [ [EndpointListItemResponse](#endpointlistitemresponse) ] | Endpoint information | Yes |
#### EndpointProviderConfigI18nResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| en_US | string | | Yes |
| ja_JP | string | | No |
| pt_BR | string | | No |
| zh_Hans | string | | No |
#### EndpointProviderConfigOptionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| label | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | Yes |
| value | string | | Yes |
#### EndpointProviderConfigResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| default | integer<br>string<br>number<br>boolean | | No |
| help | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | No |
| label | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | No |
| multiple | boolean | | No |
| name | string | | Yes |
| options | [ [EndpointProviderConfigOptionResponse](#endpointproviderconfigoptionresponse) ] | | No |
| placeholder | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | No |
| required | boolean | | No |
| scope | [EndpointProviderConfigScope](#endpointproviderconfigscope) | | No |
| type | [ProviderConfigType](#providerconfigtype) | | Yes |
| url | string | | No |
#### EndpointProviderConfigScope
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| EndpointProviderConfigScope | string | | |
#### EndpointProviderDeclarationResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| endpoints | [ [EndpointDeclarationResponse](#endpointdeclarationresponse) ] | | No |
| settings | [ [EndpointProviderConfigResponse](#endpointproviderconfigresponse) ] | | No |
#### EndpointSettingsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
| settings | object | | Yes |
#### EndpointUpdatePayload
@@ -17207,12 +17293,6 @@ Request payload for bulk downloading documents as a zip archive.
| name | string | | Yes |
| settings | object | | Yes |
#### EndpointUpdateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EnvSuggestion
| Name | Type | Description | Required |
@@ -18010,12 +18090,6 @@ Input field definition for snippet parameters.
| required | boolean | | No |
| type | string | | No |
#### InstallPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| InstallPermission | string | | |
#### InstalledAppCreatePayload
| Name | Type | Description | Required |
@@ -18321,7 +18395,7 @@ Enum class for large language model mode.
| marketplace_plugin_unique_identifier | string | | Yes |
| version | string | | No |
#### MemberActionTenantResponse
#### MemberActionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -19092,13 +19166,13 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
#### ParserAutoUpgradeFetch
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [PluginCategory](#plugincategory) | | Yes |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
#### ParserCreateCredential
@@ -19196,7 +19270,7 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [PluginCategory](#plugincategory) | | Yes |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
| plugin_id | string | | Yes |
#### ParserGetCredentials
@@ -19284,8 +19358,8 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_permission | [DebugPermission](#debugpermission) | | No |
| install_permission | [InstallPermission](#installpermission) | | No |
| debug_permission | [TenantPluginDebugPermission](#tenantplugindebugpermission) | | No |
| install_permission | [TenantPluginInstallPermission](#tenantplugininstallpermission) | | No |
#### ParserPluginIdentifierQuery
@@ -19494,7 +19568,7 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsResponseModel](#pluginautoupgradesettingsresponsemodel) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
#### PluginAutoUpgradeSettingsPayload
@@ -19502,8 +19576,8 @@ Shared permission levels for resources (datasets, credentials, etc.)
| ---- | ---- | ----------- | -------- |
| exclude_plugins | [ string ] | | No |
| include_plugins | [ string ] | | No |
| strategy_setting | [StrategySetting](#strategysetting) | | No |
| upgrade_mode | [UpgradeMode](#upgrademode) | | No |
| strategy_setting | [TenantPluginAutoUpgradeStrategySetting](#tenantpluginautoupgradestrategysetting) | | No |
| upgrade_mode | [TenantPluginAutoUpgradeMode](#tenantpluginautoupgrademode) | | No |
| upgrade_time_of_day | integer | | No |
#### PluginAutoUpgradeSettingsResponseModel
@@ -19512,8 +19586,8 @@ Shared permission levels for resources (datasets, credentials, etc.)
| ---- | ---- | ----------- | -------- |
| exclude_plugins | [ string ] | | Yes |
| include_plugins | [ string ] | | Yes |
| strategy_setting | [StrategySetting](#strategysetting) | | Yes |
| upgrade_mode | [UpgradeMode](#upgrademode) | | Yes |
| strategy_setting | [TenantPluginAutoUpgradeStrategySetting](#tenantpluginautoupgradestrategysetting) | | Yes |
| upgrade_mode | [TenantPluginAutoUpgradeMode](#tenantpluginautoupgrademode) | | Yes |
| upgrade_time_of_day | integer | | Yes |
#### PluginCategory
@@ -19635,21 +19709,21 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current_identifier | string | | No |
| type | [Type](#type) | | Yes |
| type | [PluginDependencyType](#plugindependencytype) | | Yes |
| value | [Github](#github)<br>[Marketplace](#marketplace)<br>[Package](#package) | | Yes |
#### PluginDependencyType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginDependencyType | string | | |
#### PluginDynamicOptionsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| options | | | Yes |
#### PluginEndpointListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| endpoints | [ object ] | Endpoint information | Yes |
#### PluginInstallationItemResponse
| Name | Type | Description | Required |
@@ -19730,7 +19804,13 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| type | [core__plugin__entities__parameters__PluginParameterAutoGenerate__Type](#core__plugin__entities__parameters__pluginparameterautogenerate__type) | | Yes |
| type | [PluginParameterAutoGenerateType](#pluginparameterautogeneratetype) | | Yes |
#### PluginParameterAutoGenerateType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginParameterAutoGenerateType | string | | |
#### PluginParameterOption
@@ -19750,15 +19830,15 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_permission | [DebugPermission](#debugpermission) | | Yes |
| install_permission | [InstallPermission](#installpermission) | | Yes |
| debug_permission | [TenantPluginDebugPermission](#tenantplugindebugpermission) | | Yes |
| install_permission | [TenantPluginInstallPermission](#tenantplugininstallpermission) | | Yes |
#### PluginPermissionSettingsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_permission | [DebugPermission](#debugpermission) | | No |
| install_permission | [InstallPermission](#installpermission) | | No |
| debug_permission | [TenantPluginDebugPermission](#tenantplugindebugpermission) | | No |
| install_permission | [TenantPluginInstallPermission](#tenantplugininstallpermission) | | No |
#### PluginReadmeResponse
@@ -19840,9 +19920,15 @@ Model class for common provider settings like credentials
| placeholder | [I18nObject](#i18nobject) | | No |
| required | boolean | | No |
| scope | [AppSelectorScope](#appselectorscope)<br>[ModelSelectorScope](#modelselectorscope)<br>[ToolSelectorScope](#toolselectorscope) | | No |
| type | [core__entities__provider_entities__BasicProviderConfig__Type](#core__entities__provider_entities__basicproviderconfig__type) | The type of the credentials | Yes |
| type | [ProviderConfigType](#providerconfigtype) | The type of the credentials | Yes |
| url | string | | No |
#### ProviderConfigType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ProviderConfigType | string | | |
#### ProviderCredentialResponse
| Name | Type | Description | Required |
@@ -20998,12 +21084,6 @@ Query parameters for listing snippet published workflows.
| paused | integer | | Yes |
| success | integer | | Yes |
#### StrategySetting
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| StrategySetting | string | | |
#### StringListSource
| Name | Type | Description | Required |
@@ -21258,6 +21338,36 @@ Tag type
| ---- | ---- | ----------- | -------- |
| workspaces | [ [TenantListItemResponse](#tenantlistitemresponse) ] | | Yes |
#### TenantPluginAutoUpgradeCategory
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginAutoUpgradeCategory | string | | |
#### TenantPluginAutoUpgradeMode
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginAutoUpgradeMode | string | | |
#### TenantPluginAutoUpgradeStrategySetting
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginAutoUpgradeStrategySetting | string | | |
#### TenantPluginDebugPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginDebugPermission | string | | |
#### TenantPluginInstallPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginInstallPermission | string | | |
#### TextContentResponse
| Name | Type | Description | Required |
@@ -21764,12 +21874,6 @@ Enum class for tool provider
| ---- | ---- | ----------- | -------- |
| TriggerSubscriptionListResponse | array | | |
#### Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| Type | string | | |
#### UnaddedModelConfiguration
Model class for provider unadded model configuration.
@@ -21810,12 +21914,6 @@ Payload for updating a snippet.
| icon_info | [IconInfo](#iconinfo) | | No |
| name | string | | No |
#### UpgradeMode
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| UpgradeMode | string | | |
#### UploadConfig
| Name | Type | Description | Required |
@@ -22946,7 +23044,13 @@ Workflow tool configuration
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
#### WorkspaceListResponse
#### WorkspaceLogoUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
#### WorkspacePaginationResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -22956,19 +23060,6 @@ Workflow tool configuration
| page | integer | | Yes |
| total | integer | | Yes |
#### WorkspaceLogoUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
#### WorkspaceMutationResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
| tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes |
#### WorkspacePermissionResponse
| Name | Type | Description | Required |
@@ -22983,6 +23074,13 @@ Workflow tool configuration
| ---- | ---- | ----------- | -------- |
| permission_keys | [ string ] | | No |
#### WorkspaceTenantResultResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
| tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes |
#### _AccessControlLanguageQuery
| Name | Type | Description | Required |
@@ -23051,18 +23149,6 @@ Workflow tool configuration
| ---- | ---- | ----------- | -------- |
| scope | [RBACResourceWhitelistScope](#rbacresourcewhitelistscope) | | Yes |
#### core__entities__provider_entities__BasicProviderConfig__Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| core__entities__provider_entities__BasicProviderConfig__Type | string | | |
#### core__plugin__entities__parameters__PluginParameterAutoGenerate__Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| core__plugin__entities__parameters__PluginParameterAutoGenerate__Type | string | | |
#### core__tools__entities__common_entities__I18nObject
Model class for i18n object.
+7 -7
View File
@@ -941,9 +941,15 @@ Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current_identifier | string | | No |
| type | [Type](#type) | | Yes |
| type | [PluginDependencyType](#plugindependencytype) | | Yes |
| value | [Github](#github)<br>[Marketplace](#marketplace)<br>[Package](#package) | | Yes |
#### PluginDependencyType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginDependencyType | string | | |
#### RevokeResponse
| Name | Type | Description | Required |
@@ -1024,12 +1030,6 @@ types it as a required `'success'` rather than an optional field.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| Type | string | | |
#### UsageInfo
| Name | Type | Description | Required |
+7 -7
View File
@@ -1046,12 +1046,12 @@ Execute a single datasource node within the knowledge pipeline. Returns a stream
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Streaming response with node execution events. | **text/event-stream**: [GeneratedAppResponse](#generatedappresponse)<br> |
| 401 | Unauthorized - invalid API token | |
| 403 | Forbidden - dataset API access or workspace access denied | |
| 404 | `not_found` : Dataset not found. | |
| Code | Description |
| ---- | ----------- |
| 200 | Streaming response with node execution events. |
| 401 | Unauthorized - invalid API token |
| 403 | Forbidden - dataset API access or workspace access denied |
| 404 | `not_found` : Dataset not found. |
### [POST] /datasets/{dataset_id}/pipeline/run
**Run Pipeline**
@@ -2960,7 +2960,7 @@ Enum class for custom configuration status.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credentials | [ [DatasourceCredentialInfoResponse](#datasourcecredentialinforesponse) ] | | Yes |
| credentials | [ [DatasourceCredentialInfoResponse](#datasourcecredentialinforesponse) ] | | No |
| datasource_type | string | | No |
| node_id | string | | No |
| plugin_id | string | | No |
+7 -1
View File
@@ -600,7 +600,7 @@ Get authentication passport for web application access
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Passport retrieved successfully | **application/json**: [AccessTokenData](#accesstokendata)<br> |
| 200 | Passport retrieved successfully | **application/json**: [PassportAccessTokenResponse](#passportaccesstokenresponse)<br> |
| 401 | Unauthorized - missing app code or invalid authentication | |
| 404 | Application or user not found | |
@@ -1430,6 +1430,12 @@ Form input definition.
| text_to_speech | [JSONObject](#jsonobject) | | Yes |
| user_input_form | [ [JSONObject](#jsonobject) ] | | Yes |
#### PassportAccessTokenResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_token | string | | Yes |
#### PassportQuery
| Name | Type | Description | Required |
+2 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy import select
import app
from core.helper.marketplace import fetch_global_plugin_manifest
from extensions.ext_database import db
from models.account import TenantPluginAutoUpgradeStrategy
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginAutoUpgradeStrategySetting
from tasks import process_tenant_plugin_autoupgrade_check_task as check_task
logger = logging.getLogger(__name__)
@@ -34,8 +34,7 @@ def check_upgradable_plugin_task():
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day >= now_seconds_of_day,
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day
< now_seconds_of_day + AUTO_UPGRADE_MINIMAL_CHECKING_INTERVAL,
TenantPluginAutoUpgradeStrategy.strategy_setting
!= TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
TenantPluginAutoUpgradeStrategy.strategy_setting != TenantPluginAutoUpgradeStrategySetting.DISABLED,
)
).all()
+4 -2
View File
@@ -38,6 +38,8 @@ from models.account import (
Tenant,
TenantAccountJoin,
TenantAccountRole,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantStatus,
)
@@ -1257,13 +1259,13 @@ class TenantService:
session.add(tenant)
session.commit()
for category in TenantPluginAutoUpgradeStrategy.PluginCategory:
for category in TenantPluginAutoUpgradeCategory:
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
category=category,
strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category),
upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id),
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
+6 -6
View File
@@ -2,7 +2,7 @@ import re
from configs import dify_config
from core.helper import marketplace
from core.plugin.entities.plugin import PluginDependency, PluginInstallationSource
from core.plugin.entities.plugin import PluginDependency, PluginDependencyType, PluginInstallationSource
from core.plugin.impl.plugin import PluginInstaller
from models.provider_ids import ModelProviderID, ToolProviderID
@@ -55,7 +55,7 @@ class DependenciesAnalysisService:
unique_identifier = dependency.value.plugin_unique_identifier
if unique_identifier in missing_plugin_unique_identifiers:
# Extract version for Marketplace dependencies
if dependency.type == PluginDependency.Type.Marketplace:
if dependency.type == PluginDependencyType.Marketplace:
version_match = _VERSION_REGEX.search(unique_identifier)
if version_match:
dependency.value.version = version_match.group("version")
@@ -84,7 +84,7 @@ class DependenciesAnalysisService:
if plugin.source == PluginInstallationSource.Github:
result.append(
PluginDependency(
type=PluginDependency.Type.Github,
type=PluginDependencyType.Github,
value=PluginDependency.Github(
repo=plugin.meta["repo"],
version=plugin.meta["version"],
@@ -96,7 +96,7 @@ class DependenciesAnalysisService:
elif plugin.source == PluginInstallationSource.Marketplace:
result.append(
PluginDependency(
type=PluginDependency.Type.Marketplace,
type=PluginDependencyType.Marketplace,
value=PluginDependency.Marketplace(
marketplace_plugin_unique_identifier=plugin.plugin_unique_identifier
),
@@ -105,7 +105,7 @@ class DependenciesAnalysisService:
elif plugin.source == PluginInstallationSource.Package:
result.append(
PluginDependency(
type=PluginDependency.Type.Package,
type=PluginDependencyType.Package,
value=PluginDependency.Package(plugin_unique_identifier=plugin.plugin_unique_identifier),
)
)
@@ -130,7 +130,7 @@ class DependenciesAnalysisService:
deps = marketplace.batch_fetch_plugin_manifests(dependencies)
return [
PluginDependency(
type=PluginDependency.Type.Marketplace,
type=PluginDependencyType.Marketplace,
value=PluginDependency.Marketplace(marketplace_plugin_unique_identifier=dep.latest_package_identifier),
)
for dep in deps
@@ -14,11 +14,16 @@ from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from core.plugin.impl.plugin import PluginInstaller
from models.account import TenantPluginAutoUpgradeStrategy
from models.account import (
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantPluginAutoUpgradeStrategySetting,
)
logger = logging.getLogger(__name__)
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
PluginCategory = TenantPluginAutoUpgradeCategory
PLUGIN_CATEGORIES = tuple(PluginCategory)
SECONDS_PER_DAY = 24 * 60 * 60
AUTO_UPGRADE_CHECK_SLOT_SECONDS = 15 * 60
@@ -35,10 +40,10 @@ class PluginAutoUpgradeService:
@staticmethod
def default_strategy_setting_for_category(
category: PluginCategory,
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
) -> TenantPluginAutoUpgradeStrategySetting:
if category == PluginCategory.MODEL:
return TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
return TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
return TenantPluginAutoUpgradeStrategySetting.LATEST
return TenantPluginAutoUpgradeStrategySetting.FIX_ONLY
@staticmethod
def default_upgrade_time_of_day(tenant_id: str) -> int:
@@ -102,9 +107,9 @@ class PluginAutoUpgradeService:
@staticmethod
def _has_default_strategy(strategy: TenantPluginAutoUpgradeStrategy) -> bool:
return (
strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.FIX_ONLY
and strategy.upgrade_time_of_day == 0
and strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
and strategy.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
and not strategy.exclude_plugins
and not strategy.include_plugins
)
@@ -114,7 +119,7 @@ class PluginAutoUpgradeService:
source_strategy: TenantPluginAutoUpgradeStrategy,
category: PluginCategory,
source_has_default_strategy: bool,
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
) -> TenantPluginAutoUpgradeStrategySetting:
# Only pure legacy defaults adopt the new model=latest default. User-edited
# strategies keep their original setting across all categories.
if source_has_default_strategy:
@@ -266,9 +271,9 @@ class PluginAutoUpgradeService:
session: Session,
tenant_id: str,
category: PluginCategory,
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
strategy_setting: TenantPluginAutoUpgradeStrategySetting,
upgrade_time_of_day: int,
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
upgrade_mode: TenantPluginAutoUpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
) -> None:
@@ -294,9 +299,9 @@ class PluginAutoUpgradeService:
@staticmethod
def change_strategy(
tenant_id: str,
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
strategy_setting: TenantPluginAutoUpgradeStrategySetting,
upgrade_time_of_day: int,
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
upgrade_mode: TenantPluginAutoUpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
category: PluginCategory,
@@ -329,28 +334,28 @@ class PluginAutoUpgradeService:
session,
tenant_id,
category,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
0,
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
TenantPluginAutoUpgradeMode.EXCLUDE,
[plugin_id],
[],
)
else:
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE:
# In exclude mode, disabling one plugin means adding it to exclude_plugins.
if plugin_id not in exist_strategy.exclude_plugins:
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
new_exclude_plugins.append(plugin_id)
exist_strategy.exclude_plugins = new_exclude_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeMode.PARTIAL:
# In partial mode, disabling one plugin means removing it from include_plugins.
if plugin_id in exist_strategy.include_plugins:
new_include_plugins = exist_strategy.include_plugins.copy()
new_include_plugins.remove(plugin_id)
exist_strategy.include_plugins = new_include_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeMode.ALL:
# In all mode, switch to exclude mode so only this plugin is skipped.
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
exist_strategy.exclude_plugins = [plugin_id]
@staticmethod
@@ -1,7 +1,7 @@
from sqlalchemy import select
from core.db.session_factory import session_factory
from models.account import TenantPluginPermission
from models.account import TenantPluginDebugPermission, TenantPluginInstallPermission, TenantPluginPermission
class PluginPermissionService:
@@ -15,8 +15,8 @@ class PluginPermissionService:
@staticmethod
def change_permission(
tenant_id: str,
install_permission: TenantPluginPermission.InstallPermission,
debug_permission: TenantPluginPermission.DebugPermission,
install_permission: TenantPluginInstallPermission,
debug_permission: TenantPluginDebugPermission,
):
with session_factory.create_session() as session, session.begin():
permission = session.scalar(
+2 -2
View File
@@ -73,7 +73,7 @@ class TagService:
target must be bound to all requested tags.
"""
# Check if tag_ids is not empty to avoid WHERE false condition
if not tag_ids or len(tag_ids) == 0:
if not tag_ids:
return []
# Deduplicate repeated query params so match_all counts each requested tag once.
requested_tag_ids = list(dict.fromkeys(tag_ids))
@@ -88,7 +88,7 @@ class TagService:
return []
tag_ids = list(tags)
# Check if tag_ids is not empty to avoid WHERE false condition
if not tag_ids or len(tag_ids) == 0:
if not tag_ids:
return []
if match_all:
if len(tag_ids) != len(requested_tag_ids):
@@ -6,7 +6,7 @@ from httpx import get
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.custom_tool.provider import ApiToolProviderController
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
@@ -52,7 +52,7 @@ class ApiToolManageService:
credentials_schema = [
ProviderConfig(
name="auth_type",
type=ProviderConfig.Type.SELECT,
type=ProviderConfigType.SELECT,
required=True,
default="none",
options=[
@@ -63,7 +63,7 @@ class ApiToolManageService:
),
ProviderConfig(
name="api_key_header",
type=ProviderConfig.Type.TEXT_INPUT,
type=ProviderConfigType.TEXT_INPUT,
required=False,
placeholder=I18nObject(en_US="Enter api key header", zh_Hans="输入 api key header,如:X-API-KEY"),
default="api_key",
@@ -71,7 +71,7 @@ class ApiToolManageService:
),
ProviderConfig(
name="api_key_value",
type=ProviderConfig.Type.TEXT_INPUT,
type=ProviderConfigType.TEXT_INPUT,
required=False,
placeholder=I18nObject(en_US="Enter api key", zh_Hans="输入 api key"),
default="",
@@ -459,13 +459,11 @@ class MCPToolManageService:
Returns:
JSON string of encrypted data
"""
from core.entities.provider_entities import BasicProviderConfig
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.tools.utils.encryption import create_provider_encrypter
# Create config for secret fields
config = [
BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=field) for field in secret_fields
]
config = [BasicProviderConfig(type=ProviderConfigType.SECRET_INPUT, name=field) for field in secret_fields]
encrypter_instance, _ = create_provider_encrypter(
tenant_id=tenant_id,
@@ -11,11 +11,15 @@ from core.plugin.entities.plugin import PluginInstallation, PluginInstallationSo
from core.plugin.impl.plugin import PluginInstaller
from core.plugin.plugin_service import PluginService
from extensions.ext_redis import redis_client
from models.account import TenantPluginAutoUpgradeStrategy
from models.account import (
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategySetting,
)
logger = logging.getLogger(__name__)
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
PluginCategory = TenantPluginAutoUpgradeCategory
RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3
CACHE_REDIS_KEY_PREFIX = "plugin_autoupgrade_check_task:cached_plugin_snapshot:"
CACHE_REDIS_TTL = 60 * 60 # 1 hour
@@ -95,9 +99,9 @@ def _plugin_matches_category(plugin: PluginInstallation, category: str | None) -
@shared_task(queue="plugin")
def process_tenant_plugin_autoupgrade_check_task(
tenant_id: str,
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
strategy_setting: TenantPluginAutoUpgradeStrategySetting,
upgrade_time_of_day: int,
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
upgrade_mode: TenantPluginAutoUpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
category: PluginCategory | str | None = None,
@@ -113,14 +117,14 @@ def process_tenant_plugin_autoupgrade_check_task(
)
)
if strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED:
if strategy_setting == TenantPluginAutoUpgradeStrategySetting.DISABLED:
return
# get plugin_ids to check
plugin_ids: list[tuple[str, str, str]] = [] # plugin_id, version, unique_identifier
click.echo(click.style(f"Upgrade mode: {upgrade_mode}", fg="green"))
if upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL and include_plugins:
if upgrade_mode == TenantPluginAutoUpgradeMode.PARTIAL and include_plugins:
all_plugins = manager.list_plugins(tenant_id)
for plugin in all_plugins:
@@ -137,7 +141,7 @@ def process_tenant_plugin_autoupgrade_check_task(
)
)
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
elif upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE:
# get all plugins and remove excluded plugins
all_plugins = manager.list_plugins(tenant_id)
plugin_ids = [
@@ -147,7 +151,7 @@ def process_tenant_plugin_autoupgrade_check_task(
and plugin.plugin_id not in exclude_plugins
and _plugin_matches_category(plugin, category_value)
]
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
elif upgrade_mode == TenantPluginAutoUpgradeMode.ALL:
all_plugins = manager.list_plugins(tenant_id)
plugin_ids = [
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
@@ -187,8 +191,8 @@ def process_tenant_plugin_autoupgrade_check_task(
return False
version_checker = {
TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST: operator.ne,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY: fix_only_checker,
TenantPluginAutoUpgradeStrategySetting.LATEST: operator.ne,
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY: fix_only_checker,
}
if version_checker[strategy_setting](latest_version, current_version):
@@ -3,11 +3,19 @@ from sqlalchemy import delete, func, select
from core.db.session_factory import session_factory
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.account import (
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginDebugPermission,
TenantPluginInstallPermission,
TenantPluginPermission,
)
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_permission_service import PluginPermissionService
PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
PLUGIN_CATEGORY = TenantPluginAutoUpgradeCategory.TOOL
@pytest.fixture
@@ -36,31 +44,31 @@ class TestPluginPermissionLifecycle:
def test_change_creates_row(self, tenant):
result = PluginPermissionService.change_permission(
tenant,
TenantPluginPermission.InstallPermission.ADMINS,
TenantPluginPermission.DebugPermission.EVERYONE,
TenantPluginInstallPermission.ADMINS,
TenantPluginDebugPermission.EVERYONE,
)
assert result is True
perm = PluginPermissionService.get_permission(tenant)
assert perm is not None
assert perm.install_permission == TenantPluginPermission.InstallPermission.ADMINS
assert perm.debug_permission == TenantPluginPermission.DebugPermission.EVERYONE
assert perm.install_permission == TenantPluginInstallPermission.ADMINS
assert perm.debug_permission == TenantPluginDebugPermission.EVERYONE
def test_change_updates_existing_row(self, tenant):
PluginPermissionService.change_permission(
tenant,
TenantPluginPermission.InstallPermission.ADMINS,
TenantPluginPermission.DebugPermission.NOBODY,
TenantPluginInstallPermission.ADMINS,
TenantPluginDebugPermission.NOBODY,
)
PluginPermissionService.change_permission(
tenant,
TenantPluginPermission.InstallPermission.EVERYONE,
TenantPluginPermission.DebugPermission.ADMINS,
TenantPluginInstallPermission.EVERYONE,
TenantPluginDebugPermission.ADMINS,
)
perm = PluginPermissionService.get_permission(tenant)
assert perm is not None
assert perm.install_permission == TenantPluginPermission.InstallPermission.EVERYONE
assert perm.debug_permission == TenantPluginPermission.DebugPermission.ADMINS
assert perm.install_permission == TenantPluginInstallPermission.EVERYONE
assert perm.debug_permission == TenantPluginDebugPermission.ADMINS
with session_factory.create_session() as session:
count = session.scalar(
@@ -78,9 +86,9 @@ class TestPluginAutoUpgradeLifecycle:
def test_change_creates_row(self, tenant):
result = PluginAutoUpgradeService.change_strategy(
tenant,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.LATEST,
upgrade_time_of_day=3,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
upgrade_mode=TenantPluginAutoUpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
@@ -89,24 +97,24 @@ class TestPluginAutoUpgradeLifecycle:
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
assert strategy.upgrade_time_of_day == 3
def test_change_updates_existing_row(self, tenant):
PluginAutoUpgradeService.change_strategy(
tenant,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
upgrade_mode=TenantPluginAutoUpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
)
PluginAutoUpgradeService.change_strategy(
tenant,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.LATEST,
upgrade_time_of_day=12,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
upgrade_mode=TenantPluginAutoUpgradeMode.PARTIAL,
exclude_plugins=[],
include_plugins=["plugin-a"],
category=PLUGIN_CATEGORY,
@@ -114,9 +122,9 @@ class TestPluginAutoUpgradeLifecycle:
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
assert strategy.upgrade_time_of_day == 12
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL
assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.PARTIAL
assert strategy.include_plugins == ["plugin-a"]
def test_exclude_plugin_creates_strategy_when_none_exists(self, tenant):
@@ -124,15 +132,15 @@ class TestPluginAutoUpgradeLifecycle:
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
assert "my-plugin" in strategy.exclude_plugins
def test_exclude_plugin_appends_in_exclude_mode(self, tenant):
PluginAutoUpgradeService.change_strategy(
tenant,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=["existing"],
include_plugins=[],
category=PLUGIN_CATEGORY,
@@ -147,9 +155,9 @@ class TestPluginAutoUpgradeLifecycle:
def test_exclude_plugin_dedup_in_exclude_mode(self, tenant):
PluginAutoUpgradeService.change_strategy(
tenant,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=["same-plugin"],
include_plugins=[],
category=PLUGIN_CATEGORY,
@@ -163,9 +171,9 @@ class TestPluginAutoUpgradeLifecycle:
def test_exclude_from_partial_mode_removes_from_include(self, tenant):
PluginAutoUpgradeService.change_strategy(
tenant,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
upgrade_mode=TenantPluginAutoUpgradeMode.PARTIAL,
exclude_plugins=[],
include_plugins=["p1", "p2"],
category=PLUGIN_CATEGORY,
@@ -180,9 +188,9 @@ class TestPluginAutoUpgradeLifecycle:
def test_exclude_from_all_mode_switches_to_exclude(self, tenant):
PluginAutoUpgradeService.change_strategy(
tenant,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.LATEST,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
upgrade_mode=TenantPluginAutoUpgradeMode.ALL,
exclude_plugins=[],
include_plugins=[],
category=PLUGIN_CATEGORY,
@@ -191,5 +199,5 @@ class TestPluginAutoUpgradeLifecycle:
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
assert strategy is not None
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
assert "excluded-plugin" in strategy.exclude_plugins
@@ -14,7 +14,7 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_import import (
RagPipelineImportCheckDependenciesApi,
RagPipelineImportConfirmApi,
)
from core.plugin.entities.plugin import PluginDependency
from core.plugin.entities.plugin import PluginDependency, PluginDependencyType
from models.dataset import Pipeline
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineImportInfo
@@ -237,7 +237,7 @@ class TestRagPipelineImportCheckDependenciesApi:
pipeline = MagicMock(spec=Pipeline)
dependency = PluginDependency(
type=PluginDependency.Type.Marketplace,
type=PluginDependencyType.Marketplace,
value=PluginDependency.Marketplace(
marketplace_plugin_unique_identifier="langgenius/example:0.1.0",
version="0.1.0",
@@ -607,7 +607,11 @@ class TestMiscApis:
method = unwrap(api.get)
service = MagicMock()
service.get_recommended_plugins.return_value = [{"id": "p1"}]
recommended_plugins = {
"installed_recommended_plugins": [{"id": "p1"}],
"uninstalled_recommended_plugins": [{"id": "p2"}],
}
service.get_recommended_plugins.return_value = recommended_plugins
user = make_account()
tenant_id = "tenant-1"
@@ -619,7 +623,7 @@ class TestMiscApis:
),
):
result = method(api, tenant_id, user)
assert result == [{"id": "p1"}]
assert result == recommended_plugins
service.get_recommended_plugins.assert_called_once_with("all", user, tenant_id)
@@ -10,7 +10,13 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from controllers.console.workspace import plugin_permission_required
from models.account import Tenant, TenantPluginPermission, TenantStatus
from models.account import (
Tenant,
TenantPluginDebugPermission,
TenantPluginInstallPermission,
TenantPluginPermission,
TenantStatus,
)
def _create_tenant(db_session: Session) -> Tenant:
@@ -24,8 +30,8 @@ def _create_tenant(db_session: Session) -> Tenant:
def _create_permission(
db_session: Session,
tenant_id: str,
install: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE,
debug: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE,
install: TenantPluginInstallPermission = TenantPluginInstallPermission.EVERYONE,
debug: TenantPluginDebugPermission = TenantPluginDebugPermission.EVERYONE,
) -> TenantPluginPermission:
perm = TenantPluginPermission(
tenant_id=tenant_id,
@@ -59,8 +65,8 @@ class TestPluginPermissionRequired:
_create_permission(
db_session_with_containers,
tenant.id,
install=TenantPluginPermission.InstallPermission.NOBODY,
debug=TenantPluginPermission.DebugPermission.EVERYONE,
install=TenantPluginInstallPermission.NOBODY,
debug=TenantPluginDebugPermission.EVERYONE,
)
user = SimpleNamespace(is_admin_or_owner=True)
@@ -81,8 +87,8 @@ class TestPluginPermissionRequired:
_create_permission(
db_session_with_containers,
tenant.id,
install=TenantPluginPermission.InstallPermission.ADMINS,
debug=TenantPluginPermission.DebugPermission.EVERYONE,
install=TenantPluginInstallPermission.ADMINS,
debug=TenantPluginDebugPermission.EVERYONE,
)
user = SimpleNamespace(is_admin_or_owner=False)
@@ -103,8 +109,8 @@ class TestPluginPermissionRequired:
_create_permission(
db_session_with_containers,
tenant.id,
install=TenantPluginPermission.InstallPermission.ADMINS,
debug=TenantPluginPermission.DebugPermission.EVERYONE,
install=TenantPluginInstallPermission.ADMINS,
debug=TenantPluginDebugPermission.EVERYONE,
)
user = SimpleNamespace(is_admin_or_owner=True)
@@ -124,8 +130,8 @@ class TestPluginPermissionRequired:
_create_permission(
db_session_with_containers,
tenant.id,
install=TenantPluginPermission.InstallPermission.EVERYONE,
debug=TenantPluginPermission.DebugPermission.NOBODY,
install=TenantPluginInstallPermission.EVERYONE,
debug=TenantPluginDebugPermission.NOBODY,
)
user = SimpleNamespace(is_admin_or_owner=True)
@@ -146,8 +152,8 @@ class TestPluginPermissionRequired:
_create_permission(
db_session_with_containers,
tenant.id,
install=TenantPluginPermission.InstallPermission.EVERYONE,
debug=TenantPluginPermission.DebugPermission.ADMINS,
install=TenantPluginInstallPermission.EVERYONE,
debug=TenantPluginDebugPermission.ADMINS,
)
user = SimpleNamespace(is_admin_or_owner=False)
@@ -168,8 +174,8 @@ class TestPluginPermissionRequired:
_create_permission(
db_session_with_containers,
tenant.id,
install=TenantPluginPermission.InstallPermission.EVERYONE,
debug=TenantPluginPermission.DebugPermission.ADMINS,
install=TenantPluginInstallPermission.EVERYONE,
debug=TenantPluginDebugPermission.ADMINS,
)
user = SimpleNamespace(is_admin_or_owner=True)
@@ -6,7 +6,7 @@ import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from models.account import TenantPluginPermission
from models.account import TenantPluginDebugPermission, TenantPluginInstallPermission, TenantPluginPermission
from services.plugin.plugin_permission_service import PluginPermissionService
@@ -32,8 +32,8 @@ class TestGetPermission:
tenant_id = _tenant_id()
permission = TenantPluginPermission(
tenant_id=tenant_id,
install_permission=TenantPluginPermission.InstallPermission.ADMINS,
debug_permission=TenantPluginPermission.DebugPermission.EVERYONE,
install_permission=TenantPluginInstallPermission.ADMINS,
debug_permission=TenantPluginDebugPermission.EVERYONE,
)
db_session_with_containers.add(permission)
db_session_with_containers.commit()
@@ -43,8 +43,8 @@ class TestGetPermission:
assert result is not None
assert result.id == permission.id
assert result.tenant_id == tenant_id
assert result.install_permission == TenantPluginPermission.InstallPermission.ADMINS
assert result.debug_permission == TenantPluginPermission.DebugPermission.EVERYONE
assert result.install_permission == TenantPluginInstallPermission.ADMINS
assert result.debug_permission == TenantPluginDebugPermission.EVERYONE
@pytest.mark.usefixtures("flask_app_with_containers")
def test_returns_none_when_not_found(self) -> None:
@@ -61,36 +61,36 @@ class TestChangePermission:
result = PluginPermissionService.change_permission(
tenant_id,
TenantPluginPermission.InstallPermission.EVERYONE,
TenantPluginPermission.DebugPermission.EVERYONE,
TenantPluginInstallPermission.EVERYONE,
TenantPluginDebugPermission.EVERYONE,
)
permission = _get_permission(db_session_with_containers, tenant_id)
assert result is True
assert permission is not None
assert permission.install_permission == TenantPluginPermission.InstallPermission.EVERYONE
assert permission.debug_permission == TenantPluginPermission.DebugPermission.EVERYONE
assert permission.install_permission == TenantPluginInstallPermission.EVERYONE
assert permission.debug_permission == TenantPluginDebugPermission.EVERYONE
def test_updates_existing_permission(self, db_session_with_containers: Session) -> None:
tenant_id = _tenant_id()
existing = TenantPluginPermission(
tenant_id=tenant_id,
install_permission=TenantPluginPermission.InstallPermission.EVERYONE,
debug_permission=TenantPluginPermission.DebugPermission.EVERYONE,
install_permission=TenantPluginInstallPermission.EVERYONE,
debug_permission=TenantPluginDebugPermission.EVERYONE,
)
db_session_with_containers.add(existing)
db_session_with_containers.commit()
result = PluginPermissionService.change_permission(
tenant_id,
TenantPluginPermission.InstallPermission.ADMINS,
TenantPluginPermission.DebugPermission.ADMINS,
TenantPluginInstallPermission.ADMINS,
TenantPluginDebugPermission.ADMINS,
)
permission = _get_permission(db_session_with_containers, tenant_id)
assert result is True
assert permission is not None
assert permission.id == existing.id
assert permission.install_permission == TenantPluginPermission.InstallPermission.ADMINS
assert permission.debug_permission == TenantPluginPermission.DebugPermission.ADMINS
assert permission.install_permission == TenantPluginInstallPermission.ADMINS
assert permission.debug_permission == TenantPluginDebugPermission.ADMINS
assert _count_permissions(db_session_with_containers, tenant_id) == 1
@@ -77,6 +77,10 @@ def assert_has_actionable_violation(stderr: str, path: str) -> None:
assert "no-new-getattr" in stderr
def main_branch_rev(repo: Path) -> str:
return git(repo, "rev-parse", "main")
def test_resolve_ast_grep_command_prefers_ast_grep(monkeypatch: pytest.MonkeyPatch) -> None:
module = load_guard_module()
monkeypatch.setattr(
@@ -130,8 +134,46 @@ def test_resolve_ast_grep_command_raises_without_explicit_binary(monkeypatch: py
module.resolve_ast_grep_command()
def test_cli_requires_explicit_diff_source(tmp_path: Path) -> None:
result = run_script(tmp_path)
assert result.returncode == 2
assert "one of the arguments --staged --base-rev is required" in result.stderr
def test_cli_rejects_mixed_diff_sources(tmp_path: Path) -> None:
result = run_script(tmp_path, "--staged", "--base-rev", "deadbeef")
assert result.returncode == 2
assert "not allowed with argument" in result.stderr
def test_cli_help_exposes_only_new_diff_source_flags(tmp_path: Path) -> None:
help_result = run_script(tmp_path, "--help")
assert help_result.returncode == 0
assert "--staged" in help_result.stdout
assert "--base-rev" in help_result.stdout
assert "--mode" not in help_result.stdout
assert "--merge-target" not in help_result.stdout
result = run_script(tmp_path, "--staged", "--mode", "ci")
assert result.returncode == 2
assert "unrecognized arguments: --mode ci" in result.stderr
result = run_script(tmp_path, "--base-rev", "deadbeef", "--merge-target", "main")
assert result.returncode == 2
assert "unrecognized arguments: --merge-target main" in result.stderr
def test_style_workflow_wires_no_new_getattr_guard() -> None:
workflow = (REPO_ROOT / ".github" / "workflows" / "style.yml").read_text(encoding="utf-8")
assert re.search(
r"(?ms)^on:\n workflow_call:\n inputs:\n base-rev:\n required: true\n type: string\n",
workflow,
)
python_style_job = re.search(
r"(?ms)^ python-style:\n(?P<job>.*?)(?=^ [a-z0-9-]+:\n|\Z)",
workflow,
@@ -157,8 +199,9 @@ def test_style_workflow_wires_no_new_getattr_guard() -> None:
assert "scripts/check_no_new_getattr.py\n" in files_block
assert "scripts/ast_grep_rules/no_new_getattr.yml\n" in files_block
assert ".github/workflows/style.yml\n" in files_block
assert ".github/workflows/main-ci.yml\n" in files_block
guard_command = "scripts/check_no_new_getattr.py --mode ci --merge-target main"
guard_command = 'scripts/check_no_new_getattr.py --base-rev "${{ inputs.base-rev }}"'
assert guard_command in job_text
guard_step = re.search(
@@ -168,55 +211,34 @@ def test_style_workflow_wires_no_new_getattr_guard() -> None:
)
assert guard_step is not None
pre_guard_text = job_text[: guard_step.start()]
step_pattern = r"(?ms)^ - name: [^\n]*\n(?P<step>.*?)(?=^ - name: |\Z)"
fetch_step_text = next(
(
match.group("step")
for match in re.finditer(step_pattern, pre_guard_text)
if any(
re.search(pattern, line)
for line in match.group("step").splitlines()
for pattern in (
r"git fetch .*refs/heads/main:refs/remotes/origin/main",
r"git fetch .*main:refs/remotes/origin/main",
r"git fetch .*refs/remotes/origin/main",
)
)
),
"",
)
assert fetch_step_text
assert "git fetch" in fetch_step_text
assert "origin" in fetch_step_text
assert any(
re.search(pattern, line)
for line in fetch_step_text.splitlines()
for pattern in (
r"git fetch .*refs/heads/main:refs/remotes/origin/main",
r"git fetch .*main:refs/remotes/origin/main",
r"git fetch .*refs/remotes/origin/main",
)
)
bind_step = re.search(
r"(?ms)^ - name: Bind merge target branch for getattr guard\n(?P<step>.*?)(?=^ - name: |\Z)",
pre_guard_text,
)
assert bind_step is not None
bind_step_text = bind_step.group("step")
assert any(
command in bind_step_text
for command in (
"git branch main origin/main",
"git checkout -B main origin/main",
"git switch -C main origin/main",
"git update-ref refs/heads/main refs/remotes/origin/main",
)
)
assert "GITHUB_BASE_SHA" not in guard_step.group("step")
def test_ci_mode_passes_when_only_legacy_getattr_exists(tmp_path: Path) -> None:
def test_main_ci_passes_style_base_rev_input() -> None:
workflow = (REPO_ROOT / ".github" / "workflows" / "main-ci.yml").read_text(encoding="utf-8")
style_job = re.search(
r"(?ms)^ style-check:\n(?P<job>.*?)(?=^ [a-z0-9-]+:\n|\Z)",
workflow,
)
assert style_job is not None
assert "uses: ./.github/workflows/style.yml" in style_job.group("job")
assert 'base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}' in style_job.group(
"job"
)
api_filter = re.search(
r"(?ms)^ api:\n(?P<filter>(?:^ - '[^']+'\n)+)",
workflow,
)
assert api_filter is not None
filter_text = api_filter.group("filter")
assert "scripts/check_no_new_getattr.py" in filter_text
assert "scripts/ast_grep_rules/no_new_getattr.yml" in filter_text
assert ".github/workflows/style.yml" in filter_text
assert ".github/workflows/main-ci.yml" in filter_text
def test_base_rev_mode_passes_when_only_legacy_getattr_exists(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -239,12 +261,12 @@ def test_ci_mode_passes_when_only_legacy_getattr_exists(tmp_path: Path) -> None:
)
commit_all(tmp_path, "unrelated change")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", main_branch_rev(tmp_path))
assert result.returncode == 0, result.stderr
def test_ci_mode_fails_for_new_file_with_getattr(tmp_path: Path) -> None:
def test_base_rev_mode_fails_for_new_file_with_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -267,13 +289,13 @@ def test_ci_mode_fails_for_new_file_with_getattr(tmp_path: Path) -> None:
)
commit_all(tmp_path, "add new getattr usage")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", main_branch_rev(tmp_path))
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/new_usage.py")
def test_ci_mode_fails_for_new_file_with_two_arg_getattr(tmp_path: Path) -> None:
def test_base_rev_mode_fails_for_new_file_with_two_arg_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -296,13 +318,13 @@ def test_ci_mode_fails_for_new_file_with_two_arg_getattr(tmp_path: Path) -> None
)
commit_all(tmp_path, "add new two-arg getattr usage")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", main_branch_rev(tmp_path))
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/new_usage.py")
def test_ci_mode_fails_for_new_file_with_builtins_getattr(tmp_path: Path) -> None:
def test_base_rev_mode_fails_for_new_file_with_builtins_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -328,13 +350,13 @@ def test_ci_mode_fails_for_new_file_with_builtins_getattr(tmp_path: Path) -> Non
)
commit_all(tmp_path, "add new builtins getattr usage")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", main_branch_rev(tmp_path))
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/new_usage.py")
def test_ci_mode_fails_for_new_file_with_two_arg_builtins_getattr(tmp_path: Path) -> None:
def test_base_rev_mode_fails_for_new_file_with_two_arg_builtins_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -360,13 +382,13 @@ def test_ci_mode_fails_for_new_file_with_two_arg_builtins_getattr(tmp_path: Path
)
commit_all(tmp_path, "add new two-arg builtins getattr usage")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", main_branch_rev(tmp_path))
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/new_usage.py")
def test_ci_mode_fails_for_new_file_with_dunder_builtins_getattr(tmp_path: Path) -> None:
def test_base_rev_mode_fails_for_new_file_with_dunder_builtins_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -389,13 +411,13 @@ def test_ci_mode_fails_for_new_file_with_dunder_builtins_getattr(tmp_path: Path)
)
commit_all(tmp_path, "add new dunder builtins getattr usage")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", main_branch_rev(tmp_path))
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/new_usage.py")
def test_ci_mode_fails_for_new_file_with_two_arg_dunder_builtins_getattr(tmp_path: Path) -> None:
def test_base_rev_mode_fails_for_new_file_with_two_arg_dunder_builtins_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -418,13 +440,13 @@ def test_ci_mode_fails_for_new_file_with_two_arg_dunder_builtins_getattr(tmp_pat
)
commit_all(tmp_path, "add new two-arg dunder builtins getattr usage")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", main_branch_rev(tmp_path))
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/new_usage.py")
def test_ci_mode_uses_merge_base_against_main_not_just_head_parent(tmp_path: Path) -> None:
def test_base_rev_mode_uses_provided_base_revision_not_head_parent(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -435,6 +457,7 @@ def test_ci_mode_uses_merge_base_against_main_not_just_head_parent(tmp_path: Pat
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
@@ -457,13 +480,77 @@ def test_ci_mode_uses_merge_base_against_main_not_just_head_parent(tmp_path: Pat
)
commit_all(tmp_path, "later feature commit does not touch violating file")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/introduced_earlier.py")
def test_pre_commit_mode_reads_staged_content_only(tmp_path: Path) -> None:
def test_base_rev_mode_works_without_local_main_branch(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
"pkg/existing.py",
"""
def stable() -> str:
return "ok"
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
tmp_path,
"pkg/other.py",
"""
def meaning() -> int:
return 42
""",
)
commit_all(tmp_path, "feature change")
git(tmp_path, "checkout", "--detach", "HEAD")
git(tmp_path, "branch", "-D", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 0, result.stderr
def test_base_rev_mode_ignores_github_base_sha_environment(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
"pkg/existing.py",
"""
def stable() -> str:
return "ok"
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
tmp_path,
"pkg/other.py",
"""
def meaning() -> int:
return 42
""",
)
commit_all(tmp_path, "feature change")
monkeypatch.setenv("GITHUB_BASE_SHA", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 0, result.stderr
def test_staged_mode_reads_staged_content_only(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -494,12 +581,12 @@ def test_pre_commit_mode_reads_staged_content_only(tmp_path: Path) -> None:
""",
)
result = run_script(tmp_path, "--mode", "pre-commit")
result = run_script(tmp_path, "--staged")
assert result.returncode == 0, result.stderr
def test_pre_commit_mode_fails_for_staged_two_arg_getattr(tmp_path: Path) -> None:
def test_staged_mode_fails_for_staged_two_arg_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -521,13 +608,13 @@ def test_pre_commit_mode_fails_for_staged_two_arg_getattr(tmp_path: Path) -> Non
)
git(tmp_path, "add", "pkg/module.py")
result = run_script(tmp_path, "--mode", "pre-commit")
result = run_script(tmp_path, "--staged")
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/module.py")
def test_pre_commit_mode_fails_for_staged_builtins_getattr(tmp_path: Path) -> None:
def test_staged_mode_fails_for_staged_builtins_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -552,13 +639,13 @@ def test_pre_commit_mode_fails_for_staged_builtins_getattr(tmp_path: Path) -> No
)
git(tmp_path, "add", "pkg/module.py")
result = run_script(tmp_path, "--mode", "pre-commit")
result = run_script(tmp_path, "--staged")
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/module.py")
def test_pre_commit_mode_fails_for_staged_two_arg_builtins_getattr(tmp_path: Path) -> None:
def test_staged_mode_fails_for_staged_two_arg_builtins_getattr(tmp_path: Path) -> None:
init_repo(tmp_path)
write_repo_file(
tmp_path,
@@ -583,7 +670,7 @@ def test_pre_commit_mode_fails_for_staged_two_arg_builtins_getattr(tmp_path: Pat
)
git(tmp_path, "add", "pkg/module.py")
result = run_script(tmp_path, "--mode", "pre-commit")
result = run_script(tmp_path, "--staged")
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/module.py")
@@ -603,6 +690,7 @@ def test_modified_hunk_with_same_getattr_count_is_allowed(tmp_path: Path) -> Non
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
@@ -618,7 +706,7 @@ def test_modified_hunk_with_same_getattr_count_is_allowed(tmp_path: Path) -> Non
)
commit_all(tmp_path, "touch legacy getattr hunk")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 0, result.stderr
@@ -635,6 +723,7 @@ def test_modified_hunk_with_decreased_getattr_count_is_allowed(tmp_path: Path) -
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
@@ -647,7 +736,7 @@ def test_modified_hunk_with_decreased_getattr_count_is_allowed(tmp_path: Path) -
)
commit_all(tmp_path, "remove one legacy getattr")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 0, result.stderr
@@ -663,6 +752,7 @@ def test_modified_hunk_with_increased_getattr_count_fails(tmp_path: Path) -> Non
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
@@ -676,7 +766,7 @@ def test_modified_hunk_with_increased_getattr_count_fails(tmp_path: Path) -> Non
)
commit_all(tmp_path, "add one more getattr")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/sample.py")
@@ -694,6 +784,7 @@ def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_p
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
@@ -706,7 +797,7 @@ def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_p
)
commit_all(tmp_path, "add suppressed getattr")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert "no-new-getattr needed for plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text(
encoding="utf-8"
@@ -725,6 +816,7 @@ def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path)
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
@@ -737,7 +829,7 @@ def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path)
)
commit_all(tmp_path, "add bare noqa getattr")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 1
assert_has_actionable_violation(result.stderr, "pkg/existing.py")
@@ -753,6 +845,7 @@ def test_non_python_file_with_getattr_text_does_not_fail_guard(tmp_path: Path) -
""",
)
commit_all(tmp_path, "baseline")
base_rev = main_branch_rev(tmp_path)
checkout_feature_branch(tmp_path)
write_repo_file(
@@ -764,6 +857,6 @@ def test_non_python_file_with_getattr_text_does_not_fail_guard(tmp_path: Path) -
)
commit_all(tmp_path, "document getattr example")
result = run_script(tmp_path, "--mode", "ci", "--merge-target", "main")
result = run_script(tmp_path, "--base-rev", base_rev)
assert result.returncode == 0, result.stderr
@@ -1,4 +1,5 @@
import inspect
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import pytest
@@ -23,6 +24,76 @@ from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from services.datasource_provider_service import DatasourceProviderService
from services.plugin.oauth_service import OAuthProxyService
_PROVIDER_ID = "langgenius/notion_datasource/notion"
def _i18n(text: str) -> dict[str, str]:
return {"en_US": text, "zh_Hans": text, "pt_BR": text, "ja_JP": text}
def _provider_config(name: str, type_: str, label: str, *, required: bool = True) -> dict:
return {
"type": type_,
"name": name,
"scope": None,
"required": required,
"default": None,
"options": None,
"multiple": False,
"label": _i18n(label),
"help": None,
"url": None,
"placeholder": None,
}
def _datasource_credential(credential_id: str = "cred-1", *, is_default: bool = True) -> dict:
return {
"credential": {
"api_key": "******",
"workspace": "engineering",
"database_id": "db-123",
},
"type": "api-key",
"name": "API Key",
"avatar_url": "https://cdn.example.com/notion.png",
"id": credential_id,
"is_default": is_default,
}
def _datasource_auth() -> dict:
return {
"author": "Dify",
"provider": "notion",
"plugin_id": "langgenius/notion_datasource",
"plugin_unique_identifier": "langgenius/notion_datasource:0.0.1",
"icon": "icon.svg",
"name": "notion",
"label": _i18n("Notion"),
"description": _i18n("Notion datasource"),
"credential_schema": [
_provider_config("api_key", "secret-input", "API key"),
],
"oauth_schema": {
"client_schema": [
_provider_config("client_id", "text-input", "Client ID"),
],
"credentials_schema": [
_provider_config("access_token", "secret-input", "Access token"),
],
"oauth_custom_client_params": {"client_id": "masked-client", "client_secret": "********"},
"is_oauth_custom_client_enabled": True,
"is_system_oauth_params_exists": True,
"redirect_uri": "https://api.example.com/oauth/callback",
},
"credentials_list": [_datasource_credential(), _datasource_credential("cred-2", is_default=False)],
}
def _success_response() -> dict[str, str]:
return {"result": "success"}
class TestDatasourcePluginOAuthAuthorizationUrl:
def test_get_success(self, app: Flask):
@@ -30,28 +101,50 @@ class TestDatasourcePluginOAuthAuthorizationUrl:
method = inspect.unwrap(api.get)
user = MagicMock(id="user-1")
oauth_client = {"client_id": "abc", "client_secret": "shh", "scopes": ["read", "write"]}
auth_url_payload = {
"authorization_url": "https://auth.example.com/oauth?client_id=abc&state=xyz",
}
with (
app.test_request_context("/?credential_id=cred-1"),
patch.object(
DatasourceProviderService,
"get_oauth_client",
return_value={"client_id": "abc"},
),
return_value=oauth_client,
) as get_oauth_client,
patch.object(
OAuthProxyService,
"create_proxy_context",
return_value="ctx-1",
),
) as create_proxy_context,
patch.object(
OAuthHandler,
"get_authorization_url",
return_value={"url": "http://auth"},
),
return_value=auth_url_payload,
) as get_authorization_url,
):
response = method(api, "tenant-1", user, "notion")
response = method(api, "tenant-1", user, _PROVIDER_ID)
assert response.status_code == 200
assert response.get_json() == auth_url_payload
assert "context_id=ctx-1" in response.headers.get("Set-Cookie")
provider_id = get_oauth_client.call_args.kwargs["datasource_provider_id"]
assert str(provider_id) == _PROVIDER_ID
get_oauth_client.assert_called_once()
create_proxy_context.assert_called_once_with(
user_id="user-1",
tenant_id="tenant-1",
plugin_id="langgenius/notion_datasource",
provider="notion",
credential_id="cred-1",
)
get_authorization_url.assert_called_once()
assert get_authorization_url.call_args.kwargs["tenant_id"] == "tenant-1"
assert get_authorization_url.call_args.kwargs["user_id"] == "user-1"
assert get_authorization_url.call_args.kwargs["plugin_id"] == "langgenius/notion_datasource"
assert get_authorization_url.call_args.kwargs["provider"] == "notion"
assert get_authorization_url.call_args.kwargs["system_credentials"] == oauth_client
def test_get_no_oauth_config(self, app: Flask):
api = DatasourcePluginOAuthAuthorizationUrl()
@@ -90,10 +183,10 @@ class TestDatasourcePluginOAuthAuthorizationUrl:
patch.object(
OAuthHandler,
"get_authorization_url",
return_value={"url": "http://auth"},
return_value={"authorization_url": "http://auth"},
),
):
response = method(api, "tenant-1", user, "notion")
response = method(api, "tenant-1", user, _PROVIDER_ID)
assert response.status_code == 200
assert "context_id" in response.headers.get("Set-Cookie")
@@ -106,8 +199,9 @@ class TestDatasourceOAuthCallback:
oauth_response = MagicMock()
oauth_response.credentials = {"token": "abc"}
oauth_response.expires_at = None
oauth_response.metadata = {"name": "test"}
expires_at = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC)
oauth_response.expires_at = expires_at
oauth_response.metadata = {"name": "Workspace Bot", "avatar_url": "https://avatar.example.com/bot.png"}
context = {
"user_id": "user-1",
@@ -125,7 +219,7 @@ class TestDatasourceOAuthCallback:
patch.object(
DatasourceProviderService,
"get_oauth_client",
return_value={"client_id": "abc"},
return_value={"client_id": "abc", "client_secret": "secret"},
),
patch.object(
OAuthHandler,
@@ -136,11 +230,22 @@ class TestDatasourceOAuthCallback:
DatasourceProviderService,
"add_datasource_oauth_provider",
return_value=None,
),
) as add_oauth_provider,
):
response = method(api, "notion")
response = method(api, _PROVIDER_ID)
assert response.status_code == 302
assert "/oauth-callback" in response.location
add_oauth_provider.assert_called_once()
assert add_oauth_provider.call_args.kwargs == {
"tenant_id": "tenant-1",
"provider_id": add_oauth_provider.call_args.kwargs["provider_id"],
"avatar_url": "https://avatar.example.com/bot.png",
"name": "Workspace Bot",
"expire_at": expires_at,
"credentials": {"token": "abc"},
}
assert str(add_oauth_provider.call_args.kwargs["provider_id"]) == _PROVIDER_ID
def test_callback_missing_context(self, app: Flask):
api = DatasourceOAuthCallback()
@@ -223,12 +328,16 @@ class TestDatasourceOAuthCallback:
DatasourceProviderService,
"reauthorize_datasource_oauth_provider",
return_value=None,
),
) as reauthorize_provider,
):
response = method(api, "notion")
response = method(api, _PROVIDER_ID)
assert response.status_code == 302
assert "/oauth-callback" in response.location
reauthorize_provider.assert_called_once()
assert str(reauthorize_provider.call_args.kwargs["provider_id"]) == _PROVIDER_ID
assert reauthorize_provider.call_args.kwargs["credential_id"] == "cred-1"
assert reauthorize_provider.call_args.kwargs["credentials"] == {"token": "abc"}
def test_callback_context_id_from_cookie(self, app: Flask):
api = DatasourceOAuthCallback()
@@ -278,7 +387,14 @@ class TestDatasourceAuth:
api = DatasourceAuth()
method = inspect.unwrap(api.post)
payload = {"credentials": {"key": "val"}}
payload = {
"name": "Engineering Notion",
"credentials": {
"api_key": "secret-token",
"workspace": "engineering",
"database_id": "db-123",
},
}
with (
app.test_request_context("/", json=payload),
@@ -287,11 +403,17 @@ class TestDatasourceAuth:
DatasourceProviderService,
"add_datasource_api_key_provider",
return_value=None,
),
) as add_api_key_provider,
):
response, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", _PROVIDER_ID)
assert response == _success_response()
assert status == 200
add_api_key_provider.assert_called_once()
assert add_api_key_provider.call_args.kwargs["tenant_id"] == "tenant-1"
assert str(add_api_key_provider.call_args.kwargs["provider_id"]) == _PROVIDER_ID
assert add_api_key_provider.call_args.kwargs["credentials"] == payload["credentials"]
assert add_api_key_provider.call_args.kwargs["name"] == "Engineering Notion"
def test_post_invalid_credentials(self, app: Flask):
api = DatasourceAuth()
@@ -321,19 +443,19 @@ class TestDatasourceAuth:
patch.object(
DatasourceProviderService,
"list_datasource_credentials",
return_value=[{"id": "1"}],
return_value=[_datasource_credential()],
),
):
response, status = method(api, "tenant-1", user, "notion")
response, status = method(api, "tenant-1", user, _PROVIDER_ID)
assert status == 200
assert response["result"]
assert response == {"result": [_datasource_credential()]}
def test_post_missing_credentials(self, app: Flask):
api = DatasourceAuth()
method = inspect.unwrap(api.post)
payload = {}
payload: dict[str, object] = {}
with (
app.test_request_context("/", json=payload),
@@ -375,17 +497,24 @@ class TestDatasourceAuthDeleteApi:
DatasourceProviderService,
"remove_datasource_credentials",
return_value=None,
),
) as remove_datasource_credentials,
):
response, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", _PROVIDER_ID)
assert response == _success_response()
assert status == 200
remove_datasource_credentials.assert_called_once_with(
tenant_id="tenant-1",
auth_id="cred-1",
provider="notion",
plugin_id="langgenius/notion_datasource",
)
def test_delete_missing_credential_id(self, app: Flask):
api = DatasourceAuthDeleteApi()
method = inspect.unwrap(api.post)
payload = {}
payload: dict[str, object] = {}
with (
app.test_request_context("/", json=payload),
@@ -400,7 +529,11 @@ class TestDatasourceAuthUpdateApi:
api = DatasourceAuthUpdateApi()
method = inspect.unwrap(api.post)
payload = {"credential_id": "id", "credentials": {"k": "v"}}
payload = {
"credential_id": "cred-1",
"name": "Updated Notion",
"credentials": {"api_key": "new-secret", "database_id": "db-456"},
}
with (
app.test_request_context("/", json=payload),
@@ -409,11 +542,20 @@ class TestDatasourceAuthUpdateApi:
DatasourceProviderService,
"update_datasource_credentials",
return_value=None,
),
) as update_datasource_credentials,
):
response, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", _PROVIDER_ID)
assert response == _success_response()
assert status == 201
update_datasource_credentials.assert_called_once_with(
tenant_id="tenant-1",
auth_id="cred-1",
provider="notion",
plugin_id="langgenius/notion_datasource",
credentials=payload["credentials"],
name="Updated Notion",
)
def test_update_with_credentials_none(self, app: Flask):
api = DatasourceAuthUpdateApi()
@@ -432,7 +574,9 @@ class TestDatasourceAuthUpdateApi:
):
response, status = method(api, "tenant-1", "notion")
assert response == _success_response()
update_mock.assert_called_once()
assert update_mock.call_args.kwargs["credentials"] == {}
assert status == 201
def test_update_name_only(self, app: Flask):
@@ -450,8 +594,9 @@ class TestDatasourceAuthUpdateApi:
return_value=None,
),
):
_, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", "notion")
assert response == _success_response()
assert status == 201
def test_update_with_empty_credentials_dict(self, app: Flask):
@@ -469,8 +614,9 @@ class TestDatasourceAuthUpdateApi:
return_value=None,
) as update_mock,
):
_, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", "notion")
assert response == _success_response()
update_mock.assert_called_once()
assert status == 201
@@ -485,12 +631,13 @@ class TestDatasourceAuthListApi:
patch.object(
DatasourceProviderService,
"get_all_datasource_credentials",
return_value=[{"id": "1"}],
return_value=[_datasource_auth()],
),
):
response, status = method(api, "tenant-1")
assert status == 200
assert response == {"result": [_datasource_auth()]}
def test_auth_list_empty(self, app: Flask):
api = DatasourceAuthListApi()
@@ -537,7 +684,7 @@ class TestDatasourceHardCodeAuthListApi:
patch.object(
DatasourceProviderService,
"get_hard_code_datasource_credentials",
return_value=[{"id": "1"}],
return_value=[_datasource_auth()],
),
):
response, status = method(api, "tenant-1")
@@ -550,7 +697,14 @@ class TestDatasourceAuthOauthCustomClient:
api = DatasourceAuthOauthCustomClient()
method = inspect.unwrap(api.post)
payload = {"client_params": {}, "enable_oauth_custom_client": True}
payload = {
"client_params": {
"client_id": "custom-client",
"client_secret": "custom-secret",
"authorize_url": "https://auth.example.com/authorize",
},
"enable_oauth_custom_client": True,
}
with (
app.test_request_context("/", json=payload),
@@ -559,11 +713,17 @@ class TestDatasourceAuthOauthCustomClient:
DatasourceProviderService,
"setup_oauth_custom_client_params",
return_value=None,
),
) as setup_custom_client,
):
response, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", _PROVIDER_ID)
assert response == _success_response()
assert status == 200
setup_custom_client.assert_called_once()
assert setup_custom_client.call_args.kwargs["tenant_id"] == "tenant-1"
assert str(setup_custom_client.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID
assert setup_custom_client.call_args.kwargs["client_params"] == payload["client_params"]
assert setup_custom_client.call_args.kwargs["enabled"] is True
def test_delete_success(self, app: Flask):
api = DatasourceAuthOauthCustomClient()
@@ -575,17 +735,20 @@ class TestDatasourceAuthOauthCustomClient:
DatasourceProviderService,
"remove_oauth_custom_client_params",
return_value=None,
),
) as remove_custom_client,
):
response, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", _PROVIDER_ID)
assert response == _success_response()
assert status == 200
remove_custom_client.assert_called_once()
assert str(remove_custom_client.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID
def test_post_empty_payload(self, app: Flask):
api = DatasourceAuthOauthCustomClient()
method = inspect.unwrap(api.post)
payload = {}
payload: dict[str, object] = {}
with (
app.test_request_context("/", json=payload),
@@ -596,8 +759,9 @@ class TestDatasourceAuthOauthCustomClient:
return_value=None,
),
):
_, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", "notion")
assert response == _success_response()
assert status == 200
def test_post_disabled_flag(self, app: Flask):
@@ -618,9 +782,12 @@ class TestDatasourceAuthOauthCustomClient:
return_value=None,
) as setup_mock,
):
_, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", "notion")
assert response == _success_response()
setup_mock.assert_called_once()
assert setup_mock.call_args.kwargs["client_params"] == {"a": 1}
assert setup_mock.call_args.kwargs["enabled"] is False
assert status == 200
@@ -638,17 +805,22 @@ class TestDatasourceAuthDefaultApi:
DatasourceProviderService,
"set_default_datasource_provider",
return_value=None,
),
) as set_default_datasource_provider,
):
response, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", _PROVIDER_ID)
assert response == _success_response()
assert status == 200
set_default_datasource_provider.assert_called_once()
assert set_default_datasource_provider.call_args.kwargs["tenant_id"] == "tenant-1"
assert str(set_default_datasource_provider.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID
assert set_default_datasource_provider.call_args.kwargs["credential_id"] == "cred-1"
def test_default_missing_id(self, app: Flask):
api = DatasourceAuthDefaultApi()
method = inspect.unwrap(api.post)
payload = {}
payload: dict[str, object] = {}
with (
app.test_request_context("/", json=payload),
@@ -663,7 +835,7 @@ class TestDatasourceUpdateProviderNameApi:
api = DatasourceUpdateProviderNameApi()
method = inspect.unwrap(api.post)
payload = {"credential_id": "id", "name": "New Name"}
payload = {"credential_id": "cred-1", "name": "New Name"}
with (
app.test_request_context("/", json=payload),
@@ -672,11 +844,17 @@ class TestDatasourceUpdateProviderNameApi:
DatasourceProviderService,
"update_datasource_provider_name",
return_value=None,
),
) as update_datasource_provider_name,
):
response, status = method(api, "tenant-1", "notion")
response, status = method(api, "tenant-1", _PROVIDER_ID)
assert response == _success_response()
assert status == 200
update_datasource_provider_name.assert_called_once()
assert update_datasource_provider_name.call_args.kwargs["tenant_id"] == "tenant-1"
assert str(update_datasource_provider_name.call_args.kwargs["datasource_provider_id"]) == _PROVIDER_ID
assert update_datasource_provider_name.call_args.kwargs["name"] == "New Name"
assert update_datasource_provider_name.call_args.kwargs["credential_id"] == "cred-1"
def test_update_name_too_long(self, app: Flask):
api = DatasourceUpdateProviderNameApi()
@@ -158,3 +158,65 @@ def test_rag_pipeline_workflow_patch_serializes_response_model(app: Flask, monke
assert response["id"] == "workflow-1"
assert response["marked_name"] == "Updated release"
assert response["hash"] == "hash-1"
def test_default_rag_pipeline_block_configs_serializes_root_response(monkeypatch: pytest.MonkeyPatch) -> None:
block_configs = [{"type": "start", "config": {"title": "Start"}}]
monkeypatch.setattr(
module,
"RagPipelineService",
lambda: SimpleNamespace(get_default_block_configs=lambda: block_configs),
)
api = module.DefaultRagPipelineBlockConfigsApi()
handler = unwrap_all(api.get)
response = handler(api, _pipeline())
assert response == block_configs
def test_draft_rag_pipeline_second_step_parameters_serializes_variables(app, monkeypatch: pytest.MonkeyPatch) -> None:
variables = [
{
"belong_to_node_id": "shared",
"type": "number",
"label": "Chunk size",
"variable": "chunk_size",
"default_value": 1024,
"required": True,
}
]
monkeypatch.setattr(
module,
"RagPipelineService",
lambda: SimpleNamespace(get_second_step_parameters=lambda **_kwargs: variables),
)
api = module.DraftRagPipelineSecondStepApi()
handler = unwrap_all(api.get)
with app.test_request_context("/?node_id=node-1"):
response = handler(api, _pipeline())
assert response["variables"] == variables
def test_rag_pipeline_recommended_plugins_serializes_known_envelope(app, monkeypatch: pytest.MonkeyPatch) -> None:
recommended_plugins = {
"installed_recommended_plugins": [{"name": "Dify Extractor", "meta": {"version": "1.0.0"}}],
"uninstalled_recommended_plugins": [{"plugin_id": "langgenius/notion_datasource"}],
}
monkeypatch.setattr(
module,
"RagPipelineService",
lambda: SimpleNamespace(get_recommended_plugins=lambda *_args: recommended_plugins),
)
api = module.RagPipelineRecommendedPluginApi()
handler = unwrap_all(api.get)
with app.test_request_context("/?type=tool"):
response = handler(api, "tenant-1", _account())
assert response == recommended_plugins
@@ -1,4 +1,5 @@
import inspect
from datetime import UTC, datetime
from unittest.mock import patch
import pytest
@@ -16,9 +17,39 @@ from controllers.console.workspace.endpoint import (
EndpointListApi,
EndpointListForSinglePluginApi,
)
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.plugin.entities.endpoint import EndpointEntityWithInstance, EndpointProviderDeclaration
from core.plugin.impl.exc import PluginPermissionDeniedError
def _endpoint_entity() -> EndpointEntityWithInstance:
now = datetime(2026, 1, 1, tzinfo=UTC)
return EndpointEntityWithInstance(
id="e1",
created_at=now,
updated_at=now,
tenant_id="t1",
plugin_id="p1",
settings={
"api_key": "pl********et",
"enabled": True,
"ids": ["a", "b"],
"nested": {"limit": 3},
},
expired_at=now,
declaration=EndpointProviderDeclaration(
settings=[
ProviderConfig(type=ProviderConfigType.SECRET_INPUT, name="api_key"),
ProviderConfig(type=ProviderConfigType.BOOLEAN, name="enabled"),
]
),
name="endpoint",
enabled=True,
url="https://example.test/hook-1",
hook_id="hook-1",
)
class TestEndpointCollectionApi:
def test_create_success(self, app: Flask):
api = EndpointCollectionApi()
@@ -99,15 +130,40 @@ class TestEndpointListApi:
def test_list_success(self, app: Flask):
api = EndpointListApi()
method = inspect.unwrap(api.get)
endpoint_entity = _endpoint_entity()
with (
app.test_request_context("/?page=1&page_size=10"),
patch("controllers.console.workspace.endpoint.EndpointService.list_endpoints", return_value=[{"id": "e1"}]),
patch(
"controllers.console.workspace.endpoint.EndpointService.list_endpoints",
return_value=[endpoint_entity],
),
):
result = method(api, "t1", "u1")
assert "endpoints" in result
assert len(result["endpoints"]) == 1
endpoint = result["endpoints"][0]
assert endpoint["id"] == "e1"
assert endpoint["created_at"] == "2026-01-01T00:00:00Z"
assert endpoint["updated_at"] == "2026-01-01T00:00:00Z"
assert endpoint["settings"] == {
"api_key": "pl********et",
"enabled": True,
"ids": ["a", "b"],
"nested": {"limit": 3},
}
assert endpoint["tenant_id"] == "t1"
assert endpoint["plugin_id"] == "p1"
assert endpoint["expired_at"] == "2026-01-01T00:00:00Z"
assert endpoint["declaration"]["settings"][0]["type"] == "secret-input"
assert endpoint["declaration"]["settings"][0]["name"] == "api_key"
assert endpoint["declaration"]["settings"][1]["type"] == "boolean"
assert endpoint["declaration"]["settings"][1]["name"] == "enabled"
assert endpoint["declaration"]["endpoints"] == []
assert endpoint["name"] == "endpoint"
assert endpoint["enabled"] is True
assert endpoint["url"] == "https://example.test/hook-1"
assert endpoint["hook_id"] == "hook-1"
assert endpoint_entity.settings["api_key"] == "pl********et"
def test_list_invalid_query(self, app: Flask):
api = EndpointListApi()
@@ -129,12 +185,14 @@ class TestEndpointListForSinglePluginApi:
app.test_request_context("/?page=1&page_size=10&plugin_id=p1"),
patch(
"controllers.console.workspace.endpoint.EndpointService.list_endpoints_for_single_plugin",
return_value=[{"id": "e1"}],
return_value=[_endpoint_entity()],
),
):
result = method(api, "t1", "u1")
assert "endpoints" in result
assert result["endpoints"][0]["id"] == "e1"
assert result["endpoints"][0]["settings"]["api_key"] == "pl********et"
assert result["endpoints"][0]["settings"]["nested"] == {"limit": 3}
def test_list_for_plugin_missing_param(self, app: Flask):
api = EndpointListForSinglePluginApi()
@@ -130,7 +130,7 @@ class TestMemberInviteEmailApi:
features.workspace_members.is_available.return_value = True
payload = {
"emails": ["a@test.com"],
"emails": ["A@TEST.com", "a@test.com"],
"role": "normal",
"language": "en-US",
}
@@ -138,8 +138,10 @@ class TestMemberInviteEmailApi:
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch("controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1) as mock_count,
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"
) as mock_invite,
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
@@ -148,6 +150,10 @@ class TestMemberInviteEmailApi:
assert status == 201
assert result["result"] == "success"
assert result["invitation_results"][0]["email"] == "a@test.com"
mock_count.assert_not_called()
mock_invite.assert_called_once()
assert mock_invite.call_args.kwargs["email"] == "a@test.com"
def test_invite_limit_exceeded(self, app: Flask):
api = MemberInviteEmailApi()
@@ -44,7 +44,15 @@ from controllers.console.workspace.plugin import (
)
from core.plugin.entities.plugin import PluginInstallation
from core.plugin.impl.exc import PluginDaemonClientSideError
from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.account import (
Account,
TenantAccountRole,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginDebugPermission,
TenantPluginInstallPermission,
)
def _plugin_category_list_item(category: str = "tool") -> dict[str, Any]:
@@ -393,8 +401,8 @@ class TestPluginChangePermissionApi:
user = _account(TenantAccountRole.NORMAL)
payload = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
"install_permission": TenantPluginInstallPermission.EVERYONE,
"debug_permission": TenantPluginDebugPermission.EVERYONE,
}
with (
@@ -410,8 +418,8 @@ class TestPluginChangePermissionApi:
user = _account()
payload = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
"install_permission": TenantPluginInstallPermission.EVERYONE,
"debug_permission": TenantPluginDebugPermission.EVERYONE,
}
with (
@@ -1020,11 +1028,11 @@ class TestPluginChangeAutoUpgradeApi:
user = _account()
payload = {
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value,
"category": TenantPluginAutoUpgradeCategory.TOOL.value,
"auto_upgrade": {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
"strategy_setting": TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"upgrade_mode": TenantPluginAutoUpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
},
@@ -1048,11 +1056,11 @@ class TestPluginChangeAutoUpgradeApi:
user = _account()
payload = {
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL.value,
"category": TenantPluginAutoUpgradeCategory.MODEL.value,
"auto_upgrade": {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST,
"strategy_setting": TenantPluginAutoUpgradeStrategySetting.LATEST,
"upgrade_time_of_day": 3600,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
"upgrade_mode": TenantPluginAutoUpgradeMode.ALL,
"exclude_plugins": [],
"include_plugins": [],
},
@@ -1068,7 +1076,7 @@ class TestPluginChangeAutoUpgradeApi:
assert result["success"] is True
change.assert_called_once()
assert change.call_args.kwargs["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL
assert change.call_args.kwargs["category"] == TenantPluginAutoUpgradeCategory.MODEL
def test_auto_upgrade_fail(self, app: Flask):
api = PluginChangeAutoUpgradeApi()
@@ -1077,11 +1085,11 @@ class TestPluginChangeAutoUpgradeApi:
user = MagicMock(is_admin_or_owner=True)
payload = {
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value,
"category": TenantPluginAutoUpgradeCategory.TOOL.value,
"auto_upgrade": {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
"strategy_setting": TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"upgrade_mode": TenantPluginAutoUpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
},
@@ -1102,16 +1110,16 @@ class TestPluginFetchAutoUpgradeApi:
method = unwrap(api.get)
auto_upgrade = MagicMock(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
category=TenantPluginAutoUpgradeCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=1,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
with (
app.test_request_context(f"/?category={TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}"),
app.test_request_context(f"/?category={TenantPluginAutoUpgradeCategory.TOOL.value}"),
patch(
"controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy",
return_value=auto_upgrade,
@@ -1119,7 +1127,7 @@ class TestPluginFetchAutoUpgradeApi:
):
result = method(api, "t1")
assert result["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
assert result["category"] == TenantPluginAutoUpgradeCategory.TOOL
assert result["auto_upgrade"]["upgrade_time_of_day"] == 1
@@ -1128,7 +1136,7 @@ class TestPluginAutoUpgradeExcludePluginApi:
api = PluginAutoUpgradeExcludePluginApi()
method = unwrap(api.post)
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeCategory.TOOL.value}
with (
app.test_request_context("/", json=payload),
@@ -1142,7 +1150,7 @@ class TestPluginAutoUpgradeExcludePluginApi:
api = PluginAutoUpgradeExcludePluginApi()
method = unwrap(api.post)
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeCategory.TOOL.value}
with (
app.test_request_context("/", json=payload),
@@ -1,5 +1,6 @@
import inspect
import logging
from http import HTTPStatus
from io import BytesIO
from unittest.mock import MagicMock, patch
@@ -26,7 +27,9 @@ from controllers.console.workspace.workspace import (
WebappLogoWorkspaceApi,
WorkspaceInfoApi,
WorkspaceListApi,
WorkspaceLogoUploadResponse,
WorkspacePermissionApi,
WorkspacePermissionResponse,
)
from enums.cloud_plan import CloudPlan
from libs.datetime_utils import naive_utc_now
@@ -99,7 +102,7 @@ class TestTenantListApi:
):
result, status = method(api, "t1", user)
assert status == 200
assert status == HTTPStatus.OK
assert len(result["workspaces"]) == 2
assert result["workspaces"][0]["current"] is True
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
@@ -146,7 +149,7 @@ class TestTenantListApi:
):
result, status = method(api, "t1", user)
assert status == 200
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
@@ -192,7 +195,7 @@ class TestTenantListApi:
):
result, status = method(api, "t2", user)
assert status == 200
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.TEAM
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
@@ -226,7 +229,7 @@ class TestTenantListApi:
):
result, status = method(api, "t1", user)
assert status == 200
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
get_features_mock.assert_called_once_with("t1", exclude_vector_space=True)
@@ -251,7 +254,7 @@ class TestTenantListApi:
):
result, status = method(api, "t2", user)
assert status == 200
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
assert result["workspaces"][1]["plan"] == CloudPlan.SANDBOX
assert result["workspaces"][0]["current"] is False
@@ -276,7 +279,7 @@ class TestTenantListApi:
):
result, status = method(api, None, user)
assert status == 200
assert status == HTTPStatus.OK
assert result["workspaces"] == []
get_features_mock.assert_not_called()
@@ -295,7 +298,7 @@ class TestWorkspaceListApi:
):
result, status = method(api)
assert status == 200
assert status == HTTPStatus.OK
assert result["total"] == 1
assert result["has_more"] is False
@@ -312,7 +315,7 @@ class TestWorkspaceListApi:
):
result, status = method(api)
assert status == 200
assert status == HTTPStatus.OK
assert result["has_more"] is True
@@ -332,7 +335,7 @@ class TestTenantApi:
):
result, status = method(api, user)
assert status == 200
assert status == HTTPStatus.OK
assert result["id"] == "t1"
def test_post_archived_with_switch(self, app: Flask):
@@ -386,7 +389,7 @@ class TestTenantApi:
result, status = method(api, user)
assert "Deprecated URL /info was used." in caplog.messages
assert status == 200
assert status == HTTPStatus.OK
class TestTenantInfoResponse:
@@ -586,8 +589,9 @@ class TestWebappLogoWorkspaceApi:
result, status = method(api, user)
assert status == 201
assert result["id"] == "file1"
assert status == HTTPStatus.CREATED
assert result == {"id": "file1"}
assert WorkspaceLogoUploadResponse.model_validate(result).model_dump(mode="json") == {"id": "file1"}
def test_filename_missing(self, app: Flask):
api = WebappLogoWorkspaceApi()
@@ -676,7 +680,7 @@ class TestWorkspaceInfoApi:
patch("controllers.console.workspace.workspace.db.session.commit"),
patch(
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info",
return_value={"name": "New Name"},
return_value={"id": "t1", "name": "New Name"},
),
):
result = method(api, "t1")
@@ -716,8 +720,14 @@ class TestWorkspacePermissionApi:
):
result, status = method(api, "t1")
assert status == 200
assert result["workspace_id"] == "t1"
assert status == HTTPStatus.OK
expected = {
"workspace_id": "t1",
"allow_member_invite": True,
"allow_owner_transfer": False,
}
assert result == expected
assert WorkspacePermissionResponse.model_validate(result).model_dump(mode="json") == expected
def test_no_current_tenant(self, app: Flask):
api = WorkspacePermissionApi()
@@ -325,10 +325,12 @@ class TestPipelineRunApiEntity:
def test_entity_missing_required_field(self):
"""Test entity raises on missing required field."""
with pytest.raises(ValueError):
PipelineRunApiEntity(
inputs={},
datasource_type="online_document",
# missing datasource_info_list, start_node_id, etc.
PipelineRunApiEntity.model_validate(
{
"inputs": {},
"datasource_type": "online_document",
# missing datasource_info_list, start_node_id, etc.
}
)
@@ -382,8 +384,19 @@ class TestDatasourcePluginsApiGet:
mock_dataset = Mock()
mock_db.session.scalar.return_value = mock_dataset
datasource_plugins = [
{
"node_id": "node-datasource-1",
"plugin_id": "plugin-a",
"provider_name": "provider-a",
"datasource_type": "online_document",
"title": "Online Docs",
"user_input_variables": [{"variable": "url", "label": "URL", "type": "text-input", "required": True}],
"credentials": [{"id": "cred-1", "name": "Default credential", "type": "oauth2", "is_default": True}],
}
]
mock_svc_instance = Mock()
mock_svc_instance.get_datasource_plugins.return_value = [{"name": "plugin_a"}]
mock_svc_instance.get_datasource_plugins.return_value = datasource_plugins
mock_svc_cls.return_value = mock_svc_instance
with app.test_request_context("/datasets/test/pipeline/datasource-plugins?is_published=true"):
@@ -391,11 +404,33 @@ class TestDatasourcePluginsApiGet:
response, status = api.get(tenant_id=tenant_id, dataset_id=dataset_id)
assert status == 200
assert response == [{"name": "plugin_a"}]
assert response == datasource_plugins
mock_svc_instance.get_datasource_plugins.assert_called_once_with(
tenant_id=tenant_id, dataset_id=dataset_id, is_published=True
)
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db")
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService")
def test_get_plugins_parses_false_is_published_query(self, mock_svc_cls, mock_db, app: Flask):
"""Test false query string is parsed as boolean False."""
tenant_id = str(uuid.uuid4())
dataset_id = str(uuid.uuid4())
mock_db.session.scalar.return_value = Mock()
mock_svc_instance = Mock()
mock_svc_instance.get_datasource_plugins.return_value = []
mock_svc_cls.return_value = mock_svc_instance
with app.test_request_context("/datasets/test/pipeline/datasource-plugins?is_published=false"):
api = DatasourcePluginsApi()
response, status = api.get(tenant_id=tenant_id, dataset_id=dataset_id)
assert status == 200
assert response == []
mock_svc_instance.get_datasource_plugins.assert_called_once_with(
tenant_id=tenant_id, dataset_id=dataset_id, is_published=False
)
@patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db")
def test_get_plugins_not_found(self, mock_db, app: Flask):
"""Test NotFound when dataset check fails."""
@@ -2,9 +2,10 @@
Unit tests for Service API knowledge pipeline file-upload serialization.
"""
import importlib.util
from datetime import UTC, datetime
from pathlib import Path
from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import PipelineUploadFileResponse
from libs.helper import dump_response
class FakeUploadFile:
@@ -17,21 +18,7 @@ class FakeUploadFile:
created_at: datetime | None
def _load_serialize_upload_file():
api_dir = Path(__file__).resolve().parents[5]
serializers_path = api_dir / "controllers" / "service_api" / "dataset" / "rag_pipeline" / "serializers.py"
spec = importlib.util.spec_from_file_location("rag_pipeline_serializers", serializers_path)
assert spec
assert spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[attr-defined]
return module.serialize_upload_file
def test_file_upload_created_at_is_isoformat_string():
serialize_upload_file = _load_serialize_upload_file()
created_at = datetime(2026, 2, 8, 12, 0, 0, tzinfo=UTC)
upload_file = FakeUploadFile()
upload_file.id = "file-1"
@@ -42,13 +29,11 @@ def test_file_upload_created_at_is_isoformat_string():
upload_file.created_by = "account-1"
upload_file.created_at = created_at
result = serialize_upload_file(upload_file)
result = dump_response(PipelineUploadFileResponse, upload_file)
assert result["created_at"] == created_at.isoformat()
def test_file_upload_created_at_none_serializes_to_null():
serialize_upload_file = _load_serialize_upload_file()
upload_file = FakeUploadFile()
upload_file.id = "file-1"
upload_file.name = "test.pdf"
@@ -58,5 +43,5 @@ def test_file_upload_created_at_none_serializes_to_null():
upload_file.created_by = "account-1"
upload_file.created_at = None
result = serialize_upload_file(upload_file)
result = dump_response(PipelineUploadFileResponse, upload_file)
assert result["created_at"] is None
@@ -34,12 +34,11 @@ def test_decode_enterprise_webapp_user_id_valid(monkeypatch: pytest.MonkeyPatch)
def test_exchange_token_public_flow(monkeypatch: pytest.MonkeyPatch) -> None:
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True)
call_state = {"calls": 0}
def _scalar_side_effect(*_args, **_kwargs):
if not hasattr(_scalar_side_effect, "calls"):
_scalar_side_effect.calls = 0
_scalar_side_effect.calls += 1
return site if _scalar_side_effect.calls == 1 else app_model
call_state["calls"] += 1
return site if call_state["calls"] == 1 else app_model
db_session = SimpleNamespace(scalar=_scalar_side_effect)
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
@@ -53,12 +52,11 @@ def test_exchange_token_public_flow(monkeypatch: pytest.MonkeyPatch) -> None:
def test_exchange_token_requires_external(monkeypatch: pytest.MonkeyPatch) -> None:
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True)
call_state = {"calls": 0}
def _scalar_side_effect(*_args, **_kwargs):
if not hasattr(_scalar_side_effect, "calls"):
_scalar_side_effect.calls = 0
_scalar_side_effect.calls += 1
return site if _scalar_side_effect.calls == 1 else app_model
call_state["calls"] += 1
return site if call_state["calls"] == 1 else app_model
db_session = SimpleNamespace(scalar=_scalar_side_effect)
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
@@ -71,14 +69,13 @@ def test_exchange_token_requires_external(monkeypatch: pytest.MonkeyPatch) -> No
def test_exchange_token_missing_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True, tenant_id="t1")
call_state = {"calls": 0}
def _scalar_side_effect(*_args, **_kwargs):
if not hasattr(_scalar_side_effect, "calls"):
_scalar_side_effect.calls = 0
_scalar_side_effect.calls += 1
if _scalar_side_effect.calls == 1:
call_state["calls"] += 1
if call_state["calls"] == 1:
return site
if _scalar_side_effect.calls == 2:
if call_state["calls"] == 2:
return app_model
return None
@@ -95,7 +95,7 @@ class TestEmailCodeLoginApi:
):
response = EmailCodeLoginApi().post()
assert response.get_json() == {"result": "success", "data": {"access_token": "new-access-token"}}
assert response == {"result": "success", "data": {"access_token": "new-access-token"}}
mock_get_user.assert_called_once_with("User@Example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_login.assert_called_once()
@@ -115,7 +115,7 @@ class TestLoginApi:
):
response = LoginApi().post()
assert response.get_json()["data"]["access_token"] == "access-tok"
assert response["data"]["access_token"] == "access-tok"
mock_auth.assert_called_once()
@patch(
@@ -33,6 +33,7 @@ class TestDecodeEnterpriseWebappUserId:
"user_id": "u1",
}
result = decode_enterprise_webapp_user_id("valid-jwt")
assert result is not None
assert result["user_id"] == "u1"
@patch("controllers.web.passport.PassportService")
@@ -143,7 +144,7 @@ class TestPassportResource:
with app.test_request_context("/passport", headers={"X-App-Code": "code1"}):
response = PassportResource().get()
assert response.get_json()["access_token"] == "issued-token"
assert response["access_token"] == "issued-token"
mock_db.session.add.assert_called_once()
mock_db.session.commit.assert_called_once()
@@ -167,7 +168,7 @@ class TestPassportResource:
with app.test_request_context("/passport?user_id=sess-existing", headers={"X-App-Code": "code1"}):
response = PassportResource().get()
assert response.get_json()["access_token"] == "reused-token"
assert response["access_token"] == "reused-token"
# Should not create a new end user
mock_db.session.add.assert_not_called()
@@ -5,7 +5,6 @@ from uuid import uuid4
import pytest
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.apps.base_app_runner import AppRunner
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueMessageFileEvent
@@ -81,59 +80,55 @@ class TestBaseAppRunnerMultimodal:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory) as mock_sm:
with patch("core.app.apps.base_app_runner.db") as mock_db:
# Act
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert
mock_mgr.create_file_by_url.assert_called_once_with(
user_id=mock_user_id,
tenant_id=mock_tenant_id,
file_url=image_url,
conversation_id=None,
)
# Assert
# Verify tool file was created from URL
mock_mgr.create_file_by_url.assert_called_once_with(
user_id=mock_user_id,
tenant_id=mock_tenant_id,
file_url=image_url,
conversation_id=None,
)
mock_msg_file_class.assert_called_once()
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["message_id"] == mock_message_id
assert call_kwargs["type"] == FileType.IMAGE
assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE
assert call_kwargs["belongs_to"] == "assistant"
assert call_kwargs["created_by"] == mock_user_id
# Verify message file was created with correct parameters
mock_msg_file_class.assert_called_once()
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["message_id"] == mock_message_id
assert call_kwargs["type"] == FileType.IMAGE
assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE
assert call_kwargs["belongs_to"] == "assistant"
assert call_kwargs["created_by"] == mock_user_id
# Verify independent session was used (not db.session)
mock_sm.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
file_session.add.assert_called_once_with(mock_message_file)
mock_db.session.commit.assert_not_called()
mock_db.session.close.assert_not_called()
# Verify database operations
mock_session.add.assert_called_once_with(mock_message_file)
mock_session.commit.assert_called_once()
mock_session.refresh.assert_called_once_with(mock_message_file)
# Verify event was published
mock_queue_manager.publish.assert_called_once()
publish_call = mock_queue_manager.publish.call_args
assert isinstance(publish_call[0][0], QueueMessageFileEvent)
assert publish_call[0][0].message_file_id == mock_message_file.id
# publish_from might be passed as positional or keyword argument
assert (
publish_call[0][1] == PublishFrom.APPLICATION_MANAGER
or publish_call.kwargs.get("publish_from") == PublishFrom.APPLICATION_MANAGER
)
# Verify event was published
mock_queue_manager.publish.assert_called_once()
publish_call = mock_queue_manager.publish.call_args
assert isinstance(publish_call[0][0], QueueMessageFileEvent)
assert publish_call[0][0].message_file_id == mock_message_file.id
def test_handle_multimodal_image_content_with_base64(
self,
@@ -165,50 +160,44 @@ class TestBaseAppRunnerMultimodal:
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db") as mock_db:
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert
# Verify tool file was created from base64
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
assert call_kwargs["user_id"] == mock_user_id
assert call_kwargs["tenant_id"] == mock_tenant_id
assert call_kwargs["conversation_id"] is None
assert "file_binary" in call_kwargs
assert call_kwargs["mimetype"] == "image/png"
assert call_kwargs["filename"].startswith("generated_image")
assert call_kwargs["filename"].endswith(".png")
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
assert call_kwargs["user_id"] == mock_user_id
assert call_kwargs["tenant_id"] == mock_tenant_id
assert call_kwargs["conversation_id"] is None
assert "file_binary" in call_kwargs
assert call_kwargs["mimetype"] == "image/png"
assert call_kwargs["filename"].startswith("generated_image")
assert call_kwargs["filename"].endswith(".png")
# Verify message file was created
mock_msg_file_class.assert_called_once()
mock_msg_file_class.assert_called_once()
file_session.add.assert_called_once()
mock_db.session.commit.assert_not_called()
# Verify database operations
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
mock_session.refresh.assert_called_once()
# Verify event was published
mock_queue_manager.publish.assert_called_once()
mock_queue_manager.publish.assert_called_once()
def test_handle_multimodal_image_content_with_base64_data_uri(
self,
@@ -238,33 +227,32 @@ class TestBaseAppRunnerMultimodal:
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert - verify that base64 data was extracted correctly (without prefix)
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
# The base64 data should be decoded, so we check the binary was passed
assert "file_binary" in call_kwargs
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
assert "file_binary" in call_kwargs
def test_handle_multimodal_image_content_without_url_or_base64(
self,
@@ -284,9 +272,7 @@ class TestBaseAppRunnerMultimodal:
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
# Act
# Create a mock runner with the method bound
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
@@ -299,10 +285,8 @@ class TestBaseAppRunnerMultimodal:
queue_manager=mock_queue_manager,
)
# Assert - should not create any files or publish events
mock_mgr_class.assert_not_called()
mock_msg_file_class.assert_not_called()
mock_session.add.assert_not_called()
mock_queue_manager.publish.assert_not_called()
def test_handle_multimodal_image_content_with_error(
@@ -322,20 +306,16 @@ class TestBaseAppRunnerMultimodal:
)
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
# Setup mock to raise exception
mock_mgr = MagicMock()
mock_mgr.create_file_by_url.side_effect = Exception("Network error")
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
# Act
# Create a mock runner with the method bound
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
# Should not raise exception, just log it
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
@@ -344,9 +324,7 @@ class TestBaseAppRunnerMultimodal:
queue_manager=mock_queue_manager,
)
# Assert - should not create message file or publish event on error
mock_msg_file_class.assert_not_called()
mock_session.add.assert_not_called()
mock_queue_manager.publish.assert_not_called()
def test_handle_multimodal_image_content_debugger_mode(
@@ -369,37 +347,36 @@ class TestBaseAppRunnerMultimodal:
mock_queue_manager.invoke_from = InvokeFrom.DEBUGGER
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
# Setup mock tool file manager
mock_mgr = MagicMock()
mock_mgr.create_file_by_url.return_value = mock_tool_file
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert - verify created_by_role is ACCOUNT for debugger mode
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT
def test_handle_multimodal_image_content_service_api_mode(
self,
@@ -421,34 +398,33 @@ class TestBaseAppRunnerMultimodal:
mock_queue_manager.invoke_from = InvokeFrom.SERVICE_API
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
# Setup mock tool file manager
mock_mgr = MagicMock()
mock_mgr.create_file_by_url.return_value = mock_tool_file
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert - verify created_by_role is END_USER for service API
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER
@@ -14,6 +14,9 @@ def mock_queue_manager(mocker: MockerFixture):
@pytest.fixture
def handler(mock_queue_manager, mocker: MockerFixture):
mocker.patch(
"core.callback_handler.index_tool_callback_handler.db",
)
return DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
app_id="app-1",
@@ -33,8 +36,18 @@ class TestOnQuery:
],
)
def test_on_query_success_roles(self, mocker: MockerFixture, mock_queue_manager, invoke_from, expected_role):
# Arrange
mock_session = mocker.Mock()
# Arrange — the caller passes a session, but our fix uses an independent one
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mock_session_factory = mocker.MagicMock()
mock_session_factory.begin.return_value.__enter__ = mocker.MagicMock(return_value=independent_session)
mock_session_factory.begin.return_value.__exit__ = mocker.MagicMock(return_value=False)
mocker.patch(
"core.callback_handler.index_tool_callback_handler.sessionmaker",
return_value=mock_session_factory,
)
mocker.patch("core.callback_handler.index_tool_callback_handler.db")
handler = DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
@@ -46,17 +59,28 @@ class TestOnQuery:
handler._invoke_from = invoke_from
# Act
handler.on_query("test query", "dataset-1", mock_session)
# Act — pass caller_session as required by signature
handler.on_query("test query", "dataset-1", caller_session)
# Assert
mock_session.add.assert_called_once()
dataset_query = mock_session.add.call_args.args[0]
# Assert — independent session used, not the caller's session
independent_session.add.assert_called_once()
dataset_query = independent_session.add.call_args.args[0]
assert dataset_query.created_by_role == expected_role
mock_session.commit.assert_called_once()
caller_session.add.assert_not_called()
caller_session.commit.assert_not_called()
def test_on_query_none_values(self, mocker: MockerFixture, mock_queue_manager):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mock_session_factory = mocker.MagicMock()
mock_session_factory.begin.return_value.__enter__ = mocker.MagicMock(return_value=independent_session)
mock_session_factory.begin.return_value.__exit__ = mocker.MagicMock(return_value=False)
mocker.patch(
"core.callback_handler.index_tool_callback_handler.sessionmaker",
return_value=mock_session_factory,
)
mocker.patch("core.callback_handler.index_tool_callback_handler.db")
handler = DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
@@ -66,40 +90,67 @@ class TestOnQuery:
invoke_from=None,
)
handler.on_query(None, None, mock_session)
handler.on_query(None, None, caller_session)
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
independent_session.add.assert_called_once()
caller_session.add.assert_not_called()
class TestOnToolEnd:
def test_on_tool_end_no_metadata(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
document = mocker.Mock()
document.metadata = None
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.commit.assert_not_called()
independent_session.commit.assert_called_once()
independent_session.execute.assert_not_called()
caller_session.commit.assert_not_called()
def test_on_tool_end_dataset_document_not_found(
self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture
):
mock_session = mocker.Mock()
mock_session.scalar.return_value = None
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
independent_session.scalar.return_value = None
document = mocker.Mock()
document.metadata = {"document_id": "doc-1", "doc_id": "node-1"}
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.scalar.assert_called_once()
independent_session.scalar.assert_called_once()
caller_session.scalar.assert_not_called()
def test_on_tool_end_parent_child_index_with_child(
self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture
):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
mock_dataset_doc = mocker.Mock()
from core.callback_handler.index_tool_callback_handler import IndexStructureType
@@ -111,23 +162,32 @@ class TestOnToolEnd:
mock_child_chunk = mocker.Mock()
mock_child_chunk.segment_id = "segment-1"
mock_session.scalar.side_effect = [mock_dataset_doc, mock_child_chunk]
independent_session.scalar.side_effect = [mock_dataset_doc, mock_child_chunk]
document = mocker.Mock()
document.metadata = {"document_id": "doc-1", "doc_id": "node-1"}
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.execute.assert_called_once()
mock_session.commit.assert_called_once()
independent_session.execute.assert_called_once()
independent_session.commit.assert_called_once()
caller_session.execute.assert_not_called()
def test_on_tool_end_non_parent_child_index(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
mock_dataset_doc = mocker.Mock()
mock_dataset_doc.doc_form = "OTHER"
mock_session.scalar.return_value = mock_dataset_doc
independent_session.scalar.return_value = mock_dataset_doc
document = mocker.Mock()
document.metadata = {
@@ -136,14 +196,24 @@ class TestOnToolEnd:
"dataset_id": "dataset-1",
}
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.execute.assert_called_once()
mock_session.commit.assert_called_once()
independent_session.execute.assert_called_once()
independent_session.commit.assert_called_once()
caller_session.execute.assert_not_called()
def test_on_tool_end_empty_documents(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture):
mock_session = mocker.Mock()
handler.on_tool_end([], mock_session)
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
handler.on_tool_end([], caller_session)
class TestReturnRetrieverResourceInfo:
@@ -8,7 +8,7 @@ from core.datasource.entities.datasource_entities import (
DatasourceProviderEntityWithPlugin,
DatasourceProviderType,
)
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.tools.errors import ToolProviderCredentialValidationError
@@ -149,7 +149,7 @@ class TestDatasourcePluginProviderController:
mock_config = MagicMock(spec=ProviderConfig)
mock_config.name = "text_field"
mock_config.required = True
mock_config.type = ProviderConfig.Type.TEXT_INPUT
mock_config.type = ProviderConfigType.TEXT_INPUT
mock_entity = MagicMock(spec=DatasourceProviderEntityWithPlugin)
mock_entity.credentials_schema = [mock_config]
@@ -167,7 +167,7 @@ class TestDatasourcePluginProviderController:
mock_config = MagicMock(spec=ProviderConfig)
mock_config.name = "select_field"
mock_config.required = True
mock_config.type = ProviderConfig.Type.SELECT
mock_config.type = ProviderConfigType.SELECT
mock_config.options = [mock_option]
mock_entity = MagicMock(spec=DatasourceProviderEntityWithPlugin)
@@ -206,7 +206,7 @@ class TestDatasourcePluginProviderController:
mock_config = MagicMock(spec=ProviderConfig)
mock_config.name = "valid_field"
mock_config.required = True
mock_config.type = ProviderConfig.Type.TEXT_INPUT
mock_config.type = ProviderConfigType.TEXT_INPUT
mock_entity = MagicMock(spec=DatasourceProviderEntityWithPlugin)
mock_entity.credentials_schema = [mock_config]
@@ -243,7 +243,7 @@ class TestDatasourcePluginProviderController:
mock_config_text = MagicMock(spec=ProviderConfig)
mock_config_text.name = "text_def"
mock_config_text.required = False
mock_config_text.type = ProviderConfig.Type.TEXT_INPUT
mock_config_text.type = ProviderConfigType.TEXT_INPUT
mock_config_text.default = 123 # Int default, should be converted to str
mock_config_other = MagicMock(spec=ProviderConfig)
@@ -5,6 +5,7 @@ from core.entities.provider_entities import (
BasicProviderConfig,
ModelSettings,
ProviderConfig,
ProviderConfigType,
ProviderQuotaType,
)
from core.tools.entities.common_entities import I18nObject
@@ -27,22 +28,22 @@ def test_provider_quota_type_value_of_rejects_unknown_values() -> None:
def test_basic_provider_config_type_value_of_handles_known_values() -> None:
# Arrange / Act
parameter_type = BasicProviderConfig.Type.value_of("text-input")
parameter_type = ProviderConfigType.value_of("text-input")
# Assert
assert parameter_type == BasicProviderConfig.Type.TEXT_INPUT
assert parameter_type == ProviderConfigType.TEXT_INPUT
def test_basic_provider_config_type_value_of_rejects_invalid_values() -> None:
# Arrange / Act / Assert
with pytest.raises(ValueError, match="invalid mode value"):
BasicProviderConfig.Type.value_of("unknown")
ProviderConfigType.value_of("unknown")
def test_provider_config_to_basic_provider_config_keeps_type_and_name() -> None:
# Arrange
provider_config = ProviderConfig(
type=BasicProviderConfig.Type.SELECT,
type=ProviderConfigType.SELECT,
name="workspace",
scope=AppSelectorScope.ALL,
options=[ProviderConfig.Option(value="all", label=I18nObject(en_US="All"))],
@@ -53,7 +54,7 @@ def test_provider_config_to_basic_provider_config_keeps_type_and_name() -> None:
# Assert
assert isinstance(basic_config, BasicProviderConfig)
assert basic_config.type == BasicProviderConfig.Type.SELECT
assert basic_config.type == ProviderConfigType.SELECT
assert basic_config.name == "workspace"
@@ -17,7 +17,7 @@ import pytest
from packaging.version import Version
from requests import HTTPError
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.bundle import PluginBundleDependency, PluginBundleDependencyType
from core.plugin.entities.plugin import (
MissingPluginDependency,
PluginCategory,
@@ -581,11 +581,11 @@ class TestDependencyResolution:
bundle_data = b"mock-bundle-data"
mock_dependencies = [
PluginBundleDependency(
type=PluginBundleDependency.Type.Marketplace,
type=PluginBundleDependencyType.Marketplace,
value=PluginBundleDependency.Marketplace(organization="org1", plugin="plugin1", version="1.0.0"),
),
PluginBundleDependency(
type=PluginBundleDependency.Type.Github,
type=PluginBundleDependencyType.Github,
value=PluginBundleDependency.Github(
repo_address="https://github.com/org/repo",
repo="org/repo",
@@ -603,8 +603,8 @@ class TestDependencyResolution:
# Assert: Verify dependencies were extracted
assert len(result) == 2
assert result[0].type == PluginBundleDependency.Type.Marketplace
assert result[1].type == PluginBundleDependency.Type.Github
assert result[0].type == PluginBundleDependencyType.Marketplace
assert result[1].type == PluginBundleDependencyType.Github
mock_request.assert_called_once()
def test_fetch_missing_dependencies(self, plugin_installer):
@@ -1129,7 +1129,7 @@ class TestPluginBundleOperations:
bundle_data = b"mock-marketplace-bundle"
mock_dependencies = [
PluginBundleDependency(
type=PluginBundleDependency.Type.Marketplace,
type=PluginBundleDependencyType.Marketplace,
value=PluginBundleDependency.Marketplace(
organization="langgenius", plugin="search-tool", version="1.2.0"
),
@@ -1142,7 +1142,7 @@ class TestPluginBundleOperations:
# Assert: Verify marketplace dependency was extracted
assert len(result) == 1
assert result[0].type == PluginBundleDependency.Type.Marketplace
assert result[0].type == PluginBundleDependencyType.Marketplace
assert isinstance(result[0].value, PluginBundleDependency.Marketplace)
assert result[0].value.organization == "langgenius"
assert result[0].value.plugin == "search-tool"
@@ -1158,7 +1158,7 @@ class TestPluginBundleOperations:
bundle_data = b"mock-github-bundle"
mock_dependencies = [
PluginBundleDependency(
type=PluginBundleDependency.Type.Github,
type=PluginBundleDependencyType.Github,
value=PluginBundleDependency.Github(
repo_address="https://github.com/example/plugin",
repo="example/plugin",
@@ -1174,7 +1174,7 @@ class TestPluginBundleOperations:
# Assert: Verify GitHub dependency was extracted
assert len(result) == 1
assert result[0].type == PluginBundleDependency.Type.Github
assert result[0].type == PluginBundleDependencyType.Github
assert isinstance(result[0].value, PluginBundleDependency.Github)
assert result[0].value.repo == "example/plugin"
assert result[0].value.release == "v2.0.0"
@@ -1204,7 +1204,7 @@ class TestPluginBundleOperations:
mock_dependencies = [
PluginBundleDependency(
type=PluginBundleDependency.Type.Package,
type=PluginBundleDependencyType.Package,
value=PluginBundleDependency.Package(
unique_identifier="org/bundled-plugin/1.5.0", manifest=mock_manifest
),
@@ -1217,7 +1217,7 @@ class TestPluginBundleOperations:
# Assert: Verify package dependency was extracted with manifest
assert len(result) == 1
assert result[0].type == PluginBundleDependency.Type.Package
assert result[0].type == PluginBundleDependencyType.Package
assert isinstance(result[0].value, PluginBundleDependency.Package)
assert result[0].value.unique_identifier == "org/bundled-plugin/1.5.0"
assert result[0].value.manifest.name == "bundled-plugin"
@@ -1233,11 +1233,11 @@ class TestPluginBundleOperations:
bundle_data = b"mock-mixed-bundle"
mock_dependencies = [
PluginBundleDependency(
type=PluginBundleDependency.Type.Marketplace,
type=PluginBundleDependencyType.Marketplace,
value=PluginBundleDependency.Marketplace(organization="org1", plugin="plugin1", version="1.0.0"),
),
PluginBundleDependency(
type=PluginBundleDependency.Type.Github,
type=PluginBundleDependencyType.Github,
value=PluginBundleDependency.Github(
repo_address="https://github.com/org2/plugin2",
repo="org2/plugin2",
@@ -1253,8 +1253,8 @@ class TestPluginBundleOperations:
# Assert: Verify all dependency types were extracted
assert len(result) == 2
assert result[0].type == PluginBundleDependency.Type.Marketplace
assert result[1].type == PluginBundleDependency.Type.Github
assert result[0].type == PluginBundleDependencyType.Marketplace
assert result[1].type == PluginBundleDependencyType.Github
class TestPluginTaskStatusTransitions:
@@ -5,7 +5,7 @@ from typing import Any, override
import pytest
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.tools.__base.tool import Tool
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.__base.tool_runtime import ToolRuntime
@@ -66,7 +66,7 @@ def _provider_identity() -> ToolProviderIdentity:
def test_tool_provider_controller_get_credentials_schema_returns_deep_copy():
entity = ToolProviderEntity(
identity=_provider_identity(),
credentials_schema=[ProviderConfig(type=ProviderConfig.Type.TEXT_INPUT, name="api_key", required=False)],
credentials_schema=[ProviderConfig(type=ProviderConfigType.TEXT_INPUT, name="api_key", required=False)],
)
controller = _DummyController(entity=entity)
@@ -88,10 +88,10 @@ def test_validate_credentials_format_covers_required_default_and_type_rules():
entity = ToolProviderEntity(
identity=_provider_identity(),
credentials_schema=[
ProviderConfig(type=ProviderConfig.Type.TEXT_INPUT, name="required_text", required=True),
ProviderConfig(type=ProviderConfig.Type.SECRET_INPUT, name="secret", required=False),
ProviderConfig(type=ProviderConfig.Type.SELECT, name="choice", required=False, options=select_options),
ProviderConfig(type=ProviderConfig.Type.TEXT_INPUT, name="with_default", required=False, default="x"),
ProviderConfig(type=ProviderConfigType.TEXT_INPUT, name="required_text", required=True),
ProviderConfig(type=ProviderConfigType.SECRET_INPUT, name="secret", required=False),
ProviderConfig(type=ProviderConfigType.SELECT, name="choice", required=False, options=select_options),
ProviderConfig(type=ProviderConfigType.TEXT_INPUT, name="with_default", required=False, default="x"),
],
)
controller = _DummyController(entity=entity)
@@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
import pytest
from core.entities.provider_entities import BasicProviderConfig
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.helper.provider_encryption import ProviderConfigEncrypter
from core.tools.utils.encryption import create_tool_provider_encrypter
@@ -31,7 +31,7 @@ def secret_field() -> BasicProviderConfig:
"""A SECRET_INPUT field named 'password'."""
return BasicProviderConfig(
name="password",
type=BasicProviderConfig.Type.SECRET_INPUT,
type=ProviderConfigType.SECRET_INPUT,
)
@@ -40,7 +40,7 @@ def normal_field() -> BasicProviderConfig:
"""A TEXT_INPUT field named 'username'."""
return BasicProviderConfig(
name="username",
type=BasicProviderConfig.Type.TEXT_INPUT,
type=ProviderConfigType.TEXT_INPUT,
)
@@ -185,7 +185,7 @@ def test_decrypt_swallow_exception_and_keep_original(encrypter_obj):
def test_create_tool_provider_encrypter_builds_cache_and_encrypter():
basic_config = BasicProviderConfig(name="key", type=BasicProviderConfig.Type.TEXT_INPUT)
basic_config = BasicProviderConfig(name="key", type=ProviderConfigType.TEXT_INPUT)
credential_schema_item = SimpleNamespace(to_basic_provider_config=lambda: basic_config)
controller = SimpleNamespace(
provider_type=SimpleNamespace(value="builtin"),
@@ -1,9 +1,8 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from flask_restx import marshal
from fields.snippet_fields import snippet_list_fields
from fields.snippet_fields import SnippetListItemResponse
from libs.helper import dump_response
def test_snippet_list_fields_include_author_name() -> None:
@@ -24,6 +23,6 @@ def test_snippet_list_fields_include_author_name() -> None:
updated_at=datetime.fromtimestamp(1704067201, tz=UTC),
)
result = marshal(snippet, snippet_list_fields)
result = dump_response(SnippetListItemResponse, snippet)
assert result["author_name"] == "Alice"
@@ -11,7 +11,7 @@ from unittest.mock import MagicMock, patch
import pytest
from core.plugin.entities.plugin import PluginDependency, PluginInstallationSource
from core.plugin.entities.plugin import PluginDependency, PluginDependencyType, PluginInstallationSource
from services.plugin.dependencies_analysis import DependenciesAnalysisService
@@ -44,7 +44,7 @@ class TestAnalyzeModelProviderDependency:
class TestGetLeakedDependencies:
def _make_dependency(self, identifier: str, dep_type=PluginDependency.Type.Marketplace):
def _make_dependency(self, identifier: str, dep_type=PluginDependencyType.Marketplace):
return PluginDependency(
type=dep_type,
value=PluginDependency.Marketplace(marketplace_plugin_unique_identifier=identifier),
@@ -110,7 +110,7 @@ class TestGenerateDependencies:
result = DependenciesAnalysisService.generate_dependencies("t1", ["p1"])
assert len(result) == 1
assert result[0].type == PluginDependency.Type.Github
assert result[0].type == PluginDependencyType.Github
assert result[0].value.repo == "org/repo"
@patch("services.plugin.dependencies_analysis.PluginInstaller")
@@ -120,7 +120,7 @@ class TestGenerateDependencies:
result = DependenciesAnalysisService.generate_dependencies("t1", ["p1"])
assert result[0].type == PluginDependency.Type.Marketplace
assert result[0].type == PluginDependencyType.Marketplace
@patch("services.plugin.dependencies_analysis.PluginInstaller")
def test_package_source(self, mock_installer_cls):
@@ -129,7 +129,7 @@ class TestGenerateDependencies:
result = DependenciesAnalysisService.generate_dependencies("t1", ["p1"])
assert result[0].type == PluginDependency.Type.Package
assert result[0].type == PluginDependencyType.Package
@patch("services.plugin.dependencies_analysis.PluginInstaller")
def test_remote_source_raises(self, mock_installer_cls):
@@ -169,4 +169,4 @@ class TestGenerateLatestDependencies:
result = DependenciesAnalysisService.generate_latest_dependencies(["p1"])
assert len(result) == 1
assert result[0].type == PluginDependency.Type.Marketplace
assert result[0].type == PluginDependencyType.Marketplace
@@ -4,10 +4,14 @@ from unittest.mock import MagicMock, patch
import pytest
from models.account import TenantPluginAutoUpgradeStrategy
from models.account import (
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategySetting,
)
MODULE = "services.plugin.plugin_auto_upgrade_service"
PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
PLUGIN_CATEGORY = TenantPluginAutoUpgradeCategory.TOOL
def _patched_session():
@@ -57,9 +61,9 @@ class TestChangeStrategy:
result = PluginAutoUpgradeService.change_strategy(
"t1",
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
3,
TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
TenantPluginAutoUpgradeMode.ALL,
[],
[],
category=PLUGIN_CATEGORY,
@@ -78,18 +82,18 @@ class TestChangeStrategy:
result = PluginAutoUpgradeService.change_strategy(
"t1",
TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST,
TenantPluginAutoUpgradeStrategySetting.LATEST,
5,
TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
TenantPluginAutoUpgradeMode.PARTIAL,
["p1"],
["p2"],
category=PLUGIN_CATEGORY,
)
assert result is True
assert existing.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert existing.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
assert existing.upgrade_time_of_day == 5
assert existing.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL
assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.PARTIAL
assert existing.exclude_plugins == ["p1"]
assert existing.include_plugins == ["p2"]
@@ -102,10 +106,8 @@ class TestExcludePlugin:
with (
p1,
patch(f"{MODULE}.select"),
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls,
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy"),
):
strat_cls.StrategySetting.FIX_ONLY = "fix_only"
strat_cls.UpgradeMode.EXCLUDE = "exclude"
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin(
@@ -120,14 +122,11 @@ class TestExcludePlugin:
def test_appends_to_exclude_list_in_exclude_mode(self):
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "exclude"
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
existing.exclude_plugins = ["p-existing"]
session.scalar.return_value = existing
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
with p1, patch(f"{MODULE}.select"):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY)
@@ -138,14 +137,11 @@ class TestExcludePlugin:
def test_removes_from_include_list_in_partial_mode(self):
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "partial"
existing.upgrade_mode = TenantPluginAutoUpgradeMode.PARTIAL
existing.include_plugins = ["p1", "p2"]
session.scalar.return_value = existing
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
with p1, patch(f"{MODULE}.select"):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
@@ -156,32 +152,26 @@ class TestExcludePlugin:
def test_switches_to_exclude_mode_from_all(self):
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "all"
existing.upgrade_mode = TenantPluginAutoUpgradeMode.ALL
session.scalar.return_value = existing
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
with p1, patch(f"{MODULE}.select"):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
assert result is True
assert existing.upgrade_mode == "exclude"
assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE
assert existing.exclude_plugins == ["p1"]
def test_no_duplicate_in_exclude_list(self):
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "exclude"
existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE
existing.exclude_plugins = ["p1"]
session.scalar.return_value = existing
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
with p1, patch(f"{MODULE}.select"):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
@@ -193,10 +183,10 @@ class TestBackfillStrategyCategories:
def test_creates_default_missing_categories_without_fetching_daemon(self):
p1, session = _patched_session()
tool_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
category=TenantPluginAutoUpgradeCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
@@ -209,17 +199,15 @@ class TestBackfillStrategyCategories:
result = PluginAutoUpgradeService.backfill_strategy_categories("t1")
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 1
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 1
assert result.normalized is False
installer.list_plugins.assert_not_called()
assert tool_strategy.upgrade_time_of_day == expected_time
created_strategies = [call.args[0] for call in session.add.call_args_list]
model_strategy = next(
strategy
for strategy in created_strategies
if strategy.category == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL
strategy for strategy in created_strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL
)
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST
assert model_strategy.upgrade_time_of_day == expected_time
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self):
@@ -233,18 +221,18 @@ class TestBackfillStrategyCategories:
def test_creates_missing_categories_and_splits_known_plugins(self, caplog: pytest.LogCaptureFixture):
p1, session = _patched_session()
tool_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
category=TenantPluginAutoUpgradeCategory.TOOL,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
include_plugins=["model-plugin", "tool-plugin"],
)
model_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
category=TenantPluginAutoUpgradeCategory.MODEL,
strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
include_plugins=["model-plugin", "tool-plugin"],
)
@@ -253,11 +241,11 @@ class TestBackfillStrategyCategories:
installed_plugins = [
SimpleNamespace(
plugin_id="tool-plugin",
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL),
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeCategory.TOOL),
),
SimpleNamespace(
plugin_id="model-plugin",
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL),
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeCategory.MODEL),
),
]
installer = MagicMock()
@@ -272,9 +260,9 @@ class TestBackfillStrategyCategories:
result = PluginAutoUpgradeService.backfill_strategy_categories("t1")
assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2
assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 2
assert result.normalized is True
assert session.add.call_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2
assert session.add.call_count == len(TenantPluginAutoUpgradeCategory) - 2
assert tool_strategy.exclude_plugins == ["tool-plugin"]
assert tool_strategy.include_plugins == ["tool-plugin"]
assert model_strategy.exclude_plugins == ["model-plugin"]
@@ -87,11 +87,11 @@ def test_check_dependencies_returns_empty_when_no_redis_data(mocker: MockerFixtu
def test_check_dependencies_returns_leaked_deps_from_redis(mocker: MockerFixture) -> None:
from core.plugin.entities.plugin import PluginDependency
from core.plugin.entities.plugin import PluginDependency, PluginDependencyType
from services.rag_pipeline.rag_pipeline_dsl_service import CheckDependenciesPendingData
dep = PluginDependency(
type=PluginDependency.Type.Marketplace,
type=PluginDependencyType.Marketplace,
value=PluginDependency.Marketplace(marketplace_plugin_unique_identifier="test/plugin:0.1.0"),
)
pending_data = CheckDependenciesPendingData(
@@ -1371,7 +1371,7 @@ def test_confirm_import_fails_when_no_knowledge_index_node(mocker: MockerFixture
def test_create_or_update_pipeline_saves_dependencies_to_redis(mocker: MockerFixture) -> None:
from core.plugin.entities.plugin import PluginDependency
from core.plugin.entities.plugin import PluginDependency, PluginDependencyType
session = cast(MagicMock, Mock())
service = RagPipelineDslService(session=cast(Session, session))
@@ -1386,7 +1386,7 @@ def test_create_or_update_pipeline_saves_dependencies_to_redis(mocker: MockerFix
session.scalar.return_value = None
setex = mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.setex")
dependency = PluginDependency(
type=PluginDependency.Type.Marketplace,
type=PluginDependencyType.Marketplace,
value=PluginDependency.Marketplace(marketplace_plugin_unique_identifier="langgenius/example:0.1.0"),
)

Some files were not shown because too many files have changed in this diff Show More