Compare commits

..
Author SHA1 Message Date
chariri 4ee3a2e6cb chore(api): restore summary index session callsites 2026-07-08 01:43:01 +09:00
chariri d5617b1a82 fix(api): shorten summary index session scopes 2026-07-07 18:35:41 +09:00
chariri 315dd3f82a refactor(api): thread db sessions through services 2026-07-07 18:18:36 +09:00
FFXNandGitHub fb92e9a347 chore: improve cherry pick missed message (#38496) 2026-07-07 07:53:14 +00:00
wangxiaoleiandGitHub 6922c45489 chore: update editor permission (#38505) 2026-07-07 07:53:01 +00:00
非法操作andGitHub faaa4708a6 fix: editor should not manage member (#38503) 2026-07-07 07:39:24 +00:00
dd0c4a2296 fix: resolve 36288 mypy errors (#37850)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-07-07 06:37:20 +00:00
Asuka MinatoandGitHub f3ba28463b chore: add sqlite3 to conftest (#38475) 2026-07-07 05:04:44 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
800c9f4fca test(e2e): stabilize Agent v2 external runtime checks (#38493)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-07 04:48:12 +00:00
5a342f9258 feat(mcp): support MCP protocol 2025-06-18 for workflow-as-MCP server (version negotiation + structured output) (#37892)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-07 03:26:50 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
dbd3316615 chore: set NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW default to true (#38362)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 02:20:15 +00:00
WH-2099GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
0a3426ea38 refactor(api): clarify DSL import and plugin migration boundaries (#38483)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-07 02:06:49 +00:00
JingyiandGitHub b9c7199d34 fix(web): unify detail sidebar home control (#38487) 2026-07-07 01:44:14 +00:00
QuantumGhostGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
fc01d112a0 refactor(api): Stop masking refresh-token service errors as 401 (#38463)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-06 18:14:54 +00:00
yyhGitHubJoelzyssyz123autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>林玮盐粒 Yanli
d0ea5a5e0d chore(agent-v2): sync changes (#38442)
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com>
Co-authored-by: 盐粒 Yanli <mail@yanli.one>
2026-07-06 13:51:33 +00:00
yyhandGitHub bdb3469ca0 refactor(dify-ui): consolidate radio family API (#38479) 2026-07-06 12:27:49 +00:00
非法操作andGitHub c47663d77b chore: Update sidebar web app menu translations (#38473) 2026-07-06 10:09:36 +00:00
非法操作andGitHub 1247fa28f1 fix(web): redirect imported apps with creator permissions (#38460) 2026-07-06 09:11:56 +00:00
Ryuta KOBAYASHIGitHubWH-2099autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
64d72c6fb9 feat(api): pass app_id to model plugins for provider-side cost attribution (#35859)
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-06 08:54:16 +00:00
78ca1a8a76 fix(api): resolve plugin external user ids in backwards invocation (#38098)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harshkashyap@Harshs-MacBook-Pro.local>
2026-07-06 08:25:26 +00: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
chaririandGitHub 94c0967e30 refactor(api): migrate console explore endpoints to BaseModel (#37953) 2026-07-06 02:51:10 +00:00
Harsh KashyapGitHubHarsh Kashyapautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Harsh Kashyap
ff5c5f3ca6 fix(api/tasks): use f-string for raise messages in resume_workflow_ex… (#37607)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harshkashyap@Harshs-MacBook-Pro.local>
2026-07-06 02:36:14 +00:00
yyhandGitHub 95102479c6 test(e2e): split agent build draft apply coverage (#38431) 2026-07-06 01:03:01 +00:00
yyhandGitHub 6dff7c0a85 fix(dify-ui): preserve radio value generics (#38429) 2026-07-05 14:01:36 +00:00
yyhandGitHub e742e0ec17 fix(dify-ui): improve picker type inference (#38428) 2026-07-05 14:01:21 +00:00
yyhandGitHub acfef79a8f fix(web): prevent plugin cards from overlapping marketplace panel (#38427) 2026-07-05 14:01:07 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>yyh
abaf6b1337 ci: extract external E2E into dedicated post-merge workflow (#38426)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-07-05 14:00:53 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
750075c859 style: fix provider card dropdown menu seperator margin (#38422)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-05 14:00:40 +00:00
yyhGitHub盐粒 YanliJoelzyssyz123autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>盐粒 Yanli林玮
1b3e8e9943 chore(agent-v2): sync daily changes (#38298)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: 盐粒 Yanli <mail@yanli.one>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com>
2026-07-05 08:09:38 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>yyh
f8d47616c1 refactor(dify-ui): render ContextMenu story trigger as a semantic area (#38420)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-07-05 01:45:21 +00:00
f755830607 fix(models): correct onupdate typo on trigger.py updated_at columns (#38341)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-07-04 17:14:01 +00:00
yyhandGitHub 5d7ae56deb fix(dify-ui): standardize story focus indicators (#38417) 2026-07-04 14:59:24 +00:00
yyhandGitHub a0f8347513 test(dify-ui): remove low-value style assertions (#38418) 2026-07-04 14:35:00 +00:00
ChecoGitHubAsuka Minatoautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2e1ab194b7 refactor: replace db.paginate with plain SQLAlchemy pagination (#38280)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-04 04:50:01 +00:00
Byron.wangandGitHub 070aed81d9 refactor: make session boundaries explicit for migration flows (#38379) 2026-07-04 04:43:01 +00:00
Wu TianweiandGitHub 5b4ceacbe7 fix(chat): enhance pointer event handling in chat components (#38385) 2026-07-03 09:46:22 +00:00
林玮 (Jade Lin)GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
5e17af4530 feat(api): add sandbox info endpoint for Agent App conversations (#38390)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-03 09:43:57 +00:00
KVOJJJinandGitHub d3ed533716 fix(web): adjust snippets input count badge color (#38386) 2026-07-03 09:05:52 +00:00
非法操作andGitHub 91cc50371b chore: use zstd for plugin model provider cache (#38382) 2026-07-03 08:58:30 +00:00
81cc43b753 fix: incorrect backgroud when dark mode and no plugins (#38378)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-07-03 08:21:50 +00:00
Byron.wangandGitHub 5cb76f5eff refactor: manage rag pipeline sessions explicitly (#38274) 2026-07-03 08:15:37 +00:00
L1nSn0wandGitHub 262b0b1a89 fix(cli): report GitHub API rate limits in difyctl install scripts (#38375) 2026-07-03 08:12:58 +00:00
非法操作GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
059a1fe090 chore: compress large plugin_model obj in redis (#38374)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-03 07:32:07 +00:00
1114 changed files with 37977 additions and 23302 deletions
@@ -45,6 +45,7 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance.
- Inspect the target feature area.
- Reuse an existing step when wording and behavior already match.
- Add a new step only for a genuinely new user action or assertion.
- Before adding several similar steps, scan the target capability for an existing domain noun that can be parameterized without hiding behavior.
- Keep edits close to the current capability folder unless the step is broadly reusable.
2. Write behavior-first scenarios.
- Describe user-observable behavior, not DOM mechanics.
@@ -53,12 +54,16 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance.
3. Write step definitions in the local style.
- Keep one step to one user-visible action or one assertion.
- Prefer Cucumber Expressions such as `{string}` and `{int}`.
- Use a bounded regex only when the accepted values are a small explicit domain set and Cucumber Expressions would make the Gherkin less natural.
- Do not create one-off steps for each case variant when the same domain action or outcome applies to named surfaces, modes, or resources.
- Scope locators to stable containers when the page has repeated elements.
- Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them.
4. Use Playwright in the local style.
- Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts.
- Use web-first `expect(...)` assertions.
- Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior.
- Use `expect.poll` for API persistence, backend eventual consistency, captured browser events, or other non-DOM state; prefer locator assertions for DOM readiness and visible UI state.
- If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id.
5. Validate narrowly.
- Run the narrowest tagged scenario or flow that exercises the change.
- Run `vpr lint --fix --quiet` from the repository root and `pnpm -C e2e type-check`.
@@ -39,6 +39,7 @@ Prefer reuse when:
- the user action is genuinely the same
- the expected outcome is genuinely the same
- the wording stays natural across features
- the parameter is a real product domain value such as a named surface, mode, resource, or status
Write a new step when:
@@ -46,6 +47,8 @@ Write a new step when:
- reusing the old wording would make the scenario misleading
- a supposedly generic step would become an implementation-detail wrapper
Do not optimize for a low step count by making vague steps. Optimize for a small set of truthful, domain-owned steps.
### 4. Prefer Cucumber Expressions
Use Cucumber Expressions for parameters unless regex is clearly necessary.
@@ -59,6 +62,8 @@ Common examples:
Keep expressions readable. If a step needs complicated parsing logic, first ask whether the scenario wording should be simpler.
Use regex for a bounded natural-language alternative only when it keeps Gherkin readable, for example `/(Web app|Backend service API)/`. Avoid broad regexes that accept unowned language.
### 5. Keep step definitions thin and meaningful
Step definitions are glue between Gherkin and automation, not a second abstraction language.
@@ -41,6 +41,7 @@ Also remember:
- repeated content usually needs scoping to a stable container
- exact text matching is often too brittle when role/name or label already exists
- `getByTestId` is acceptable when semantics are weak but the contract is intentional
- when a real UI region, card, status, or icon lacks an accessible name, prefer adding that semantic contract in product code before falling back to `getByTestId`
### 3. Use web-first assertions
@@ -62,6 +63,8 @@ Avoid:
If a condition genuinely needs custom retry logic, use Playwright's polling/assertion tools deliberately and keep that choice local and explicit.
Use `expect.poll` for non-DOM truth such as API state, backend eventual consistency, generated resources, or captured browser events. For DOM state, use locator assertions so Playwright can apply actionability and web-first retry semantics.
### 4. Let actions wait for actionability
Locator actions already wait for the element to be actionable. Do not preface every click/fill with extra timing logic unless the action needs a specific visible/ready assertion for clarity.
@@ -40,9 +40,13 @@ Flag:
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
- Thin wrappers that rename Base UI parts without adding semantics.
- Generic Base UI selection primitives wrapped without preserving their value generics, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
- Shared select/radio option components that type selected values as `string` while callers pass enums, unions, booleans, numbers, objects, or nullable placeholder values.
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioRoot<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
## Forms
Flag:
@@ -69,6 +69,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Preserve domain value types for selection components. Do not widen enum, union, boolean, numeric, object, or nullable select/radio values to `string`; keep wrappers and option value carriers typed from their feature option collection.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
+1 -1
View File
@@ -45,7 +45,7 @@ while IFS= read -r commit_sha; do
)
if [[ -z "$source_sha" ]]; then
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT If version differences prevent using git cherry-pick -x, manually add '(cherry picked from commit <sha>)' to the commit message."
failed=1
continue
fi
+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
+3 -4
View File
@@ -6,8 +6,6 @@ on:
merge_group:
branches: ["main"]
types: [checks_requested]
push:
branches: ["main"]
permissions:
actions: write
@@ -48,8 +46,8 @@ 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: |
@@ -324,6 +322,7 @@ jobs:
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
uses: ./.github/workflows/web-e2e.yml
secrets: inherit
web-e2e-skip:
name: Skip Web Full-Stack E2E
+54
View File
@@ -0,0 +1,54 @@
name: Post-Merge Checks
on:
push:
branches: ["main"]
permissions:
contents: read
concurrency:
group: post-merge-${{ github.sha }}
cancel-in-progress: false
jobs:
check-changes:
name: Check Changed Files
runs-on: depot-ubuntu-24.04
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |
external_e2e:
- 'e2e/features/agent-v2/**'
- 'e2e/scripts/**'
- 'e2e/support/**'
- '.github/workflows/post-merge.yml'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'
- 'dify-agent/**'
- 'api/clients/agent_backend/**'
- 'api/core/app/apps/agent_app/**'
- 'api/core/workflow/nodes/agent_v2/**'
- 'api/controllers/console/agent/**'
- 'api/services/agent/**'
- 'api/core/plugin/**'
- 'api/services/plugin/**'
- 'api/core/tools/**'
- 'api/services/tools/**'
- 'web/features/agent-v2/**'
- 'web/app/(commonLayout)/roster/**'
- 'web/app/components/workflow/nodes/agent-v2/**'
external-e2e:
name: External Runtime E2E
needs: check-changes
if: needs.check-changes.outputs.external-e2e-changed == 'true'
uses: ./.github/workflows/web-e2e.yml
with:
run-external-runtime: true
secrets: inherit
+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
+8 -8
View File
@@ -19,7 +19,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
@@ -36,7 +36,7 @@ jobs:
- 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"
@@ -88,7 +88,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 +139,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 +168,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 +185,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 +196,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 +214,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 }}
+51 -4
View File
@@ -2,6 +2,11 @@ name: Web Full-Stack E2E
on:
workflow_call:
inputs:
run-external-runtime:
required: false
type: boolean
default: false
permissions:
contents: read
@@ -20,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
@@ -28,11 +33,13 @@ 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"
cache-dependency-glob: api/uv.lock
cache-dependency-glob: |
api/uv.lock
dify-agent/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
@@ -51,12 +58,52 @@ jobs:
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e:full
- name: Run external runtime E2E tests
if: ${{ inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_AGENT_DECISION_MODEL_NAME: ${{ vars.E2E_AGENT_DECISION_MODEL_NAME || 'gpt-5.5' }}
E2E_AGENT_DECISION_MODEL_PROVIDER: ${{ vars.E2E_AGENT_DECISION_MODEL_PROVIDER || 'openai' }}
E2E_AGENT_DECISION_MODEL_TYPE: ${{ vars.E2E_AGENT_DECISION_MODEL_TYPE || 'llm' }}
E2E_EXTERNAL_RUNTIME_SEED_SPECS: ${{ vars.E2E_EXTERNAL_RUNTIME_SEED_SPECS }}
E2E_EXTERNAL_RUNTIME_TAGS: ${{ vars.E2E_EXTERNAL_RUNTIME_TAGS }}
E2E_FORCE_WEB_BUILD: "1"
E2E_INIT_PASSWORD: E2eInit12345
E2E_MARKETPLACE_API_URL: ${{ vars.E2E_MARKETPLACE_API_URL }}
E2E_MARKETPLACE_PLUGIN_IDS: ${{ vars.E2E_MARKETPLACE_PLUGIN_IDS }}
E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: ${{ vars.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS }}
E2E_MODEL_PROVIDER_CREDENTIALS_JSON: ${{ secrets.E2E_MODEL_PROVIDER_CREDENTIALS_JSON }}
E2E_START_AGENT_BACKEND: ${{ vars.E2E_START_AGENT_BACKEND || '1' }}
E2E_STABLE_MODEL_NAME: ${{ vars.E2E_STABLE_MODEL_NAME || 'gpt-5-nano' }}
E2E_STABLE_MODEL_PROVIDER: ${{ vars.E2E_STABLE_MODEL_PROVIDER || 'openai' }}
E2E_STABLE_MODEL_TYPE: ${{ vars.E2E_STABLE_MODEL_TYPE || 'llm' }}
run: |
if [[ -z "${E2E_MODEL_PROVIDER_CREDENTIALS_JSON}" ]]; then
echo "E2E_MODEL_PROVIDER_CREDENTIALS_JSON is required for external runtime E2E." >&2
exit 1
fi
if [[ -d cucumber-report ]]; then
rm -rf cucumber-report-non-external
mv cucumber-report cucumber-report-non-external
fi
trap 'vp run e2e:middleware:down' EXIT
vp run e2e:middleware:up
vp run e2e:external:prepare
vp run e2e:external
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report
path: e2e/cucumber-report
path: |
e2e/cucumber-report
e2e/cucumber-report-non-external
retention-days: 7
- name: Upload E2E logs
+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
@@ -67,6 +67,7 @@ class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
output: JsonValue
session_snapshot: CompositorSessionSnapshot
usage: dict[str, JsonValue] | None = None
class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase):
@@ -76,6 +77,7 @@ class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase):
deferred_tool_call: DeferredToolCallPayload
message: str | None = None
session_snapshot: CompositorSessionSnapshot
usage: dict[str, JsonValue] | None = None
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
@@ -140,6 +142,7 @@ class AgentBackendRunEventAdapter:
deferred_tool_call=event.data.deferred_tool_call,
message=_deferred_tool_call_message(event.data.deferred_tool_call),
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
return [
@@ -148,6 +151,7 @@ class AgentBackendRunEventAdapter:
source_event_id=event.id,
output=event.data.output,
session_snapshot=event.data.session_snapshot,
usage=_agent_run_usage(event.data.usage),
)
]
case RunFailedEvent():
@@ -184,3 +188,13 @@ def _deferred_tool_call_message(payload: DeferredToolCallPayload) -> str:
return title
return f"Agent backend requested external input via deferred tool '{payload.tool_name}'."
def _agent_run_usage(usage: object | None) -> dict[str, JsonValue] | None:
"""Return JSON-safe usage metadata from optional Agent backend usage."""
if usage is None:
return None
dumped = _EVENT_DATA_ADAPTER.dump_python(usage, mode="json")
if not isinstance(dumped, dict):
return None
return cast(dict[str, JsonValue], dumped)
+18 -11
View File
@@ -171,7 +171,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
@@ -220,7 +220,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
knowledge: DifyKnowledgeBaseLayerConfig | None = None
config_layer_config: DifyConfigLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
# through the back proxy, never inline content.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635).
@@ -254,10 +254,11 @@ class AgentBackendRunRequestBuilder:
"""Build an Agent App conversation-turn run request.
Layer graph: optional Agent Soul system prompt → user prompt →
execution context → optional history (multi-turn) → LLM → optional
plugin-direct tools / core-routed tools / knowledge search →
optional structured output. Mirrors the workflow-node layer ordering
minus the workflow-job / previous-node prompt.
execution context → optional shell / config / drive / history
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
knowledge search / ask_human / structured output. Mirrors the
workflow-node layer ordering minus the workflow-job / previous-node
prompt.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
@@ -354,11 +355,14 @@ class AgentBackendRunRequestBuilder:
)
if run_input.tools is not None and run_input.tools.tools:
plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_shell:
plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID
layers.append(
RunLayerSpec(
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=plugin_tool_deps,
metadata=run_input.metadata,
config=run_input.tools,
)
@@ -474,9 +478,9 @@ class AgentBackendRunRequestBuilder:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts → execution context →
optional drive/history → LLM → optional plugin-direct tools /
core-routed tools / knowledge search → optional auxiliary layers such
as ask_human, shell, and structured output.
optional shell / config / drive / history → LLM → optional
plugin-direct tools / core-routed tools / knowledge search /
ask_human / structured output.
"""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
@@ -581,11 +585,14 @@ class AgentBackendRunRequestBuilder:
)
if run_input.tools is not None and run_input.tools.tools:
plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_shell:
plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID
layers.append(
RunLayerSpec(
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=plugin_tool_deps,
metadata=run_input.metadata,
config=run_input.tools,
)
+4 -4
View File
@@ -25,7 +25,7 @@ def reset_password(email, new_password, password_confirm):
return
normalized_email = email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
account = AccountService.get_account_by_email_with_case_fallback(db.session(), email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -67,7 +67,7 @@ def reset_email(email, new_email, email_confirm):
return
normalized_new_email = new_email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
account = AccountService.get_account_by_email_with_case_fallback(db.session(), email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -133,9 +133,9 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
password=new_password,
language=language,
create_workspace_required=False,
session=db.session,
session=db.session(),
)
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session)
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session())
click.echo(
click.style(
+24 -16
View File
@@ -9,7 +9,9 @@ from uuid import UUID
import click
import sqlalchemy as sa
import yaml
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from extensions.ext_database import db
from models import Tenant
from models.model import App
@@ -106,7 +108,8 @@ def export_migration_data(input_file: str | None, output_file: str | None, overw
assert output_file is not None
raw_config = _load_json_object(input_file, "Export config")
selection = ExportConfigParser().parse(raw_config)
result = MigrationExportService().export(selection)
with session_factory.create_session() as session:
result = MigrationExportService().export(selection, session=session)
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
click.echo(click.style(f"Output written to {output_file}", fg="green"))
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
@@ -153,19 +156,21 @@ def import_migration_data(
_require_options(("--input", input_file))
assert input_file is not None
package = MigrationPackageService().load_package(input_file)
result = MigrationImportService().import_package(
ImportRequest(
package=package,
cli_target_tenant=target_tenant,
operator_email=operator_email,
options_override=_build_options_override(
package.metadata.import_options,
id_strategy=id_strategy,
conflict_strategy=conflict_strategy,
create_app_api_token_on_import=create_app_api_token_on_import,
with session_factory.create_session() as session:
result = MigrationImportService().import_package(
ImportRequest(
package=package,
cli_target_tenant=target_tenant,
operator_email=operator_email,
options_override=_build_options_override(
package.metadata.import_options,
id_strategy=id_strategy,
conflict_strategy=conflict_strategy,
create_app_api_token_on_import=create_app_api_token_on_import,
),
),
session=session,
)
)
_render_report(result.report_items, context=result.report_context)
except MigrationDataError as exc:
raise click.ClickException(str(exc)) from exc
@@ -213,7 +218,9 @@ def migration_data_wizard() -> None:
default=True,
show_default=False,
)
auto_tools = _discover_auto_tools([app for app in apps if app.id in set(app_ids)], include_referenced_tools)
auto_tools = _discover_auto_tools(
[app for app in apps if app.id in set(app_ids)], include_referenced_tools, session=db.session()
)
auto_tools = _resolve_auto_tool_names(tenant.id, auto_tools)
_print_auto_tools(auto_tools)
additional_tools = _prompt_additional_tools(tenant.id, auto_tools)
@@ -248,7 +255,8 @@ def migration_data_wizard() -> None:
conflict_strategy=conflict_strategy,
output_file=output_file,
)
result = MigrationExportService().export(selection)
with session_factory.create_session() as session:
result = MigrationExportService().export(selection, session=session)
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
click.echo(click.style(f"Output written to {output_file}", fg="green"))
_print_wizard_step("Report")
@@ -389,13 +397,13 @@ def _prompt_import_options() -> tuple[bool, bool, str, str]:
return include_secrets, create_tokens, id_strategy, conflict_strategy
def _discover_auto_tools(apps: list[App], include_referenced_tools: bool) -> WizardToolMap:
def _discover_auto_tools(apps: list[App], include_referenced_tools: bool, *, session: Session) -> WizardToolMap:
auto_tools: WizardToolMap = {"api_tools": {}, "workflow_tools": {}, "mcp_tools": {}}
if not include_referenced_tools:
return auto_tools
discovery_service = DependencyDiscoveryService()
for app in apps:
dsl_content = AppDslService.export_dsl(app_model=app, include_secret=False)
dsl_content = AppDslService.export_dsl(app_model=app, session=session, include_secret=False)
raw_dsl = yaml.safe_load(dsl_content) if dsl_content else {}
dsl = raw_dsl if isinstance(raw_dsl, dict) else {}
for dependency in discovery_service.discover_from_dsl(dsl):
+16 -15
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
@@ -188,13 +188,13 @@ def transform_datasource_credentials(environment: str):
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
jina_plugin_id = "langgenius/jina_datasource"
if environment == "online":
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id)
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id)
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
notion_package_identifier = plugin_migration._fetch_latest_package_identifier(notion_plugin_id)
firecrawl_package_identifier = plugin_migration._fetch_latest_package_identifier(firecrawl_plugin_id)
jina_package_identifier = plugin_migration._fetch_latest_package_identifier(jina_plugin_id)
else:
notion_plugin_unique_identifier = None
firecrawl_plugin_unique_identifier = None
jina_plugin_unique_identifier = None
notion_package_identifier = None
firecrawl_package_identifier = None
jina_package_identifier = None
oauth_credential_type = CredentialType.OAUTH2
api_key_credential_type = CredentialType.API_KEY
@@ -219,9 +219,9 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if notion_plugin_id not in installed_plugins_ids:
if notion_plugin_unique_identifier:
if notion_package_identifier:
# install notion plugin
PluginService.install_from_marketplace_pkg(tenant_id, [notion_plugin_unique_identifier])
PluginService.install_from_marketplace_pkg(tenant_id, [notion_package_identifier])
auth_count = 0
for notion_tenant_credential in notion_tenant_credentials:
auth_count += 1
@@ -279,9 +279,9 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if firecrawl_plugin_id not in installed_plugins_ids:
if firecrawl_plugin_unique_identifier:
if firecrawl_package_identifier:
# install firecrawl plugin
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_plugin_unique_identifier])
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_package_identifier])
auth_count = 0
for firecrawl_tenant_credential in firecrawl_tenant_credentials:
@@ -343,10 +343,10 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if jina_plugin_id not in installed_plugins_ids:
if jina_plugin_unique_identifier:
if jina_package_identifier:
# install jina plugin
logger.debug("Installing Jina plugin %s", jina_plugin_unique_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_plugin_unique_identifier])
logger.debug("Installing Jina plugin %s", jina_package_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_package_identifier])
auth_count = 0
for jina_tenant_credential in jina_tenant_credentials:
@@ -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)
@@ -472,6 +472,7 @@ def backfill_plugin_auto_upgrade(
try:
result = PluginAutoUpgradeService.backfill_strategy_categories(
current_tenant_id,
session=db.session(),
)
except Exception as e:
failed_count += 1
+31 -4
View File
@@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
import click
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
@@ -131,16 +132,35 @@ def _replace_member_role(
operator_account_id: str,
member_account_id: str,
role_id: str,
*,
session: Session,
) -> str:
RBACService.MemberRoles.replace(
tenant_id=tenant_id,
account_id=operator_account_id,
member_account_id=member_account_id,
role_ids=[role_id],
session=session,
)
return member_account_id
def _replace_member_role_with_new_session(
tenant_id: str,
operator_account_id: str,
member_account_id: str,
role_id: str,
) -> str:
with session_factory.create_session() as session:
return _replace_member_role(
tenant_id=tenant_id,
operator_account_id=operator_account_id,
member_account_id=member_account_id,
role_id=role_id,
session=session,
)
@click.command(
"rbac-migrate-member-roles", help="Migrate legacy workspace member roles into RBAC member-role bindings."
)
@@ -217,14 +237,21 @@ def migrate_member_roles_to_rbac(
if replace_jobs:
if workers == 1:
for member_account_id, resolved_role_id in replace_jobs:
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
migrated_count += 1
with session_factory.create_session() as session:
for member_account_id, resolved_role_id in replace_jobs:
_replace_member_role(
workspace_id,
owner_account_id,
member_account_id,
resolved_role_id,
session=session,
)
migrated_count += 1
else:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(
_replace_member_role,
_replace_member_role_with_new_session,
workspace_id,
owner_account_id,
member_account_id,
+3 -2
View File
@@ -13,6 +13,7 @@ from core.rag.index_processor.constant.built_in_field import BuiltInField
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.models.document import ChildDocument, Document
from extensions.ext_database import db
from libs.pagination import paginate_query
from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, DatasetMetadataBinding, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.enums import DatasetMetadataType, IndexingStatus, SegmentStatus
@@ -183,7 +184,7 @@ def migrate_knowledge_vector_database():
.order_by(Dataset.created_at.desc())
)
datasets = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False)
datasets = paginate_query(stmt, page=page, per_page=50, max_per_page=50)
if not datasets.items:
break
except SQLAlchemyError:
@@ -409,7 +410,7 @@ def old_metadata_migration():
.where(DatasetDocument.doc_metadata.is_not(None))
.order_by(DatasetDocument.created_at.desc())
)
documents = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False)
documents = paginate_query(stmt, page=page, per_page=50, max_per_page=50)
except SQLAlchemyError:
raise
if not documents:
+5 -7
View File
@@ -1,4 +1,4 @@
from pydantic import Field
from pydantic import Field, NonNegativeFloat
from pydantic_settings import BaseSettings
@@ -32,12 +32,10 @@ class AgentBackendConfig(BaseSettings):
default=False,
)
AGENT_DRIVE_MANIFEST_ENABLED: bool = Field(
AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS: NonNegativeFloat = Field(
description=(
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
"into Agent runs. The declaration is an index only — the agent backend "
"pulls the actual SKILL.md / files through the back proxy. Set this to "
"false only when temporarily rolling back the drive integration."
"Buffer Agent App assistant text deltas for up to this many seconds before "
"publishing SSE chunks. Set to 0 to publish each delta immediately."
),
default=True,
default=0.5,
)
@@ -0,0 +1,54 @@
from typing import Any
from sqlalchemy import select
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from extensions.ext_database import db
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App
def get_published_agent_app_feature_dict_and_user_input_form(
app_model: App,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Return public Agent App parameters backed by the published Agent Soul."""
app_model_config = app_model.app_model_config
agent_id = app_model.bound_agent_id
if not agent_id:
raise AgentAppGeneratorError("Agent App has no bound Agent")
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
# active_config_is_published means the draft has no unpublished edits; the public app
# can still read parameters from the active snapshot while a newer draft is pending.
if not agent.active_config_snapshot_id:
raise AgentAppNotPublishedError("Agent has not been published")
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
features_dict = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
+2 -1
View File
@@ -4,6 +4,7 @@ from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from extensions.ext_database import db
from services.enterprise import rbac_service as enterprise_rbac_service
if TYPE_CHECKING:
@@ -76,7 +77,7 @@ def resolve_app_access_filter(
inner-API round trip; otherwise it is fetched here.
"""
if permissions is None:
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id)
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id, session=db.session())
whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources(tenant_id, account_id)
can_manage_own_apps = _MANAGE_OWN_APPS_PERMISSION_KEY in permissions.workspace.permission_keys
+57
View File
@@ -0,0 +1,57 @@
"""Controller session decorators.
`with_session` is an HTTP controller helper: it opens one SQLAlchemy session
for a Resource handler and injects it as the first argument after `self`.
Handlers use a transaction by default so migrated write paths keep
commit/rollback handling; pure read handlers may opt out with `write=False`.
"""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate, overload
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
@overload
def with_session[T, **P, R](
view: Callable[Concatenate[T, Session, P], R],
*,
write: bool = True,
) -> Callable[Concatenate[T, P], R]: ...
@overload
def with_session[T, **P, R](
view: None = None,
*,
write: bool = True,
) -> Callable[[Callable[Concatenate[T, Session, P], R]], Callable[Concatenate[T, P], R]]: ...
def with_session[T, **P, R](
view: Callable[Concatenate[T, Session, P], R] | None = None,
*,
write: bool = True,
) -> (
Callable[Concatenate[T, P], R] | Callable[[Callable[Concatenate[T, Session, P], R]], Callable[Concatenate[T, P], R]]
):
"""Inject a request-scoped session, using a transaction only for write handlers."""
def decorator(view: Callable[Concatenate[T, Session, P], R]) -> Callable[Concatenate[T, P], R]:
@wraps(view)
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
if write:
with session_factory.get_session_maker().begin() as session:
return view(self, session, *args, **kwargs)
with session_factory.create_session() as session:
return view(self, session, *args, **kwargs)
return wrapper
if view is None:
return decorator
return decorator(view)
+16 -4
View File
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user_id,
)
from extensions.ext_database import db
from fields.agent_fields import (
AgentAppComposerResponse,
AgentComposerCandidatesResponse,
@@ -69,6 +70,7 @@ class WorkflowAgentComposerApi(Resource):
node_id=node_id,
account_id=account_id,
snapshot_id=query.snapshot_id,
session=db.session(),
),
)
@@ -94,6 +96,7 @@ class WorkflowAgentComposerApi(Resource):
node_id=node_id,
account_id=account_id,
payload=payload,
session=db.session(),
),
)
@@ -126,6 +129,7 @@ class WorkflowAgentComposerCopyFromRosterApi(Resource):
source_agent_id=payload.source_agent_id,
source_snapshot_id=payload.source_snapshot_id,
idempotency_key=payload.idempotency_key,
session=db.session(),
),
)
@@ -149,8 +153,9 @@ class WorkflowAgentComposerValidateApi(Resource):
tenant_id=tenant_id,
payload=payload,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
tenant_id=tenant_id, app_id=app_model.id, node_id=node_id, session=db.session()
),
session=db.session(),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -174,6 +179,7 @@ class WorkflowAgentComposerCandidatesApi(Resource):
app_id=app_model.id,
node_id=node_id,
user_id=current_user_id,
session=db.session(),
),
)
@@ -196,7 +202,9 @@ class WorkflowAgentComposerImpactApi(Resource):
)
return dump_response(
AgentComposerImpactResponse,
AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id),
AgentComposerService.calculate_impact(
tenant_id=tenant_id, current_snapshot_id=current_snapshot_id, session=db.session()
),
)
@@ -224,6 +232,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
node_id=node_id,
account_id=account_id,
payload=payload,
session=db.session(),
),
)
@@ -238,7 +247,7 @@ class AgentComposerApi(Resource):
def get(self, tenant_id: str, agent_id: UUID):
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id)),
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id), session=db.session()),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@@ -259,6 +268,7 @@ class AgentComposerApi(Resource):
agent_id=str(agent_id),
account_id=account_id,
payload=payload,
session=db.session(),
),
)
@@ -274,7 +284,7 @@ class AgentComposerValidateApi(Resource):
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id))
AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=str(agent_id), session=db.session())
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
@@ -282,6 +292,7 @@ class AgentComposerValidateApi(Resource):
tenant_id=tenant_id,
payload=payload,
agent_id=str(agent_id),
session=db.session(),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@@ -303,5 +314,6 @@ class AgentComposerCandidatesApi(Resource):
tenant_id=tenant_id,
agent_id=str(agent_id),
user_id=current_user_id,
session=db.session(),
),
)
+24 -22
View File
@@ -91,33 +91,31 @@ class AgentIdPath(BaseModel):
class AgentAppCreatePayload(BaseModel):
name: str = Field(..., min_length=1, description="Agent name")
description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400)
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
role: str | None = Field(default=None, description="Agent role", max_length=255)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("role")
@classmethod
def validate_role(cls, value: str) -> str:
role = value.strip()
if not role:
raise ValueError("Agent role is required.")
return role
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
# Keep agent-app roster DTOs agent-specific instead of reusing the shared
# /apps response/request models. The roster surface needs Agent-only fields such
# as `role`, while the generic console/apps contracts must stay unchanged.
class AgentAppUpdatePayload(GenericUpdateAppPayload):
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
role: str | None = Field(default=None, description="Agent role", max_length=255)
@field_validator("role")
@classmethod
def validate_role(cls, value: str) -> str:
role = value.strip()
if not role:
raise ValueError("Agent role is required.")
return role
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
class AgentAppCopyPayload(BaseModel):
@@ -133,10 +131,7 @@ class AgentAppCopyPayload(BaseModel):
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
role = value.strip()
if not role:
raise ValueError("Agent role is required when provided.")
return role
return value.strip()
class AgentApiStatusPayload(BaseModel):
@@ -531,6 +526,7 @@ class AgentAppListApi(Resource):
page=args.page,
limit=args.limit,
mode="agent",
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
@@ -538,7 +534,7 @@ class AgentAppListApi(Resource):
status="normal",
)
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, db.session)
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, db.session())
if app_pagination is None:
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json")
@@ -565,13 +561,13 @@ class AgentAppListApi(Resource):
name=args.name,
description=args.description,
mode="agent",
agent_role=args.role,
agent_role=args.role or "",
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
)
app = AppService().create_app(current_tenant_id, params, current_user)
app = AppService().create_app(current_tenant_id, params, current_user, session=db.session())
return _serialize_agent_app_detail(app, current_user=current_user), 201
@@ -611,7 +607,7 @@ class AgentAppApi(Resource):
"max_active_requests": args.max_active_requests or 0,
"role": args.role,
}
updated = AppService().update_app(app_model, args_dict)
updated = AppService().update_app(app_model, args_dict, session=db.session())
return _serialize_agent_app_detail(updated, current_user=current_user)
@console_ns.response(204, "Agent app deleted successfully")
@@ -623,7 +619,7 @@ class AgentAppApi(Resource):
@with_current_tenant_id
def delete(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
AppService().delete_app(app_model)
AppService().delete_app(app_model, session=db.session())
return "", 204
@@ -672,6 +668,7 @@ class AgentPublishApi(Resource):
agent_id=str(agent_id),
account_id=current_user.id,
version_note=args.version_note,
session=db.session(),
)
@@ -692,6 +689,7 @@ class AgentBuildDraftCheckoutApi(Resource):
agent_id=str(agent_id),
account_id=current_user.id,
force=args.force,
session=db.session(),
)
@@ -709,6 +707,7 @@ class AgentBuildDraftApi(Resource):
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
session=db.session(),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@@ -726,6 +725,7 @@ class AgentBuildDraftApi(Resource):
agent_id=str(agent_id),
account_id=current_user.id,
payload=payload,
session=db.session(),
)
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
@@ -740,6 +740,7 @@ class AgentBuildDraftApi(Resource):
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
session=db.session(),
)
@@ -757,6 +758,7 @@ class AgentBuildDraftApplyApi(Resource):
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
session=db.session(),
)
@@ -814,7 +816,7 @@ class AgentApiStatusApi(Resource):
def post(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentApiStatusPayload.model_validate(console_ns.payload)
app_model = AppService().update_app_api_status(app_model, args.enable_api)
app_model = AppService().update_app_api_status(app_model, args.enable_api, session=db.session())
return _serialize_agent_api_access(app_model)
+9 -3
View File
@@ -172,7 +172,7 @@ register_response_schema_models(
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
if node_id and app_model.mode != AppMode.AGENT:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id, session=db.session()
)
return app_model.bound_agent_id
@@ -202,6 +202,7 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
session=db.session(),
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
@@ -240,6 +241,7 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
value_owned_by_drive=True,
)
],
session=db.session(),
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
@@ -273,6 +275,7 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
user_id=current_user.id,
agent_id=agent_id,
items=[DriveCommitItem(key=key, file_ref=None)],
session=db.session(),
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
@@ -298,6 +301,7 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
],
session=db.session(),
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
@@ -313,7 +317,9 @@ def _infer_skill_tools_for_app(*, app_model: App, slug: str):
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug)
return SkillToolInferenceService().infer(
tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=db.session()
)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
@@ -335,7 +341,7 @@ class AgentLogApi(Resource):
"""Get agent logs"""
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id)
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id, db.session())
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
@@ -93,7 +93,7 @@ class AgentAppFeatureConfigResource(Resource):
app_model=app_model,
account=current_user,
config=args.model_dump(exclude_none=True),
session=db.session,
session=db.session(),
)
app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config)
@@ -25,6 +25,7 @@ from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
@@ -44,6 +45,10 @@ class AgentSandboxListQuery(BaseModel):
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
class AgentSandboxInfoQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
class AgentSandboxFileQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
@@ -91,6 +96,11 @@ class SandboxListResponse(ResponseModel):
truncated: bool = False
class SandboxInfoResponse(ResponseModel):
session_id: str
workspace_cwd: str
class SandboxReadResponse(ResponseModel):
path: str
size: int | None = None
@@ -99,14 +109,8 @@ class SandboxReadResponse(ResponseModel):
text: str | None = None
class SandboxToolFileResponse(ResponseModel):
transfer_method: Literal["tool_file"] = "tool_file"
reference: str
class SandboxUploadResponse(ResponseModel):
path: str
file: SandboxToolFileResponse
url: str
register_schema_models(
@@ -114,7 +118,13 @@ register_schema_models(
AgentSandboxUploadPayload,
WorkflowAgentSandboxUploadPayload,
)
register_response_schema_models(console_ns, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse)
register_response_schema_models(
console_ns,
SandboxInfoResponse,
SandboxListResponse,
SandboxReadResponse,
SandboxUploadResponse,
)
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
@@ -133,6 +143,30 @@ def _handle(exc: Exception) -> tuple[dict[str, object], int]:
raise exc
@console_ns.route("/agent/<uuid:agent_id>/sandbox")
class AgentAppSandboxInfoResource(Resource):
@console_ns.doc("get_agent_app_sandbox_info")
@console_ns.doc(description="Get basic information for an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxInfoQuery)})
@console_ns.response(200, "Sandbox information returned", console_ns.models[SandboxInfoResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = query_params_from_request(AgentSandboxInfoQuery)
try:
result = AgentAppSandboxService().get_info(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files")
class AgentAppSandboxListResource(Resource):
@console_ns.doc("list_agent_app_sandbox_files")
@@ -186,7 +220,7 @@ class AgentAppSandboxReadResource(Resource):
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
@console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL")
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@@ -236,6 +270,7 @@ class WorkflowAgentSandboxListResource(Resource):
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
session=db.session(),
)
except Exception as exc:
return _handle(exc)
@@ -272,6 +307,7 @@ class WorkflowAgentSandboxReadResource(Resource):
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
session=db.session(),
)
except Exception as exc:
return _handle(exc)
@@ -283,7 +319,7 @@ class WorkflowAgentSandboxReadResource(Resource):
)
class WorkflowAgentSandboxUploadResource(Resource):
@console_ns.doc("upload_workflow_agent_sandbox_file")
@console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping")
@console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@@ -301,6 +337,7 @@ class WorkflowAgentSandboxUploadResource(Resource):
node_id=node_id,
node_execution_id=payload.node_execution_id,
path=payload.path,
session=db.session(),
)
except Exception as exc:
return _handle(exc)
@@ -253,6 +253,7 @@ def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
tenant_id=app_model.tenant_id,
app_id=app_model.id,
node_id=node_id,
session=db.session(),
)
return app_model.bound_agent_id
@@ -288,13 +289,16 @@ def _resolve_console_version(
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
session=db.session(),
)
draft = state.get("draft") or {}
draft_id = draft.get("id")
if isinstance(draft_id, str) and draft_id:
return draft_id, AgentConfigVersionKind.BUILD_DRAFT
else:
state = AgentComposerService.load_agent_composer(tenant_id=tenant_id, agent_id=agent_id)
state = AgentComposerService.load_agent_composer(
tenant_id=tenant_id, agent_id=agent_id, session=db.session()
)
draft = state.get("draft") or {}
draft_id = draft.get("id")
if isinstance(draft_id, str) and draft_id:
@@ -28,6 +28,7 @@ from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
@@ -147,7 +148,7 @@ def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
if node_id:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id, session=db.session()
)
return app_model.bound_agent_id
@@ -184,7 +185,9 @@ class AgentDriveListByAgentApi(Resource):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
items = AgentDriveService().manifest(
tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=db.session()
)
except AgentDriveError as exc:
return _handle(exc)
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@@ -203,7 +206,7 @@ class AgentDriveSkillListByAgentApi(Resource):
def get(self, tenant_id: str, agent_id: UUID):
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=db.session())
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@@ -227,6 +230,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
tenant_id=tenant_id,
agent_id=str(agent_id),
skill_path=skill_path,
session=db.session(),
)
)
except AgentDriveError as exc:
@@ -247,7 +251,9 @@ class AgentDrivePreviewByAgentApi(Resource):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
return AgentDriveService().preview(
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=db.session()
)
except AgentDriveError as exc:
return _handle(exc)
@@ -266,7 +272,9 @@ class AgentDriveDownloadByAgentApi(Resource):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
url = AgentDriveService().download_url(
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=db.session()
)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
@@ -288,7 +296,9 @@ class AgentDriveListApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
items = AgentDriveService().manifest(
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=db.session()
)
except AgentDriveError as exc:
return _handle(exc)
# the inner manifest exposes file_id for agent-side pulls; the console
@@ -312,7 +322,9 @@ class AgentDriveSkillListApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
items = AgentDriveService().list_skills(
tenant_id=app_model.tenant_id, agent_id=agent_id, session=db.session()
)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@@ -345,6 +357,7 @@ class AgentDriveSkillInspectApi(Resource):
tenant_id=app_model.tenant_id,
agent_id=agent_id,
skill_path=skill_path,
session=db.session(),
)
)
except AgentDriveError as exc:
@@ -367,7 +380,9 @@ class AgentDrivePreviewApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
return AgentDriveService().preview(
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=db.session()
)
except AgentDriveError as exc:
return _handle(exc)
@@ -388,7 +403,9 @@ class AgentDriveDownloadApi(Resource):
if not agent_id:
return _agent_not_bound()
try:
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
url = AgentDriveService().download_url(
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=db.session()
)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
+15 -10
View File
@@ -211,7 +211,7 @@ class AppAnnotationSettingDetailApi(Resource):
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID):
result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id))
result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id), session=db.session())
return dump_response(AnnotationSettingResponse, result), 200
@@ -235,7 +235,7 @@ class AppAnnotationSettingUpdateApi(Resource):
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold}
result = AppAnnotationService.update_app_annotation_setting(
str(app_id), annotation_setting_id_str, setting_args
str(app_id), annotation_setting_id_str, setting_args, session=db.session()
)
return dump_response(AnnotationSettingResponse, result), 200
@@ -292,7 +292,9 @@ class AnnotationApi(Resource):
limit = args.limit
keyword = args.keyword
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(str(app_id), page, limit, keyword)
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(
str(app_id), page, limit, keyword, session=db.session()
)
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
return AnnotationList(
data=annotation_models, has_more=len(annotation_list) == limit, limit=limit, total=total, page=page
@@ -321,7 +323,9 @@ class AnnotationApi(Resource):
upsert_args["message_id"] = args.message_id
if args.question is not None:
upsert_args["question"] = args.question
annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, str(app_id))
annotation = AppAnnotationService.up_insert_app_annotation_from_message(
upsert_args, str(app_id), session=db.session()
)
return dump_response(Annotation, annotation), 201
@setup_required
@@ -345,11 +349,11 @@ class AnnotationApi(Resource):
}, 400
app_ref = _get_app_ref(str(app_id))
AppAnnotationService.delete_app_annotations_in_batch(app_ref, annotation_ids)
AppAnnotationService.delete_app_annotations_in_batch(app_ref, annotation_ids, session=db.session())
return "", 204
# If no annotation_ids are provided, handle clearing all annotations
else:
AppAnnotationService.clear_all_annotations(str(app_id))
AppAnnotationService.clear_all_annotations(str(app_id), session=db.session())
return "", 204
@@ -370,7 +374,7 @@ class AnnotationExportApi(Resource):
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
def get(self, app_id: UUID):
annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id))
annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id), session=db.session())
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
return (
AnnotationExportList(data=annotation_models).model_dump(mode="json"),
@@ -406,7 +410,7 @@ class AnnotationUpdateDeleteApi(Resource):
update_args["question"] = args.question
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
annotation = AppAnnotationService.update_app_annotation_directly(update_args, annotation_ref, db.session)
annotation = AppAnnotationService.update_app_annotation_directly(update_args, annotation_ref, db.session())
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required
@@ -418,7 +422,7 @@ class AnnotationUpdateDeleteApi(Resource):
def delete(self, app_id: UUID, annotation_id: UUID):
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
AppAnnotationService.delete_app_annotation(annotation_ref, db.session)
AppAnnotationService.delete_app_annotation(annotation_ref, db.session())
return "", 204
@@ -477,7 +481,7 @@ class AnnotationBatchImportApi(Resource):
return dump_response(
AnnotationBatchImportResponse,
AppAnnotationService.batch_import_app_annotations(str(app_id), file),
AppAnnotationService.batch_import_app_annotations(str(app_id), file, session=db.session()),
)
@@ -538,6 +542,7 @@ class AnnotationHitHistoryListApi(Resource):
annotation_ref,
page,
limit,
session=db.session(),
)
history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python(
annotation_hit_history_list, from_attributes=True
+16 -10
View File
@@ -584,6 +584,7 @@ class AppListApi(Resource):
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
str(current_tenant_id),
current_user_id,
session=db.session(),
)
if dify_config.RBAC_ENABLED:
access_filter = resolve_app_access_filter(
@@ -595,7 +596,7 @@ class AppListApi(Resource):
# get app list
app_service = AppService()
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, db.session)
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, session)
if not app_pagination:
response = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return response.model_dump(mode="json"), 200
@@ -643,11 +644,12 @@ class AppListApi(Resource):
)
app_service = AppService()
app = app_service.create_app(current_tenant_id, params, current_user)
app = app_service.create_app(current_tenant_id, params, current_user, session=db.session())
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
str(current_tenant_id),
current_user.id,
[str(app.id)],
session=db.session(),
)
app_detail = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy(
update={"permission_keys": permission_keys_map.get(str(app.id), [])}
@@ -681,7 +683,7 @@ class StarredAppListApi(Resource):
is_created_by_me=args.is_created_by_me,
)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params, db.session)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params, session)
if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
@@ -753,6 +755,7 @@ class AppApi(Resource):
str(current_tenant_id),
current_user.id,
app_id=str(app_model.id),
session=db.session(),
)
permission_keys_map = permissions.app.permission_keys_by_resource_ids([str(app_model.id)])
@@ -789,7 +792,7 @@ class AppApi(Resource):
"use_icon_as_answer_icon": args.use_icon_as_answer_icon or False,
"max_active_requests": args.max_active_requests or 0,
}
app_model = app_service.update_app(app_model, args_dict)
app_model = app_service.update_app(app_model, args_dict, session=db.session())
return dump_response(AppDetailWithSite, app_model)
@console_ns.doc("delete_app")
@@ -806,7 +809,7 @@ class AppApi(Resource):
def delete(self, app_model: App):
"""Delete app"""
app_service = AppService()
app_service.delete_app(app_model)
app_service.delete_app(app_model, session=db.session())
return "", 204
@@ -835,7 +838,7 @@ class AppCopyApi(Resource):
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session)
yaml_content = import_service.export_dsl(app_model=app_model, include_secret=True)
yaml_content = import_service.export_dsl(app_model=app_model, session=session, include_secret=True)
result = import_service.import_app(
account=current_user,
import_mode=ImportMode.YAML_CONTENT,
@@ -877,6 +880,7 @@ class AppCopyApi(Resource):
str(current_tenant_id),
current_user.id,
[str(app.id)],
session=db.session(),
)
response_model = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy(
update={"permission_keys": permission_keys_map.get(str(app.id), [])}
@@ -905,6 +909,7 @@ class AppExportApi(Resource):
response = AppExportResponse(
data=AppDslService.export_dsl(
app_model=app_model,
session=db.session(),
include_secret=args.include_secret,
workflow_id=args.workflow_id,
)
@@ -929,7 +934,7 @@ class AppPublishToCreatorsPlatformApi(Resource):
if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
return {"error": "Creators Platform features are not enabled"}, 403
dsl_content = AppDslService.export_dsl(app_model=app_model, include_secret=False)
dsl_content = AppDslService.export_dsl(app_model=app_model, session=db.session(), include_secret=False)
dsl_bytes = dsl_content.encode("utf-8")
claim_code = upload_dsl(dsl_bytes)
@@ -955,7 +960,7 @@ class AppNameApi(Resource):
args = AppNamePayload.model_validate(console_ns.payload)
app_service = AppService()
app_model = app_service.update_app_name(app_model, args.name)
app_model = app_service.update_app_name(app_model, args.name, session=db.session())
return dump_response(AppDetail, app_model)
@@ -982,6 +987,7 @@ class AppIconApi(Resource):
args.icon or "",
args.icon_background or "",
args.icon_type,
session=db.session(),
)
return dump_response(AppDetail, app_model)
@@ -1004,7 +1010,7 @@ class AppSiteStatus(Resource):
args = AppSiteStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
app_model = app_service.update_app_site_status(app_model, args.enable_site)
app_model = app_service.update_app_site_status(app_model, args.enable_site, session=db.session())
return dump_response(AppDetail, app_model)
@@ -1026,7 +1032,7 @@ class AppApiStatus(Resource):
args = AppApiStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
app_model = app_service.update_app_api_status(app_model, args.enable_api)
app_model = app_service.update_app_api_status(app_model, args.enable_api, session=db.session())
return dump_response(AppDetail, app_model)
+1 -1
View File
@@ -161,7 +161,7 @@ class ChatMessageTextApi(Resource):
# response-contract:ignore
return AudioService.transcript_tts(
app_model=app_model,
session=db.session,
session=db.session(),
text=payload.text,
voice=payload.voice,
message_ref=message_ref,
+81 -13
View File
@@ -1,7 +1,7 @@
import json
import logging
from collections.abc import Generator
from typing import Any, Literal
from collections.abc import Generator, Iterator, Mapping
from typing import Any, Literal, Protocol, runtime_checkable
from uuid import UUID
from flask import request
@@ -60,6 +60,11 @@ from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@runtime_checkable
class _ClosableStream(Protocol):
def close(self) -> None: ...
def _resolve_debugger_chat_streaming(
*, app_mode: AppMode, response_mode: str, response_mode_provided: bool = True
) -> bool:
@@ -118,15 +123,13 @@ edit workspace files, run validation or debugging commands, make exploratory che
Use only the current Build chat message history to identify changes that need to be persisted. Do not inspect, test, or
validate old config unless the message history already shows that the old config is invalid.
Persist only the build-draft config resources that need to change, using the Agent config CLI usage provided in the
runtime prompt:
Only update the build-draft config note when the current Build chat contains durable context that later runs need.
Write the config note in the language used by the message history.
Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config
skills, config env, tools, models, knowledge, or prompt settings.
- config files for reusable artifacts that should be available later,
- config skills for reusable procedures or tools that should be available later,
- config env when environment keys or values need to be recorded,
- config note for concise durable context when useful.
When updating the config note, record only durable context needed by later runs, such as:
When updating the config note with the Agent config CLI usage provided in the runtime prompt, record only durable
context needed by later runs, such as:
- what you installed or configured outside the workspace for this agent,
- where those external updates live, including CLI tools, packages, and persistent $HOME paths,
@@ -437,7 +440,6 @@ def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[
changes the HTTP boundary: it drains the SSE stream server-side and returns
success after the generated build-chat message reaches ``message_end``.
"""
close = getattr(response, "close", None)
try:
for chunk in response:
for raw_event in chunk.split("\n\n"):
@@ -467,8 +469,8 @@ def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[
if payload_event == "error":
raise CompletionRequestError(str(payload.get("message") or "Build chat finalization failed."))
finally:
if callable(close):
close()
if isinstance(response, _ClosableStream):
response.close()
raise CompletionRequestError("Build chat finalization did not complete.")
@@ -531,6 +533,8 @@ def _generate_chat_message_response(
args=args,
streaming=streaming,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT and streaming:
response = _raise_agent_stream_error_before_response(response)
return helper.compact_generate_response(response)
@@ -543,3 +547,67 @@ def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
def _raise_agent_stream_error_before_response(response):
"""Surface immediate Agent App stream errors as HTTP errors before SSE starts.
The shared streaming helper always returns HTTP 200 once the SSE response is
created. Agent v2 configuration errors, such as an invalid model API key,
can be the first real stream event after the initial ping; pre-reading that
first non-ping event lets the console API return the existing 400 error
contract instead of a successful HTTP response carrying only an SSE error.
"""
if isinstance(response, Mapping):
return response
buffered: list[str] = []
iterator = iter(response)
while True:
try:
chunk = next(iterator)
except StopIteration:
return iter(buffered)
if not isinstance(chunk, str):
return _prepend_stream_chunks(buffered, chunk, iterator)
if _is_sse_ping(chunk):
buffered.append(chunk)
continue
error_payload = _extract_sse_error_payload(chunk)
if error_payload is not None:
if isinstance(response, _ClosableStream):
response.close()
message = error_payload.get("message")
raise CompletionRequestError(str(message or "Agent App chat failed."))
return _prepend_stream_chunks(buffered, chunk, iterator)
def _prepend_stream_chunks(buffered: list[Any], first: Any, iterator: Iterator[Any]) -> Generator[Any, None, None]:
yield from buffered
yield first
yield from iterator
def _is_sse_ping(chunk: str) -> bool:
return chunk.strip() == "event: ping"
def _extract_sse_error_payload(chunk: str) -> dict[str, Any] | None:
for raw_event in chunk.split("\n\n"):
data_lines: list[str] = []
for line in raw_event.splitlines():
if line.startswith("data: "):
data_lines.append(line.removeprefix("data: "))
if not data_lines:
continue
try:
payload = json.loads("\n".join(data_lines))
except json.JSONDecodeError:
continue
if isinstance(payload, dict) and payload.get("event") == "error":
return payload
return None
+5 -4
View File
@@ -41,6 +41,7 @@ from fields.conversation_fields import (
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from libs.pagination import paginate_query
from models import Conversation, EndUser, Message, MessageAnnotation
from models.account import Account
from models.model import App, AppMode
@@ -156,7 +157,7 @@ class CompletionConversationApi(Resource):
query = query.order_by(Conversation.created_at.desc())
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
conversations = paginate_query(query, page=args.page, per_page=args.limit)
return dump_response(ConversationPaginationResponse, conversations)
@@ -199,7 +200,7 @@ class CompletionConversationDetailApi(Resource):
conversation_id_str = str(conversation_id)
try:
ConversationService.delete(app_model, conversation_id_str, current_user)
ConversationService.delete(app_model, conversation_id_str, current_user, session=db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -310,7 +311,7 @@ class ChatConversationApi(Resource):
case _:
query = query.order_by(Conversation.created_at.desc())
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
conversations = paginate_query(query, page=args.page, per_page=args.limit)
return dump_response(ConversationWithSummaryPaginationResponse, conversations)
@@ -353,7 +354,7 @@ class ChatConversationDetailApi(Resource):
conversation_id_str = str(conversation_id)
try:
ConversationService.delete(app_model, conversation_id_str, current_user)
ConversationService.delete(app_model, conversation_id_str, current_user, session=db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
+6 -1
View File
@@ -363,6 +363,7 @@ def _list_chat_messages(*, app_model: App, current_user: Account | None = None):
app_model=app_model,
conversation_id=args.conversation_id,
user=current_user,
session=db.session(),
)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -474,7 +475,11 @@ def _get_message_suggested_questions(*, current_user: Account, app_model: App, m
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
app_model=app_model,
message_id=message_id_str,
user=current_user,
invoke_from=InvokeFrom.DEBUGGER,
session=db.session(),
)
except MessageNotExistsError:
raise NotFound("Message not found")
+13 -4
View File
@@ -17,6 +17,7 @@ from controllers.console.wraps import (
rbac_permission_required,
setup_required,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models import App
@@ -78,7 +79,7 @@ class TraceAppConfigApi(Resource):
try:
trace_config = OpsService.get_tracing_app_config(
app_id=app_model.id, tracing_provider=args.tracing_provider
app_id=app_model.id, tracing_provider=args.tracing_provider, session=db.session()
)
if not trace_config:
return {"has_not_configured": True}
@@ -109,7 +110,10 @@ class TraceAppConfigApi(Resource):
try:
result = OpsService.create_tracing_app_config(
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
app_id=app_model.id,
tracing_provider=args.tracing_provider,
tracing_config=args.tracing_config,
session=db.session(),
)
if not result:
raise TracingConfigIsExist()
@@ -142,7 +146,10 @@ class TraceAppConfigApi(Resource):
try:
result = OpsService.update_tracing_app_config(
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
app_id=app_model.id,
tracing_provider=args.tracing_provider,
tracing_config=args.tracing_config,
session=db.session(),
)
if not result:
raise TracingConfigNotExist()
@@ -168,7 +175,9 @@ class TraceAppConfigApi(Resource):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
try:
result = OpsService.delete_tracing_app_config(app_id=app_model.id, tracing_provider=args.tracing_provider)
result = OpsService.delete_tracing_app_config(
app_id=app_model.id, tracing_provider=args.tracing_provider, session=db.session()
)
if not result:
raise TracingConfigNotExist()
return "", 204
@@ -1,6 +1,9 @@
from extensions.ext_database import db
from services.enterprise import rbac_service as enterprise_rbac_service
def get_app_permission_keys(tenant_id: str, account_id: str | None, app_id: str) -> list[str]:
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(tenant_id, account_id, [app_id])
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
tenant_id, account_id, [app_id], session=db.session()
)
return permission_keys_map.get(app_id, [])
+47 -16
View File
@@ -2,7 +2,7 @@ import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any, NotRequired, TypedDict, cast
from typing import Any, NotRequired, TypedDict
from flask import abort, request
from flask_restx import Resource, fields
@@ -522,7 +522,7 @@ class DraftWorkflowApi(Resource):
"""
# fetch draft workflow by app_model
workflow_service = WorkflowService()
workflow = workflow_service.get_draft_workflow(app_model=app_model)
workflow = workflow_service.get_draft_workflow(app_model=app_model, session=db.session())
if not workflow:
raise DraftWorkflowNotExist()
@@ -533,7 +533,7 @@ class DraftWorkflowApi(Resource):
# front-end can treat draft graph node data as the editing source.
response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=cast(Session, db.session),
session=db.session(),
draft_workflow=workflow,
)
return response
@@ -602,6 +602,7 @@ class DraftWorkflowApi(Resource):
account=current_user,
environment_variables=environment_variables,
conversation_variables=conversation_variables,
session=db.session(),
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
@@ -695,7 +696,12 @@ class AdvancedChatDraftRunIterationNodeApi(Resource):
try:
response = AppGenerateService.generate_single_iteration(
app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
app_model=app_model,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
)
return helper.compact_generate_response(response)
@@ -738,7 +744,12 @@ class WorkflowDraftRunIterationNodeApi(Resource):
try:
response = AppGenerateService.generate_single_iteration(
app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
app_model=app_model,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
)
return helper.compact_generate_response(response)
@@ -777,7 +788,12 @@ class AdvancedChatDraftRunLoopNodeApi(Resource):
try:
response = AppGenerateService.generate_single_loop(
app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
app_model=app_model,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
)
return helper.compact_generate_response(response)
@@ -820,7 +836,12 @@ class WorkflowDraftRunLoopNodeApi(Resource):
try:
response = AppGenerateService.generate_single_loop(
app_model=app_model, user=current_user, node_id=node_id, args=args, streaming=True
app_model=app_model,
user=current_user,
node_id=node_id,
args=args,
session=db.session(),
streaming=True,
)
return helper.compact_generate_response(response)
@@ -897,6 +918,7 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource):
account=current_user,
node_id=node_id,
inputs=inputs,
session=db.session(),
)
return jsonable_encoder(preview)
@@ -932,6 +954,7 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource):
form_inputs=args.form_inputs,
inputs=args.inputs,
action=args.action,
session=db.session(),
)
return jsonable_encoder(result)
@@ -963,6 +986,7 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource):
account=current_user,
node_id=node_id,
inputs=inputs,
session=db.session(),
)
return jsonable_encoder(preview)
@@ -998,6 +1022,7 @@ class WorkflowDraftHumanInputFormRunApi(Resource):
form_inputs=args.form_inputs,
inputs=args.inputs,
action=args.action,
session=db.session(),
)
return jsonable_encoder(result)
@@ -1028,6 +1053,7 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource):
node_id=node_id,
delivery_method_id=args.delivery_method_id,
inputs=args.inputs,
session=db.session(),
)
return jsonable_encoder({})
@@ -1138,7 +1164,7 @@ class DraftWorkflowNodeRunApi(Resource):
workflow_srv = WorkflowService()
# fetch draft workflow by app_model
draft_workflow = workflow_srv.get_draft_workflow(app_model=app_model)
draft_workflow = workflow_srv.get_draft_workflow(app_model=app_model, session=db.session())
if not draft_workflow:
raise ValueError("Workflow not initialized")
files = _parse_file(draft_workflow, args.get("files"))
@@ -1181,7 +1207,7 @@ class PublishedWorkflowApi(Resource):
"""
# fetch published workflow by app_model
workflow_service = WorkflowService()
workflow = workflow_service.get_published_workflow(app_model=app_model)
workflow = workflow_service.get_published_workflow(app_model=app_model, session=db.session())
# return workflow, if not found, return None
if workflow is None:
@@ -1323,7 +1349,9 @@ class ConvertToWorkflowApi(Resource):
# convert to workflow mode
workflow_service = WorkflowService()
new_app_model = workflow_service.convert_to_workflow(app_model=app_model, account=current_user, args=args)
new_app_model = workflow_service.convert_to_workflow(
app_model=app_model, account=current_user, args=args, session=db.session()
)
# return app id
return {
@@ -1358,7 +1386,9 @@ class WorkflowFeaturesApi(Resource):
features = args.features.model_dump(mode="json", exclude_unset=True)
workflow_service = WorkflowService()
workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user)
workflow_service.update_draft_workflow_features(
app_model=app_model, features=features, account=current_user, session=db.session()
)
return {"result": "success"}
@@ -1439,6 +1469,7 @@ class DraftWorkflowRestoreApi(Resource):
app_model=app_model,
workflow_id=workflow_id,
account=current_user,
session=db.session(),
)
except IsDraftWorkflowError as exc:
raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc
@@ -1553,7 +1584,7 @@ class DraftWorkflowNodeLastRunApi(Resource):
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def get(self, app_model: App, node_id: str):
srv = WorkflowService()
workflow = srv.get_draft_workflow(app_model)
workflow = srv.get_draft_workflow(app_model, session=db.session())
if not workflow:
raise NotFound("Workflow not found")
node_exec = srv.get_node_last_run(
@@ -1606,7 +1637,7 @@ class DraftWorkflowTriggerRunApi(Resource):
args = DraftWorkflowTriggerRunPayload.model_validate(console_ns.payload or {})
node_id = args.node_id
workflow_service = WorkflowService()
draft_workflow = workflow_service.get_draft_workflow(app_model)
draft_workflow = workflow_service.get_draft_workflow(app_model, session=db.session())
if not draft_workflow:
raise ValueError("Workflow not found")
@@ -1675,7 +1706,7 @@ class DraftWorkflowTriggerNodeApi(Resource):
"""
workflow_service = WorkflowService()
draft_workflow = workflow_service.get_draft_workflow(app_model)
draft_workflow = workflow_service.get_draft_workflow(app_model, session=db.session())
if not draft_workflow:
raise ValueError("Workflow not found")
@@ -1759,7 +1790,7 @@ class DraftWorkflowTriggerRunAllApi(Resource):
args = DraftWorkflowTriggerRunAllPayload.model_validate(console_ns.payload or {})
node_ids = args.node_ids
workflow_service = WorkflowService()
draft_workflow = workflow_service.get_draft_workflow(app_model)
draft_workflow = workflow_service.get_draft_workflow(app_model, session=db.session())
if not draft_workflow:
raise ValueError("Workflow not found")
@@ -1828,7 +1859,7 @@ class WorkflowOnlineUsersApi(Resource):
return {"data": []}
workflow_service = WorkflowService()
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id)
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id, session=db.session())
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
users_json_by_app_id: dict[str, Any] = {}
@@ -490,7 +490,7 @@ class WorkflowCommentMentionUsersApi(Resource):
current_tenant = current_user.current_tenant # need the tenant object here
if current_tenant is None:
raise ValueError("current tenant is required")
members = TenantService.get_tenant_members(current_tenant, session=db.session)
members = TenantService.get_tenant_members(current_tenant, session=db.session())
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = WorkflowCommentMentionUsersPayload(users=users)
return response.model_dump(mode="json"), 200
@@ -337,7 +337,7 @@ class WorkflowVariableCollectionApi(Resource):
# fetch draft workflow by app_model
workflow_service = WorkflowService()
workflow_exist = workflow_service.is_workflow_exist(app_model=app_model)
workflow_exist = workflow_service.is_workflow_exist(app_model=app_model, session=db.session())
if not workflow_exist:
raise DraftWorkflowNotExist()
@@ -553,7 +553,7 @@ class VariableResetApi(Resource):
)
workflow_srv = WorkflowService()
draft_workflow = workflow_srv.get_draft_workflow(app_model)
draft_workflow = workflow_srv.get_draft_workflow(app_model, session=db.session())
if draft_workflow is None:
raise NotFoundError(
f"Draft workflow not found, app_id={app_model.id}",
@@ -606,7 +606,7 @@ class ConversationVariableCollectionApi(Resource):
# NOTE(QuantumGhost): Prefill conversation variables into the draft variables table
# so their IDs can be returned to the caller.
workflow_srv = WorkflowService()
draft_workflow = workflow_srv.get_draft_workflow(app_model)
draft_workflow = workflow_srv.get_draft_workflow(app_model, session=db.session())
if draft_workflow is None:
raise NotFoundError(description=f"draft workflow not found, id={app_model.id}")
draft_var_srv = WorkflowDraftVariableService(db.session())
@@ -646,6 +646,7 @@ class ConversationVariableCollectionApi(Resource):
app_model=app_model,
account=current_user,
conversation_variables=conversation_variables,
session=db.session(),
)
return {"result": "success"}
@@ -683,7 +684,7 @@ class EnvironmentVariableCollectionApi(Resource):
"""
# fetch draft workflow by app_model
workflow_service = WorkflowService()
workflow = workflow_service.get_draft_workflow(app_model=app_model)
workflow = workflow_service.get_draft_workflow(app_model=app_model, session=db.session())
if workflow is None:
raise DraftWorkflowNotExist()
@@ -740,6 +741,7 @@ class EnvironmentVariableCollectionApi(Resource):
app_model=app_model,
account=current_user,
environment_variables=environment_variables,
session=db.session(),
)
return {"result": "success"}
@@ -41,6 +41,7 @@ from controllers.console.wraps import (
rbac_permission_required,
setup_required,
)
from extensions.ext_database import db
from libs.exception import BaseHTTPException
from libs.login import login_required
from models import App, AppMode
@@ -92,7 +93,9 @@ def _serve_snapshot(app_model: App, run_id: UUID) -> dict:
Flask request context.
"""
try:
snapshot = _service().snapshot_workflow_run(app_model=app_model, workflow_run_id=str(run_id))
snapshot = _service().snapshot_workflow_run(
app_model=app_model, workflow_run_id=str(run_id), session=db.session()
)
except NodeOutputInspectorError as error:
raise _InspectorNotFound(error) from error
return snapshot.model_dump(mode="json")
@@ -105,6 +108,7 @@ def _serve_node_detail(app_model: App, run_id: UUID, node_id: str) -> dict:
app_model=app_model,
workflow_run_id=str(run_id),
node_id=node_id,
session=db.session(),
)
except NodeOutputInspectorError as error:
raise _InspectorNotFound(error) from error
@@ -119,6 +123,7 @@ def _serve_output_preview(app_model: App, run_id: UUID, node_id: str, output_nam
workflow_run_id=str(run_id),
node_id=node_id,
output_name=output_name,
session=db.session(),
)
except NodeOutputInspectorError as error:
raise _InspectorNotFound(error) from error
@@ -245,7 +250,7 @@ def _stream_inspector_events(app_model: App, run_id: UUID) -> Iterator[str]:
# if the run is gone (raised before yielding any bytes, so Flask turns it
# into the normal HTTP 404 path).
try:
snapshot = service.snapshot_workflow_run(app_model=app_model, workflow_run_id=run_id_str)
snapshot = service.snapshot_workflow_run(app_model=app_model, workflow_run_id=run_id_str, session=db.session())
except NodeOutputInspectorError as error:
raise _InspectorNotFound(error) from error
@@ -308,6 +313,7 @@ def _stream_inspector_events(app_model: App, run_id: UUID) -> Iterator[str]:
app_model=app_model,
workflow_run_id=run_id_str,
node_id=message.node_id,
session=db.session(),
)
except NodeOutputInspectorError:
# Node may not appear in the graph yet (race with persistence); skip.
+8 -50
View File
@@ -1,26 +1,26 @@
"""Controller decorators for console app resources.
`with_session` opens one SQLAlchemy session for a request handler and injects it
as the first argument after `self`. Handlers use a transaction by default so
migrated write paths keep commit/rollback handling; pure read handlers may opt
out with `write=False`. App-loading decorators prefer that injected session when
present, while still supporting existing handlers that have not been migrated
yet and still rely on Flask-SQLAlchemy's scoped `db.session`.
App-loading decorators prefer a session injected by
`controllers.common.session.with_session` when present, while still supporting
existing handlers that have not been migrated yet and still rely on
Flask-SQLAlchemy's scoped `db.session`.
"""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate, cast, overload
from typing import cast, overload
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.session import with_session
from controllers.console.app.error import AppNotFoundError
from core.db.session_factory import session_factory
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode
__all__ = ["get_app_model", "get_app_model_with_trial", "with_session"]
def _load_app_model(session: Session, app_id: str) -> App | None:
"""Load the tenant-scoped app row with the request session owned by `with_session`."""
@@ -45,48 +45,6 @@ def _load_app_model_with_trial(app_id: str) -> App | None:
return app_model
@overload
def with_session[T, **P, R](
view: Callable[Concatenate[T, Session, P], R],
*,
write: bool = True,
) -> Callable[Concatenate[T, P], R]: ...
@overload
def with_session[T, **P, R](
view: None = None,
*,
write: bool = True,
) -> Callable[[Callable[Concatenate[T, Session, P], R]], Callable[Concatenate[T, P], R]]: ...
def with_session[T, **P, R](
view: Callable[Concatenate[T, Session, P], R] | None = None,
*,
write: bool = True,
) -> (
Callable[Concatenate[T, P], R] | Callable[[Callable[Concatenate[T, Session, P], R]], Callable[Concatenate[T, P], R]]
):
"""Inject a request-scoped session, using a transaction only for write handlers."""
def decorator(view: Callable[Concatenate[T, Session, P], R]) -> Callable[Concatenate[T, P], R]:
@wraps(view)
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
if write:
with session_factory.get_session_maker().begin() as session:
return view(self, session, *args, **kwargs)
with session_factory.create_session() as session:
return view(self, session, *args, **kwargs)
return wrapper
if view is None:
return decorator
return decorator(view)
def _get_injected_session(args: tuple[object, ...]) -> Session | None:
"""Return the request session inserted by `with_session`, if this handler has been migrated."""
if len(args) < 2:
+4 -4
View File
@@ -90,7 +90,7 @@ class ActivateCheckApi(Resource):
token = args.token
invitation = RegisterService.get_invitation_with_case_fallback(
workspaceId, args.email, token, session=db.session
workspaceId, args.email, token, session=db.session()
)
if invitation:
data = invitation.get("data", {})
@@ -140,7 +140,7 @@ class ActivateApi(Resource):
normalized_request_email = args.email.lower() if args.email else None
invitation = RegisterService.get_invitation_with_case_fallback(
args.workspace_id, args.email, args.token, session=db.session
args.workspace_id, args.email, args.token, session=db.session()
)
if invitation is None:
raise AlreadyActivateError()
@@ -178,7 +178,7 @@ class ActivateApi(Resource):
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
if membership_id is None:
TenantService.create_tenant_member(tenant, account, db.session, role=role)
TenantService.create_tenant_member(tenant, account, db.session(), role=role)
if setup_fields:
account.name = setup_fields[0]
@@ -188,6 +188,6 @@ class ActivateApi(Resource):
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
TenantService.switch_tenant(account, tenant.id, session=db.session)
TenantService.switch_tenant(account, tenant.id, session=db.session())
return {"result": "success"}
@@ -101,7 +101,7 @@ class EmailRegisterSendEmailApi(Resource):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session(), args.email)
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
return {"result": "success", "data": token}
@@ -176,7 +176,7 @@ class EmailRegisterResetApi(Resource):
email = register_data.get("email", "")
normalized_email = email.lower()
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
account = AccountService.get_account_by_email_with_case_fallback(db.session(), email)
if account:
raise EmailAlreadyInUseError()
@@ -187,7 +187,7 @@ class EmailRegisterResetApi(Resource):
timezone=args.timezone,
language=args.language,
)
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
token_pair = AccountService.login(account=account, session=db.session(), ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
return {"result": "success", "data": token_pair.model_dump()}
@@ -206,7 +206,7 @@ class EmailRegisterResetApi(Resource):
password=password,
interface_language=get_valid_language(language),
timezone=timezone,
session=db.session,
session=db.session(),
)
except AccountRegisterError:
raise AccountInFreezeError()
@@ -82,7 +82,7 @@ class ForgotPasswordSendEmailApi(Resource):
else:
language = "en-US"
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session(), args.email)
token = AccountService.send_reset_password_email(
account=account,
@@ -180,7 +180,7 @@ class ForgotPasswordResetApi(Resource):
password_hashed = hash_password(args.new_password, salt)
email = reset_data.get("email", "")
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
account = AccountService.get_account_by_email_with_case_fallback(db.session(), email)
if account:
account = db.session.merge(account)
@@ -198,10 +198,10 @@ class ForgotPasswordResetApi(Resource):
# Create workspace if needed
if (
not TenantService.get_join_tenants(account, session=db.session)
not TenantService.get_join_tenants(account, session=db.session())
and FeatureService.get_system_features().is_allow_create_workspace
):
tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
TenantService.create_tenant_member(tenant, account, db.session, role="owner")
tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session())
TenantService.create_tenant_member(tenant, account, db.session(), role="owner")
account.current_tenant = tenant
tenant_was_created.send(tenant)
+54 -37
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 (
@@ -51,7 +56,7 @@ from models.account import Account
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
from services.billing_service import BillingService
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
from services.errors.account import AccountRegisterError
from services.errors.account import AccountRegisterError, RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
@@ -87,6 +92,7 @@ register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPay
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
SimpleResultMessageResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
@@ -120,7 +126,7 @@ class LoginApi(Resource):
invitation_data: InvitationDetailDict | None = None
if invite_token:
invitation_data = RegisterService.get_invitation_with_case_fallback(
None, request_email, invite_token, session=db.session
None, request_email, invite_token, session=db.session()
)
if invitation_data is None:
invite_token = None
@@ -147,23 +153,26 @@ class LoginApi(Resource):
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS)
raise AuthenticationFailedError() from exc
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account, session=db.session)
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
system_features = FeatureService.get_system_features()
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))
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")
@@ -293,7 +301,7 @@ class EmailCodeLoginApi(Resource):
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
if account:
tenants = TenantService.get_join_tenants(account, session=db.session)
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
workspaces = FeatureService.get_system_features().license.workspaces
if not workspaces.is_available():
@@ -301,8 +309,8 @@ class EmailCodeLoginApi(Resource):
if not FeatureService.get_system_features().is_allow_create_workspace:
raise NotAllowedCreateWorkspace()
else:
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
TenantService.create_tenant_member(new_tenant, account, db.session, role="owner")
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session())
TenantService.create_tenant_member(new_tenant, account, db.session(), role="owner")
account.current_tenant = new_tenant
tenant_was_created.send(new_tenant)
@@ -313,7 +321,7 @@ class EmailCodeLoginApi(Resource):
name=user_email,
interface_language=get_valid_language(language),
timezone=args.timezone,
session=db.session,
session=db.session(),
)
except WorkSpaceNotAllowedCreateError:
raise NotAllowedCreateWorkspace()
@@ -322,11 +330,12 @@ class EmailCodeLoginApi(Resource):
raise AccountInFreezeError()
except WorkspacesLimitExceededError:
raise WorkspacesLimitExceeded()
token_pair = AccountService.login(account, session=db.session, ip_address=extract_remote_ip(request))
token_pair = AccountService.login(account, session=db.session(), ip_address=extract_remote_ip(request))
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,45 +347,53 @@ 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)
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session())
except Unauthorized as exc:
return SimpleResultMessageResponse(result="fail", message=exc.description or "Unauthorized.").model_dump(
mode="json"
), 401
except (RefreshTokenNotFoundError, RefreshTokenAccountNotFoundError) as exc:
return SimpleResultMessageResponse(result="fail", message=str(exc)).model_dump(mode="json"), 401
# Create response with new cookies
response = make_response({"result": "success"})
# Create response with new cookies
# 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)
set_access_token_to_cookie(request, response, new_token_pair.access_token)
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
# Update cookies with new tokens
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
set_access_token_to_cookie(request, response, new_token_pair.access_token)
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
return response
def _get_account_with_case_fallback(email: str):
account = AccountService.get_user_through_email(email, session=db.session)
account = AccountService.get_user_through_email(email, session=db.session())
if account or email == email.lower():
return account
return AccountService.get_user_through_email(email.lower(), session=db.session)
return AccountService.get_user_through_email(email.lower(), session=db.session())
def _authenticate_account_with_case_fallback(
original_email: str, normalized_email: str, password: str, invite_token: str | None
):
try:
return AccountService.authenticate(original_email, password, invite_token, session=db.session)
return AccountService.authenticate(original_email, password, invite_token, session=db.session())
except services.errors.account.AccountPasswordError:
if original_email == normalized_email:
raise
return AccountService.authenticate(normalized_email, password, invite_token, session=db.session)
return AccountService.authenticate(normalized_email, password, invite_token, session=db.session())
def _log_console_login_failure(*, email: str, reason: LoginFailureReason) -> None:
+8 -8
View File
@@ -195,7 +195,7 @@ class OAuthCallback(Resource):
db.session.commit()
try:
TenantService.create_owner_tenant_if_not_exist(account, session=db.session)
TenantService.create_owner_tenant_if_not_exist(account, session=db.session())
except Unauthorized:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Workspace not found.")
except WorkSpaceNotAllowedCreateError:
@@ -206,7 +206,7 @@ class OAuthCallback(Resource):
token_pair = AccountService.login(
account=account,
session=db.session,
session=db.session(),
ip_address=extract_remote_ip(request),
)
@@ -225,7 +225,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
account: Account | None = Account.get_by_openid(provider, user_info.id)
if not account:
account = AccountService.get_account_by_email_with_case_fallback(db.session, user_info.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session(), user_info.email)
return account
@@ -241,13 +241,13 @@ def _generate_account(
oauth_new_user = False
if account:
tenants = TenantService.get_join_tenants(account, session=db.session)
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
if not FeatureService.get_system_features().is_allow_create_workspace:
raise WorkSpaceNotAllowedCreateError()
else:
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
TenantService.create_tenant_member(new_tenant, account, db.session, role="owner")
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session())
TenantService.create_tenant_member(new_tenant, account, db.session(), role="owner")
account.current_tenant = new_tenant
tenant_was_created.send(new_tenant)
@@ -273,10 +273,10 @@ def _generate_account(
provider=provider,
language=interface_language,
timezone=timezone,
session=db.session,
session=db.session(),
)
# Link account
AccountService.link_account_integrate(provider, user_info.id, account, session=db.session)
AccountService.link_account_integrate(provider, user_info.id, account, session=db.session())
return account, oauth_new_user
+4 -1
View File
@@ -10,6 +10,7 @@ from werkzeug.exceptions import BadRequest, NotFound
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
from extensions.ext_database import db
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.login import login_required
from models import Account
@@ -131,7 +132,9 @@ def oauth_server_access_token_required[T, **P, R](
response.headers["WWW-Authenticate"] = "Bearer"
return response
account = OAuthServerService.validate_oauth_access_token(oauth_provider_app.client_id, access_token)
account = OAuthServerService.validate_oauth_access_token(
oauth_provider_app.client_id, access_token, db.session()
)
if not account:
response = jsonify({"error": "access_token or client_id is invalid"})
response.status_code = 401
+2 -2
View File
@@ -56,7 +56,7 @@ class Subscription(Resource):
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
BillingService.is_tenant_owner_or_admin(db.session, current_user)
BillingService.is_tenant_owner_or_admin(db.session(), current_user)
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
@@ -70,7 +70,7 @@ class Invoices(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
BillingService.is_tenant_owner_or_admin(db.session, current_user)
BillingService.is_tenant_owner_or_admin(db.session(), current_user)
return BillingService.get_invoices(current_user.email, current_tenant_id)
@@ -245,7 +245,7 @@ class DataSourceNotionListApi(Resource):
exist_page_ids = []
# import notion in the exist dataset
if query.dataset_id:
dataset = DatasetService.get_dataset(query.dataset_id, db.session)
dataset = DatasetService.get_dataset(query.dataset_id, db.session())
if not dataset:
raise NotFound("Dataset not found.")
if dataset.data_source_type != "notion_import":
@@ -400,11 +400,11 @@ class DataSourceNotionDatasetSyncApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
def get(self, dataset_id: UUID) -> tuple[dict[str, str], int]:
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
documents = DocumentService.get_document_by_dataset_id(dataset_id_str, db.session)
documents = DocumentService.get_document_by_dataset_id(dataset_id_str, db.session())
for document in documents:
document_indexing_sync_task.delay(dataset_id_str, document.id)
return {"result": "success"}, 200
@@ -420,11 +420,11 @@ class DataSourceNotionDocumentSyncApi(Resource):
def get(self, dataset_id: UUID, document_id: UUID) -> tuple[dict[str, str], int]:
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if document is None:
raise NotFound("Document not found.")
document_indexing_sync_task.delay(dataset_id_str, document_id_str)
+28 -24
View File
@@ -418,6 +418,7 @@ class DatasetListApi(Resource):
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
str(current_tenant_id),
current_user.id,
session=db.session(),
)
accessible_dataset_ids: list[str] | None = None
@@ -461,7 +462,7 @@ class DatasetListApi(Resource):
datasets, total = DatasetService.get_datasets(
query.page,
query.limit,
db.session,
db.session(),
current_tenant_id,
current_user,
query.keyword,
@@ -573,6 +574,7 @@ class DatasetListApi(Resource):
current_tenant_id,
current_user.id,
[dataset.id],
session=session,
)
item = DatasetDetailWithPartialMembersResponse.model_validate(dataset, from_attributes=True).model_dump(
@@ -602,17 +604,18 @@ class DatasetApi(Resource):
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
current_tenant_id,
current_user.id,
dataset_id=dataset_id_str,
session=db.session(),
)
permission_keys_map = permissions.dataset.permission_keys_by_resource_ids([dataset_id_str])
data = dump_response(DatasetDetailResponse, dataset)
@@ -622,7 +625,7 @@ class DatasetApi(Resource):
provider_id = ModelProviderID(dataset.embedding_model_provider)
data["embedding_model_provider"] = str(provider_id)
if data.get("permission") == "partial_members":
part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str, db.session)
part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str, db.session())
data.update({"partial_member_list": part_users_list})
# check embedding setting
@@ -666,7 +669,7 @@ class DatasetApi(Resource):
@with_session
def patch(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
@@ -697,6 +700,7 @@ class DatasetApi(Resource):
current_tenant_id,
current_user.id,
[dataset_id_str],
session=session,
)
result_data = dump_response(DatasetDetailResponse, dataset)
result_data["permission_keys"] = permission_keys_map.get(dataset_id_str, [])
@@ -704,13 +708,13 @@ class DatasetApi(Resource):
if payload.partial_member_list is not None and payload.permission == DatasetPermissionEnum.PARTIAL_TEAM:
DatasetPermissionService.update_partial_member_list(
tenant_id, dataset_id_str, payload.partial_member_list, db.session
tenant_id, dataset_id_str, payload.partial_member_list, db.session()
)
# clear partial member list when permission is only_me or all_team_members
elif payload.permission in {DatasetPermissionEnum.ONLY_ME, DatasetPermissionEnum.ALL_TEAM}:
DatasetPermissionService.clear_partial_member_list(dataset_id_str, db.session)
DatasetPermissionService.clear_partial_member_list(dataset_id_str, db.session())
partial_member_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str, db.session)
partial_member_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str, db.session())
result_data.update({"partial_member_list": partial_member_list})
return result_data, 200
@@ -729,8 +733,8 @@ class DatasetApi(Resource):
raise Forbidden()
try:
if DatasetService.delete_dataset(dataset_id_str, current_user, db.session):
DatasetPermissionService.clear_partial_member_list(dataset_id_str, db.session)
if DatasetService.delete_dataset(dataset_id_str, current_user, db.session()):
DatasetPermissionService.clear_partial_member_list(dataset_id_str, db.session())
return "", 204
else:
raise NotFound("Dataset not found.")
@@ -755,7 +759,7 @@ class DatasetUseCheckApi(Resource):
def get(self, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset_is_using = DatasetService.dataset_use_check(dataset_id_str, db.session)
dataset_is_using = DatasetService.dataset_use_check(dataset_id_str, db.session())
return {"is_using": dataset_is_using}, 200
@@ -776,12 +780,12 @@ class DatasetQueryApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
def get(self, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -917,16 +921,16 @@ class DatasetRelatedAppListApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
def get(self, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
app_dataset_joins = DatasetService.get_related_apps(dataset.id, db.session)
app_dataset_joins = DatasetService.get_related_apps(dataset.id, db.session())
related_apps = []
for app_dataset_join in app_dataset_joins:
@@ -1101,7 +1105,7 @@ class DatasetEnableApiApi(Resource):
def post(self, dataset_id: UUID, status: str):
dataset_id_str = str(dataset_id)
DatasetService.update_dataset_api_status(dataset_id_str, status == "enable", db.session)
DatasetService.update_dataset_api_status(dataset_id_str, status == "enable", db.session())
return {"result": "success"}, 200
@@ -1170,10 +1174,10 @@ class DatasetErrorDocs(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
def get(self, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
results = DocumentService.get_error_documents_by_dataset_id(dataset_id_str, db.session)
results = DocumentService.get_error_documents_by_dataset_id(dataset_id_str, db.session())
return dump_response(ErrorDocsResponse, {"data": results, "total": len(results)}), 200
@@ -1197,15 +1201,15 @@ class DatasetPermissionUserListApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
def get(self, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
partial_members_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str, db.session)
partial_members_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str, db.session())
return dump_response(PartialMemberListResponse, {"data": partial_members_list}), 200
@@ -1227,8 +1231,8 @@ class DatasetAutoDisableLogApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY)
def get(self, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
auto_disable_logs = DatasetService.get_dataset_auto_disable_logs(dataset_id_str, db.session)
auto_disable_logs = DatasetService.get_dataset_auto_disable_logs(dataset_id_str, db.session())
return dump_response(AutoDisableLogsResponse, auto_disable_logs), 200
@@ -46,6 +46,7 @@ from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
from libs.datetime_utils import naive_utc_now
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from models import Account, DatasetProcessRule, Document, DocumentSegment, UploadFile
from models.dataset import DocumentPipelineExecutionLog
from models.enums import IndexingStatus, SegmentStatus
@@ -182,16 +183,16 @@ class DocumentResource(Resource):
def get_document(
self, dataset_id: str, document_id: str, current_user: Account, current_tenant_id: str
) -> Document:
dataset = DatasetService.get_dataset(dataset_id, db.session)
dataset = DatasetService.get_dataset(dataset_id, db.session())
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
document = DocumentService.get_document(dataset_id, document_id, session=db.session)
document = DocumentService.get_document(dataset_id, document_id, session=db.session())
if not document:
raise NotFound("Document not found.")
@@ -202,16 +203,16 @@ class DocumentResource(Resource):
return document
def get_batch_documents(self, dataset_id: str, batch: str, current_user: Account) -> Sequence[Document]:
dataset = DatasetService.get_dataset(dataset_id, db.session)
dataset = DatasetService.get_dataset(dataset_id, db.session())
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
documents = DocumentService.get_batch_documents(dataset_id, batch, db.session)
documents = DocumentService.get_batch_documents(dataset_id, batch, db.session())
if not documents:
raise NotFound("Documents not found.")
@@ -242,13 +243,13 @@ class GetProcessRuleApi(Resource):
# get the latest process rule
document = db.get_or_404(Document, document_id)
dataset = DatasetService.get_dataset(document.dataset_id, db.session)
dataset = DatasetService.get_dataset(document.dataset_id, db.session())
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -318,12 +319,12 @@ class DatasetDocumentListApi(Resource):
)
except (ArgumentTypeError, ValueError, Exception):
fetch = False
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -368,13 +369,14 @@ class DatasetDocumentListApi(Resource):
desc(Document.position),
)
paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False)
paginated_documents = paginate_query(query, page=page, per_page=limit, max_per_page=100)
documents = paginated_documents.items
DocumentService.enrich_documents_with_summary_index_status(
documents=documents,
dataset=dataset,
tenant_id=current_tenant_id,
session=db.session(),
)
if fetch:
@@ -422,7 +424,7 @@ class DatasetDocumentListApi(Resource):
def post(self, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
@@ -432,7 +434,7 @@ class DatasetDocumentListApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -446,9 +448,9 @@ class DatasetDocumentListApi(Resource):
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset, knowledge_config, current_user, session=db.session
dataset, knowledge_config, current_user, session=db.session()
)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@@ -467,7 +469,7 @@ class DatasetDocumentListApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
def delete(self, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
# check user's model setting
@@ -476,7 +478,7 @@ class DatasetDocumentListApi(Resource):
try:
document_ids = request.args.getlist("document_id")
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
DocumentService.delete_documents(dataset_ref, document_ids, dataset.doc_form, db.session)
DocumentService.delete_documents(dataset_ref, document_ids, dataset.doc_form, db.session())
except services.errors.document.DocumentIndexingError:
raise DocumentIndexingError("Cannot delete document during indexing.")
@@ -535,7 +537,7 @@ class DatasetInitApi(Resource):
tenant_id=current_tenant_id,
knowledge_config=knowledge_config,
account=current_user,
session=db.session,
session=db.session(),
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@@ -872,7 +874,7 @@ class DocumentApi(DocumentResource):
if metadata == "only":
response = {"id": document.id, "doc_type": document.doc_type, "doc_metadata": document.doc_metadata_details}
elif metadata == "without":
dataset_process_rules = DatasetService.get_process_rules(dataset_id_str, db.session)
dataset_process_rules = DatasetService.get_process_rules(dataset_id_str, db.session())
document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {}
response = {
"id": document.id,
@@ -906,7 +908,7 @@ class DocumentApi(DocumentResource):
"need_summary": document.need_summary if document.need_summary is not None else False,
}
else:
dataset_process_rules = DatasetService.get_process_rules(dataset_id_str, db.session)
dataset_process_rules = DatasetService.get_process_rules(dataset_id_str, db.session())
document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {}
response = {
"id": document.id,
@@ -955,7 +957,7 @@ class DocumentApi(DocumentResource):
def delete(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID):
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
# check user's model setting
@@ -964,7 +966,7 @@ class DocumentApi(DocumentResource):
document = self.get_document(dataset_id_str, document_id_str, current_user, current_tenant_id)
try:
DocumentService.delete_document(document, db.session)
DocumentService.delete_document(document, db.session())
except services.errors.document.DocumentIndexingError:
raise DocumentIndexingError("Cannot delete document during indexing.")
@@ -988,7 +990,7 @@ class DocumentDownloadApi(DocumentResource):
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID) -> dict[str, Any]:
# Reuse the shared permission/tenant checks implemented in DocumentResource.
document = self.get_document(str(dataset_id), str(document_id), current_user, current_tenant_id)
return {"url": DocumentService.get_document_download_url(document, db.session)}
return {"url": DocumentService.get_document_download_url(document, db.session())}
@console_ns.route("/datasets/<uuid:dataset_id>/documents/download-zip")
@@ -1018,7 +1020,7 @@ class DocumentBatchDownloadZipApi(DocumentResource):
document_ids=document_ids,
tenant_id=current_tenant_id,
current_user=current_user,
session=db.session,
session=db.session(),
)
# Delegate ZIP packing to FileService, but keep Flask response+cleanup in the route.
@@ -1167,7 +1169,7 @@ class DocumentStatusApi(DocumentResource):
self, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable", "archive", "un_archive"]
):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
@@ -1179,12 +1181,12 @@ class DocumentStatusApi(DocumentResource):
DatasetService.check_dataset_model_setting(dataset)
# check user's permission
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
document_ids = request.args.getlist("document_id")
try:
DocumentService.batch_update_document_status(dataset, document_ids, action, current_user, db.session)
DocumentService.batch_update_document_status(dataset, document_ids, action, current_user, db.session())
except services.errors.document.DocumentIndexingError as e:
raise InvalidActionError(str(e))
except ValueError as e:
@@ -1208,11 +1210,11 @@ class DocumentPauseApi(DocumentResource):
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session)
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session())
# 404 if document not found
if document is None:
@@ -1224,7 +1226,7 @@ class DocumentPauseApi(DocumentResource):
try:
# pause document
DocumentService.pause_document(document, db.session)
DocumentService.pause_document(document, db.session())
except services.errors.document.DocumentIndexingError:
raise DocumentIndexingError("Cannot pause completed document.")
@@ -1243,10 +1245,10 @@ class DocumentRecoverApi(DocumentResource):
"""recover document."""
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session)
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session())
# 404 if document not found
if document is None:
@@ -1257,7 +1259,7 @@ class DocumentRecoverApi(DocumentResource):
raise ArchivedDocumentImmutableError()
try:
# pause document
DocumentService.recover_document(document, db.session)
DocumentService.recover_document(document, db.session())
except services.errors.document.DocumentIndexingError:
raise DocumentIndexingError("Document is not in paused status.")
@@ -1277,13 +1279,13 @@ class DocumentRetryApi(DocumentResource):
"""retry document."""
payload = DocumentRetryPayload.model_validate(console_ns.payload or {})
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
retry_documents = []
if not dataset:
raise NotFound("Dataset not found.")
for document_id in payload.document_ids:
try:
document = DocumentService.get_document(dataset.id, document_id, session=db.session)
document = DocumentService.get_document(dataset.id, document_id, session=db.session())
# 404 if document not found
if document is None:
@@ -1301,7 +1303,7 @@ class DocumentRetryApi(DocumentResource):
logger.exception("Failed to retry document, document id: %s", document_id)
continue
# retry document
DocumentService.retry_document(dataset_id_str, retry_documents, db.session)
DocumentService.retry_document(dataset_id_str, retry_documents, db.session())
return "", 204
@@ -1319,14 +1321,14 @@ class DocumentRenameApi(DocumentResource):
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
if not current_user.is_dataset_editor:
raise Forbidden()
dataset = DatasetService.get_dataset(dataset_id, db.session)
dataset = DatasetService.get_dataset(dataset_id, db.session())
if not dataset:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_operator_permission(current_user, dataset, session=db.session)
DatasetService.check_dataset_operator_permission(current_user, dataset, session=db.session())
payload = DocumentRenamePayload.model_validate(console_ns.payload or {})
try:
document = DocumentService.rename_document(str(dataset_id), str(document_id), payload.name, db.session)
document = DocumentService.rename_document(str(dataset_id), str(document_id), payload.name, db.session())
except services.errors.document.DocumentIndexingError:
raise DocumentIndexingError("Cannot delete document during indexing.")
@@ -1344,11 +1346,11 @@ class WebsiteDocumentSyncApi(DocumentResource):
def get(self, current_tenant_id: str, dataset_id: UUID, document_id: UUID):
"""sync website document."""
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
document_id_str = str(document_id)
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session)
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
if document.tenant_id != current_tenant_id:
@@ -1359,7 +1361,7 @@ class WebsiteDocumentSyncApi(DocumentResource):
if DocumentService.check_archived(document):
raise ArchivedDocumentImmutableError()
# sync document
DocumentService.sync_website_document(dataset_id_str, document, db.session)
DocumentService.sync_website_document(dataset_id_str, document, db.session())
return {"result": "success"}, 200
@@ -1379,10 +1381,10 @@ class DocumentPipelineExecutionLogApi(DocumentResource):
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session)
document = DocumentService.get_document(dataset.id, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
log = db.session.scalar(
@@ -1437,7 +1439,7 @@ class DocumentGenerateSummaryApi(Resource):
dataset_id_str = str(dataset_id)
# Get dataset
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
@@ -1446,7 +1448,7 @@ class DocumentGenerateSummaryApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -1471,7 +1473,7 @@ class DocumentGenerateSummaryApi(Resource):
raise ValueError("Summary index is not enabled for this dataset. Please enable it in the dataset settings.")
# Verify all documents exist and belong to the dataset
documents = DocumentService.get_documents_by_ids(dataset_id_str, document_list, db.session)
documents = DocumentService.get_documents_by_ids(dataset_id_str, document_list, db.session())
if len(documents) != len(document_list):
found_ids = {doc.id for doc in documents}
@@ -1487,7 +1489,7 @@ class DocumentGenerateSummaryApi(Resource):
DocumentService.update_documents_need_summary(
dataset_id=dataset_id_str,
document_ids=document_ids_to_update,
session=db.session,
session=db.session(),
need_summary=True,
)
@@ -1538,13 +1540,13 @@ class DocumentSummaryStatusApi(DocumentResource):
document_id_str = str(document_id)
# Get dataset
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# Check permissions
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -1554,7 +1556,7 @@ class DocumentSummaryStatusApi(DocumentResource):
result = SummaryIndexService.get_document_summary_status_detail(
document_id=document_id_str,
dataset_id=dataset_id_str,
session=db.session,
session=db.session(),
)
return result, 200
@@ -57,6 +57,7 @@ from fields.segment_fields import (
from graphon.model_runtime.entities.model_entities import ModelType
from libs.helper import dump_response, escape_like_pattern
from libs.login import login_required
from libs.pagination import paginate_query
from models import Account
from models.dataset import Dataset, Document, DocumentSegment
from models.model import UploadFile
@@ -172,7 +173,7 @@ def _get_segment_for_document(
raise NotFound("Document not found.")
segment_ref = DatasetRefService.create_segment_ref(document_ref, segment_id)
segment = SegmentService.get_segment_by_ref(segment_ref)
segment = SegmentService.get_segment_by_ref(segment_ref, db.session())
if not segment:
raise NotFound("Segment not found.")
return segment_ref, segment
@@ -192,16 +193,16 @@ class DatasetDocumentSegmentListApi(Resource):
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID):
dataset_id_str = str(dataset_id)
document_id_str = str(document_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
@@ -270,14 +271,14 @@ class DatasetDocumentSegmentListApi(Resource):
elif args.enabled.lower() == "false":
query = query.where(DocumentSegment.enabled == False)
segments = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False)
segments = paginate_query(query, page=page, per_page=limit, max_per_page=100)
segment_list = list(segments.items)
segment_ids = [segment.id for segment in segment_list]
summaries: dict[str, str | None] = {}
if segment_ids:
summary_records = SummaryIndexService.get_segments_summaries(
segment_ids=segment_ids, dataset_id=dataset_id_str
segment_ids=segment_ids, dataset_id=dataset_id_str, session=db.session()
)
summaries = {chunk_id: summary.summary_content for chunk_id, summary in summary_records.items()}
@@ -302,14 +303,14 @@ class DatasetDocumentSegmentListApi(Resource):
def delete(self, current_user: Account, dataset_id: UUID, document_id: UUID):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
segment_ids = request.args.getlist("segment_id")
@@ -318,10 +319,10 @@ class DatasetDocumentSegmentListApi(Resource):
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
SegmentService.delete_segments(segment_ids, document, dataset, db.session)
SegmentService.delete_segments(segment_ids, document, dataset, db.session())
return "", 204
@@ -347,11 +348,11 @@ class DatasetDocumentSegmentApi(Resource):
action: Literal["enable", "disable"],
):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
# check user's model setting
@@ -361,7 +362,7 @@ class DatasetDocumentSegmentApi(Resource):
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
@@ -387,7 +388,7 @@ class DatasetDocumentSegmentApi(Resource):
if cache_result is not None:
raise InvalidActionError("Document is being indexed, please try again later")
try:
SegmentService.update_segments_status(segment_ids, action, dataset, document, db.session)
SegmentService.update_segments_status(segment_ids, action, dataset, document, db.session())
except Exception as e:
raise InvalidActionError(str(e))
return dump_response(SimpleResultResponse, {"result": "success"}), 200
@@ -410,12 +411,12 @@ class DatasetDocumentSegmentAddApi(Resource):
def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
if not current_user.is_dataset_editor:
@@ -437,15 +438,20 @@ class DatasetDocumentSegmentAddApi(Resource):
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# validate args
payload = SegmentCreatePayload.model_validate(console_ns.payload or {})
payload_dict = payload.model_dump(exclude_none=True)
SegmentService.segment_create_args_validate(payload_dict, document)
segment = type_cast(DocumentSegment, SegmentService.create_segment(payload_dict, document, dataset, db.session))
summary = SummaryIndexService.get_segment_summary(segment_id=segment.id, dataset_id=dataset_id_str)
segment = type_cast(
DocumentSegment,
SegmentService.create_segment(payload_dict, document, dataset, db.session()),
)
summary = SummaryIndexService.get_segment_summary(
segment_id=segment.id, dataset_id=dataset_id_str, session=db.session()
)
response = {
"data": segment_response_with_summary(segment, summary.summary_content if summary else None),
"doc_form": document.doc_form,
@@ -471,21 +477,21 @@ class DatasetDocumentSegmentUpdateApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
@@ -517,9 +523,11 @@ class DatasetDocumentSegmentUpdateApi(Resource):
segment,
document,
dataset,
db.session,
db.session(),
)
summary = SummaryIndexService.get_segment_summary(
segment_id=segment.id, dataset_id=dataset_id_str, session=db.session()
)
summary = SummaryIndexService.get_segment_summary(segment_id=segment.id, dataset_id=dataset_id_str)
response = {
"data": segment_response_with_summary(segment, summary.summary_content if summary else None),
"doc_form": document.doc_form,
@@ -540,26 +548,26 @@ class DatasetDocumentSegmentUpdateApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
SegmentService.delete_segment(segment, document, dataset, db.session)
SegmentService.delete_segment(segment, document, dataset, db.session())
return "", 204
@@ -582,12 +590,12 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
@@ -657,18 +665,18 @@ class ChildChunkAddApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# check embedding model setting
@@ -692,7 +700,7 @@ class ChildChunkAddApi(Resource):
# validate args
try:
payload = ChildChunkCreatePayload.model_validate(console_ns.payload or {})
child_chunk = SegmentService.create_child_chunk(payload.content, segment, document, dataset, db.session)
child_chunk = SegmentService.create_child_chunk(payload.content, segment, document, dataset, db.session())
except ChildChunkIndexingServiceError as e:
raise ChildChunkIndexingError(str(e))
return dump_response(ChildChunkDetailResponse, {"data": child_chunk}), 200
@@ -708,14 +716,14 @@ class ChildChunkAddApi(Resource):
def get(self, current_tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
segment_id_str = str(segment_id)
@@ -758,21 +766,21 @@ class ChildChunkAddApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
@@ -780,7 +788,7 @@ class ChildChunkAddApi(Resource):
# validate args
payload = ChildChunkBatchUpdatePayload.model_validate(console_ns.payload or {})
try:
child_chunks = SegmentService.update_child_chunks(payload.chunks, segment, document, dataset, db.session)
child_chunks = SegmentService.update_child_chunks(payload.chunks, segment, document, dataset, db.session())
except ChildChunkIndexingServiceError as e:
raise ChildChunkIndexingError(str(e))
return dump_response(ChildChunkBatchUpdateResponse, {"data": child_chunks}), 200
@@ -810,31 +818,31 @@ class ChildChunkUpdateApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
segment_ref, _ = _get_segment_for_document(dataset, document, segment_id_str)
child_chunk_id_str = str(child_chunk_id)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref, db.session())
if not child_chunk:
raise NotFound("Child chunk not found.")
try:
SegmentService.delete_child_chunk(child_chunk, dataset, db.session)
SegmentService.delete_child_chunk(child_chunk, dataset, db.session())
except ChildChunkDeleteIndexServiceError as e:
raise ChildChunkDeleteIndexError(str(e))
return "", 204
@@ -861,34 +869,34 @@ class ChildChunkUpdateApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
DatasetService.check_dataset_model_setting(dataset)
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session())
if not document:
raise NotFound("Document not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
segment_ref, segment = _get_segment_for_document(dataset, document, segment_id_str)
child_chunk_id_str = str(child_chunk_id)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref, db.session())
if not child_chunk:
raise NotFound("Child chunk not found.")
# validate args
try:
payload = ChildChunkUpdatePayload.model_validate(console_ns.payload or {})
child_chunk = SegmentService.update_child_chunk(
payload.content, child_chunk, segment, document, dataset, db.session
payload.content, child_chunk, segment, document, dataset, db.session()
)
except ChildChunkIndexingServiceError as e:
raise ChildChunkIndexingError(str(e))
+7 -6
View File
@@ -299,7 +299,9 @@ class ExternalApiTemplateApi(Resource):
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
ExternalDatasetService.delete_external_knowledge_api(session, current_tenant_id, external_knowledge_api_id_str)
ExternalDatasetService.delete_external_knowledge_api(
current_tenant_id, external_knowledge_api_id_str, session=session
)
return "", 204
@@ -318,9 +320,7 @@ class ExternalApiUseCheckApi(Resource):
external_knowledge_api_id_str = str(external_knowledge_api_id)
external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check(
session,
external_knowledge_api_id_str,
current_tenant_id,
external_knowledge_api_id_str, current_tenant_id, session=session
)
return {"is_using": external_knowledge_api_is_using, "count": count}, 200
@@ -366,6 +366,7 @@ class ExternalDatasetCreateApi(Resource):
str(current_tenant_id),
current_user.id,
[dataset_id_str],
session=session,
)
item["permission_keys"] = permission_keys_map.get(dataset_id_str, [])
@@ -393,12 +394,12 @@ class ExternalKnowledgeHitTestingApi(Resource):
@with_session
def post(self, session: Session, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
@@ -86,12 +86,12 @@ class DatasetsHitTestingBase:
dataset_id: str, current_user: Account | None = None, current_tenant_id: str | None = None
) -> Dataset:
current_user, _ = resolve_account_fallback(current_user, current_tenant_id)
dataset = DatasetService.get_dataset(dataset_id, db.session)
dataset = DatasetService.get_dataset(dataset_id, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
+11 -11
View File
@@ -61,10 +61,10 @@ class DatasetMetadataCreateApi(Resource):
metadata_args = MetadataArgs.model_validate(console_ns.payload or {})
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
metadata = MetadataService.create_metadata(
db.session(), dataset_id_str, metadata_args, current_user, current_tenant_id
@@ -81,7 +81,7 @@ class DatasetMetadataCreateApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT)
def get(self, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
metadata = MetadataService.get_dataset_metadatas(db.session(), dataset)
@@ -105,10 +105,10 @@ class DatasetMetadataApi(Resource):
dataset_id_str = str(dataset_id)
metadata_id_str = str(metadata_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
metadata = MetadataService.update_metadata_name(
db.session(), dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
@@ -125,10 +125,10 @@ class DatasetMetadataApi(Resource):
def delete(self, current_user: Account, dataset_id: UUID, metadata_id: UUID):
dataset_id_str = str(dataset_id)
metadata_id_str = str(metadata_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
@@ -162,10 +162,10 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
def post(self, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable"]):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
match action:
case "enable":
@@ -191,10 +191,10 @@ class DocumentMetadataEditApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
def post(self, current_user: Account, dataset_id: UUID):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, db.session)
dataset = DatasetService.get_dataset(dataset_id_str, db.session())
if dataset is None:
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user, db.session)
DatasetService.check_dataset_permission(dataset, current_user, db.session())
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
@@ -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,14 @@ 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 extensions.ext_database import db
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 +36,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 +48,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 +82,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 +139,9 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
DatasourceCredentialsResponse,
DatasourceCredentialListResponse,
DatasourceProviderAuthListResponse,
PluginOAuthAuthorizationUrlResponse,
RedirectResponse,
SimpleResultResponse,
)
@@ -100,7 +151,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 +191,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 +206,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 +266,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 +289,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):
@@ -255,8 +310,9 @@ class DatasourceAuth(Resource):
provider=datasource_provider_id.provider_name,
plugin_id=datasource_provider_id.plugin_id,
user=user,
session=db.session(),
)
return {"result": datasources}, 200
return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/delete")
@@ -281,14 +337,17 @@ class DatasourceAuthDeleteApi(Resource):
auth_id=payload.credential_id,
provider=provider_name,
plugin_id=plugin_id,
session=db.session(),
)
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,39 +367,53 @@ 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
@with_current_tenant_id
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
datasources = datasource_provider_service.get_all_datasource_credentials(
tenant_id=current_tenant_id, session=db.session()
)
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
@with_current_tenant_id
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
datasources = datasource_provider_service.get_hard_code_datasource_credentials(
tenant_id=current_tenant_id, session=db.session()
)
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 +430,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 +444,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 +466,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 +489,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,12 +3,13 @@ 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
from extensions.ext_database import db
from libs.login import login_required
from models import Account
from models.dataset import Pipeline
@@ -21,18 +22,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
@@ -46,7 +42,7 @@ class DataSourceContentPreviewApi(Resource):
inputs = args.inputs
datasource_type = args.datasource_type
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
preview_content = rag_pipeline_service.run_datasource_node_preview(
pipeline=pipeline,
node_id=node_id,
@@ -5,7 +5,7 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import NotFound
from controllers.common.fields import SimpleDataResponse
@@ -16,6 +16,7 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.wraps import with_session
from controllers.console.wraps import (
account_initialization_required,
enterprise_license_required,
@@ -102,10 +103,16 @@ class PipelineTemplateListApi(Resource):
@account_initialization_required
@enterprise_license_required
@with_current_tenant_id
def get(self, current_tenant_id: str) -> JsonResponseWithStatus:
@with_session
def get(self, session: Session, current_tenant_id: str) -> JsonResponseWithStatus:
query = PipelineTemplateListQuery.model_validate(request.args.to_dict(flat=True))
# get pipeline templates
pipeline_templates = RagPipelineService.get_pipeline_templates(query.type, query.language, current_tenant_id)
pipeline_templates = RagPipelineService.get_pipeline_templates(
type=query.type,
language=query.language,
current_tenant_id=current_tenant_id,
session=session,
)
return dump_response(PipelineTemplateListResponse, pipeline_templates), 200
@@ -117,10 +124,14 @@ class PipelineTemplateDetailApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
def get(self, template_id: str) -> JsonResponseWithStatus:
@with_session
def get(self, session: Session, template_id: str) -> JsonResponseWithStatus:
query = PipelineTemplateDetailQuery.model_validate(request.args.to_dict(flat=True))
rag_pipeline_service = RagPipelineService()
pipeline_template = rag_pipeline_service.get_pipeline_template_detail(template_id, query.type)
pipeline_template = RagPipelineService.get_pipeline_template_detail(
template_id,
type=query.type,
session=session,
)
if pipeline_template is None:
raise NotFound("Pipeline template not found from upstream service.")
return dump_response(PipelineTemplateDetailResponse, pipeline_template), 200
@@ -140,7 +151,7 @@ class CustomizedPipelineTemplateApi(Resource):
payload = CustomizedPipelineTemplatePayload.model_validate(console_ns.payload or {})
pipeline_template_info = PipelineTemplateInfoEntity.model_validate(payload.model_dump())
RagPipelineService.update_customized_pipeline_template(
template_id, pipeline_template_info, current_user, current_tenant_id
template_id, pipeline_template_info, current_user, current_tenant_id, session=db.session()
)
return "", 204
@@ -151,7 +162,7 @@ class CustomizedPipelineTemplateApi(Resource):
@enterprise_license_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, template_id: str) -> tuple[str, int]:
RagPipelineService.delete_customized_pipeline_template(template_id, current_tenant_id)
RagPipelineService.delete_customized_pipeline_template(template_id, current_tenant_id, session=db.session())
return "", 204
@setup_required
@@ -183,8 +194,8 @@ class PublishCustomizedPipelineTemplateApi(Resource):
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, pipeline_id: str) -> tuple[str, int]:
payload = CustomizedPipelineTemplatePayload.model_validate(console_ns.payload or {})
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
rag_pipeline_service.publish_customized_pipeline_template(
pipeline_id, payload.model_dump(), current_user, current_tenant_id
pipeline_id, payload.model_dump(), current_user, current_tenant_id, session=db.session()
)
return "", 204
@@ -65,7 +65,7 @@ class CreateRagPipelineDatasetApi(Resource):
yaml_content=payload.yaml_content,
)
try:
rag_pipeline_dsl_service = RagPipelineDslService(db.session)
rag_pipeline_dsl_service = RagPipelineDslService(db.session())
import_info = rag_pipeline_dsl_service.create_rag_pipeline_dataset(
tenant_id=current_tenant_id,
rag_pipeline_dataset_create_entity=rag_pipeline_dataset_create_entity,
@@ -75,7 +75,7 @@ class CreateRagPipelineDatasetApi(Resource):
current_tenant_id,
import_info["dataset_id"],
rag_pipeline_dataset_create_entity.partial_member_list,
db.session,
db.session(),
)
db.session.commit()
except services.errors.dataset.DatasetNameDuplicateError:
@@ -110,6 +110,6 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
permission=DatasetPermissionEnum.ONLY_ME,
partial_member_list=None,
),
session=db.session,
session=db.session(),
)
return dump_response(DatasetDetailResponse, dataset), 201
@@ -98,7 +98,7 @@ class RagPipelineVariableCollectionApi(Resource):
query = PaginationQuery.model_validate(request.args.to_dict())
# fetch draft workflow by app_model
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow_exist = rag_pipeline_service.is_workflow_exist(pipeline=pipeline)
if not workflow_exist:
raise DraftWorkflowNotExist()
@@ -290,7 +290,7 @@ class RagPipelineVariableResetApi(Resource):
session=db.session(),
)
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
draft_workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline)
if draft_workflow is None:
raise NotFoundError(
@@ -347,7 +347,7 @@ class RagPipelineEnvironmentVariableCollectionApi(Resource):
Get draft workflow
"""
# fetch draft workflow by app_model
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline)
if workflow is None:
raise DraftWorkflowNotExist()
@@ -6,7 +6,7 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, ValidationError
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
@@ -26,6 +26,7 @@ from controllers.console.app.workflow import (
WorkflowPaginationResponse,
WorkflowResponse,
)
from controllers.console.app.wraps import with_session
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import (
RBACPermission,
@@ -196,7 +197,7 @@ class DraftRagPipelineApi(Resource):
Get draft rag pipeline's workflow
"""
# fetch draft workflow by app_model
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline)
if not workflow:
@@ -230,7 +231,7 @@ class DraftRagPipelineApi(Resource):
return {"message": "Invalid JSON data"}, 400
else:
abort(415)
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
try:
environment_variables_list = Workflow.normalize_environment_variable_mappings(
@@ -282,7 +283,7 @@ class RagPipelineDraftRunIterationNodeApi(Resource):
try:
response = PipelineGenerateService.generate_single_iteration(
pipeline=pipeline, user=current_user, node_id=node_id, args=args, streaming=True
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
)
return helper.compact_generate_response(response)
@@ -317,7 +318,7 @@ class RagPipelineDraftRunLoopNodeApi(Resource):
try:
response = PipelineGenerateService.generate_single_loop(
pipeline=pipeline, user=current_user, node_id=node_id, args=args, streaming=True
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
)
return helper.compact_generate_response(response)
@@ -343,7 +344,8 @@ class DraftRagPipelineRunApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline):
@with_session
def post(self, session: Session, current_user: Account, pipeline: Pipeline):
"""
Run draft workflow
"""
@@ -352,6 +354,7 @@ class DraftRagPipelineRunApi(Resource):
try:
response = PipelineGenerateService.generate(
session=session,
pipeline=pipeline,
user=current_user,
args=args,
@@ -375,7 +378,8 @@ class PublishedRagPipelineRunApi(Resource):
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline):
@with_session
def post(self, session: Session, current_user: Account, pipeline: Pipeline):
"""
Run published workflow
"""
@@ -385,6 +389,7 @@ class PublishedRagPipelineRunApi(Resource):
try:
response = PipelineGenerateService.generate(
session=session,
pipeline=pipeline,
user=current_user,
args=args,
@@ -414,7 +419,7 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource):
"""
payload = DatasourceNodeRunPayload.model_validate(console_ns.payload or {})
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
return helper.compact_generate_response(
PipelineGenerator.convert_to_event_stream(
rag_pipeline_service.run_datasource_workflow_node(
@@ -447,7 +452,7 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource):
"""
payload = DatasourceNodeRunPayload.model_validate(console_ns.payload or {})
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
return helper.compact_generate_response(
PipelineGenerator.convert_to_event_stream(
rag_pipeline_service.run_datasource_workflow_node(
@@ -485,7 +490,7 @@ class RagPipelineDraftNodeRunApi(Resource):
payload = NodeRunRequiredPayload.model_validate(console_ns.payload or {})
inputs = payload.inputs
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow_node_execution = rag_pipeline_service.run_draft_workflow_node(
pipeline=pipeline, node_id=node_id, user_inputs=inputs, account=current_user
)
@@ -538,7 +543,7 @@ class PublishedRagPipelineApi(Resource):
if not pipeline.is_published:
return None
# fetch published workflow by pipeline
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow = rag_pipeline_service.get_published_workflow(pipeline=pipeline)
# return workflow, if not found, return None
@@ -559,9 +564,9 @@ class PublishedRagPipelineApi(Resource):
"""
Publish workflow
"""
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow = rag_pipeline_service.publish_workflow(
session=db.session, # type: ignore[reportArgumentType,arg-type]
session=db.session(),
pipeline=pipeline,
account=current_user,
)
@@ -594,7 +599,7 @@ class DefaultRagPipelineBlockConfigsApi(Resource):
Get default block config
"""
# Get default block configs
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
return rag_pipeline_service.get_default_block_configs()
@@ -626,7 +631,7 @@ class DefaultRagPipelineBlockConfigApi(Resource):
raise ValueError("Invalid filters")
# Get default block configs
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
return rag_pipeline_service.get_default_block_config(node_type=block_type, filters=filters)
@@ -661,7 +666,7 @@ class PublishedAllRagPipelineApi(Resource):
if user_id != current_user.id:
raise Forbidden()
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
with sessionmaker(db.engine).begin() as session:
workflows, has_more = rag_pipeline_service.get_all_published_workflow(
session=session,
@@ -693,7 +698,7 @@ class RagPipelineDraftWorkflowRestoreApi(Resource):
@with_current_user
@get_rag_pipeline
def post(self, current_user: Account, pipeline: Pipeline, workflow_id: str):
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
try:
workflow = rag_pipeline_service.restore_published_workflow_to_draft(
@@ -738,7 +743,7 @@ class RagPipelineByIdApi(Resource):
if not update_data:
return {"message": "No valid fields to update"}, 400
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow_ref = WorkflowRefService.create_pipeline_workflow_ref(pipeline, workflow_id)
# Create a session and manage the transaction
@@ -804,7 +809,7 @@ class PublishedRagPipelineSecondStepApi(Resource):
"""
query = NodeIdQuery.model_validate(request.args.to_dict())
node_id = query.node_id
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
variables = rag_pipeline_service.get_second_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=False)
return {
"variables": variables,
@@ -827,7 +832,7 @@ class PublishedRagPipelineFirstStepApi(Resource):
"""
query = NodeIdQuery.model_validate(request.args.to_dict())
node_id = query.node_id
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
variables = rag_pipeline_service.get_first_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=False)
return {
"variables": variables,
@@ -850,7 +855,7 @@ class DraftRagPipelineFirstStepApi(Resource):
"""
query = NodeIdQuery.model_validate(request.args.to_dict())
node_id = query.node_id
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
variables = rag_pipeline_service.get_first_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=True)
return {
"variables": variables,
@@ -874,7 +879,7 @@ class DraftRagPipelineSecondStepApi(Resource):
query = NodeIdQuery.model_validate(request.args.to_dict())
node_id = query.node_id
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
variables = rag_pipeline_service.get_second_step_parameters(pipeline=pipeline, node_id=node_id, is_draft=True)
return {
"variables": variables,
@@ -908,7 +913,7 @@ class RagPipelineWorkflowRunListApi(Resource):
"limit": query.limit,
}
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
result = rag_pipeline_service.get_rag_pipeline_paginate_workflow_runs(pipeline=pipeline, args=args)
return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json")
@@ -931,7 +936,7 @@ class RagPipelineWorkflowRunDetailApi(Resource):
"""
run_id_str = str(run_id)
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow_run = rag_pipeline_service.get_rag_pipeline_workflow_run(pipeline=pipeline, run_id=run_id_str)
if workflow_run is None:
raise NotFound("Workflow run not found")
@@ -957,7 +962,7 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource):
"""
run_id_str = str(run_id)
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
user = cast("Account | EndUser", current_user)
node_executions = rag_pipeline_service.get_rag_pipeline_workflow_run_node_executions(
pipeline=pipeline,
@@ -993,7 +998,7 @@ class RagPipelineWorkflowLastRunApi(Resource):
@account_initialization_required
@get_rag_pipeline
def get(self, pipeline: Pipeline, node_id: str):
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline)
if not workflow:
raise NotFound("Workflow not found")
@@ -1014,13 +1019,14 @@ class RagPipelineTransformApi(Resource):
@login_required
@account_initialization_required
@with_current_user
def post(self, current_user: Account, dataset_id: UUID):
@with_session
def post(self, session: Session, current_user: Account, dataset_id: UUID):
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
raise Forbidden()
dataset_id_str = str(dataset_id)
rag_pipeline_transform_service = RagPipelineTransformService()
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, db.session)
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, session)
return result
@@ -1045,7 +1051,7 @@ class RagPipelineDatasourceVariableApi(Resource):
"""
args = DatasourceVariablesPayload.model_validate(console_ns.payload or {}).model_dump()
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
workflow_node_execution = rag_pipeline_service.set_datasource_variables(
pipeline=pipeline,
args=args,
@@ -1068,6 +1074,6 @@ class RagPipelineRecommendedPluginApi(Resource):
def get(self, current_tenant_id: str, current_user: Account):
query = RagPipelineRecommendedPluginQuery.model_validate(request.args.to_dict())
rag_pipeline_service = RagPipelineService()
rag_pipeline_service = RagPipelineService(db.session())
recommended_plugins = rag_pipeline_service.get_recommended_plugins(query.type, current_user, current_tenant_id)
return recommended_plugins
+1 -1
View File
@@ -113,7 +113,7 @@ class ChatTextApi(InstalledAppResource):
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
session=db.session(),
text=text,
voice=voice,
message_ref=message_ref,
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.app.error import (
AppUnavailableError,
@@ -75,7 +75,7 @@ class ChatMessagePayload(BaseModel):
register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessagePayload)
register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse)
register_response_schema_models(console_ns, SimpleResultResponse)
# define completion api for user
@@ -85,7 +85,7 @@ register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultRe
)
class CompletionApi(InstalledAppResource):
@console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__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, installed_app: InstalledApp):
@@ -114,6 +114,7 @@ class CompletionApi(InstalledAppResource):
streaming=streaming,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -158,7 +159,7 @@ class CompletionStopApi(InstalledAppResource):
app_mode=AppMode.value_of(app_model.mode),
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route(
@@ -167,7 +168,7 @@ class CompletionStopApi(InstalledAppResource):
)
class ChatApi(InstalledAppResource):
@console_ns.expect(console_ns.models[ChatMessagePayload.__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, installed_app: InstalledApp):
@@ -196,6 +197,7 @@ class ChatApi(InstalledAppResource):
streaming=True,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -243,4 +245,4 @@ class ChatStopApi(InstalledAppResource):
app_mode=app_mode,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@@ -111,7 +111,7 @@ class ConversationApi(InstalledAppResource):
conversation_id = str(c_id)
try:
ConversationService.delete(app_model, conversation_id, current_user)
ConversationService.delete(app_model, conversation_id, current_user, session=db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -140,7 +140,7 @@ class ConversationRenameApi(InstalledAppResource):
try:
conversation = ConversationService.rename(
app_model, conversation_id, current_user, payload.name, payload.auto_generate
app_model, conversation_id, current_user, payload.name, payload.auto_generate, session=db.session()
)
return (
TypeAdapter(SimpleConversation)
@@ -169,7 +169,7 @@ class ConversationPinApi(InstalledAppResource):
conversation_id = str(c_id)
try:
WebConversationService.pin(app_model, conversation_id, current_user)
WebConversationService.pin(app_model, conversation_id, current_user, db.session())
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -192,6 +192,6 @@ class ConversationUnPinApi(InstalledAppResource):
raise NotChatAppError()
conversation_id = str(c_id)
WebConversationService.unpin(app_model, conversation_id, current_user)
WebConversationService.unpin(app_model, conversation_id, current_user, db.session())
return ResultResponse(result="success").model_dump(mode="json")
@@ -181,7 +181,7 @@ class InstalledAppsListApi(Resource):
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session)
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
installed_app_list: list[dict[str, Any]] = []
for installed_app, app_model in installed_apps:
installed_app_list.append(
+10 -4
View File
@@ -8,7 +8,6 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.fields import GeneratedAppResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console.app.error import (
AppMoreLikeThisDisabledError,
@@ -28,6 +27,7 @@ from controllers.console.explore.wraps import InstalledAppResource
from controllers.console.wraps import with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from fields.conversation_fields import ResultResponse
from fields.message_fields import (
ExploreMessageInfiniteScrollPagination,
@@ -61,7 +61,6 @@ class MoreLikeThisQuery(BaseModel):
register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, MoreLikeThisQuery)
register_response_schema_models(
console_ns,
GeneratedAppResponse,
ExploreMessageInfiniteScrollPagination,
ResultResponse,
SuggestedQuestionsResponse,
@@ -93,6 +92,7 @@ class MessageListApi(InstalledAppResource):
args.conversation_id,
args.first_id or None,
args.limit,
session=db.session(),
)
adapter = TypeAdapter(ExploreMessageListItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
@@ -131,6 +131,7 @@ class MessageFeedbackApi(InstalledAppResource):
user=current_user,
rating=FeedbackRating(payload.rating) if payload.rating else None,
content=payload.content,
session=db.session(),
)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
@@ -144,7 +145,7 @@ class MessageFeedbackApi(InstalledAppResource):
)
class MessageMoreLikeThisApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(MoreLikeThisQuery))
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
def get(self, session: Session, current_user: Account, installed_app: InstalledApp, message_id: UUID):
@@ -169,6 +170,7 @@ class MessageMoreLikeThisApi(InstalledAppResource):
invoke_from=InvokeFrom.EXPLORE,
streaming=streaming,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
@@ -208,7 +210,11 @@ class MessageSuggestedQuestionApi(InstalledAppResource):
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, user=current_user, message_id=message_id_str, invoke_from=InvokeFrom.EXPLORE
app_model=app_model,
user=current_user,
message_id=message_id_str,
invoke_from=InvokeFrom.EXPLORE,
session=db.session(),
)
except MessageNotExistsError:
raise NotFound("Message not found")
+2 -1
View File
@@ -8,6 +8,7 @@ from controllers.console import console_ns
from controllers.console.app.error import AppUnavailableError
from controllers.console.explore.wraps import InstalledAppResource
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from extensions.ext_database import db
from models.model import AppMode, InstalledApp
from services.app_service import AppService
@@ -64,4 +65,4 @@ class ExploreAppMetaApi(InstalledAppResource):
app_model = installed_app.app
if not app_model:
raise ValueError("App not found")
return AppService().get_app_meta(app_model)
return AppService().get_app_meta(app_model, session=db.session())
@@ -120,7 +120,7 @@ class RecommendedAppListApi(Resource):
language_prefix = _resolve_language(args.language, current_user)
return RecommendedAppListResponse.model_validate(
RecommendedAppService.get_recommended_apps_and_categories(db.session, language_prefix),
RecommendedAppService.get_recommended_apps_and_categories(db.session(), language_prefix),
from_attributes=True,
).model_dump(mode="json")
@@ -137,7 +137,7 @@ class LearnDifyAppListApi(Resource):
language_prefix = _resolve_language(args.language, current_user)
return LearnDifyAppListResponse.model_validate(
RecommendedAppService.get_learn_dify_apps(db.session, language_prefix),
RecommendedAppService.get_learn_dify_apps(db.session(), language_prefix),
from_attributes=True,
).model_dump(mode="json")
@@ -148,4 +148,4 @@ class RecommendedAppApi(Resource):
@login_required
@account_initialization_required
def get(self, app_id: UUID):
return RecommendedAppService.get_recommend_app_detail(db.session, str(app_id))
return RecommendedAppService.get_recommend_app_detail(db.session(), str(app_id))
+37 -84
View File
@@ -1,9 +1,9 @@
import logging
from datetime import datetime
from typing import Any, Literal, cast
from typing import Any, Literal
from flask import request
from flask_restx import Resource, fields, marshal, marshal_with
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -13,13 +13,11 @@ import services
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
)
from controllers.common.fields import Parameters as ParametersResponse
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import (
get_or_create_model,
query_params_from_model,
register_response_schema_models,
register_schema_models,
@@ -58,27 +56,12 @@ from core.errors.error import (
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.app_fields import (
app_detail_fields_with_site,
deleted_tool_fields,
model_config_fields,
site_fields,
tag_fields,
)
from fields.base import ResponseModel
from fields.dataset_fields import dataset_fields
from fields.member_fields import simple_account_fields
from fields.message_fields import SuggestedQuestionsResponse
from fields.workflow_fields import (
conversation_variable_fields,
pipeline_variable_fields,
workflow_fields,
workflow_partial_fields,
)
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.helper import to_timestamp, uuid_value
from libs.helper import dump_response, to_timestamp, uuid_value
from models import Account
from models.account import TenantStatus
from models.model import AppMode, Site
@@ -106,48 +89,6 @@ from services.recommended_app_service import RecommendedAppService
logger = logging.getLogger(__name__)
model_config_model = get_or_create_model("TrialAppModelConfig", model_config_fields)
workflow_partial_model = get_or_create_model("TrialWorkflowPartial", workflow_partial_fields)
deleted_tool_model = get_or_create_model("TrialDeletedTool", deleted_tool_fields)
tag_model = get_or_create_model("TrialTag", tag_fields)
site_model = get_or_create_model("TrialSite", site_fields)
app_detail_fields_with_site_copy = app_detail_fields_with_site.copy()
app_detail_fields_with_site_copy["model_config"] = fields.Nested(
model_config_model, attribute="app_model_config", allow_null=True
)
app_detail_fields_with_site_copy["workflow"] = fields.Nested(workflow_partial_model, allow_null=True)
app_detail_fields_with_site_copy["deleted_tools"] = fields.List(fields.Nested(deleted_tool_model))
app_detail_fields_with_site_copy["tags"] = fields.List(fields.Nested(tag_model))
app_detail_fields_with_site_copy["site"] = fields.Nested(site_model)
app_detail_with_site_model = get_or_create_model("TrialAppDetailWithSite", app_detail_fields_with_site_copy)
simple_account_model = get_or_create_model("TrialSimpleAccount", simple_account_fields)
conversation_variable_model = get_or_create_model("TrialConversationVariable", conversation_variable_fields)
pipeline_variable_model = get_or_create_model("TrialPipelineVariable", pipeline_variable_fields)
workflow_fields_copy = workflow_fields.copy()
workflow_fields_copy["created_by"] = fields.Nested(simple_account_model, attribute="created_by_account")
workflow_fields_copy["updated_by"] = fields.Nested(
simple_account_model, attribute="updated_by_account", allow_null=True
)
workflow_fields_copy["conversation_variables"] = fields.List(fields.Nested(conversation_variable_model))
workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipeline_variable_model))
workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy)
dataset_model = get_or_create_model("TrialDataset", dataset_fields)
dataset_list_model = get_or_create_model(
"TrialDatasetList",
{
"data": fields.List(fields.Nested(dataset_model)),
"has_more": fields.Boolean,
"limit": fields.Integer,
"total": fields.Integer,
"page": fields.Integer,
},
)
class WorkflowRunRequest(BaseModel):
inputs: dict
files: list | None = Field(default=None)
@@ -387,6 +328,11 @@ class TrialDatasetResponse(ResponseModel):
created_at: int | None = None
permission_keys: list[str] = Field(default_factory=list)
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class TrialDatasetListResponse(ResponseModel):
data: list[TrialDatasetResponse]
@@ -396,7 +342,7 @@ class TrialDatasetListResponse(ResponseModel):
page: int
class TrialWorkflowAccount(ResponseModel):
class TrialSimpleAccount(ResponseModel):
id: str
name: str | None = None
email: str | None = None
@@ -410,12 +356,12 @@ class TrialWorkflowResponse(ResponseModel):
version: str | None = None
marked_name: str | None = None
marked_comment: str | None = None
created_by: TrialWorkflowAccount | None = Field(
created_by: TrialSimpleAccount | None = Field(
default=None,
validation_alias=AliasChoices("created_by_account", "created_by"),
)
created_at: int | None = None
updated_by: TrialWorkflowAccount | None = Field(
updated_by: TrialSimpleAccount | None = Field(
default=None,
validation_alias=AliasChoices("updated_by_account", "updated_by"),
)
@@ -444,7 +390,6 @@ register_response_schema_models(
ParametersResponse,
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
SiteResponse,
SuggestedQuestionsResponse,
@@ -453,11 +398,13 @@ register_response_schema_models(
TrialWorkflowResponse,
)
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):
@@ -484,7 +431,8 @@ class TrialAppWorkflowRunApi(TrialAppResource):
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(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)
@@ -529,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
@@ -563,7 +511,8 @@ class TrialChatApi(TrialAppResource):
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(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.")
@@ -602,7 +551,11 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource):
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, user=current_user, message_id=message_id, invoke_from=InvokeFrom.EXPLORE
app_model=app_model,
user=current_user,
message_id=message_id,
invoke_from=InvokeFrom.EXPLORE,
session=db.session(),
)
except MessageNotExistsError:
raise NotFound("Message not found")
@@ -640,7 +593,7 @@ class TrialChatAudioApi(TrialAppResource):
user_id = current_user.id
response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=None)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(db.session(), app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@@ -696,12 +649,12 @@ class TrialChatTextApi(TrialAppResource):
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
session=db.session(),
text=text,
voice=voice,
message_ref=message_ref,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(db.session(), app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@@ -731,7 +684,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
@@ -760,7 +713,8 @@ class TrialCompletionApi(TrialAppResource):
streaming=streaming,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(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.")
@@ -840,27 +794,28 @@ class TrialAppParameterApi(Resource):
class AppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
@get_app_model_with_trial(None)
@marshal_with(app_detail_with_site_model)
def get(self, app_model):
"""Get app detail"""
app_service = AppService()
app_model = app_service.get_app(app_model)
return app_model
return dump_response(TrialAppDetailResponse, app_model)
class AppWorkflowApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
@get_app_model_with_trial(None)
@marshal_with(workflow_model)
def get(self, app_model):
"""Get workflow detail"""
if not app_model.workflow_id:
raise AppUnavailableError()
workflow = db.session.get(Workflow, app_model.workflow_id)
return workflow
if workflow is None:
raise AppUnavailableError()
return dump_response(TrialWorkflowResponse, workflow)
class DatasetListApi(Resource):
@@ -878,10 +833,8 @@ class DatasetListApi(Resource):
else:
raise NeedAddIdsError()
data = cast(list[dict[str, Any]], marshal(datasets, dataset_fields))
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
return response
response = {"data": datasets, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
return dump_response(TrialDatasetListResponse, response)
console_ns.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
+5 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError
from controllers.common.controller_schemas import WorkflowRunPayload
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.console.app.error import (
CompletionRequestError,
@@ -38,13 +38,13 @@ from .. import console_ns
logger = logging.getLogger(__name__)
register_schema_model(console_ns, WorkflowRunPayload)
register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/installed-apps/<uuid:installed_app_id>/workflows/run")
class InstalledAppWorkflowRunApi(InstalledAppResource):
@console_ns.expect(console_ns.models[WorkflowRunPayload.__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, installed_app: InstalledApp):
@@ -70,6 +70,7 @@ class InstalledAppWorkflowRunApi(InstalledAppResource):
streaming=True,
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@@ -109,4 +110,4 @@ class InstalledAppWorkflowTaskStopApi(InstalledAppResource):
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
+35 -43
View File
@@ -4,18 +4,18 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, field_validator
from pydantic import BaseModel, Field, RootModel, field_validator
from constants import HIDDEN_VALUE
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models.api_based_extension import APIBasedExtension
from services.api_based_extension_service import APIBasedExtensionService
from services.code_based_extension_service import CodeBasedExtensionService
from ..common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, query_params_from_model, register_schema_models
from ..common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from . import console_ns
from .wraps import account_initialization_required, setup_required, with_current_tenant_id
@@ -61,36 +61,21 @@ class APIBasedExtensionResponse(ResponseModel):
return to_timestamp(value)
class APIBasedExtensionListResponse(RootModel[list[APIBasedExtensionResponse]]):
pass
register_schema_models(
console_ns,
CodeBasedExtensionQuery,
APIBasedExtensionPayload,
)
register_response_schema_models(
console_ns,
CodeBasedExtensionResponse,
APIBasedExtensionResponse,
APIBasedExtensionListResponse,
)
console_ns.schema_model(
"APIBasedExtensionListResponse",
TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
)
def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, Any]:
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")
def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
"""Serialize a saved extension with the plaintext key used for response masking only.
APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
"""
return APIBasedExtensionResponse(
id=extension.id,
name=extension.name,
api_endpoint=extension.api_endpoint,
api_key=api_key,
created_at=to_timestamp(extension.created_at),
).model_dump(mode="json")
@console_ns.route("/code-based-extension")
@@ -119,16 +104,16 @@ class CodeBasedExtensionAPI(Resource):
class APIBasedExtensionAPI(Resource):
@console_ns.doc("get_api_based_extensions")
@console_ns.doc(description="Get all API-based extensions for current tenant")
@console_ns.response(200, "Success", console_ns.models["APIBasedExtensionListResponse"])
@console_ns.response(200, "Success", console_ns.models[APIBasedExtensionListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
return [
_serialize_api_based_extension(extension)
for extension in APIBasedExtensionService.get_all_by_tenant_id(db.session(), current_tenant_id)
]
return dump_response(
APIBasedExtensionListResponse,
APIBasedExtensionService.get_all_by_tenant_id(db.session(), current_tenant_id),
)
@console_ns.doc("create_api_based_extension")
@console_ns.doc(description="Create a new API-based extension")
@@ -148,12 +133,14 @@ class APIBasedExtensionAPI(Resource):
api_key=payload.api_key,
)
return (
_serialize_saved_api_based_extension(
APIBasedExtensionService.save(db.session(), extension_data), payload.api_key
),
201,
)
extension = APIBasedExtensionService.save(db.session(), extension_data)
return APIBasedExtensionResponse(
id=extension.id,
name=extension.name,
api_endpoint=extension.api_endpoint,
api_key=payload.api_key,
created_at=to_timestamp(extension.created_at),
).model_dump(mode="json"), 201
@console_ns.route("/api-based-extension/<uuid:id>")
@@ -169,8 +156,9 @@ class APIBasedExtensionDetailAPI(Resource):
def get(self, current_tenant_id: str, id: UUID):
api_based_extension_id = str(id)
return _serialize_api_based_extension(
APIBasedExtensionService.get_with_tenant_id(db.session(), current_tenant_id, api_based_extension_id)
return dump_response(
APIBasedExtensionResponse,
APIBasedExtensionService.get_with_tenant_id(db.session(), current_tenant_id, api_based_extension_id),
)
@console_ns.doc("update_api_based_extension")
@@ -199,10 +187,14 @@ class APIBasedExtensionDetailAPI(Resource):
extension_data_from_db.api_key = payload.api_key
api_key_for_response = payload.api_key
return _serialize_saved_api_based_extension(
APIBasedExtensionService.save(db.session(), extension_data_from_db),
api_key_for_response,
)
APIBasedExtensionService.save(db.session(), extension_data_from_db)
return APIBasedExtensionResponse(
id=extension_data_from_db.id,
name=extension_data_from_db.name,
api_endpoint=extension_data_from_db.api_endpoint,
api_key=api_key_for_response,
created_at=to_timestamp(extension_data_from_db.created_at),
).model_dump(mode="json")
@console_ns.doc("delete_api_based_extension")
@console_ns.doc(description="Delete API-based extension")
+1 -1
View File
@@ -50,7 +50,7 @@ def get_init_status() -> InitStatusResponse:
@only_edition_self_hosted
def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse:
"""Validate initialization password."""
tenant_count = TenantService.get_tenant_count(session=db.session)
tenant_count = TenantService.get_tenant_count(session=db.session())
if tenant_count > 0:
raise AlreadySetupError()
+2 -2
View File
@@ -79,7 +79,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
if get_setup_status():
raise AlreadySetupError()
tenant_count = TenantService.get_tenant_count(session=db.session)
tenant_count = TenantService.get_tenant_count(session=db.session())
if tenant_count > 0:
raise AlreadySetupError()
@@ -94,7 +94,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
password=payload.password,
ip_address=extract_remote_ip(request),
language=payload.language,
session=db.session,
session=db.session(),
)
return SetupResponse(result="success")
+2 -2
View File
@@ -44,7 +44,7 @@ def socket_connect(sid, environ, auth):
return False
with sio.app.app_context():
user = AccountService.load_logged_in_account(account_id=user_id, session=db.session)
user = AccountService.load_logged_in_account(account_id=user_id, session=db.session())
if not user:
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
return False
@@ -69,7 +69,7 @@ def handle_user_connect(sid, data):
if not workflow_id:
return {"msg": "workflow_id is required"}, 400
result = collaboration_service.authorize_and_join_workflow_room(workflow_id, sid)
result = collaboration_service.authorize_and_join_workflow_room(workflow_id, sid, session=db.session())
if not result:
return {"msg": "unauthorized"}, 401
+23 -25
View File
@@ -3,7 +3,7 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
@@ -23,6 +23,7 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models import Account
from models.enums import TagType
@@ -85,6 +86,10 @@ class TagResponse(ResponseModel):
return str(value)
class TagListResponse(RootModel[list[TagResponse]]):
pass
register_schema_models(
console_ns,
TagBasePayload,
@@ -92,9 +97,8 @@ register_schema_models(
TagBindingPayload,
TagBindingRemovePayload,
TagListQueryParam,
TagResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse)
register_response_schema_models(console_ns, SimpleResultResponse, TagResponse, TagListResponse)
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
@@ -128,18 +132,14 @@ class TagListApi(Resource):
@login_required
@account_initialization_required
@console_ns.doc(params=query_params_from_model(TagListQueryParam))
@console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])})
@console_ns.response(200, "Success", console_ns.models[TagListResponse.__name__])
@with_current_tenant_id
def get(self, current_tenant_id: str):
raw_args = request.args.to_dict()
param = TagListQueryParam.model_validate(raw_args)
tags = TagService.get_tags(db.session(), param.type, current_tenant_id, param.keyword)
serialized_tags = [
TagResponse.model_validate(tag, from_attributes=True).model_dump(mode="json") for tag in tags
]
return serialized_tags, 200
return dump_response(TagListResponse, tags), 200
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
@@ -154,13 +154,9 @@ class TagListApi(Resource):
payload = TagBasePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session())
response = TagResponse.model_validate(
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}
).model_dump(mode="json")
return response, 200
return dump_response(TagResponse, {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}), 200
@console_ns.route("/tags/<uuid:tag_id>")
@@ -179,15 +175,17 @@ class TagUpdateDeleteApi(Resource):
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session())
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session())
response = TagResponse.model_validate(
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}
).model_dump(mode="json")
return response, 200
return (
dump_response(
TagResponse,
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count},
),
200,
)
@setup_required
@login_required
@@ -198,7 +196,7 @@ class TagUpdateDeleteApi(Resource):
tag_id_str = str(tag_id)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
TagService.delete_tag(tag_id_str, db.session)
TagService.delete_tag(tag_id_str, db.session())
return "", 204
@@ -225,7 +223,7 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
target_id=payload.target_id,
type=payload.type,
),
db.session,
db.session(),
)
return {"result": "success"}, 200
@@ -241,7 +239,7 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
target_id=payload.target_id,
type=payload.type,
),
db.session,
db.session(),
)
return {"result": "success"}, 200
@@ -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)
+84 -94
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)
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,21 +351,21 @@ 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 {}
args = AccountAvatarPayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session, avatar=args.avatar)
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,17 +374,17 @@ 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 {}
args = AccountInterfaceLanguagePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session, interface_language=args.interface_language
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,17 +393,17 @@ 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 {}
args = AccountInterfaceThemePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session, interface_theme=args.interface_theme
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,15 +412,15 @@ 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 {}
args = AccountTimezonePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session, timezone=args.timezone)
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 {}
@@ -448,11 +437,11 @@ class AccountPasswordApi(Resource):
try:
assert args.password is not None
AccountService.update_account_password(current_user, args.password, args.new_password, session=db.session)
AccountService.update_account_password(current_user, args.password, args.new_password, session=db.session())
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
@@ -529,15 +514,15 @@ class AccountDeleteApi(Resource):
if not AccountService.verify_account_deletion_code(args.token, args.code):
raise InvalidAccountDeletionCodeError()
AccountService.delete_account(account)
AccountService.delete_account(account, session=db.session())
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 {}
@@ -736,7 +726,7 @@ class ChangeEmailResetApi(Resource):
if AccountService.is_account_in_freeze(normalized_new_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_new_email, session=db.session):
if not AccountService.check_email_unique(normalized_new_email, session=db.session()):
raise EmailAlreadyInUseError()
reset_data = AccountService.get_change_email_data(args.token)
@@ -761,20 +751,20 @@ class ChangeEmailResetApi(Resource):
AccountService.revoke_change_email_token(args.token)
updated_account = AccountService.update_account_email(
current_user, email=normalized_new_email, session=db.session
current_user, email=normalized_new_email, session=db.session()
)
AccountService.send_change_email_completed_notify_email(
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 {}
@@ -782,6 +772,6 @@ class CheckEmailUnique(Resource):
normalized_email = args.email.lower()
if AccountService.is_account_in_freeze(normalized_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session):
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")
@@ -10,6 +10,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
@@ -69,6 +70,7 @@ class LoadBalancingCredentialsValidateApi(Resource):
model=payload.model,
model_type=payload.model_type,
credentials=payload.credentials,
session=db.session(),
)
except CredentialsValidateFailedError as ex:
result = False
@@ -118,6 +120,7 @@ class LoadBalancingConfigCredentialsValidateApi(Resource):
model=payload.model,
model_type=payload.model_type,
credentials=payload.credentials,
session=db.session(),
config_id=config_id,
)
except CredentialsValidateFailedError as ex:
+82 -81
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,14 +132,10 @@ 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:
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
account = AccountService.get_account_by_email_with_case_fallback(db.session(), email)
if not account:
new_member_count += 1
continue
@@ -179,14 +183,14 @@ 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:
current_user, _ = current_account_with_tenant()
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_tenant_members(current_user.current_tenant, session=db.session)
members = TenantService.get_tenant_members(current_user.current_tenant, session=db.session())
if dify_config.RBAC_ENABLED:
member_ids = [member.id for member in members]
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
@@ -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
@@ -273,67 +275,69 @@ class MemberInviteEmailApi(Resource):
language=interface_language,
role=invitee_role,
inviter=inviter,
session=db.session,
session=db.session(),
)
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
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,32 +356,32 @@ 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"
TenantService.update_member_role(
current_user.current_tenant, member, new_role, current_user, session=db.session
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
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session())
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
@@ -418,7 +420,7 @@ class SendOwnerTransferEmailApi(Resource):
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
if args.language is not None and args.language == "zh-Hans":
@@ -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
@@ -453,7 +455,7 @@ class OwnerTransferCheckApi(Resource):
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
user_email = current_user.email
@@ -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
@@ -499,7 +501,7 @@ class OwnerTransfer(Resource):
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session()):
raise NotOwnerError()
if current_user.id == str(member_id):
@@ -516,18 +518,17 @@ 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")
if not TenantService.is_member(member, current_user.current_tenant, session=db.session):
if not TenantService.is_member(member, current_user.current_tenant, session=db.session()):
raise MemberNotInTenantError()
try:
assert member is not None, "Member not found"
TenantService.update_member_role(
current_user.current_tenant, member, "owner", current_user, session=db.session
current_user.current_tenant, member, "owner", current_user, session=db.session()
)
AccountService.send_new_owner_transfer_notify_email(
@@ -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")

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