Compare commits
211 Commits
deploy/ope
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d9c8897a | ||
|
|
519ef8b8c2 | ||
|
|
0cd3824608 | ||
|
|
869c1dbf07 | ||
|
|
2738bad1fe | ||
|
|
a4cefbe669 | ||
|
|
1f5afb5a48 | ||
|
|
9d285e38eb | ||
|
|
9ce90a4075 | ||
|
|
a5eea69efc | ||
|
|
8260acf04b | ||
|
|
5ea0b59bdd | ||
|
|
0753e6480b | ||
|
|
1aeba1c6bc | ||
|
|
642bfe40eb | ||
|
|
10c5da16f3 | ||
|
|
3b093f881f | ||
|
|
a212b1b0b1 | ||
|
|
8023189566 | ||
|
|
d73c93d2cf | ||
|
|
63d2af7d27 | ||
|
|
81f627cfa1 | ||
|
|
e160419159 | ||
|
|
22cce965f3 | ||
|
|
ea9a3dbb4b | ||
|
|
fd99ce64c9 | ||
|
|
64f028564e | ||
|
|
4214abf28f | ||
|
|
f4027ad6ef | ||
|
|
9d01361c91 | ||
|
|
7d13952e24 | ||
|
|
54486a14c1 | ||
|
|
3be01cbb7a | ||
|
|
de09a17ba9 | ||
|
|
cf957564d8 | ||
|
|
5c0c9dfff0 | ||
|
|
e92d13220f | ||
|
|
70ef9cbd5d | ||
|
|
f0266537cb | ||
|
|
16bbfc50bd | ||
|
|
1cbb6dc385 | ||
|
|
09e372b237 | ||
|
|
c687d0dc6f | ||
|
|
8222397ea9 | ||
|
|
01f632d059 | ||
|
|
98c9a6a23a | ||
|
|
c12c01b8be | ||
|
|
7f4e357418 | ||
|
|
43af5082d3 | ||
|
|
f2755a79c7 | ||
|
|
e6e4cd9406 | ||
|
|
b7bf3f8a29 | ||
|
|
0f611811c2 | ||
|
|
efcf73b4da | ||
|
|
daadf78c02 | ||
|
|
baf6888007 | ||
|
|
4b89972f55 | ||
|
|
a2427050e1 | ||
|
|
f3074ced5b | ||
|
|
1dbf027277 | ||
|
|
9f0e3a4c17 | ||
|
|
a54589a672 | ||
|
|
ba28e3ad82 | ||
|
|
49a4a13846 | ||
|
|
934427ce16 | ||
|
|
f100adf0ea | ||
|
|
63094c91ba | ||
|
|
9f617f9d3f | ||
|
|
30eadcc9e5 | ||
|
|
bf62165e22 | ||
|
|
c797b8e99e | ||
|
|
fa4f49df00 | ||
|
|
6cca398f1c | ||
|
|
b4ae43986c | ||
|
|
c05a1c4e9a | ||
|
|
84cc8ef77a | ||
|
|
f2e13873b5 | ||
|
|
a0c004d877 | ||
|
|
14cd89ded5 | ||
|
|
8055521b49 | ||
|
|
aff59ef19a | ||
|
|
f93bec4ea5 | ||
|
|
087dd1d1ae | ||
|
|
1b86126ab1 | ||
|
|
ca27376211 | ||
|
|
c0fa5c3bcd | ||
|
|
46f32a7d22 | ||
|
|
5c6f46c952 | ||
|
|
e23c02340a | ||
|
|
b3188a299b | ||
|
|
570552c6fa | ||
|
|
636e0f0ace | ||
|
|
fecc3af674 | ||
|
|
0b9b0db408 | ||
|
|
a2b0b2a143 | ||
|
|
0fa381f3e7 | ||
|
|
d28b99ef6d | ||
|
|
38f1ecdd3b | ||
|
|
a0330a756f | ||
|
|
723aeb8b10 | ||
|
|
cba5e5503a | ||
|
|
4c640ff898 | ||
|
|
0bd950fd01 | ||
|
|
f004f6b67d | ||
|
|
50695cbf92 | ||
|
|
23e13a5e93 | ||
|
|
6423a83b78 | ||
|
|
4534c37aa7 | ||
|
|
431c185706 | ||
|
|
5260f0eee6 | ||
|
|
9fa987a550 | ||
|
|
1beefc2b50 | ||
|
|
1a918ea407 | ||
|
|
fac6179309 | ||
|
|
3c17c90bea | ||
|
|
5e3065ec7d | ||
|
|
71e5b353b1 | ||
|
|
9988dcc720 | ||
|
|
95373bc4fd | ||
|
|
6decc78fd5 | ||
|
|
bf41e733ba | ||
|
|
d7600ad9a7 | ||
|
|
52d586a7b4 | ||
|
|
4433d56a88 | ||
|
|
db317bf9a1 | ||
|
|
1642281176 | ||
|
|
8edc40e2f1 | ||
|
|
fe49d123c0 | ||
|
|
5ab572e0a7 | ||
|
|
2cb6431582 | ||
|
|
e48680f32b | ||
|
|
a743a9150f | ||
|
|
2f6b14525e | ||
|
|
df016cd309 | ||
|
|
d5c3fb9ecd | ||
|
|
0effc36f45 | ||
|
|
28697d9587 | ||
|
|
ea38e484b8 | ||
|
|
5bae0bb010 | ||
|
|
51fa4a25f9 | ||
|
|
b4cb02ccb9 | ||
|
|
77ec1b2027 | ||
|
|
68726eb097 | ||
|
|
b4dd2c4886 | ||
|
|
cf02d52d0d | ||
|
|
a4524c215f | ||
|
|
96abb8ea22 | ||
|
|
216151ffa7 | ||
|
|
7b0660b45b | ||
|
|
a4f949f173 | ||
|
|
0ed392fb62 | ||
|
|
3f0032f20b | ||
|
|
bfd5636bf0 | ||
|
|
f79ed842fa | ||
|
|
a67cd87eb0 | ||
|
|
c005932db9 | ||
|
|
af7f6abb95 | ||
|
|
2a1211baa5 | ||
|
|
3813c4d552 | ||
|
|
8fc8178ff7 | ||
|
|
fb0e55f5e7 | ||
|
|
fb9b97b26d | ||
|
|
80b34b5ab6 | ||
|
|
8db1712ccd | ||
|
|
6dc9de24d8 | ||
|
|
9c11e36cc5 | ||
|
|
1d02154638 | ||
|
|
b4bc25cab6 | ||
|
|
948fd166dd | ||
|
|
38f42f357e | ||
|
|
f24586323b | ||
|
|
bc0279572e | ||
|
|
20d9f15933 | ||
|
|
d42276a5c0 | ||
|
|
5c4489911c | ||
|
|
657624154d | ||
|
|
be8744264f | ||
|
|
0fec636da5 | ||
|
|
ce27ef9dc3 | ||
|
|
f7e5946d81 | ||
|
|
90f7fbd468 | ||
|
|
1545517fe8 | ||
|
|
1b9ddf9ae5 | ||
|
|
9bb9935f44 | ||
|
|
1ff3d3db68 | ||
|
|
18fea7ff0d | ||
|
|
357f697b16 | ||
|
|
e6f1d77ed5 | ||
|
|
4f8bf32b72 | ||
|
|
d8187b6e51 | ||
|
|
51940eac9c | ||
|
|
005f019a80 | ||
|
|
5f6bb5e6d3 | ||
|
|
7020120197 | ||
|
|
fe5dfd756f | ||
|
|
4808976d0c | ||
|
|
1b21b8220d | ||
|
|
bf55d1f6a6 | ||
|
|
855842410a | ||
|
|
33b2f09325 | ||
|
|
4713c3853b | ||
|
|
89e6b71b2d | ||
|
|
27cdf50c7d | ||
|
|
2b88685fbd | ||
|
|
52dcf83de6 | ||
|
|
a4a18e0652 | ||
|
|
6441436b0c | ||
|
|
0c5f2507d0 | ||
|
|
2515cf5b48 | ||
|
|
5283462286 | ||
|
|
f95edc85f5 |
@@ -1,93 +1,15 @@
|
||||
# Rule Catalog — Architecture
|
||||
# Architecture review
|
||||
|
||||
## Scope
|
||||
- Covers: controller/service/core-domain/libs/model layering, dependency direction, responsibility placement, observability-friendly flow.
|
||||
The [API agent guide](../../../../api/AGENTS.md) owns package boundaries. In particular, `api/core/` is migration-only: do not propose new files or extracted implementations there.
|
||||
|
||||
## Rules
|
||||
## Transport and domain boundaries
|
||||
|
||||
### Keep business logic out of controllers
|
||||
- Category: maintainability
|
||||
- Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test.
|
||||
- Suggested fix: Move domain/business logic into the service or core/domain layer. Keep controller handlers thin and orchestration-focused.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
@bp.post("/apps/<app_id>/publish")
|
||||
def publish_app(app_id: str):
|
||||
payload = request.get_json() or {}
|
||||
if payload.get("force") and current_user.role != "admin":
|
||||
raise ValueError("only admin can force publish")
|
||||
app = App.query.get(app_id)
|
||||
app.status = "published"
|
||||
db.session.commit()
|
||||
return {"result": "ok"}
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
@bp.post("/apps/<app_id>/publish")
|
||||
def publish_app(app_id: str):
|
||||
payload = PublishRequest.model_validate(request.get_json() or {})
|
||||
app_service.publish_app(app_id=app_id, force=payload.force, actor_id=current_user.id)
|
||||
return {"result": "ok"}
|
||||
```
|
||||
Controllers own request parsing, service calls, and response serialization. Trace business decisions to their service or domain owner; flag controller policy when it duplicates or bypasses that owner. Move extracted behavior to an appropriate owner outside `core/`.
|
||||
|
||||
### Preserve layer dependency direction
|
||||
- Category: best practices
|
||||
- Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code.
|
||||
- Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
from controllers.console.app import request_context
|
||||
## Dependency direction
|
||||
|
||||
Transport layers may depend on services and domain contracts. Domain code must not import controller or request context. Pass validated actor, tenant, and resource data explicitly instead of reaching upward for it. Check existing owners and import-linter contracts before proposing a shared abstraction.
|
||||
|
||||
def can_publish() -> bool:
|
||||
return request_context.current_user.is_admin
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
def can_publish(role: str) -> bool:
|
||||
return role == "admin"
|
||||
## Business-agnostic libraries
|
||||
|
||||
|
||||
# service layer adapts web/user context to domain input
|
||||
allowed = can_publish(role=current_user.role)
|
||||
```
|
||||
|
||||
### Keep libs business-agnostic
|
||||
- Category: maintainability
|
||||
- Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions.
|
||||
- Suggested fix:
|
||||
- If business logic appears in `api/libs/`, extract it into the appropriate `services/` or `core/` module and keep `libs` focused on generic, cross-cutting helpers.
|
||||
- Keep `libs` dependencies clean: avoid importing service/controller/domain-specific modules into `api/libs/`.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# api/libs/conversation_filter.py
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
|
||||
def should_archive_conversation(conversation, tenant_id: str) -> bool:
|
||||
# Domain policy and service dependency are leaking into libs.
|
||||
service = ConversationService()
|
||||
if service.has_paid_plan(tenant_id):
|
||||
return conversation.idle_days > 90
|
||||
return conversation.idle_days > 30
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# api/libs/datetime_utils.py (business-agnostic helper)
|
||||
def older_than_days(idle_days: int, threshold_days: int) -> bool:
|
||||
return idle_days > threshold_days
|
||||
|
||||
|
||||
# services/conversation_service.py (business logic stays in service/core)
|
||||
from libs.datetime_utils import older_than_days
|
||||
|
||||
|
||||
def should_archive_conversation(conversation, tenant_id: str) -> bool:
|
||||
threshold_days = 90 if has_paid_plan(tenant_id) else 30
|
||||
return older_than_days(conversation.idle_days, threshold_days)
|
||||
```
|
||||
`api/libs/` must not own product policy or orchestration. Flag service/controller dependencies and domain-specific decisions in library helpers. Move that behavior to its existing service or domain owner outside `core/`; retain only reusable infrastructure in `libs/`.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation.
|
||||
- Suggested fix:
|
||||
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
|
||||
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies), while preserving dependency direction (service/core depends on abstraction; infra provides implementation).
|
||||
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies). Follow `api/AGENTS.md`: put new abstractions and implementations outside migration-only `api/core/`, with services/domain code depending on abstractions and infrastructure providing implementations.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Development with devcontainer
|
||||
|
||||
This project includes a devcontainer configuration that allows you to open the project in a container with a fully configured development environment.
|
||||
Both frontend and backend environments are initialized when the container is started.
|
||||
The create hook installs workspace JavaScript dependencies and `uv`; the start hook synchronizes backend dependencies. Follow the [backend setup](../api/README.md) and [frontend guide](../web/README.md) to configure environment files and start services.
|
||||
|
||||
## GitHub Codespaces
|
||||
|
||||
@@ -19,20 +19,6 @@ if you have VS Code installed, you can click the button above to open this proje
|
||||
|
||||
You can learn more in the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||
|
||||
## Pros of Devcontainer
|
||||
|
||||
Unified Development Environment: By using devcontainers, you can ensure that all developers are developing in the same environment, reducing the occurrence of "it works on my machine" type of issues.
|
||||
|
||||
Quick Start: New developers can set up their development environment in a few simple steps, without spending a lot of time on environment configuration.
|
||||
|
||||
Isolation: Devcontainers isolate your project from your host operating system, reducing the chance of OS updates or other application installations impacting the development environment.
|
||||
|
||||
## Cons of Devcontainer
|
||||
|
||||
Learning Curve: For developers unfamiliar with Docker and VS Code, using devcontainers may be somewhat complex.
|
||||
|
||||
Performance Impact: While usually minimal, programs running inside a devcontainer may be slightly slower than those running directly on the host.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
if you see such error message when you open this project in codespaces:
|
||||
|
||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -9,3 +9,6 @@
|
||||
# Codegen output must stay byte-identical across platforms so
|
||||
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
||||
*.generated.ts text eol=lf
|
||||
|
||||
# Skill scripts run on every platform, so keep LF line endings.
|
||||
/skills/** text=auto eol=lf
|
||||
|
||||
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -57,13 +57,10 @@
|
||||
|
||||
# Backend - RAG (Retrieval Augmented Generation)
|
||||
/api/core/rag/ @JohnJyong
|
||||
/api/services/knowledge/ @JohnJyong
|
||||
/api/services/rag_pipeline/ @JohnJyong
|
||||
/api/services/dataset_service.py @JohnJyong
|
||||
/api/services/knowledge_service.py @JohnJyong
|
||||
/api/services/external_knowledge_service.py @JohnJyong
|
||||
/api/services/hit_testing_service.py @JohnJyong
|
||||
/api/services/metadata_service.py @JohnJyong
|
||||
/api/services/vector_service.py @JohnJyong
|
||||
/api/services/entities/knowledge_entities/ @JohnJyong
|
||||
/api/services/entities/external_knowledge_entities/ @JohnJyong
|
||||
/api/controllers/console/datasets/ @JohnJyong
|
||||
|
||||
2
.github/actions/setup-core-e2e/action.yml
vendored
2
.github/actions/setup-core-e2e/action.yml
vendored
@@ -15,7 +15,7 @@ runs:
|
||||
run-install: 'false'
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
4
.github/actions/setup-web/action.yml
vendored
4
.github/actions/setup-web/action.yml
vendored
@@ -13,11 +13,11 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm and Node.js
|
||||
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
||||
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
|
||||
with:
|
||||
install: false
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@9fd26ff49f9b5e6276c2b493da4454ad4527f7f4 # v1.20.0
|
||||
uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1
|
||||
with:
|
||||
node-manager: false
|
||||
package-manager: |
|
||||
|
||||
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal file
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
const { execFile } = require('node:child_process')
|
||||
const { appendFile, mkdtemp, rm } = require('node:fs/promises')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const waitForBuild = require('./wait.cjs')
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
async function downloadWebBuild({
|
||||
env = process.env,
|
||||
execute = execFileAsync,
|
||||
webDir = path.resolve(__dirname, '../../../web'),
|
||||
} = {}) {
|
||||
const repository = env.GITHUB_REPOSITORY
|
||||
const runId = env.GITHUB_RUN_ID
|
||||
const runAttempt = Number(env.GITHUB_RUN_ATTEMPT)
|
||||
if (
|
||||
!repository ||
|
||||
!/^\d+$/.test(runId || '') ||
|
||||
!Number.isInteger(runAttempt) ||
|
||||
runAttempt < 1 ||
|
||||
!env.GH_TOKEN
|
||||
) {
|
||||
throw new Error(
|
||||
'Downloading the Web build requires GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GH_TOKEN.',
|
||||
)
|
||||
}
|
||||
|
||||
const run = (command, args, timeout = 60_000) =>
|
||||
execute(command, args, {
|
||||
env,
|
||||
timeout,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
const api = async (endpoint, paginate = false) => {
|
||||
const { stdout } = await run('gh', [
|
||||
'api',
|
||||
endpoint,
|
||||
...(paginate ? ['--paginate', '--slurp'] : []),
|
||||
])
|
||||
return JSON.parse(stdout)
|
||||
}
|
||||
const prefix = `repos/${repository}/actions`
|
||||
let summary = ''
|
||||
const artifactId = await waitForBuild({
|
||||
github: {
|
||||
rest: {
|
||||
actions: { listJobsForWorkflowRunAttempt: 'jobs', listWorkflowRunArtifacts: 'artifacts' },
|
||||
},
|
||||
paginate: async (kind) => {
|
||||
const endpoint =
|
||||
kind === 'jobs'
|
||||
? `${prefix}/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100`
|
||||
: `${prefix}/runs/${runId}/artifacts?per_page=100`
|
||||
const pages = await api(endpoint, true)
|
||||
return pages.flatMap((page) => page[kind])
|
||||
},
|
||||
},
|
||||
context: { repo: {}, runId },
|
||||
core: {
|
||||
info: console.log,
|
||||
summary: {
|
||||
addHeading(text) {
|
||||
summary += `### ${text}\n\n`
|
||||
return this
|
||||
},
|
||||
addRaw(text) {
|
||||
summary += `${text}\n`
|
||||
return this
|
||||
},
|
||||
async write() {
|
||||
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary)
|
||||
},
|
||||
},
|
||||
},
|
||||
runAttempt,
|
||||
})
|
||||
|
||||
const artifact = await api(`${prefix}/artifacts/${artifactId}`)
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'dify-e2e-web-build-'))
|
||||
try {
|
||||
// Artifact names are immutable within a run. Use the name selected by wait.cjs,
|
||||
// including its matching upload timestamp when only consumer jobs are rerun.
|
||||
await run(
|
||||
'gh',
|
||||
['run', 'download', runId, '--repo', repository, '--name', artifact.name, '--dir', directory],
|
||||
300_000,
|
||||
)
|
||||
await run('tar', ['-xf', path.join(directory, 'e2e-web-build.tar'), '-C', webDir])
|
||||
console.log(`Web build artifact ${artifactId} extracted and ready.`)
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = downloadWebBuild
|
||||
|
||||
if (require.main === module) {
|
||||
downloadWebBuild().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal file
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { access } = require('node:fs/promises')
|
||||
const { test } = require('node:test')
|
||||
const downloadWebBuild = require('./download.cjs')
|
||||
|
||||
function fixture({ downloadFails = false, producerFails = false } = {}) {
|
||||
const calls = []
|
||||
const env = {
|
||||
GITHUB_REPOSITORY: 'owner/repo',
|
||||
GITHUB_RUN_ID: '123',
|
||||
GITHUB_RUN_ATTEMPT: '4',
|
||||
GH_TOKEN: 'test-token',
|
||||
}
|
||||
const execute = async (command, args) => {
|
||||
calls.push({ command, args })
|
||||
if (command === 'tar') return { stdout: '' }
|
||||
if (args[0] === 'run') {
|
||||
if (downloadFails) throw new Error('artifact download failed')
|
||||
return { stdout: '' }
|
||||
}
|
||||
if (args[1].includes('/jobs?')) {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
jobs: [
|
||||
{
|
||||
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
|
||||
status: 'completed',
|
||||
conclusion: producerFails ? 'failure' : 'success',
|
||||
steps: [
|
||||
{
|
||||
name: 'Upload Web build',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-09-22T04:08:08Z',
|
||||
completed_at: '2026-09-22T04:08:12Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}
|
||||
}
|
||||
if (args[1].includes('/artifacts?')) {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{ artifacts: [] },
|
||||
{
|
||||
artifacts: [
|
||||
{
|
||||
id: 42,
|
||||
name: 'core-e2e-web-build-2',
|
||||
created_at: '2026-09-22T04:08:11Z',
|
||||
expired: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}
|
||||
}
|
||||
assert.equal(args[1], 'repos/owner/repo/actions/artifacts/42')
|
||||
return { stdout: JSON.stringify({ name: 'core-e2e-web-build-2' }) }
|
||||
}
|
||||
return { calls, options: { env, execute, webDir: '/test/web' } }
|
||||
}
|
||||
|
||||
test('downloads the producer-selected artifact on consumer-only reruns, including paginated results', async () => {
|
||||
const { calls, options } = fixture()
|
||||
await downloadWebBuild(options)
|
||||
assert.match(calls[0].args[1], /runs\/123\/attempts\/4\/jobs/)
|
||||
const download = calls.find(({ args }) => args[0] === 'run')
|
||||
assert.deepEqual(download.args.slice(0, 7), [
|
||||
'run',
|
||||
'download',
|
||||
'123',
|
||||
'--repo',
|
||||
'owner/repo',
|
||||
'--name',
|
||||
'core-e2e-web-build-2',
|
||||
])
|
||||
const extract = calls.at(-1)
|
||||
assert.equal(extract.command, 'tar')
|
||||
assert.deepEqual(extract.args.slice(-2), ['-C', '/test/web'])
|
||||
await assert.rejects(access(download.args.at(-1)), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('does not extract a failed download and removes its temporary directory', async () => {
|
||||
const { calls, options } = fixture({ downloadFails: true })
|
||||
await assert.rejects(downloadWebBuild(options), /artifact download failed/)
|
||||
assert.equal(
|
||||
calls.some(({ command }) => command === 'tar'),
|
||||
false,
|
||||
)
|
||||
await assert.rejects(access(calls.at(-1).args.at(-1)), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('does not download or extract when the producer fails', async () => {
|
||||
const { calls, options } = fixture({ producerFails: true })
|
||||
await assert.rejects(downloadWebBuild(options), /Web build finished with failure/)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
|
||||
test('requires CI metadata and credentials before starting preparation', async () => {
|
||||
const { calls, options } = fixture()
|
||||
for (const key of Object.keys(options.env)) {
|
||||
await assert.rejects(
|
||||
downloadWebBuild({ ...options, env: { ...options.env, [key]: '' } }),
|
||||
/requires GITHUB/,
|
||||
)
|
||||
}
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@@ -117,7 +117,7 @@ jobs:
|
||||
-p no:benchmark \
|
||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||
-n auto \
|
||||
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--file-shard-plan unit-test-plan.json \
|
||||
--shard-index "${{ matrix.shardIndex }}" \
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
run: |
|
||||
uv run --project api pytest \
|
||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--junitxml=test-results/controllers.xml \
|
||||
api/tests/unit_tests/controllers
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
--start-middleware \
|
||||
--middleware-stop-timeout 2 \
|
||||
-n auto \
|
||||
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||
api/tests/integration_tests/workflow \
|
||||
|
||||
16
.github/workflows/cli-e2e.yml
vendored
16
.github/workflows/cli-e2e.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -396,7 +396,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-last-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
|
||||
1
.github/workflows/cli-edge.yml
vendored
1
.github/workflows/cli-edge.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/generated/api/openapi/**'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
5
.github/workflows/cli-release.yml
vendored
5
.github/workflows/cli-release.yml
vendored
@@ -77,11 +77,6 @@ jobs:
|
||||
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::target Dify release ${tag}"
|
||||
|
||||
- name: Compatibility check
|
||||
env:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Run smoke against live dify
|
||||
working-directory: ./cli
|
||||
run: pnpm exec node scripts/run-smoke.ts --base-url http://localhost:5001
|
||||
run: DIFY_SERVER=http://localhost:5001 bun scripts/run-smoke.ts
|
||||
|
||||
- name: Dump dify logs on failure
|
||||
if: failure()
|
||||
|
||||
225
.github/workflows/main-ci.yml
vendored
225
.github/workflows/main-ci.yml
vendored
@@ -21,9 +21,10 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
|
||||
|
||||
jobs:
|
||||
# Keep orchestration independent of the Depot pool used for test execution.
|
||||
pre_job:
|
||||
name: Skip Duplicate Checks
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
||||
steps:
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
# Check which paths were changed to determine which tests to run
|
||||
check-changes:
|
||||
name: Check Changed Files
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
api-changed: ${{ steps.changes.outputs.api }}
|
||||
cli-changed: ${{ steps.changes.outputs.cli }}
|
||||
@@ -62,10 +63,13 @@ jobs:
|
||||
- 'api/**'
|
||||
- 'scripts/ast_grep_guard.py'
|
||||
- 'scripts/check_no_new_getattr.py'
|
||||
- 'scripts/check_no_spec_mock.py'
|
||||
- 'scripts/check_no_new_controller_sqlalchemy.py'
|
||||
- 'scripts/lint_controller_sqlalchemy.py'
|
||||
- 'scripts/ast_grep_rules/no_new_getattr.yml'
|
||||
- 'scripts/ast_grep_rules/no_spec_mock.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
@@ -78,6 +82,7 @@ jobs:
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
cli:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/**'
|
||||
- 'packages/tsconfig/**'
|
||||
- 'package.json'
|
||||
@@ -159,10 +164,14 @@ jobs:
|
||||
- 'api/**'
|
||||
- 'scripts/ast_grep_guard.py'
|
||||
- 'scripts/check_no_new_getattr.py'
|
||||
- 'scripts/check_no_spec_mock.py'
|
||||
- 'scripts/check_no_new_controller_sqlalchemy.py'
|
||||
- 'scripts/check_no_new_session_mock.py'
|
||||
- 'scripts/lint_controller_sqlalchemy.py'
|
||||
- 'scripts/ast_grep_rules/no_new_getattr.yml'
|
||||
- 'scripts/ast_grep_rules/no_spec_mock.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
dify-agent-style:
|
||||
@@ -187,6 +196,7 @@ jobs:
|
||||
ts-common-style:
|
||||
- 'web/**'
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'e2e/**'
|
||||
- 'sdks/nodejs-client/**'
|
||||
- 'packages/**'
|
||||
@@ -212,7 +222,8 @@ jobs:
|
||||
- '.editorconfig'
|
||||
- '.vite-hooks/**'
|
||||
|
||||
# Run tests in parallel while always emitting stable required checks.
|
||||
# Finalizers report unchanged paths directly, avoiding separate skip-only jobs.
|
||||
# Keep each required check name stable and fail closed if classification fails.
|
||||
api-tests-run:
|
||||
name: Run API Tests
|
||||
needs:
|
||||
@@ -222,17 +233,6 @@ jobs:
|
||||
uses: ./.github/workflows/api-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
api-tests-skip:
|
||||
name: Skip API Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped API tests
|
||||
run: echo "No API-related changes detected; skipping API tests."
|
||||
|
||||
api-tests:
|
||||
name: API Tests
|
||||
if: ${{ always() }}
|
||||
@@ -240,21 +240,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- api-tests-run
|
||||
- api-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize API Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.api-changed }}
|
||||
RUN_RESULT: ${{ needs.api-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.api-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "API tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "API tests ran successfully."
|
||||
@@ -265,12 +270,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "API tests were skipped because no API-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
cli-tests-run:
|
||||
@@ -282,17 +287,6 @@ jobs:
|
||||
uses: ./.github/workflows/cli-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
cli-tests-skip:
|
||||
name: Skip CLI Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped CLI tests
|
||||
run: echo "No CLI-related changes detected; skipping CLI tests."
|
||||
|
||||
cli-tests:
|
||||
name: CLI Tests
|
||||
if: ${{ always() }}
|
||||
@@ -300,21 +294,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- cli-tests-run
|
||||
- cli-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize CLI Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
|
||||
RUN_RESULT: ${{ needs.cli-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "CLI tests ran successfully."
|
||||
@@ -325,12 +324,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "CLI tests were skipped because no CLI-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-tests-run:
|
||||
@@ -342,17 +341,6 @@ jobs:
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
web-tests-skip:
|
||||
name: Skip Web Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web tests
|
||||
run: echo "No web-related changes detected; skipping web tests."
|
||||
|
||||
web-tests:
|
||||
name: Web Tests
|
||||
if: ${{ always() }}
|
||||
@@ -360,21 +348,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-tests-run
|
||||
- web-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.web-changed }}
|
||||
RUN_RESULT: ${{ needs.web-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web tests ran successfully."
|
||||
@@ -385,12 +378,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Web tests were skipped because no web-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-e2e-run:
|
||||
@@ -404,17 +397,6 @@ jobs:
|
||||
run-external-runtime: false
|
||||
secrets: inherit
|
||||
|
||||
web-e2e-skip:
|
||||
name: Skip Web Full-Stack E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web full-stack e2e
|
||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||
|
||||
web-e2e:
|
||||
name: Web Full-Stack E2E
|
||||
if: ${{ always() }}
|
||||
@@ -422,21 +404,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-e2e-run
|
||||
- web-e2e-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Full-Stack E2E status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
|
||||
RUN_RESULT: ${{ needs.web-e2e-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web full-stack E2E ran successfully."
|
||||
@@ -447,12 +434,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Web full-stack E2E was skipped because no E2E-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
style-check:
|
||||
@@ -480,17 +467,6 @@ jobs:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed == 'true'
|
||||
uses: ./.github/workflows/vdb-tests.yml
|
||||
|
||||
vdb-tests-skip:
|
||||
name: Skip VDB Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped VDB tests
|
||||
run: echo "No VDB-related changes detected; skipping VDB tests."
|
||||
|
||||
vdb-tests:
|
||||
name: VDB Tests
|
||||
if: ${{ always() }}
|
||||
@@ -498,21 +474,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- vdb-tests-run
|
||||
- vdb-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize VDB Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.vdb-changed }}
|
||||
RUN_RESULT: ${{ needs.vdb-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.vdb-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "VDB tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "VDB tests ran successfully."
|
||||
@@ -523,12 +504,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "VDB tests were skipped because no VDB-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
db-migration-test-run:
|
||||
@@ -539,17 +520,6 @@ jobs:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed == 'true'
|
||||
uses: ./.github/workflows/db-migration-test.yml
|
||||
|
||||
db-migration-test-skip:
|
||||
name: Skip DB Migration Test
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped DB migration tests
|
||||
run: echo "No migration-related changes detected; skipping DB migration tests."
|
||||
|
||||
db-migration-test:
|
||||
name: DB Migration Test
|
||||
if: ${{ always() }}
|
||||
@@ -557,21 +527,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- db-migration-test-run
|
||||
- db-migration-test-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize DB Migration Test status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.migration-changed }}
|
||||
RUN_RESULT: ${{ needs.db-migration-test-run.result }}
|
||||
SKIP_RESULT: ${{ needs.db-migration-test-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "DB migration tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "DB migration tests ran successfully."
|
||||
@@ -582,12 +557,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "DB migration tests were skipped because no migration-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
sandbox-runtime-tests-run:
|
||||
@@ -599,17 +574,6 @@ jobs:
|
||||
uses: ./.github/workflows/sandbox-runtime-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
sandbox-runtime-tests-skip:
|
||||
name: Skip Sandbox Runtime Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped sandbox runtime tests
|
||||
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
|
||||
|
||||
sandbox-runtime-tests:
|
||||
name: Sandbox Runtime Tests
|
||||
if: ${{ always() }}
|
||||
@@ -617,21 +581,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- sandbox-runtime-tests-run
|
||||
- sandbox-runtime-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Sandbox Runtime Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.sandbox-runtime-changed }}
|
||||
RUN_RESULT: ${{ needs.sandbox-runtime-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.sandbox-runtime-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Sandbox runtime tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Sandbox runtime tests ran successfully."
|
||||
@@ -642,12 +611,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Sandbox runtime tests were skipped because no sandbox-runtime-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
dify-agent-tests-run:
|
||||
@@ -659,17 +628,6 @@ jobs:
|
||||
uses: ./.github/workflows/dify-agent-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
dify-agent-tests-skip:
|
||||
name: Skip Dify Agent Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped dify agent tests
|
||||
run: echo "No dify-agent-related changes detected; skipping dify agent tests."
|
||||
|
||||
dify-agent-tests:
|
||||
name: Dify Agent Tests
|
||||
if: ${{ always() }}
|
||||
@@ -677,21 +635,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- dify-agent-tests-run
|
||||
- dify-agent-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Dify Agent Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.dify-agent-changed }}
|
||||
RUN_RESULT: ${{ needs.dify-agent-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Dify agent tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Dify agent tests ran successfully."
|
||||
@@ -702,10 +665,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Dify agent tests were skipped because no dify-agent-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
10
.github/workflows/style.yml
vendored
10
.github/workflows/style.yml
vendored
@@ -89,6 +89,14 @@ jobs:
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_new_controller_sqlalchemy.py --base-rev "${{ inputs.base-rev }}"
|
||||
|
||||
- name: Run No New Session Mock Guard
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_new_session_mock.py --base-rev "${{ inputs.base-rev }}"
|
||||
|
||||
- name: Run No Spec Mock Guard
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_spec_mock.py
|
||||
|
||||
- name: Run Type Checks
|
||||
if: inputs.run-python-style
|
||||
env:
|
||||
@@ -243,7 +251,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Super-linter
|
||||
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
|
||||
uses: super-linter/super-linter/slim@2da136927bd4a73596db63044b504547c62cb854 # v9.0.0
|
||||
env:
|
||||
BASH_SEVERITY: warning
|
||||
DEFAULT_BRANCH: origin/main
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@@ -163,7 +163,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1.0.235
|
||||
uses: anthropics/claude-code-action@ed670b4cf9de2a5a570d130d2f6197b9e543cd64 # v1.0.240
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
42
.github/workflows/web-e2e.yml
vendored
42
.github/workflows/web-e2e.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test Web build synchronization
|
||||
run: node --test .github/actions/wait-e2e-web-build/wait.test.cjs
|
||||
run: node --test .github/actions/wait-e2e-web-build/*.test.cjs
|
||||
|
||||
- name: Compute build source hash
|
||||
id: build-source
|
||||
@@ -119,24 +119,12 @@ jobs:
|
||||
with:
|
||||
browser: chromium
|
||||
|
||||
# Install dependencies while core-build runs, then wait only for its artifact.
|
||||
- name: Wait for Web build
|
||||
id: web-build
|
||||
timeout-minutes: 35
|
||||
uses: ./.github/actions/wait-e2e-web-build
|
||||
|
||||
- name: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
# The runner prepares the backend while waiting for and downloading the build.
|
||||
- name: Run Chromium full shard
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: vp run e2e:full --download-web-build -- --shard ${{ matrix.shard }}/3
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -183,24 +171,12 @@ jobs:
|
||||
with:
|
||||
browser: webkit
|
||||
|
||||
# Install dependencies while core-build runs, then wait only for its artifact.
|
||||
- name: Wait for Web build
|
||||
id: web-build
|
||||
timeout-minutes: 35
|
||||
uses: ./.github/actions/wait-e2e-web-build
|
||||
|
||||
- name: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
# Keep the same overlap and teardown ownership for the WebKit smoke lane.
|
||||
- name: Run WebKit browser smoke
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --tags '@browser-smoke'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: vp run e2e:full --download-web-build -- --tags '@browser-smoke'
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
9
.vscode/README.md
vendored
9
.vscode/README.md
vendored
@@ -1,14 +1,11 @@
|
||||
# Debugging with VS Code
|
||||
|
||||
This `launch.json.template` file provides various debug configurations for the Dify project within VS Code / Cursor. To use these configurations, you should copy the contents of this file into a new file named `launch.json` in the same `.vscode` directory.
|
||||
[`launch.json.template`](launch.json.template) provides API and Celery worker debug configurations for VS Code / Cursor. Both use `api/.venv/bin/python`; complete the [backend setup](../api/README.md) first.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Create `launch.json`**: If you don't have one, create a file named `launch.json` inside the `.vscode` directory.
|
||||
1. **Copy Content**: Copy the entire content from `launch.json.template` into your newly created `launch.json` file.
|
||||
1. Copy `.vscode/launch.json.template` to `.vscode/launch.json`, or merge the configurations into an existing file.
|
||||
1. **Select Debug Configuration**: Go to the Run and Debug view in VS Code / Cursor (Ctrl+Shift+D or Cmd+Shift+D).
|
||||
1. **Start Debugging**: Select the desired configuration from the dropdown menu and click the green play button.
|
||||
|
||||
## Tips
|
||||
|
||||
- If you need to debug with Edge browser instead of Chrome, modify the `serverReadyAction` configuration in the "Next.js: debug full stack" section, change `"debugWithChrome"` to `"debugWithEdge"` to use Microsoft Edge for debugging.
|
||||
Start the frontend separately using the [frontend guide](../web/README.md).
|
||||
|
||||
@@ -81,24 +81,13 @@ AI tools are welcome, but you are responsible for understanding and verifying ev
|
||||
|
||||
#### Frontend
|
||||
|
||||
For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
|
||||
For setting up the frontend service, please refer to our comprehensive [guide](web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
|
||||
|
||||
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](https://github.com/langgenius/dify/blob/main/web/docs/test.md) for the canonical frontend testing guidelines.
|
||||
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](web/docs/test.md) for the canonical frontend testing guidelines.
|
||||
|
||||
#### Backend
|
||||
|
||||
For setting up the backend service, kindly refer to our detailed [instructions](https://github.com/langgenius/dify/blob/main/api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
|
||||
|
||||
#### Other things to note
|
||||
|
||||
We recommend reviewing this document carefully before proceeding with the setup, as it contains essential information about:
|
||||
|
||||
- Prerequisites and dependencies
|
||||
- Installation steps
|
||||
- Configuration details
|
||||
- Common troubleshooting tips
|
||||
|
||||
Feel free to reach out if you encounter any issues during the setup process.
|
||||
For setting up the backend service, kindly refer to our detailed [instructions](api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
|
||||
|
||||
## Getting Help
|
||||
|
||||
|
||||
1
Makefile
1
Makefile
@@ -80,6 +80,7 @@ lint:
|
||||
@uv run --project api --dev ruff format ./api
|
||||
@uv run --project api --dev ruff check --fix ./api
|
||||
@$(MAKE) api-contract-lint
|
||||
@uv run --project api python scripts/check_no_spec_mock.py
|
||||
@uv run --directory api --dev lint-imports
|
||||
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
|
||||
@echo "✅ Linting complete"
|
||||
|
||||
@@ -83,7 +83,7 @@ docker compose up -d
|
||||
|
||||
After running, you can access the Dify dashboard in your browser at [http://localhost/install](http://localhost/install) and start the initialization process.
|
||||
|
||||
#### Seeking help
|
||||
### Seeking help
|
||||
|
||||
Please refer to our [FAQ](https://docs.dify.ai/getting-started/install-self-hosted/faqs) if you encounter problems setting up Dify. Reach out to [the community and us](#community--contact) if you are still having issues.
|
||||
|
||||
@@ -116,7 +116,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
|
||||
## Using Dify
|
||||
|
||||
- **Cloud <br/>**
|
||||
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
|
||||
We host [Dify Cloud](https://cloud.dify.ai) so you can try Dify without managing a server. See the [pricing page](https://dify.ai/pricing) for current plans and usage allowances. If you run into issues, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
|
||||
|
||||
- **Self-hosting Dify Community Edition<br/>**
|
||||
Quickly get Dify running in your environment with this [starter guide](#quick-start).
|
||||
@@ -171,7 +171,7 @@ Choose the channel that best fits your question:
|
||||
|
||||
## Security disclosure
|
||||
|
||||
To protect your privacy, please avoid posting security issues on GitHub. Instead, report issues to security@dify.ai, and our team will respond with detailed answer.
|
||||
Report vulnerabilities privately through GitHub Security Advisories, following our [security policy](SECURITY.md). Do not disclose them in public issues, discussions, or pull requests.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ ARCHIVE_STORAGE_EXPORT_BUCKET=
|
||||
ARCHIVE_STORAGE_ACCESS_KEY=
|
||||
ARCHIVE_STORAGE_SECRET_KEY=
|
||||
ARCHIVE_STORAGE_REGION=auto
|
||||
ARCHIVE_STORAGE_ADDRESS_STYLE=path
|
||||
|
||||
# Azure Blob Storage configuration
|
||||
AZURE_BLOB_ACCOUNT_NAME=your-account-name
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
[importlinter]
|
||||
root_packages =
|
||||
core
|
||||
core.rag.extractor.entity
|
||||
constants
|
||||
context
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
factories
|
||||
fields
|
||||
libs
|
||||
machinery
|
||||
models
|
||||
@@ -16,7 +18,7 @@ root_packages =
|
||||
include_external_packages = True
|
||||
|
||||
[importlinter:contract:backend-layers]
|
||||
name = Backend modules follow the transport, application, domain, and shared-library layers
|
||||
name = Backend modules follow the transport, application, execution-kernel, and shared-library layers
|
||||
type = layers
|
||||
layers =
|
||||
controllers
|
||||
@@ -27,7 +29,7 @@ layers =
|
||||
# error makes this list shrink whenever a legacy dependency is removed.
|
||||
unmatched_ignore_imports_alerting = error
|
||||
ignore_imports =
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.dataset_service
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.knowledge.dataset_service
|
||||
core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.advanced_chat.app_generator -> services.conversation_service
|
||||
core.app.apps.advanced_chat.app_generator -> services.errors.conversation
|
||||
@@ -48,45 +50,41 @@ ignore_imports =
|
||||
core.app.apps.message_based_app_generator -> services.errors.conversation
|
||||
core.app.apps.message_based_app_generator -> services.errors.message
|
||||
core.app.apps.pipeline.pipeline_generator -> services
|
||||
core.app.apps.pipeline.pipeline_generator -> services.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.datasource_provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.knowledge.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.data_source.provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.feature_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.pipeline.pipeline_runner -> services.dataset_ref_service
|
||||
core.app.apps.workflow.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.workflow.app_generator -> services.snippet_generate_service
|
||||
core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task
|
||||
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.dataset_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.knowledge.dataset_service
|
||||
core.app.llm.quota -> services.credit_pool_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.account_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
|
||||
core.app.workflow.layers.persistence -> services.workflow.inspector_events
|
||||
core.datasource.datasource_manager -> services.datasource_provider_service
|
||||
core.helper.credential_utils -> services.enterprise.plugin_manager_service
|
||||
core.helper.credential_utils -> services.system_feature_service
|
||||
core.indexing_runner -> services.vector_space_admission_service
|
||||
core.mcp.auth_client -> services
|
||||
core.provider_manager -> services.credential_permission_service
|
||||
core.provider_manager -> services.credit_pool_service
|
||||
core.provider_manager -> services.feature_service
|
||||
core.rag.datasource.retrieval_service -> services.external_knowledge_service
|
||||
core.rag.datasource.retrieval_service -> services.knowledge.external.service
|
||||
core.rag.index_processor.index_processor -> services.vector_space_admission_service
|
||||
core.rag.index_processor.index_processor_base -> services.file_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.summary_index_service
|
||||
core.rag.retrieval.dataset_retrieval -> services.external_knowledge_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.retrieval.dataset_retrieval -> services.knowledge.external.service
|
||||
core.rag.retrieval.dataset_retrieval -> services.knowledge.retrieval.attachments
|
||||
core.rag.retrieval.dataset_retrieval -> services.feature_service
|
||||
core.rag.summary_index.summary_index -> services.summary_index_service
|
||||
core.rag.summary_index.summary_index -> services.knowledge.summaries.adapters
|
||||
core.rag.summary_index.summary_index -> tasks.generate_summary_index_task
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.file_service
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator
|
||||
core.tools.tool_manager -> services
|
||||
core.workflow.node_runtime -> services
|
||||
# Node construction delegates datasource dependency assembly to the composition root.
|
||||
core.workflow.node_factory -> extensions.application_services.data_sources
|
||||
core.workflow.nodes.agent.message_transformer -> services
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service
|
||||
@@ -99,7 +97,7 @@ ignore_imports =
|
||||
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
|
||||
libs.email_i18n -> services.entities.feature_entities
|
||||
libs.email_i18n -> services.system_feature_service
|
||||
libs.external_api -> core
|
||||
libs.external_api -> core.plugin
|
||||
libs.external_api -> core.errors.error
|
||||
libs.external_api -> extensions.ext_logging
|
||||
libs.flask_utils -> models
|
||||
@@ -114,8 +112,6 @@ ignore_imports =
|
||||
libs.oauth_bearer -> models
|
||||
libs.rsa -> extensions.ext_storage
|
||||
libs.workspace_permission -> services.enterprise.enterprise_service
|
||||
services.account_service -> controllers
|
||||
services.account_service -> controllers.console.error
|
||||
services.app_generate_service -> controllers.console.app.workflow
|
||||
|
||||
[importlinter:contract:no-direct-rsa-imports]
|
||||
@@ -155,18 +151,159 @@ forbidden_modules =
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-query-service-boundary]
|
||||
name = Workspace query application service is framework and persistence neutral
|
||||
name = Workspace application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_query_service
|
||||
services.workspace.service
|
||||
services.workspace.provisioning_service
|
||||
services.workspace.contracts
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.billing_service
|
||||
services.feature_service
|
||||
services.workspace.gateways
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:dataset-application-boundary]
|
||||
name = Dataset application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core.rag.extractor.entity.extract_setting
|
||||
services.knowledge.entities.segments
|
||||
services.knowledge.entities.datasets
|
||||
services.knowledge.entities.document_creation
|
||||
services.data_source.binding_application_service
|
||||
services.data_source.credential_gateway
|
||||
services.data_source.notion_import_application_service
|
||||
services.data_source.entities.credential
|
||||
services.data_source.entities.notion_import
|
||||
services.knowledge.entities.indexing_estimate
|
||||
services.knowledge.dataset_access
|
||||
services.knowledge.datasets.application
|
||||
services.knowledge.datasets.retrieval
|
||||
services.knowledge.document_sync
|
||||
services.knowledge.documents.application
|
||||
services.knowledge.external.application
|
||||
services.knowledge.indexing.errors
|
||||
services.knowledge.indexing.estimate
|
||||
services.knowledge.indexing.execution
|
||||
services.knowledge.resource_scope
|
||||
services.knowledge.segments.application
|
||||
services.knowledge.metadata.application
|
||||
services.knowledge.summaries.application
|
||||
forbidden_modules =
|
||||
fields
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
services.knowledge.dataset_service
|
||||
services.data_source.provider_service
|
||||
services.knowledge.indexing.adapters
|
||||
services.knowledge.segments.adapters
|
||||
services.knowledge.segments.indexing
|
||||
core.datasource.datasource_manager
|
||||
core.rag.extractor.notion_extractor
|
||||
|
||||
[importlinter:contract:knowledge-repository-boundary]
|
||||
name = Knowledge persistence adapters only depend on their consumer-owned application contracts
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.knowledge
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
services
|
||||
tasks
|
||||
werkzeug
|
||||
allow_indirect_imports = True
|
||||
ignore_imports =
|
||||
repositories.knowledge.dataset_api_key_repository -> services.auth.api_key_contracts
|
||||
repositories.knowledge.dataset_api_key_repository -> services.knowledge.api_key_service
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.segments.application
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.dataset_access
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.indexing.estimate
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.document_repository -> services.knowledge.document_sync
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.estimate
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.errors
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.execution
|
||||
repositories.knowledge.document_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.segment_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.segment_repository -> services.knowledge.indexing.execution
|
||||
repositories.knowledge.segment_repository -> services.knowledge.segments.application
|
||||
repositories.knowledge.segment_repository -> services.knowledge.entities.segments
|
||||
repositories.knowledge.metadata_repository -> services.errors.metadata
|
||||
repositories.knowledge.metadata_repository -> services.knowledge.metadata.application
|
||||
repositories.knowledge.metadata_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.summary_repository -> services.knowledge.summaries.application
|
||||
|
||||
[importlinter:contract:datasource-credential-repository-boundary]
|
||||
name = Data source credential persistence does not depend on SQLAlchemy service helpers
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.credentials.query_repository
|
||||
repositories.data_source.credential_repository
|
||||
forbidden_modules =
|
||||
services.data_source.provider_service
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:datasource-provider-persistence-boundary]
|
||||
name = Datasource provider service consumes injected persistence contracts
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source.provider_service
|
||||
forbidden_modules =
|
||||
repositories
|
||||
extensions.application_services
|
||||
extensions.ext_application_services
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:credential-permission-query-policy-boundary]
|
||||
name = Credential query contracts do not expose persistence implementations
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.credentials.query
|
||||
forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
models
|
||||
extensions
|
||||
flask
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:credential-query-implementation-boundary]
|
||||
name = Credential query implementations are wired only by composition or other repositories
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers
|
||||
services
|
||||
core
|
||||
forbidden_modules =
|
||||
repositories.credentials.query_repository
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:pipeline-document-lookup-boundary]
|
||||
name = Pipeline core depends on its document store port
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core.app.apps.pipeline.pipeline_generator
|
||||
core.app.apps.pipeline.pipeline_runner
|
||||
forbidden_modules =
|
||||
repositories
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:init-validation-service-boundary]
|
||||
name = Initialization validation application service is framework and persistence neutral
|
||||
@@ -295,16 +432,16 @@ source_modules =
|
||||
services.account_avatar_service
|
||||
services.account_change_email_ports
|
||||
services.account_change_email_service
|
||||
services.account_email_registration_service
|
||||
services.account.email_registration_service
|
||||
services.account_deletion_service
|
||||
services.account_deletion_feedback_service
|
||||
services.account_education_service
|
||||
services.account_errors
|
||||
services.account_forgot_password_service
|
||||
services.account.forgot_password_service
|
||||
services.account_initialization_service
|
||||
services.account_integration_service
|
||||
services.account_login_service
|
||||
services.account_oauth_service
|
||||
services.account.login_service
|
||||
services.account.oauth_service
|
||||
services.account_password_service
|
||||
services.account_ports
|
||||
services.account_profile_service
|
||||
@@ -443,6 +580,35 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-authentication-service-boundary]
|
||||
name = Web authentication application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_authentication_service
|
||||
services.entities.authentication_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:web-authentication-adapters-persistence-boundary]
|
||||
name = Web authentication adapters do not access persistence directly
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.web_authentication_adapters
|
||||
allow_indirect_imports = True
|
||||
forbidden_modules =
|
||||
configs
|
||||
extensions.ext_database
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
|
||||
[importlinter:contract:web-app-runtime-query-service-boundary]
|
||||
name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules
|
||||
type = forbidden
|
||||
@@ -479,7 +645,7 @@ forbidden_modules =
|
||||
name = Data source OAuth application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source_oauth_service
|
||||
services.data_source.oauth_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
@@ -531,7 +697,7 @@ forbidden_modules =
|
||||
name = Workspace member query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_member_query_service
|
||||
services.workspace.member_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
@@ -540,7 +706,7 @@ forbidden_modules =
|
||||
models
|
||||
repositories
|
||||
services.enterprise
|
||||
services.workspace_member_role_resolver
|
||||
services.workspace.gateways
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -666,6 +832,36 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:api-based-extension-application-service-boundary]
|
||||
name = API-based extension application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.api_based_extension_application_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
core
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:console-extension-controller-boundary]
|
||||
name = Console extension controller reaches persistence only through the composition root
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.extension
|
||||
forbidden_modules =
|
||||
extensions.ext_database
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
# The composition root (extensions.ext_application_services) wires repositories,
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:tool-file-download-service-boundary]
|
||||
name = Tool file download application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -697,6 +893,20 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-package-service-boundary]
|
||||
name = Recommended package application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_package_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-catalog-gateway-boundary]
|
||||
name = Recommended app catalog gateway does not depend on Flask
|
||||
type = forbidden
|
||||
@@ -786,6 +996,86 @@ forbidden_modules =
|
||||
tasks
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:knowledge-segment-presentation-boundary]
|
||||
name = Knowledge operations do not serialize HTTP responses
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
services.knowledge.segments
|
||||
services.knowledge.documents
|
||||
services.knowledge.datasets
|
||||
services.knowledge.external
|
||||
forbidden_modules =
|
||||
controllers
|
||||
fields
|
||||
|
||||
[importlinter:contract:knowledge-response-persistence-boundary]
|
||||
name = Knowledge response schemas do not load database state
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
fields.dataset_fields
|
||||
fields.document_fields
|
||||
fields.segment_fields
|
||||
forbidden_modules =
|
||||
sqlalchemy
|
||||
models
|
||||
repositories
|
||||
services.knowledge
|
||||
|
||||
[importlinter:contract:dataset-model-infrastructure-boundary]
|
||||
name = Dataset models do not execute infrastructure or application operations
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
models.dataset
|
||||
forbidden_modules =
|
||||
repositories
|
||||
services
|
||||
extensions
|
||||
core.tools
|
||||
models.engine
|
||||
|
||||
[importlinter:contract:app-mcp-server-service-boundary]
|
||||
name = App MCP server application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app.mcp_server_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:console-app-mcp-server-controller-boundary]
|
||||
name = Console App MCP server controller reaches persistence only through the composition root
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.app.mcp_server
|
||||
forbidden_modules =
|
||||
extensions.ext_database
|
||||
models.model
|
||||
repositories
|
||||
sqlalchemy
|
||||
# The composition root (extensions.ext_application_services) wires repositories,
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:console-app-wraps-boundary]
|
||||
name = Console app decorators load App rows without reaching into repositories
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.app.wraps
|
||||
forbidden_modules =
|
||||
repositories
|
||||
# models.model / models.workflow lazily import repositories.factory (legacy debt),
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:app-audio-boundary]
|
||||
name = App audio operations and values are framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -801,3 +1091,20 @@ forbidden_modules =
|
||||
services.audio_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-lifecycle-boundary]
|
||||
name = Account lifecycle use cases and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account.service
|
||||
services.account.contracts
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.account.adapters
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -15,7 +15,8 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
|
||||
|
||||
## Architecture And Boundaries
|
||||
|
||||
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in `core/` or its domain owner. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
|
||||
- Treat `api/core/` as a migration-only area: do not add files or move implementations into it. Move responsibilities out; limit changes to existing callers to the adaptations needed for that migration.
|
||||
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in its domain owner. New or extracted implementations must stay outside `core/`. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
|
||||
- Before changing controller schemas, generated API contracts, or `SystemFeatureModel`, read `controllers/API_SCHEMA_GUIDE.md`. Treat `/system-features` as a minimal unauthenticated bootstrap allowlist, not a general configuration registry.
|
||||
- Scope tenant-owned reads and writes by the complete owner chain, and propagate `tenant_id` across every affected layer. Reconstruct trusted internal references from validated database state after payload or async boundaries.
|
||||
- Keep write transactions explicit and bounded. Do not perform external I/O inside an open transaction unless a documented consistency contract requires it.
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
|
||||
## Setup and Run
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> In the v1.3.0 release, `poetry` has been replaced with
|
||||
> [`uv`](https://docs.astral.sh/uv/) as the package manager
|
||||
> for Dify API backend service.
|
||||
|
||||
`uv` and `pnpm` are required to run the setup and development commands below.
|
||||
[`uv`](https://docs.astral.sh/uv/), Docker Compose, and the Node.js and pnpm versions pinned in the root `package.json` are required. Run the commands below from the repository root, with each long-running service in its own terminal.
|
||||
|
||||
### Using scripts (recommended)
|
||||
|
||||
The scripts resolve paths relative to their location, so you can run them from anywhere.
|
||||
|
||||
1. Run setup (copies env files and installs dependencies).
|
||||
1. Run setup for a new checkout (copies env files and installs dependencies). This overwrites `api/.env`, `web/.env.local`, and `docker/middleware.env`; preserve existing configuration before rerunning it.
|
||||
|
||||
```bash
|
||||
./dev/setup
|
||||
@@ -44,7 +36,7 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
|
||||
1. Set up your application by visiting `http://localhost:3000`.
|
||||
|
||||
1. Start the worker service (async and scheduler tasks, runs from `api`).
|
||||
1. Start the worker service (executes queued async tasks).
|
||||
|
||||
```bash
|
||||
./dev/start-worker
|
||||
@@ -62,51 +54,35 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
>
|
||||
> When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the site’s top-level domain (e.g., `example.com`). The frontend and backend must be under the same top-level domain in order to share authentication cookies.
|
||||
|
||||
- Generate a `SECRET_KEY` in the `.env` file.
|
||||
|
||||
bash for Linux
|
||||
|
||||
```bash
|
||||
sed -i "/^SECRET_KEY=/c\\SECRET_KEY=$(openssl rand -base64 42)" .env
|
||||
```
|
||||
|
||||
bash for Mac
|
||||
|
||||
```bash
|
||||
secret_key=$(openssl rand -base64 42)
|
||||
sed -i '' "/^SECRET_KEY=/c\\
|
||||
SECRET_KEY=${secret_key}" .env
|
||||
```
|
||||
Leave `SECRET_KEY` empty to let Dify generate a persistent key in the storage directory. To manage it explicitly, generate a value with `openssl rand -base64 42` and set `SECRET_KEY` in `api/.env`.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Install dependencies for both the backend and the test environment
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
1. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`, more can check [AGENTS.md](../AGENTS.md)
|
||||
|
||||
Continue in the `api` directory from the previous step.
|
||||
|
||||
```bash
|
||||
uv run pytest # Run all tests
|
||||
uv run pytest tests/unit_tests/ # Unit tests only
|
||||
uv run pytest tests/integration_tests/ # Integration tests
|
||||
|
||||
# Code quality
|
||||
../dev/reformat # Run all formatters and linters
|
||||
uv run ruff check --fix ./ # Fix linting issues
|
||||
uv run ruff format ./ # Format code
|
||||
uv run pyrefly check # Type checking
|
||||
```
|
||||
|
||||
## Generate TS stub
|
||||
|
||||
```
|
||||
uv run dev/generate_swagger_specs.py --output-dir openapi
|
||||
```bash
|
||||
uv sync --project api --group dev
|
||||
make test
|
||||
make test TARGET_TESTS=./api/tests/unit_tests/<path>
|
||||
make lint
|
||||
make type-check
|
||||
```
|
||||
|
||||
use https://jsontotable.org/openapi-to-typescript to convert to typescript
|
||||
`make test` includes provider unit tests and runs controller tests separately. Integration suites are CI-only and are not expected to run locally. Test environment defaults live in `api/pyproject.toml` under `tool.pytest_env`; see the backend [agent guide](AGENTS.md) for package conventions.
|
||||
|
||||
## API contracts
|
||||
|
||||
The [API schema guide](controllers/API_SCHEMA_GUIDE.md) owns schema changes and verification. Generate the OpenAPI specifications and TypeScript/Zod contracts through the workspace script:
|
||||
|
||||
```bash
|
||||
pnpm -C packages/contracts gen-api-contract
|
||||
```
|
||||
|
||||
Refresh the checked-in Markdown reference with:
|
||||
|
||||
```bash
|
||||
uv run --project api python api/dev/generate_swagger_markdown_docs.py \
|
||||
--swagger-dir packages/contracts/openapi --markdown-dir api/openapi/markdown --keep-swagger-json
|
||||
```
|
||||
|
||||
Update the backend schema owner and regenerate contracts instead of converting types manually or editing generated files.
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import base64
|
||||
import secrets
|
||||
|
||||
import click
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants.languages import languages
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import email as email_validate
|
||||
from libs.password import hash_password, password_pattern, valid_password
|
||||
from services.account_email import normalize_email
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from libs.password import password_pattern, valid_password
|
||||
|
||||
|
||||
@click.command("reset-password", help="Reset the account password.")
|
||||
@@ -26,7 +22,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
return
|
||||
normalized_email = email.strip().lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=db.session())
|
||||
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
|
||||
|
||||
if not account:
|
||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||
@@ -38,19 +34,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
|
||||
return
|
||||
|
||||
# generate password salt
|
||||
salt = secrets.token_bytes(16)
|
||||
base64_salt = base64.b64encode(salt).decode()
|
||||
|
||||
# encrypt password with salt
|
||||
password_hashed = hash_password(new_password, salt)
|
||||
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
||||
with Session(db.engine) as session:
|
||||
account = session.merge(account)
|
||||
account.password = base64_password_hashed
|
||||
account.password_salt = base64_salt
|
||||
session.commit()
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
application_services().accounts.lifecycle.reset_password(account.id, new_password, email=normalized_email)
|
||||
click.echo(click.style("Password reset successfully.", fg="green"))
|
||||
|
||||
|
||||
@@ -68,7 +52,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(email.strip(), session=db.session())
|
||||
account = application_services().accounts.lifecycle.get_account_by_email_with_case_fallback(email.strip())
|
||||
|
||||
if not account:
|
||||
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
|
||||
@@ -80,11 +64,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
||||
return
|
||||
|
||||
with Session(db.engine) as session:
|
||||
account = session.merge(account)
|
||||
account.email = normalized_new_email
|
||||
account.normalized_email = normalize_email(normalized_new_email)
|
||||
session.commit()
|
||||
application_services().accounts.lifecycle.set_email(account.id, normalized_new_email)
|
||||
click.echo(click.style("Email updated successfully.", fg="green"))
|
||||
|
||||
|
||||
@@ -129,15 +109,14 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
|
||||
return
|
||||
|
||||
# register account
|
||||
account = RegisterService.register(
|
||||
account = application_services().accounts.lifecycle.register(
|
||||
email=email,
|
||||
name=account_name,
|
||||
password=new_password,
|
||||
language=language,
|
||||
create_workspace_required=False,
|
||||
session=db.session(),
|
||||
)
|
||||
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session())
|
||||
application_services().workspaces.provisioning.create_owner_workspace(account.id, name=name, if_missing=True)
|
||||
|
||||
click.echo(
|
||||
click.style(
|
||||
|
||||
@@ -463,6 +463,36 @@ def _workspace_member_account_id_batches(tenant_id: str, batch_size: int) -> Ite
|
||||
last_join_id = str(rows[-1].id)
|
||||
|
||||
|
||||
def _workspace_non_admin_account_id_batches(
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
batch_size: int,
|
||||
) -> Iterator[list[str]]:
|
||||
"""Yield workspace members except accounts bound to RBAC's builtin Owner/Admin roles.
|
||||
|
||||
Resource administrators receive access implicitly through RBAC and their
|
||||
resource access-policy bindings are locked. Resolve their current RBAC
|
||||
bindings in batches instead of relying on the legacy workspace role column.
|
||||
"""
|
||||
for batch in _workspace_member_account_id_batches(tenant_id, batch_size):
|
||||
member_roles = RBACService.MemberRoles.batch_get(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
member_account_ids=batch,
|
||||
)
|
||||
protected_account_ids = {
|
||||
member.account_id
|
||||
for member in member_roles
|
||||
if any(
|
||||
role.is_builtin and role.category == "global_system_default" and role.role_tag in {"owner", "admin"}
|
||||
for role in member.roles
|
||||
)
|
||||
}
|
||||
writable_batch = [account_id for account_id in batch if account_id not in protected_account_ids]
|
||||
if writable_batch:
|
||||
yield writable_batch
|
||||
|
||||
|
||||
def _replace_resource_whitelist(
|
||||
resource_type: str,
|
||||
*,
|
||||
@@ -725,7 +755,7 @@ def migrate_resource_whitelist_scopes_to_automatic_include(
|
||||
migrated_count += 1
|
||||
|
||||
if scope is RBACResourceWhitelistScope.ALL:
|
||||
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
|
||||
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
|
||||
_replace_resource_default_access_policies(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
@@ -877,7 +907,7 @@ def migrate_only_me_resource_whitelist_scopes_to_automatic_include(
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
for batch in _workspace_member_account_id_batches(workspace_id, member_batch_size):
|
||||
for batch in _workspace_non_admin_account_id_batches(workspace_id, operator_account_id, member_batch_size):
|
||||
_replace_resource_default_access_policies(
|
||||
current_resource_type,
|
||||
tenant_id=workspace_id,
|
||||
@@ -1187,7 +1217,11 @@ def _report_backing_app_specific_whitelist(options: _AgentAccessBootstrapOptions
|
||||
|
||||
|
||||
def _write_agent_access_rows(options: _AgentAccessBootstrapOptions) -> None:
|
||||
for batch in _workspace_member_account_id_batches(options.tenant_id, options.member_batch_size):
|
||||
for batch in _workspace_non_admin_account_id_batches(
|
||||
options.tenant_id,
|
||||
options.operator_account_id,
|
||||
options.member_batch_size,
|
||||
):
|
||||
RBACService.AgentAccess.replace_user_access_policies(
|
||||
tenant_id=options.tenant_id,
|
||||
account_id=options.operator_account_id,
|
||||
|
||||
@@ -19,6 +19,7 @@ from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, D
|
||||
from models.dataset import Document as DatasetDocument
|
||||
from models.enums import DatasetMetadataType, IndexingStatus, SegmentStatus
|
||||
from models.model import App, AppAnnotationSetting, MessageAnnotation
|
||||
from repositories.knowledge.dataset_read_repository import get_segment_child_chunks
|
||||
|
||||
|
||||
@click.command("vdb-migrate", help="Migrate vector db.")
|
||||
@@ -278,7 +279,7 @@ def migrate_knowledge_vector_database():
|
||||
},
|
||||
)
|
||||
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
|
||||
child_chunks = segment.get_child_chunks(session=db_session)
|
||||
child_chunks = get_segment_child_chunks(segment, session=db_session)
|
||||
if child_chunks:
|
||||
child_documents = []
|
||||
for child_chunk in child_chunks:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -41,3 +43,8 @@ class ArchiveStorageConfig(BaseSettings):
|
||||
description="Region for storage (use 'auto' if the provider supports it)",
|
||||
default="auto",
|
||||
)
|
||||
|
||||
ARCHIVE_STORAGE_ADDRESS_STYLE: Literal["path", "virtual", "auto"] = Field(
|
||||
description="S3 addressing style for archive storage: 'path' (default), 'virtual' (virtual-hosted), or 'auto'",
|
||||
default="path",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
from collections import OrderedDict
|
||||
from http import HTTPStatus
|
||||
from typing import cast
|
||||
|
||||
from flask import current_app, got_request_exception
|
||||
from flask_restx import Api
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.base import NoPermissionError
|
||||
|
||||
|
||||
class FilenameNotExistsError(BaseHTTPException):
|
||||
@@ -84,6 +92,39 @@ class NotFoundError(BaseHTTPException):
|
||||
code = 404
|
||||
|
||||
|
||||
class UnauthorizedError(BaseHTTPException):
|
||||
error_code = "unauthorized"
|
||||
code = HTTPStatus.UNAUTHORIZED
|
||||
description = "Authentication is required."
|
||||
|
||||
|
||||
class InternalServerError(BaseHTTPException):
|
||||
"""Expose a safe response while retaining the original exception in server logs."""
|
||||
|
||||
error_code = "internal_server_error"
|
||||
code = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
description = (
|
||||
"The server encountered an internal error and was unable to complete your request. "
|
||||
"Either the server is overloaded or there is an error in the application."
|
||||
)
|
||||
|
||||
|
||||
class InvalidArgumentError(BaseHTTPException):
|
||||
error_code = "invalid_param"
|
||||
code = 400
|
||||
|
||||
|
||||
def register_permission_error_handler(api: Api) -> None:
|
||||
"""Preserve the default permission-error response for Console and Service API."""
|
||||
|
||||
@api.errorhandler(NoPermissionError)
|
||||
def handle_permission_error(error: NoPermissionError) -> tuple[dict[str, str | int], int]:
|
||||
got_request_exception.send(current_app, exception=error)
|
||||
return {
|
||||
"code": InvalidArgumentError.error_code,
|
||||
"message": str(error),
|
||||
"status": HTTPStatus.BAD_REQUEST,
|
||||
}, HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Flask-RESTX picks the first matching handler, including the generic Exception handler.
|
||||
cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(NoPermissionError, last=False)
|
||||
|
||||
@@ -4,6 +4,7 @@ from .checks import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
|
||||
from .locators import (
|
||||
AgentBehindApp,
|
||||
AgentId,
|
||||
DatasetByDocument,
|
||||
DatasetByPipeline,
|
||||
DatasetId,
|
||||
PlainApp,
|
||||
@@ -16,6 +17,7 @@ __all__ = [
|
||||
"RBAC_CHECKS_ATTR",
|
||||
"AgentBehindApp",
|
||||
"AgentId",
|
||||
"DatasetByDocument",
|
||||
"DatasetByPipeline",
|
||||
"DatasetId",
|
||||
"PlainApp",
|
||||
|
||||
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"AgentBehindApp",
|
||||
"AgentId",
|
||||
"DatasetByDocument",
|
||||
"DatasetByPipeline",
|
||||
"DatasetId",
|
||||
"PlainApp",
|
||||
@@ -116,7 +117,7 @@ class AgentBehindApp(_ParamLocator):
|
||||
binding = agent_binding(tenant_id, _required(path_args, self.param))
|
||||
if binding is None or binding.scope == AgentScope.WORKFLOW_ONLY:
|
||||
return None
|
||||
return ResourceIdentity(self.scope, str(binding.id))
|
||||
return ResourceIdentity(self.scope, binding.id)
|
||||
|
||||
def owner_id(self, tenant_id: str, identity: ResourceIdentity) -> str | None:
|
||||
return None
|
||||
@@ -133,6 +134,18 @@ class DatasetId(_ParamLocator):
|
||||
return RBACResourceService.get_dataset_maintainer(db.session, tenant_id, identity.id)
|
||||
|
||||
|
||||
class DatasetByDocument(DatasetId):
|
||||
default_param = "document_id"
|
||||
|
||||
@override
|
||||
def locate(self, tenant_id: str, path_args: Mapping[str, object]) -> ResourceIdentity | None:
|
||||
document_id = _required(path_args, self.param)
|
||||
dataset_id = RBACResourceService.get_dataset_id_by_document(db.session, tenant_id, document_id)
|
||||
if dataset_id is None:
|
||||
raise NotFound("Document not found")
|
||||
return ResourceIdentity(self.scope, dataset_id)
|
||||
|
||||
|
||||
class DatasetByPipeline(DatasetId):
|
||||
default_param = "pipeline_id"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import cast
|
||||
from flask import Blueprint, current_app, got_request_exception
|
||||
from flask_restx import Namespace
|
||||
|
||||
from controllers.common.errors import register_permission_error_handler
|
||||
from libs.external_api import ExternalApi
|
||||
from machinery.errors import ActiveWorkspaceRequiredError
|
||||
|
||||
@@ -17,6 +18,7 @@ api = ExternalApi(
|
||||
title="Console API",
|
||||
description="Console management APIs for app configuration, monitoring, and administration",
|
||||
)
|
||||
register_permission_error_handler(api)
|
||||
|
||||
|
||||
@api.errorhandler(ActiveWorkspaceRequiredError)
|
||||
|
||||
@@ -24,7 +24,7 @@ from services.auth.api_key_contracts import (
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.knowledge.api_key_service import UnknownDatasetIdsError
|
||||
|
||||
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
|
||||
@@ -7,14 +7,16 @@ import io
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response, request, send_file, url_for
|
||||
from flask import Response, make_response, request, send_file, url_for
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import BinaryFileResponse
|
||||
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
@@ -25,6 +27,8 @@ from controllers.common.schema import (
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import AppUnavailableError
|
||||
from controllers.console.app.preview_admission import get_preview_app
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -36,7 +40,11 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.agent_fields import AgentAppComposerResponse
|
||||
from fields.base import ResponseModel
|
||||
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode
|
||||
@@ -50,6 +58,8 @@ from services.agent_config_service import (
|
||||
ConfigPushPayload,
|
||||
ConfigPushSkillItem,
|
||||
)
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.app_preview_query_service import AppPreviewRef
|
||||
|
||||
|
||||
class AgentConfigQuery(BaseModel):
|
||||
@@ -215,6 +225,7 @@ class AgentConfigDeleteResponse(ResponseModel):
|
||||
register_schema_models(console_ns, AgentConfigFileUploadPayload)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
BinaryFileResponse,
|
||||
AgentConfigDeleteResponse,
|
||||
AgentConfigDownloadResponse,
|
||||
AgentConfigFileItemResponse,
|
||||
@@ -239,7 +250,7 @@ register_response_schema_models(
|
||||
class _ResolvedConsoleTarget:
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
account_id: str
|
||||
account_id: str | None
|
||||
version_id: str
|
||||
version_kind: AgentConfigVersionKind
|
||||
|
||||
@@ -247,6 +258,51 @@ class _ResolvedConsoleTarget:
|
||||
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
class TrialAgentConfigQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: UUID | None = Field(default=None, description="Must match the current published template snapshot")
|
||||
|
||||
|
||||
class TrialAgentSkillFileQuery(TrialAgentConfigQuery):
|
||||
path: str = Field(description="Normalized member path inside the published Skill package")
|
||||
|
||||
|
||||
register_schema_models(console_ns, TrialAgentConfigQuery, TrialAgentSkillFileQuery)
|
||||
|
||||
|
||||
def _with_trial_config[T, Q: TrialAgentConfigQuery, **P](
|
||||
view: Callable[Concatenate[T, _ResolvedConsoleTarget, Q, P], Response | dict[str, object]],
|
||||
) -> Callable[Concatenate[T, Q, AppPreviewRef, P], Response]:
|
||||
@wraps(view)
|
||||
def decorated(self: T, query: Q, app: AppPreviewRef, /, *args: P.args, **kwargs: P.kwargs) -> Response:
|
||||
try:
|
||||
state = AgentAppComposerResponse.model_validate(
|
||||
application_services().app_previews.get_agent_composer(app=app)
|
||||
)
|
||||
snapshot = state.active_config_snapshot
|
||||
if snapshot is None or (query.version_id is not None and str(query.version_id) != snapshot.id):
|
||||
raise AgentConfigServiceError(
|
||||
"config_version_not_found", "published template version is unavailable", status_code=404
|
||||
)
|
||||
target = _ResolvedConsoleTarget(
|
||||
tenant_id=app.tenant_id,
|
||||
agent_id=state.agent.id,
|
||||
account_id=None,
|
||||
version_id=snapshot.id,
|
||||
version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
response = make_response(view(self, target, query, *args, **kwargs))
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
except AgentConfigServiceError as exc:
|
||||
response = make_response(_handle(exc))
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _service() -> AgentConfigService:
|
||||
return AgentConfigService()
|
||||
|
||||
@@ -481,6 +537,7 @@ def _read_single_upload() -> tuple[bytes, str]:
|
||||
|
||||
|
||||
def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]:
|
||||
assert target.account_id is not None
|
||||
return _upload_skill_for_target(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -517,6 +574,7 @@ def _upload_skill_for_target(
|
||||
def _file_upload_response(
|
||||
target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload
|
||||
) -> tuple[dict[str, object], int]:
|
||||
assert target.account_id is not None
|
||||
manifest = _service().push_file_for_console(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -616,6 +674,7 @@ def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str,
|
||||
|
||||
|
||||
def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
|
||||
assert target.account_id is not None
|
||||
_service().push_for_console(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -654,6 +713,7 @@ def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Respon
|
||||
|
||||
|
||||
def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
|
||||
assert target.account_id is not None
|
||||
_service().push_for_console(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
@@ -1360,5 +1420,124 @@ class AgentConfigFileApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills")
|
||||
class TrialAgentConfigSkillsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigSkillListResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
|
||||
return dump_response(AgentConfigSkillListResponse, _skill_list_response(target))
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files")
|
||||
class TrialAgentConfigFilesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFileListResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
|
||||
return dump_response(AgentConfigFileListResponse, _file_list_response(target))
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/inspect")
|
||||
class TrialAgentConfigSkillInspectApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(
|
||||
200, "Published template resource", console_ns.models[AgentConfigSkillInspectResponse.__name__]
|
||||
)
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
|
||||
return _skill_inspect_response(target, name)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/download")
|
||||
class TrialAgentConfigSkillDownloadApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
|
||||
return _skill_download_response(target, name)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/preview")
|
||||
class TrialAgentConfigSkillFilePreviewApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
|
||||
@console_ns.response(
|
||||
200, "Published template resource", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__]
|
||||
)
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentSkillFileQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> dict[str, object]:
|
||||
return dump_response(
|
||||
AgentConfigSkillFilePreviewResponse, _skill_file_preview_response(target, name, query.path)
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/download")
|
||||
class TrialAgentConfigSkillFileDownloadApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentSkillFileQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
|
||||
assert request.view_args is not None
|
||||
return _skill_file_download_response(
|
||||
target,
|
||||
name=name,
|
||||
path=query.path,
|
||||
raw_endpoint="console.trial_agent_config_skill_file_content",
|
||||
route_params={"app_id": str(request.view_args["app_id"])},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/content",
|
||||
endpoint="trial_agent_config_skill_file_content",
|
||||
)
|
||||
class TrialAgentConfigSkillFileContentApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
|
||||
@console_ns.doc(
|
||||
produces=["application/octet-stream"],
|
||||
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/octet-stream"]},
|
||||
)
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[BinaryFileResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentSkillFileQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
|
||||
return _skill_file_raw_download_response(target, name, query.path)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/preview")
|
||||
class TrialAgentConfigFilePreviewApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFilePreviewResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> dict[str, object]:
|
||||
return dump_response(AgentConfigFilePreviewResponse, _file_preview_response(target, name))
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/download")
|
||||
class TrialAgentConfigFileDownloadApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
|
||||
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
|
||||
@get_preview_app
|
||||
@model_validate(TrialAgentConfigQuery)
|
||||
@_with_trial_config
|
||||
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
|
||||
return _file_download_response(target, name)
|
||||
|
||||
|
||||
# pyrefly: ignore [unresolvable-dunder-all]
|
||||
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]
|
||||
|
||||
@@ -79,7 +79,8 @@ from services.entities.app_entities import (
|
||||
UpdateAppParams,
|
||||
)
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.knowledge.entities.knowledge_entities import (
|
||||
DataSource,
|
||||
InfoList,
|
||||
NotionIcon,
|
||||
@@ -91,7 +92,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
WeightModel,
|
||||
WeightVectorSetting,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
|
||||
register_enum_models(console_ns, IconType)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from werkzeug.exceptions import Forbidden
|
||||
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import RBACPermission, validate_request
|
||||
from extensions.ext_application_services import application_services
|
||||
@@ -16,7 +17,8 @@ from models.account import TenantAccountRole
|
||||
from services.agent.errors import InvalidRosterAgentPackageError
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.recommended_app_query_service import RecommendedAppNotFoundError
|
||||
|
||||
from .. import console_ns
|
||||
|
||||
@@ -86,6 +88,8 @@ class AppImportApi(Resource):
|
||||
result = application_services().apps.console.import_app(context, payload, source=source)
|
||||
except NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
except RecommendedAppNotFoundError:
|
||||
raise RecommendedAppNotFoundHttpError() from None
|
||||
status_code = {ImportStatus.FAILED: HTTPStatus.BAD_REQUEST, ImportStatus.PENDING: HTTPStatus.ACCEPTED}.get(
|
||||
result.status, HTTPStatus.OK
|
||||
)
|
||||
|
||||
@@ -401,6 +401,7 @@ class InstructionGenerateApi(Resource):
|
||||
instruction=req_data.instruction,
|
||||
model_config=req_data.model_config_data,
|
||||
ideal_output=req_data.ideal_output,
|
||||
session=session,
|
||||
)
|
||||
if req_data.node_id != "" and req_data.current != "": # For workflow node
|
||||
return LLMGenerator.instruction_modify_workflow(
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.exceptions import Conflict, NotFound
|
||||
|
||||
from controllers.common.rbac import PlainApp, RBACCheck
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import RBACPermission, validate_request
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models.enums import AppMCPServerStatus
|
||||
from models.model import App, AppMCPServer
|
||||
from services.app_ref_service import AppRefService
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.app.mcp_server_service import (
|
||||
AppMCPServerAlreadyExistsError,
|
||||
AppMCPServerAppNotFoundError,
|
||||
AppMCPServerNotFoundError,
|
||||
AppMCPServerStatus,
|
||||
)
|
||||
|
||||
_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
|
||||
|
||||
class MCPServerCreatePayload(BaseModel):
|
||||
@@ -38,13 +37,13 @@ class MCPServerCreatePayload(BaseModel):
|
||||
|
||||
|
||||
class MCPServerUpdatePayload(BaseModel):
|
||||
id: str = Field(..., description="Server ID")
|
||||
id: UUID = Field(..., description="Server ID")
|
||||
description: str | None = Field(default=None, description="Server description")
|
||||
parameters: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Server parameters configuration",
|
||||
)
|
||||
status: str | None = Field(default=None, description="Server status")
|
||||
status: AppMCPServerStatus | None = Field(default=None, description="Server status")
|
||||
|
||||
|
||||
class AppMCPServerResponse(ResponseModel):
|
||||
@@ -84,13 +83,12 @@ class AppMCPServerController(Resource):
|
||||
@console_ns.response(
|
||||
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
|
||||
)
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
||||
@console_account_admission(rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())])
|
||||
def get(self, request_context: RequestContext, app_id: UUID):
|
||||
try:
|
||||
server = application_services().app_mcp_servers.get(request_context, str(app_id))
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
if server is None:
|
||||
return {}
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
@@ -103,30 +101,18 @@ class AppMCPServerController(Resource):
|
||||
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
|
||||
)
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@account_initialization_required
|
||||
@login_required
|
||||
@setup_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@with_current_tenant_id
|
||||
@get_app_model
|
||||
@model_validate(MCPServerCreatePayload)
|
||||
def post(self, req_data: MCPServerCreatePayload, current_tenant_id: str, app_model: App):
|
||||
description = req_data.description
|
||||
if not description:
|
||||
description = app_model.description or ""
|
||||
|
||||
server = AppMCPServer(
|
||||
name=app_model.name,
|
||||
description=description,
|
||||
parameters=json.dumps(req_data.parameters, ensure_ascii=False),
|
||||
status=AppMCPServerStatus.ACTIVE,
|
||||
app_id=app_model.id,
|
||||
tenant_id=current_tenant_id,
|
||||
server_code=AppMCPServer.generate_server_code(16, session=db.session()),
|
||||
)
|
||||
db.session.add(server)
|
||||
db.session.commit()
|
||||
@console_ns.response(409, "MCP server already exists for this app")
|
||||
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
|
||||
def post(self, request_context: RequestContext, app_id: UUID):
|
||||
payload = validate_request(MCPServerCreatePayload)
|
||||
try:
|
||||
server = application_services().app_mcp_servers.create(
|
||||
request_context, str(app_id), description=payload.description, parameters=payload.parameters
|
||||
)
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppMCPServerAlreadyExistsError as error:
|
||||
raise Conflict(str(error)) from error
|
||||
return dump_response(AppMCPServerResponse, server), 201
|
||||
|
||||
@console_ns.doc("update_app_mcp_server")
|
||||
@@ -138,43 +124,22 @@ class AppMCPServerController(Resource):
|
||||
)
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Server not found")
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@get_app_model
|
||||
@model_validate(MCPServerUpdatePayload)
|
||||
def put(self, req_data: MCPServerUpdatePayload, app_model: App):
|
||||
app_ref = AppRefService.create_app_ref(app_model)
|
||||
server_ref = AppRefService.create_mcp_server_ref(app_ref, req_data.id)
|
||||
server = db.session.scalar(
|
||||
select(AppMCPServer)
|
||||
.where(
|
||||
AppMCPServer.id == server_ref.server_id,
|
||||
AppMCPServer.tenant_id == server_ref.app.tenant_id,
|
||||
AppMCPServer.app_id == server_ref.app.app_id,
|
||||
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
|
||||
def put(self, request_context: RequestContext, app_id: UUID):
|
||||
payload = validate_request(MCPServerUpdatePayload)
|
||||
try:
|
||||
server = application_services().app_mcp_servers.update(
|
||||
request_context,
|
||||
str(app_id),
|
||||
server_id=str(payload.id),
|
||||
description=payload.description,
|
||||
parameters=payload.parameters,
|
||||
status=payload.status,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if not server:
|
||||
raise NotFound()
|
||||
|
||||
description = req_data.description
|
||||
if description is None or not description:
|
||||
server.description = app_model.description or ""
|
||||
else:
|
||||
server.description = description
|
||||
|
||||
server.name = app_model.name
|
||||
|
||||
server.parameters = json.dumps(req_data.parameters, ensure_ascii=False)
|
||||
if req_data.status:
|
||||
try:
|
||||
server.status = AppMCPServerStatus(req_data.status)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid status")
|
||||
db.session.commit()
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppMCPServerNotFoundError as error:
|
||||
raise NotFound from error
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
|
||||
|
||||
@@ -186,21 +151,12 @@ class AppMCPServerRefreshController(Resource):
|
||||
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(404, "Server not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
|
||||
@with_current_tenant_id
|
||||
@get_app_model
|
||||
def post(self, current_tenant_id: str, app_model: App):
|
||||
server = db.session.scalar(
|
||||
select(AppMCPServer)
|
||||
.where(AppMCPServer.app_id == app_model.id, AppMCPServer.tenant_id == current_tenant_id)
|
||||
.limit(1)
|
||||
)
|
||||
if not server:
|
||||
raise NotFound()
|
||||
server.server_code = AppMCPServer.generate_server_code(16, session=db.session())
|
||||
db.session.commit()
|
||||
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
|
||||
def post(self, request_context: RequestContext, app_id: UUID):
|
||||
try:
|
||||
server = application_services().app_mcp_servers.refresh(request_context, str(app_id))
|
||||
except AppMCPServerAppNotFoundError as error:
|
||||
raise AppNotFoundError() from error
|
||||
except AppMCPServerNotFoundError as error:
|
||||
raise NotFound from error
|
||||
return dump_response(AppMCPServerResponse, server)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,7 +7,7 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
|
||||
from controllers.common.fields import SimpleResultResponse, TextFileResponse
|
||||
@@ -16,6 +17,8 @@ from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppNotFoundError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
@@ -23,6 +26,7 @@ from controllers.console.app.error import (
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
@@ -33,9 +37,9 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.conversation_fields import (
|
||||
@@ -46,13 +50,19 @@ from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import dump_response, uuid_value
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from libs.login import login_required
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.app.agent_app_contracts import AgentAppNotFoundError
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.conversation_service import ConversationService
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.message_service import MessageService, attach_message_extra_contents
|
||||
from services.message_service import attach_message_extra_contents
|
||||
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -296,21 +306,25 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.doc(description="Get suggested questions for a message")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
HTTPStatus.OK,
|
||||
"Suggested questions retrieved successfully",
|
||||
console_ns.models[SuggestedQuestionsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Message or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def get(self, session: Session, current_user: Account, app_model: App, message_id: UUID):
|
||||
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "App, message, or conversation not found")
|
||||
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
|
||||
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
|
||||
def get(self, context: RequestContext, app_id: UUID, message_id: UUID) -> dict[str, object]:
|
||||
try:
|
||||
app = application_services().apps.console.get_reference(context, str(app_id))
|
||||
except ConsoleAppNotFoundError as exc:
|
||||
raise AppNotFoundError() from exc
|
||||
if app.mode not in (AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT):
|
||||
raise AppNotFoundError("App mode does not support suggested questions")
|
||||
return _get_message_suggested_questions(
|
||||
session=session, current_user=current_user, app_model=app_model, message_id=message_id
|
||||
context=context, app_id=app.id, app_mode=app.mode, message_id=message_id
|
||||
)
|
||||
|
||||
|
||||
@@ -320,26 +334,23 @@ class AgentMessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.doc(description="Get suggested questions for an Agent App message")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
HTTPStatus.OK,
|
||||
"Suggested questions retrieved successfully",
|
||||
console_ns.models[SuggestedQuestionsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Agent, message, or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(
|
||||
session=session,
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
|
||||
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
|
||||
@console_ns.response(HTTPStatus.NOT_FOUND, "Agent, message, or conversation not found")
|
||||
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
|
||||
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()),))
|
||||
def get(self, context: RequestContext, agent_id: UUID, message_id: UUID) -> dict[str, object]:
|
||||
try:
|
||||
app_id = application_services().agent_apps.access.resolve_existing_runtime_app_id(context, str(agent_id))
|
||||
except AgentAppNotFoundError as exc:
|
||||
raise AgentNotFoundError() from exc
|
||||
return _get_message_suggested_questions(
|
||||
session=session, current_user=current_user, app_model=app_model, message_id=message_id
|
||||
context=context, app_id=app_id, app_mode=AppMode.AGENT, message_id=message_id
|
||||
)
|
||||
|
||||
|
||||
@@ -552,17 +563,22 @@ def _update_message_feedback(
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
|
||||
def _get_message_suggested_questions(*, session: Session, current_user: Account, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
def _get_message_suggested_questions(
|
||||
*, context: RequestContext, app_id: str, app_mode: str, message_id: UUID
|
||||
) -> dict[str, object]:
|
||||
|
||||
try:
|
||||
questions = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model,
|
||||
message_id=message_id_str,
|
||||
user=current_user,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
session=session,
|
||||
questions = application_services().message_suggested_questions.get_suggested_questions(
|
||||
app_id=app_id,
|
||||
app_owner_tenant_id=context.active_workspace_id,
|
||||
expected_app_mode=app_mode,
|
||||
actor=SuggestedQuestionsAccount(account_id=context.account_id, invoke_from="debugger"),
|
||||
message_id=str(message_id),
|
||||
)
|
||||
except AppDefinitionUnavailableError as exc:
|
||||
raise AppUnavailableError() from exc
|
||||
except SuggestedQuestionsActorNotFoundError as exc:
|
||||
raise Unauthorized("Account no longer exists") from exc
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
@@ -577,6 +593,8 @@ def _get_message_suggested_questions(*, session: Session, current_user: Account,
|
||||
raise CompletionRequestError(e.description)
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountWithRole
|
||||
@@ -31,7 +32,6 @@ from models.comment import (
|
||||
from models.comment import (
|
||||
WorkflowCommentReply as WorkflowCommentReplyModel,
|
||||
)
|
||||
from services.account_service import TenantService
|
||||
from services.workflow_comment_service import WorkflowCommentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -585,7 +585,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 = application_services().workspaces.member_queries.list_members(current_tenant.id)
|
||||
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = WorkflowCommentMentionUsersPayload(users=users)
|
||||
return response.model_dump(mode="json"), 200
|
||||
|
||||
@@ -18,6 +18,7 @@ from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models import App, AppMode
|
||||
from models.agent import AgentScope
|
||||
from models.enums import AppStatus
|
||||
|
||||
__all__ = [
|
||||
"get_app_model",
|
||||
@@ -25,31 +26,28 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def _is_hidden_backing_app(app_model: App, session: Session | scoped_session) -> bool:
|
||||
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
|
||||
return binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY
|
||||
|
||||
|
||||
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`."""
|
||||
def _load_app_model(session: Session | scoped_session, app_id: str) -> App | None:
|
||||
"""Load the tenant-scoped Console app row through the given session."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
app_model = session.scalar(
|
||||
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
|
||||
select(App)
|
||||
.where(
|
||||
App.id == app_id,
|
||||
App.tenant_id == current_tenant_id,
|
||||
App.status == AppStatus.NORMAL,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if app_model is not None and _is_hidden_backing_app(app_model, session):
|
||||
return None
|
||||
if app_model is not None:
|
||||
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
|
||||
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
|
||||
return None
|
||||
return app_model
|
||||
|
||||
|
||||
def _load_app_model_from_scoped_session(app_id: str) -> App | None:
|
||||
"""Load the app row for legacy handlers that have not adopted request session injection yet."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
app_model = db.session.scalar(
|
||||
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
|
||||
)
|
||||
if app_model is not None and _is_hidden_backing_app(app_model, db.session):
|
||||
return None
|
||||
return app_model
|
||||
return _load_app_model(db.session, app_id)
|
||||
|
||||
|
||||
def _get_injected_session(args: tuple[object, ...]) -> Session | None:
|
||||
|
||||
@@ -12,14 +12,8 @@ from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, dump_response, timezone
|
||||
from libs.login import current_account_with_tenant
|
||||
from libs.token import extract_access_token
|
||||
from services.account_activation_service import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
)
|
||||
from services.account_activation_service import (
|
||||
FrozenAccountError,
|
||||
InvalidInvitationError,
|
||||
InvitationAccountMismatchError,
|
||||
)
|
||||
from services.account_errors import AccountEmailDomainSuspendedError as EmailDomainSuspendedRegistrationError
|
||||
from services.account_errors import FrozenAccountError, InvalidInvitationError, InvitationAccountMismatchError
|
||||
from services.entities.account_activation_entities import ActivationCommand, InvitationLookup
|
||||
|
||||
|
||||
@@ -91,7 +85,7 @@ class ActivateCheckApi(Resource):
|
||||
)
|
||||
@model_validate(ActivateCheckQuery)
|
||||
def get(self, args: ActivateCheckQuery):
|
||||
result = application_services().account_activation.check(
|
||||
result = application_services().accounts.activation.check(
|
||||
InvitationLookup(
|
||||
workspace_id=args.workspace_id,
|
||||
email=args.email,
|
||||
@@ -115,20 +109,20 @@ class ActivateApi(Resource):
|
||||
console_ns.models[ActivationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Already activated or invalid token")
|
||||
def post(self):
|
||||
@model_validate(ActivatePayload)
|
||||
def post(self, args: ActivatePayload):
|
||||
"""Accept an invitation without letting an existing session act for another account.
|
||||
|
||||
Token-only activation remains available for legacy clients. When the request already
|
||||
carries a console session, that session must belong to the account encoded in the
|
||||
invitation before the token is consumed or tenant membership is changed.
|
||||
"""
|
||||
args = ActivatePayload.model_validate(console_ns.payload or {})
|
||||
authenticated_account_id: str | None = None
|
||||
if extract_access_token(request) is not None:
|
||||
authenticated_account_id = current_account_with_tenant().account.id
|
||||
|
||||
try:
|
||||
application_services().account_activation.activate(
|
||||
application_services().accounts.activation.activate(
|
||||
ActivationCommand(
|
||||
invitation=InvitationLookup(
|
||||
workspace_id=args.workspace_id,
|
||||
|
||||
@@ -27,7 +27,7 @@ from services.auth.errors import (
|
||||
InvalidDataSourceApiKeyAuthCredentialsError,
|
||||
UnsupportedDataSourceApiKeyAuthProviderError,
|
||||
)
|
||||
from services.entities.data_source_api_key_auth_entities import (
|
||||
from services.data_source.entities.api_key_auth import (
|
||||
DataSourceApiKeyAuthBindingCreate,
|
||||
DataSourceApiKeyAuthCredentials,
|
||||
)
|
||||
@@ -80,7 +80,7 @@ class ApiKeyAuthDataSource(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[ApiKeyAuthDataSourceListResponse.__name__])
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
bindings = application_services().data_source_api_key_auth.list_bindings(request_context)
|
||||
bindings = application_services().data_sources.api_key_auth.list_bindings(request_context)
|
||||
return dump_response(
|
||||
ApiKeyAuthDataSourceListResponse,
|
||||
{
|
||||
@@ -120,7 +120,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
|
||||
),
|
||||
)
|
||||
try:
|
||||
application_services().data_source_api_key_auth.create_binding(request_context, command)
|
||||
application_services().data_sources.api_key_auth.create_binding(request_context, command)
|
||||
except UnsupportedDataSourceApiKeyAuthProviderError as exc:
|
||||
raise DataSourceApiKeyAuthProviderNotSupportedError() from exc
|
||||
except InvalidDataSourceApiKeyAuthCredentialsError as exc:
|
||||
@@ -140,5 +140,5 @@ class ApiKeyAuthDataSourceBindingDelete(Resource):
|
||||
rbac_checks=[RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace())],
|
||||
)
|
||||
def delete(self, request_context: RequestContext, binding_id: UUID):
|
||||
application_services().data_source_api_key_auth.delete_binding(request_context, str(binding_id))
|
||||
application_services().data_sources.api_key_auth.delete_binding(request_context, str(binding_id))
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
@@ -24,13 +24,13 @@ from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.data_source_oauth_service import (
|
||||
from services.data_source.entities.oauth import DataSourceOAuthCallback
|
||||
from services.data_source.oauth_service import (
|
||||
DataSourceOAuthConfigurationError,
|
||||
DataSourceOAuthError,
|
||||
InvalidDataSourceOAuthCodeError,
|
||||
InvalidDataSourceOAuthProviderError,
|
||||
)
|
||||
from services.entities.data_source_oauth_entities import DataSourceOAuthCallback
|
||||
|
||||
from .. import console_ns
|
||||
|
||||
@@ -116,7 +116,7 @@ class OAuthDataSource(Resource):
|
||||
)
|
||||
def get(self, request_context: RequestContext, provider: str):
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
authorization = service.start_authorization(request_context)
|
||||
except InvalidDataSourceOAuthProviderError:
|
||||
return _invalid_provider_response()
|
||||
@@ -141,7 +141,7 @@ class OAuthDataSourceCallback(Resource):
|
||||
def get(self, provider: str):
|
||||
query = query_params_from_request(OAuthDataSourceCallbackQuery)
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
callback = service.complete_callback(
|
||||
code=query.code,
|
||||
error=query.error,
|
||||
@@ -172,7 +172,7 @@ class OAuthDataSourceBinding(Resource):
|
||||
if not query.code:
|
||||
return {"error": "Invalid code"}, HTTPStatus.BAD_REQUEST
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
service.bind(request_context, code=query.code)
|
||||
except InvalidDataSourceOAuthProviderError:
|
||||
return _invalid_provider_response()
|
||||
@@ -198,7 +198,7 @@ class OAuthDataSourceSync(Resource):
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, provider: str, binding_id: UUID):
|
||||
try:
|
||||
service = application_services().resolve_data_source_oauth(provider)
|
||||
service = application_services().data_sources.resolve_oauth(provider)
|
||||
service.sync(
|
||||
request_context,
|
||||
binding_id=str(binding_id),
|
||||
|
||||
@@ -154,7 +154,7 @@ class EmailRegisterResetApi(Resource):
|
||||
@model_validate(EmailRegisterResetPayload)
|
||||
def post(self, args: EmailRegisterResetPayload):
|
||||
try:
|
||||
token_pair = application_services().accounts.email_registration.register(
|
||||
tokens = application_services().accounts.email_registration.register(
|
||||
remote_ip=extract_remote_ip(request),
|
||||
token=args.token,
|
||||
new_password=args.new_password,
|
||||
@@ -179,5 +179,5 @@ class EmailRegisterResetApi(Resource):
|
||||
|
||||
return dump_response(
|
||||
EmailRegisterResetResponse,
|
||||
{"result": "success", "data": token_pair},
|
||||
{"result": "success", "data": tokens},
|
||||
)
|
||||
|
||||
@@ -60,12 +60,8 @@ from libs.token import (
|
||||
set_refresh_token_to_cookie,
|
||||
)
|
||||
from services import account_errors
|
||||
from services.entities.account_login_entities import (
|
||||
AuthTokenPair,
|
||||
EmailCodeLoginCommand,
|
||||
EmailCodeSendCommand,
|
||||
PasswordLoginCommand,
|
||||
)
|
||||
from services.entities.account_entities import AccountSessionTokens
|
||||
from services.entities.account_login_entities import EmailCodeLoginCommand, EmailCodeSendCommand, PasswordLoginCommand
|
||||
from services.entities.auth_entities import LoginPayloadBase
|
||||
|
||||
|
||||
@@ -141,7 +137,7 @@ class LoginApi(Resource):
|
||||
except account_errors.AccountApplicationError as error:
|
||||
_raise_request_error(error)
|
||||
|
||||
if not result.workspace_found or result.token_pair is None:
|
||||
if not result.workspace_found or result.tokens is None:
|
||||
return dump_response(
|
||||
SimpleResultOptionalDataResponse,
|
||||
{
|
||||
@@ -149,7 +145,7 @@ class LoginApi(Resource):
|
||||
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
|
||||
},
|
||||
)
|
||||
return _token_response(result.token_pair, SimpleResultOptionalDataResponse, {"result": "success"})
|
||||
return _token_response(result.tokens, SimpleResultOptionalDataResponse, {"result": "success"})
|
||||
|
||||
|
||||
@console_ns.route("/logout")
|
||||
@@ -218,7 +214,7 @@ class EmailCodeLoginApi(Resource):
|
||||
@model_validate(EmailCodeLoginPayload)
|
||||
def post(self, req_data: EmailCodeLoginPayload):
|
||||
try:
|
||||
token_pair = application_services().accounts.authentication.login_with_email_code(
|
||||
tokens = application_services().accounts.authentication.login_with_email_code(
|
||||
EmailCodeLoginCommand(
|
||||
email=req_data.email,
|
||||
code=req_data.code,
|
||||
@@ -231,7 +227,7 @@ class EmailCodeLoginApi(Resource):
|
||||
)
|
||||
except account_errors.AccountApplicationError as error:
|
||||
_raise_request_error(error)
|
||||
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
|
||||
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
|
||||
|
||||
|
||||
@console_ns.route("/refresh-token")
|
||||
@@ -247,21 +243,21 @@ class RefreshTokenApi(Resource):
|
||||
), HTTPStatus.UNAUTHORIZED
|
||||
|
||||
try:
|
||||
token_pair = application_services().accounts.authentication.refresh(refresh_token)
|
||||
tokens = application_services().accounts.authentication.refresh(refresh_token)
|
||||
except account_errors.InvalidRefreshTokenError as error:
|
||||
return dump_response(
|
||||
SimpleResultMessageResponse,
|
||||
{"result": "fail", "message": str(error)},
|
||||
), HTTPStatus.UNAUTHORIZED
|
||||
return _token_response(token_pair, SimpleResultResponse, {"result": "success"})
|
||||
return _token_response(tokens, SimpleResultResponse, {"result": "success"})
|
||||
|
||||
|
||||
def _token_response(token_pair: AuthTokenPair, response_model: type[BaseModel], data: object):
|
||||
def _token_response(tokens: AccountSessionTokens, response_model: type[BaseModel], data: object):
|
||||
# response-contract:ignore cookie-bearing Flask response
|
||||
response = make_response(dump_response(response_model, data))
|
||||
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
|
||||
set_access_token_to_cookie(request, response, token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
|
||||
set_csrf_token_to_cookie(request, response, tokens.csrf_token)
|
||||
set_access_token_to_cookie(request, response, tokens.access_token)
|
||||
set_refresh_token_to_cookie(request, response, tokens.refresh_token)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -1,53 +1,59 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Never
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.errors import InvalidArgumentError, NotFoundError
|
||||
from controllers.common.fields import SimpleResultResponse, TextContentResponse
|
||||
from controllers.common.rbac import DatasetId, RBACCheck, Workspace
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType, OnlineDocumentPagesMessage
|
||||
from core.datasource.online_document.online_document_plugin import OnlineDocumentDatasourcePlugin
|
||||
from core.entities.knowledge_entities import IndexingEstimate
|
||||
from core.indexing_runner import IndexingRunner
|
||||
from core.rag.extractor.entity.datasource_type import DatasourceType
|
||||
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo
|
||||
from core.rag.extractor.notion_extractor import NotionExtractor
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, DataSourceOauthBinding, Document
|
||||
from services.dataset_service import DatasetService, DocumentService
|
||||
from services.datasource_provider_service import DatasourceProviderService
|
||||
from tasks.document_indexing_sync_task import document_indexing_sync_task
|
||||
|
||||
from .. import console_ns
|
||||
from ..wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
is_admin_or_owner_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import ProviderNotInitializeError
|
||||
from controllers.console.datasets.error import (
|
||||
DatasetAccessDeniedRequestError,
|
||||
IndexingEstimateError,
|
||||
InvalidActionError,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.entities.knowledge_entities import IndexingEstimate
|
||||
from core.rbac import RBACPermission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from fields.dataset_fields import NotionEstimateWorkspacePayload
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.data_source.binding_application_service import (
|
||||
BindingAction,
|
||||
DataSourceBindingNotFoundError,
|
||||
DataSourceBindingStateError,
|
||||
)
|
||||
from services.data_source.entities.notion_import import NotionPageType
|
||||
from services.data_source.notion_import_application_service import (
|
||||
DatasetIsNotNotionSourceError,
|
||||
NotionImportCredentialUnavailableError,
|
||||
)
|
||||
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
|
||||
from services.knowledge.document_sync import SyncDocumentNotFoundError, SyncDocumentSourceError
|
||||
from services.knowledge.entities.indexing_estimate import NewSourcesEstimateCommand, NotionEstimateSource
|
||||
from services.knowledge.indexing.estimate import (
|
||||
EstimateSourceNotFoundError,
|
||||
IndexingEstimateCredentialUnavailableError,
|
||||
IndexingEstimateExecutionError,
|
||||
IndexingEstimateProviderUnavailableError,
|
||||
)
|
||||
|
||||
_ADMIN_OR_OWNER_ROLES = frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
|
||||
_DATA_SOURCE_OAUTH_BASE_PATH = "/console/api/oauth/data-source"
|
||||
|
||||
|
||||
class NotionEstimatePayload(BaseModel):
|
||||
notion_info_list: list[dict[str, Any]]
|
||||
process_rule: dict[str, Any]
|
||||
notion_info_list: list[NotionEstimateWorkspacePayload] = Field(min_length=1)
|
||||
process_rule: dict[str, object]
|
||||
doc_form: str = Field(default="text_model")
|
||||
doc_language: str = Field(default="English")
|
||||
|
||||
@@ -72,7 +78,7 @@ class DataSourceIntegratePageResponse(ResponseModel):
|
||||
page_id: str
|
||||
page_icon: DataSourceIntegrateIconResponse | None
|
||||
parent_id: str
|
||||
type: str
|
||||
type: NotionPageType
|
||||
|
||||
|
||||
class DataSourceIntegrateWorkspaceResponse(ResponseModel):
|
||||
@@ -106,7 +112,7 @@ class NotionIntegratePageResponse(ResponseModel):
|
||||
page_id: str
|
||||
page_icon: DataSourceIntegrateIconResponse | None
|
||||
parent_id: str | None
|
||||
type: str
|
||||
page_type: NotionPageType = Field(alias="type")
|
||||
is_bound: bool
|
||||
|
||||
|
||||
@@ -132,325 +138,204 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/data-source/integrates",
|
||||
"/data-source/integrates/<uuid:binding_id>/<string:action>",
|
||||
)
|
||||
class DataSourceApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()))
|
||||
@account_initialization_required
|
||||
def _raise_dataset_access_error(error: DatasetNotFoundError | DatasetAccessDeniedError) -> Never:
|
||||
if isinstance(error, DatasetNotFoundError):
|
||||
raise NotFoundError(description="Dataset not found.") from None
|
||||
raise DatasetAccessDeniedRequestError(description=str(error)) from None
|
||||
|
||||
|
||||
@console_ns.route("/data-source/integrates")
|
||||
class DataSourceIntegrationListApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[DataSourceIntegrateListResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str) -> tuple[dict[str, Any], int]:
|
||||
# get workspace data source integrates
|
||||
data_source_integrates = db.session.scalars(
|
||||
select(DataSourceOauthBinding).where(
|
||||
DataSourceOauthBinding.tenant_id == current_tenant_id,
|
||||
DataSourceOauthBinding.disabled == False,
|
||||
)
|
||||
).all()
|
||||
@console_account_admission(
|
||||
allowed_roles=_ADMIN_OR_OWNER_ROLES,
|
||||
rbac_checks=(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext) -> tuple[dict[str, object], int]:
|
||||
bindings = application_services().data_sources.bindings.list_integrations(request_context)
|
||||
base_url = dify_config.CONSOLE_API_URL.rstrip("/")
|
||||
data = [
|
||||
{
|
||||
"id": binding.id,
|
||||
"provider": binding.provider,
|
||||
"created_at": binding.created_at,
|
||||
"is_bound": True,
|
||||
"disabled": binding.disabled,
|
||||
"source_info": binding.source_info,
|
||||
"link": f"{base_url}{_DATA_SOURCE_OAUTH_BASE_PATH}/{binding.provider}",
|
||||
}
|
||||
for binding in bindings
|
||||
]
|
||||
return dump_response(DataSourceIntegrateListResponse, {"data": data}), 200
|
||||
|
||||
base_url = request.url_root.rstrip("/")
|
||||
data_source_oauth_base_path = "/console/api/oauth/data-source"
|
||||
providers = ["notion"]
|
||||
|
||||
integrate_data: list[dict[str, bool | datetime | dict[str, Any] | str | None]] = []
|
||||
for provider in providers:
|
||||
# existing_integrate = next((ai for ai in data_source_integrates if ai.provider == provider), None)
|
||||
existing_integrates = filter(lambda item: item.provider == provider, data_source_integrates)
|
||||
if existing_integrates:
|
||||
for existing_integrate in list(existing_integrates):
|
||||
integrate_data.append(
|
||||
{
|
||||
"id": existing_integrate.id,
|
||||
"provider": provider,
|
||||
"created_at": existing_integrate.created_at,
|
||||
"is_bound": True,
|
||||
"disabled": existing_integrate.disabled,
|
||||
"source_info": existing_integrate.source_info,
|
||||
"link": f"{base_url}{data_source_oauth_base_path}/{provider}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
integrate_data.append(
|
||||
{
|
||||
"id": None,
|
||||
"provider": provider,
|
||||
"created_at": None,
|
||||
"source_info": None,
|
||||
"is_bound": False,
|
||||
"disabled": None,
|
||||
"link": f"{base_url}{data_source_oauth_base_path}/{provider}",
|
||||
}
|
||||
)
|
||||
return dump_response(DataSourceIntegrateListResponse, {"data": integrate_data}), 200
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()))
|
||||
@account_initialization_required
|
||||
@console_ns.route("/data-source/integrates/<uuid:binding_id>/<string:action>")
|
||||
class DataSourceIntegrationApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
allowed_roles=_ADMIN_OR_OWNER_ROLES,
|
||||
rbac_checks=(RBACCheck(RBACPermission.CREDENTIAL_MANAGE, Workspace()),),
|
||||
)
|
||||
def patch(
|
||||
self, session: Session, current_tenant_id: str, binding_id: UUID, action: Literal["enable", "disable"]
|
||||
) -> tuple[dict[str, str], int]:
|
||||
binding_id_str = str(binding_id)
|
||||
data_source_binding = session.scalar(
|
||||
select(DataSourceOauthBinding).where(
|
||||
DataSourceOauthBinding.id == binding_id_str, DataSourceOauthBinding.tenant_id == current_tenant_id
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
binding_id: UUID,
|
||||
action: str,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
validated_action = BindingAction(action)
|
||||
except ValueError as error:
|
||||
raise InvalidActionError() from error
|
||||
try:
|
||||
application_services().data_sources.bindings.change_state(
|
||||
request_context,
|
||||
str(binding_id),
|
||||
validated_action,
|
||||
)
|
||||
)
|
||||
if data_source_binding is None:
|
||||
raise NotFound("Data source binding not found.")
|
||||
# enable binding
|
||||
match action:
|
||||
case "enable":
|
||||
if data_source_binding.disabled:
|
||||
data_source_binding.disabled = False
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
else:
|
||||
raise ValueError("Data source is not disabled.")
|
||||
# disable binding
|
||||
case "disable":
|
||||
if not data_source_binding.disabled:
|
||||
data_source_binding.disabled = True
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
else:
|
||||
raise ValueError("Data source is disabled.")
|
||||
return {"result": "success"}, 200
|
||||
except DataSourceBindingNotFoundError as error:
|
||||
raise NotFoundError(description="Data source binding not found.") from error
|
||||
except DataSourceBindingStateError as error:
|
||||
raise InvalidArgumentError(description=str(error)) from error
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
|
||||
@console_ns.route("/notion/pre-import/pages")
|
||||
class DataSourceNotionListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc(params=query_params_from_model(DataSourceNotionListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[NotionIntegrateInfoListResponse.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@console_account_admission()
|
||||
@model_validate(DataSourceNotionListQuery)
|
||||
def get(
|
||||
self,
|
||||
req_data: DataSourceNotionListQuery,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=req_data.credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
if not credential:
|
||||
raise NotFound("Credential not found.")
|
||||
exist_page_ids = []
|
||||
# import notion in the exist dataset
|
||||
if req_data.dataset_id:
|
||||
dataset = DatasetService.get_dataset(req_data.dataset_id, session)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
if dataset.data_source_type != "notion_import":
|
||||
raise ValueError("Dataset is not notion type.")
|
||||
|
||||
documents = session.scalars(
|
||||
select(Document).where(
|
||||
Document.dataset_id == req_data.dataset_id,
|
||||
Document.tenant_id == current_tenant_id,
|
||||
Document.data_source_type == "notion_import",
|
||||
Document.enabled.is_(True),
|
||||
)
|
||||
).all()
|
||||
if documents:
|
||||
for document in documents:
|
||||
data_source_info = json.loads(document.data_source_info)
|
||||
exist_page_ids.append(data_source_info["notion_page_id"])
|
||||
# get all authorized pages
|
||||
from core.datasource.datasource_manager import DatasourceManager
|
||||
|
||||
datasource_runtime = DatasourceManager.get_datasource_runtime(
|
||||
provider_id="langgenius/notion_datasource/notion_datasource",
|
||||
datasource_name="notion_datasource",
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_type=DatasourceProviderType.ONLINE_DOCUMENT,
|
||||
)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
if credential:
|
||||
datasource_runtime.runtime.credentials = credential
|
||||
datasource_runtime = cast(OnlineDocumentDatasourcePlugin, datasource_runtime)
|
||||
online_document_result: Generator[OnlineDocumentPagesMessage, None, None] = (
|
||||
datasource_runtime.get_online_document_pages(
|
||||
user_id=current_user.id,
|
||||
datasource_parameters={},
|
||||
provider_type=datasource_runtime.datasource_provider_type(),
|
||||
)
|
||||
)
|
||||
request_context: RequestContext,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
pages = []
|
||||
workspace_info = {}
|
||||
for message in online_document_result:
|
||||
result = message.result
|
||||
for info in result:
|
||||
workspace_info = {
|
||||
"workspace_id": info.workspace_id,
|
||||
"workspace_name": info.workspace_name,
|
||||
"workspace_icon": info.workspace_icon,
|
||||
}
|
||||
for page in info.pages:
|
||||
page_info = {
|
||||
"page_id": page.page_id,
|
||||
"page_name": page.page_name,
|
||||
"type": page.type,
|
||||
"parent_id": page.parent_id,
|
||||
"is_bound": page.page_id in exist_page_ids,
|
||||
"page_icon": page.page_icon,
|
||||
}
|
||||
pages.append(page_info)
|
||||
except Exception as e:
|
||||
raise e
|
||||
notion_info = [{**workspace_info, "pages": pages}] if workspace_info else []
|
||||
return dump_response(NotionIntegrateInfoListResponse, {"notion_info": notion_info}), 200
|
||||
result = application_services().data_sources.notion_imports.list_pages(
|
||||
request_context,
|
||||
credential_id=req_data.credential_id,
|
||||
dataset_id=req_data.dataset_id,
|
||||
)
|
||||
except NotionImportCredentialUnavailableError as error:
|
||||
raise NotFoundError(description="Credential not found.") from error
|
||||
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
|
||||
_raise_dataset_access_error(error)
|
||||
except DatasetIsNotNotionSourceError as error:
|
||||
raise InvalidArgumentError(description="Dataset is not notion type.") from error
|
||||
return dump_response(NotionIntegrateInfoListResponse, {"notion_info": result.workspaces}), 200
|
||||
|
||||
|
||||
@console_ns.route("/notion/pages/<uuid:page_id>/<string:page_type>/preview")
|
||||
class DataSourceNotionPreviewApi(Resource):
|
||||
"""Preview one authorized Notion page through the datasource credential."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc(params=query_params_from_model(DataSourceNotionPreviewQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[TextContentResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(DataSourceNotionPreviewQuery)
|
||||
def get(
|
||||
self,
|
||||
req_data: DataSourceNotionPreviewQuery,
|
||||
current_tenant_id: str,
|
||||
request_context: RequestContext,
|
||||
page_id: UUID,
|
||||
page_type: str,
|
||||
) -> tuple[dict[str, str], int]:
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=req_data.credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
|
||||
page_id_str = str(page_id)
|
||||
|
||||
extractor = NotionExtractor(
|
||||
notion_workspace_id="",
|
||||
notion_obj_id=page_id_str,
|
||||
notion_page_type=page_type,
|
||||
notion_access_token=credential.get("integration_secret"),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
text_docs = extractor.extract()
|
||||
return {"content": "\n".join([doc.page_content for doc in text_docs])}, 200
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
validated_page_type = NotionPageType(page_type)
|
||||
except ValueError as error:
|
||||
raise InvalidArgumentError(description="Invalid Notion page type.") from error
|
||||
try:
|
||||
content = application_services().data_sources.notion_imports.preview_page(
|
||||
request_context,
|
||||
credential_id=req_data.credential_id,
|
||||
page_id=str(page_id),
|
||||
page_type=validated_page_type,
|
||||
)
|
||||
except NotionImportCredentialUnavailableError as error:
|
||||
raise NotFoundError(description="Credential not found.") from error
|
||||
return dump_response(TextContentResponse, {"content": content}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/notion-indexing-estimate")
|
||||
class DataSourceNotionIndexingEstimateApi(Resource):
|
||||
"""Estimate indexing work for selected Notion pages."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.expect(console_ns.models[NotionEstimatePayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[IndexingEstimate.__name__])
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission()
|
||||
@model_validate(NotionEstimatePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: NotionEstimatePayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
args = req_data.model_dump()
|
||||
# validate args
|
||||
DocumentService.estimate_args_validate(args)
|
||||
notion_info_list = req_data.notion_info_list
|
||||
extract_settings = []
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info["workspace_id"]
|
||||
credential_id = notion_info.get("credential_id")
|
||||
for page in notion_info["pages"]:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
{
|
||||
"credential_id": credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_obj_id": page["page_id"],
|
||||
"notion_page_type": page["type"],
|
||||
"tenant_id": current_tenant_id,
|
||||
}
|
||||
),
|
||||
document_model=args["doc_form"],
|
||||
request_context: RequestContext,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
command = NewSourcesEstimateCommand(
|
||||
sources=tuple(
|
||||
NotionEstimateSource(
|
||||
workspace_id=workspace.workspace_id,
|
||||
page_id=page.page_id,
|
||||
page_type=page.page_type,
|
||||
credential_id=workspace.credential_id,
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
indexing_runner = IndexingRunner()
|
||||
response = indexing_runner.indexing_estimate(
|
||||
tenant_id=current_tenant_id,
|
||||
extract_settings=extract_settings,
|
||||
tmp_processing_rule=args["process_rule"],
|
||||
doc_form=args["doc_form"],
|
||||
doc_language=args["doc_language"],
|
||||
session=session,
|
||||
for workspace in req_data.notion_info_list
|
||||
for page in workspace.pages
|
||||
),
|
||||
process_rule=req_data.process_rule,
|
||||
doc_form=req_data.doc_form,
|
||||
doc_language=req_data.doc_language,
|
||||
)
|
||||
try:
|
||||
response = application_services().knowledge.indexing_estimates.estimate_new_sources(
|
||||
request_context,
|
||||
command,
|
||||
)
|
||||
except IndexingEstimateCredentialUnavailableError as error:
|
||||
raise NotFoundError(description="Credential not found.") from error
|
||||
except EstimateSourceNotFoundError as error:
|
||||
raise NotFoundError(description=str(error)) from error
|
||||
except IndexingEstimateProviderUnavailableError as error:
|
||||
raise ProviderNotInitializeError(str(error)) from error
|
||||
except IndexingEstimateExecutionError as error:
|
||||
raise IndexingEstimateError(str(error)) from error
|
||||
return dump_response(IndexingEstimate, response), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/notion/sync")
|
||||
class DataSourceNotionDatasetSyncApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, dataset_id: UUID) -> tuple[dict[str, str], int]:
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
documents = DocumentService.get_document_by_dataset_id(dataset_id_str, session)
|
||||
for document in documents:
|
||||
document_indexing_sync_task.delay(dataset_id_str, document.id)
|
||||
return {"result": "success"}, 200
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
def get(self, request_context: RequestContext, dataset_id: UUID) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
application_services().knowledge.document_sync.sync_dataset(request_context, str(dataset_id))
|
||||
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
|
||||
_raise_dataset_access_error(error)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/notion/sync")
|
||||
class DataSourceNotionDocumentSyncApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, 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, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
document = DocumentService.get_document(dataset_id_str, document_id_str, session=session)
|
||||
if document is None:
|
||||
raise NotFound("Document not found.")
|
||||
document_indexing_sync_task.delay(dataset_id_str, document_id_str)
|
||||
return {"result": "success"}, 200
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
def get(
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
dataset_id: UUID,
|
||||
document_id: UUID,
|
||||
) -> tuple[dict[str, object], int]:
|
||||
try:
|
||||
application_services().knowledge.document_sync.sync_document(
|
||||
request_context,
|
||||
str(dataset_id),
|
||||
str(document_id),
|
||||
)
|
||||
except (DatasetNotFoundError, DatasetAccessDeniedError) as error:
|
||||
_raise_dataset_access_error(error)
|
||||
except SyncDocumentNotFoundError as error:
|
||||
raise NotFoundError(description="Document not found.") from error
|
||||
except SyncDocumentSourceError as error:
|
||||
raise InvalidArgumentError(description=str(error)) from error
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,12 @@ class InvalidActionError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class DatasetAccessDeniedRequestError(BaseHTTPException):
|
||||
error_code = "dataset_access_denied"
|
||||
description = "Dataset access denied."
|
||||
code = 403
|
||||
|
||||
|
||||
class DocumentAlreadyFinishedError(BaseHTTPException):
|
||||
error_code = "document_already_finished"
|
||||
description = "The document has been processed. Please refresh the page or go to the document details."
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Never
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.fields import UsageCountResponse
|
||||
from controllers.common.rbac import DatasetId, RBACCheck, Workspace
|
||||
from controllers.common.schema import (
|
||||
@@ -18,31 +15,42 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.datasets.error import DatasetNameDuplicateError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
|
||||
from fields.dataset_fields import (
|
||||
DatasetDetailResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from libs.pagination import clamp_pagination
|
||||
from models import Account
|
||||
from models.dataset import ExternalKnowledgeApis
|
||||
from services.dataset_service import DatasetService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.entities.external_knowledge_entities.external_knowledge_entities import ExternalDatasetCreatePayload
|
||||
from services.external_knowledge_service import ExternalDatasetService
|
||||
from services.hit_testing_service import HitTestingService
|
||||
from services.errors.dataset import DatasetNameDuplicateError as DatasetNameDuplicateFailure
|
||||
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
|
||||
from services.knowledge.external.application import ExternalHitTestingError, ExternalTemplateNotFoundError
|
||||
|
||||
_DATASET_EDIT_ROLES = frozenset(
|
||||
{TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.DATASET_OPERATOR}
|
||||
)
|
||||
_EXTERNAL_CONNECT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
|
||||
|
||||
def _raise_external_error(error: Exception) -> Never:
|
||||
if isinstance(error, (DatasetNotFoundError, ExternalTemplateNotFoundError)):
|
||||
raise NotFound(str(error)) from error
|
||||
if isinstance(error, DatasetAccessDeniedError):
|
||||
raise Forbidden(str(error)) from error
|
||||
if isinstance(error, DatasetNameDuplicateFailure):
|
||||
raise DatasetNameDuplicateError() from error
|
||||
if isinstance(error, ExternalHitTestingError):
|
||||
raise InternalServerError(str(error)) from error
|
||||
raise error
|
||||
|
||||
|
||||
class ExternalKnowledgeApiPayload(BaseModel):
|
||||
@@ -85,28 +93,6 @@ class ExternalKnowledgeApiResponse(ResponseModel):
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalKnowledgeApiResponseSource:
|
||||
external_knowledge_api: ExternalKnowledgeApis
|
||||
session: Session
|
||||
|
||||
@property
|
||||
def dataset_bindings(self) -> Any:
|
||||
return self.external_knowledge_api.get_dataset_bindings(session=self.session)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields
|
||||
|
||||
|
||||
def external_knowledge_api_response(
|
||||
external_knowledge_api: ExternalKnowledgeApis, *, session: Session
|
||||
) -> ExternalKnowledgeApiResponse:
|
||||
return ExternalKnowledgeApiResponse.model_validate(
|
||||
ExternalKnowledgeApiResponseSource(external_knowledge_api=external_knowledge_api, session=session),
|
||||
from_attributes=True,
|
||||
)
|
||||
|
||||
|
||||
class ExternalKnowledgeApiListResponse(ResponseModel):
|
||||
data: list[ExternalKnowledgeApiResponse]
|
||||
has_more: bool
|
||||
@@ -161,25 +147,13 @@ class ExternalApiTemplateListApi(Resource):
|
||||
"External API templates retrieved successfully",
|
||||
console_ns.models[ExternalKnowledgeApiListResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_tenant_id
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@console_account_admission()
|
||||
@model_validate(ExternalApiTemplateListQuery)
|
||||
def get(self, req_data: ExternalApiTemplateListQuery, session: Session, current_tenant_id: str):
|
||||
|
||||
effective_page, effective_limit = clamp_pagination(req_data.page, req_data.limit, 100)
|
||||
external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis(
|
||||
effective_page, effective_limit, current_tenant_id, req_data.keyword, session=session
|
||||
def get(self, req_data: ExternalApiTemplateListQuery, request_context: RequestContext):
|
||||
result = application_services().knowledge.external.list_templates(
|
||||
request_context, page=req_data.page, limit=req_data.limit, keyword=req_data.keyword
|
||||
)
|
||||
return ExternalKnowledgeApiListResponse(
|
||||
data=[external_knowledge_api_response(item, session=session) for item in external_knowledge_apis],
|
||||
has_more=effective_page * effective_limit < total,
|
||||
limit=effective_limit,
|
||||
total=total,
|
||||
page=effective_page,
|
||||
).model_dump(mode="json"), 200
|
||||
return dump_response(ExternalKnowledgeApiListResponse, result), 200
|
||||
|
||||
@console_ns.doc("create_external_api_template")
|
||||
@console_ns.doc(description="Create external knowledge API template")
|
||||
@@ -190,48 +164,16 @@ class ExternalApiTemplateListApi(Resource):
|
||||
console_ns.models[ExternalKnowledgeApiResponse.__name__],
|
||||
)
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission(allowed_roles=_DATASET_EDIT_ROLES)
|
||||
@model_validate(ExternalKnowledgeApiPayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: ExternalKnowledgeApiPayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
):
|
||||
|
||||
ExternalDatasetService.validate_api_list(req_data.settings)
|
||||
|
||||
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
def post(self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext):
|
||||
try:
|
||||
external_knowledge_api = ExternalDatasetService.create_external_knowledge_api(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
args=req_data.model_dump(),
|
||||
session=session,
|
||||
result = application_services().knowledge.external.create_template(
|
||||
request_context, name=req_data.name, settings=req_data.settings
|
||||
)
|
||||
except services.errors.dataset.DatasetNameDuplicateError:
|
||||
raise DatasetNameDuplicateError()
|
||||
|
||||
# if dify_config.RBAC_ENABLED:
|
||||
# enterprise_rbac_service.RBACService.DatasetAccess.replace_whitelist(
|
||||
# current_tenant_id,
|
||||
# current_user.id,
|
||||
# external_knowledge_api.id,
|
||||
# enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
|
||||
# )
|
||||
# initialize_created_app_rbac_access_task.delay(
|
||||
# current_tenant_id, current_user.id, dataset_id=external_knowledge_api.id)
|
||||
|
||||
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 201
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalKnowledgeApiResponse, result), 201
|
||||
|
||||
|
||||
@console_ns.route("/datasets/external-knowledge-api/<uuid:external_knowledge_api_id>")
|
||||
@@ -245,20 +187,15 @@ class ExternalApiTemplateApi(Resource):
|
||||
console_ns.models[ExternalKnowledgeApiResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Template not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def get(self, session: Session, current_tenant_id: str, external_knowledge_api_id: UUID):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
external_knowledge_api = ExternalDatasetService.get_external_knowledge_api(
|
||||
external_knowledge_api_id=external_knowledge_api_id_str, tenant_id=current_tenant_id, session=session
|
||||
)
|
||||
if external_knowledge_api is None:
|
||||
raise NotFound("API template not found.")
|
||||
|
||||
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 200
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, external_knowledge_api_id: UUID):
|
||||
try:
|
||||
result = application_services().knowledge.external.get_template(
|
||||
request_context, template_id=str(external_knowledge_api_id)
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalKnowledgeApiResponse, result), 200
|
||||
|
||||
@console_ns.doc("update_external_api_template")
|
||||
@console_ns.doc(description="Update external knowledge API template")
|
||||
@@ -270,51 +207,31 @@ class ExternalApiTemplateApi(Resource):
|
||||
console_ns.models[ExternalKnowledgeApiResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Template not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission()
|
||||
@model_validate(ExternalKnowledgeApiPayload)
|
||||
def patch(
|
||||
self,
|
||||
req_data: ExternalKnowledgeApiPayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
external_knowledge_api_id: UUID,
|
||||
self, req_data: ExternalKnowledgeApiPayload, request_context: RequestContext, external_knowledge_api_id: UUID
|
||||
):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
try:
|
||||
result = application_services().knowledge.external.update_template(
|
||||
request_context,
|
||||
template_id=str(external_knowledge_api_id),
|
||||
name=req_data.name,
|
||||
settings=req_data.settings,
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalKnowledgeApiResponse, result), 200
|
||||
|
||||
ExternalDatasetService.validate_api_list(req_data.settings)
|
||||
|
||||
external_knowledge_api = ExternalDatasetService.update_external_knowledge_api(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
external_knowledge_api_id=external_knowledge_api_id_str,
|
||||
args=req_data.model_dump(),
|
||||
session=session,
|
||||
)
|
||||
|
||||
return external_knowledge_api_response(external_knowledge_api, session=session).model_dump(mode="json"), 200
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(204, "External knowledge API deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, current_tenant_id: str, current_user: Account, external_knowledge_api_id: UUID):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
|
||||
raise Forbidden()
|
||||
|
||||
ExternalDatasetService.delete_external_knowledge_api(
|
||||
current_tenant_id, external_knowledge_api_id_str, session=session
|
||||
)
|
||||
@console_account_admission(allowed_roles=_DATASET_EDIT_ROLES)
|
||||
def delete(self, request_context: RequestContext, external_knowledge_api_id: UUID):
|
||||
try:
|
||||
application_services().knowledge.external.delete_template(
|
||||
request_context, template_id=str(external_knowledge_api_id)
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -324,18 +241,12 @@ class ExternalApiUseCheckApi(Resource):
|
||||
@console_ns.doc(description="Check if external knowledge API is being used")
|
||||
@console_ns.doc(params={"external_knowledge_api_id": "External knowledge API ID"})
|
||||
@console_ns.response(200, "Usage check completed successfully", console_ns.models[UsageCountResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def get(self, session: Session, current_tenant_id: str, external_knowledge_api_id: UUID):
|
||||
external_knowledge_api_id_str = str(external_knowledge_api_id)
|
||||
|
||||
external_knowledge_api_is_using, count = ExternalDatasetService.external_knowledge_api_use_check(
|
||||
external_knowledge_api_id_str, current_tenant_id, session=session
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, external_knowledge_api_id: UUID):
|
||||
is_using, count = application_services().knowledge.external.template_usage(
|
||||
request_context, template_id=str(external_knowledge_api_id)
|
||||
)
|
||||
return UsageCountResponse(is_using=external_knowledge_api_is_using, count=count).model_dump(mode="json"), 200
|
||||
return dump_response(UsageCountResponse, {"is_using": is_using, "count": count}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/external")
|
||||
@@ -348,50 +259,17 @@ class ExternalDatasetCreateApi(Resource):
|
||||
)
|
||||
@console_ns.response(400, "Invalid parameters")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EXTERNAL_CONNECT, Workspace()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
allowed_roles=_EXTERNAL_CONNECT_ROLES,
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EXTERNAL_CONNECT, Workspace()),),
|
||||
)
|
||||
@model_validate(ExternalDatasetCreatePayload)
|
||||
def post(
|
||||
self,
|
||||
req_data: ExternalDatasetCreatePayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
|
||||
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
def post(self, req_data: ExternalDatasetCreatePayload, request_context: RequestContext):
|
||||
try:
|
||||
dataset = ExternalDatasetService.create_external_dataset(
|
||||
tenant_id=current_tenant_id,
|
||||
user_id=current_user.id,
|
||||
args=req_data,
|
||||
session=session,
|
||||
)
|
||||
except services.errors.dataset.DatasetNameDuplicateError:
|
||||
raise DatasetNameDuplicateError()
|
||||
|
||||
dataset_id_str = str(dataset.id)
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_user.id,
|
||||
[dataset_id_str],
|
||||
session=session,
|
||||
)
|
||||
data = DatasetDetailResponse.model_validate(
|
||||
dataset_detail_response_source(dataset, session=session)
|
||||
).model_dump(mode="json")
|
||||
data["permission_keys"] = permission_keys_map.get(dataset_id_str, [])
|
||||
return data, 201
|
||||
result = application_services().knowledge.external.create_dataset(request_context, payload=req_data)
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(DatasetDetailResponse, result), 201
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/external-hit-testing")
|
||||
@@ -407,36 +285,17 @@ class ExternalKnowledgeHitTestingApi(Resource):
|
||||
)
|
||||
@console_ns.response(404, "Dataset not found")
|
||||
@console_ns.response(400, "Invalid parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.DATASET_PIPELINE_TEST, DatasetId()),))
|
||||
@model_validate(ExternalHitTestingPayload)
|
||||
def post(self, req_data: ExternalHitTestingPayload, session: Session, current_user: Account, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
def post(self, req_data: ExternalHitTestingPayload, request_context: RequestContext, dataset_id: UUID):
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
HitTestingService.hit_testing_args_check(req_data.model_dump())
|
||||
|
||||
try:
|
||||
response = HitTestingService.external_retrieve(
|
||||
session=session,
|
||||
dataset=dataset,
|
||||
result = application_services().knowledge.external.hit_testing(
|
||||
request_context,
|
||||
dataset_id=str(dataset_id),
|
||||
query=req_data.query,
|
||||
account=current_user,
|
||||
external_retrieval_model=req_data.external_retrieval_model,
|
||||
metadata_filtering_conditions=req_data.metadata_filtering_conditions,
|
||||
retrieval_model=req_data.external_retrieval_model,
|
||||
metadata_filters=req_data.metadata_filtering_conditions,
|
||||
)
|
||||
|
||||
return dump_response(ExternalHitTestingResponse, response)
|
||||
except Exception as e:
|
||||
raise InternalServerError(str(e))
|
||||
except Exception as error:
|
||||
_raise_external_error(error)
|
||||
return dump_response(ExternalHitTestingResponse, result)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
import services.errors.base
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@@ -23,9 +23,9 @@ from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import resolve_account_fallback
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset
|
||||
from services.dataset_service import DatasetService
|
||||
from services.entities.knowledge_entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
|
||||
from services.hit_testing_service import HitTestingService
|
||||
from services.knowledge.dataset_service import DatasetService
|
||||
from services.knowledge.entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,7 +97,7 @@ class DatasetsHitTestingBase:
|
||||
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
except services.errors.base.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
return dataset
|
||||
|
||||
@@ -1,43 +1,47 @@
|
||||
from typing import Literal
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.controller_schemas import MetadataUpdatePayload
|
||||
from controllers.common.rbac import DatasetId, RBACCheck
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
enterprise_license_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.rbac import RBACPermission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.dataset_fields import (
|
||||
DatasetMetadataBuiltInFieldsResponse,
|
||||
DatasetMetadataListResponse,
|
||||
DatasetMetadataResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from services.dataset_service import DatasetService
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
from machinery.context import RequestContext
|
||||
from services.errors.metadata import MetadataResourceNotFoundError
|
||||
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
|
||||
from services.knowledge.entities.knowledge_entities import (
|
||||
DocumentMetadataOperation,
|
||||
MetadataArgs,
|
||||
MetadataDetail,
|
||||
MetadataOperationData,
|
||||
)
|
||||
from services.errors.metadata import MetadataResourceNotFoundError
|
||||
from services.metadata_service import MetadataService
|
||||
|
||||
|
||||
def metadata_errors[**P, T](method: Callable[P, T]) -> Callable[P, T]:
|
||||
@wraps(method)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except (DatasetNotFoundError, MetadataResourceNotFoundError) as error:
|
||||
raise NotFound(str(error)) from error
|
||||
except DatasetAccessDeniedError as error:
|
||||
raise Forbidden(str(error)) from error
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns, MetadataArgs, MetadataOperationData, MetadataUpdatePayload, DocumentMetadataOperation, MetadataDetail
|
||||
@@ -52,199 +56,111 @@ register_response_schema_models(
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/metadata")
|
||||
class DatasetMetadataCreateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(201, "Metadata created successfully", console_ns.models[DatasetMetadataResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[MetadataArgs.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
@model_validate(MetadataArgs)
|
||||
def post(
|
||||
self,
|
||||
req_data: MetadataArgs,
|
||||
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, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
metadata = MetadataService.create_metadata(
|
||||
dataset_id_str, req_data, current_user, current_tenant_id, session=session
|
||||
)
|
||||
def post(self, req_data: MetadataArgs, request_context: RequestContext, dataset_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
metadata = service.create_metadata(ref, req_data, actor_id=request_context.account_id)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 201
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(
|
||||
200, "Metadata retrieved successfully", console_ns.models[DatasetMetadataListResponse.__name__]
|
||||
)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
metadata = MetadataService.get_dataset_metadatas(dataset, session)
|
||||
return dump_response(DatasetMetadataListResponse, metadata), 200
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
def get(self, request_context: RequestContext, dataset_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
return dump_response(DatasetMetadataListResponse, service.get_dataset_metadatas(ref)), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/metadata/<uuid:metadata_id>")
|
||||
class DatasetMetadataApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(200, "Metadata updated successfully", console_ns.models[DatasetMetadataResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[MetadataUpdatePayload.__name__])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
@model_validate(MetadataUpdatePayload)
|
||||
def patch(
|
||||
self,
|
||||
req_data: MetadataUpdatePayload,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
metadata_id: UUID,
|
||||
self, req_data: MetadataUpdatePayload, request_context: RequestContext, dataset_id: UUID, metadata_id: UUID
|
||||
):
|
||||
name = req_data.name
|
||||
|
||||
dataset_id_str = str(dataset_id)
|
||||
metadata_id_str = str(metadata_id)
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
metadata = MetadataService.update_metadata_name(dataset, metadata_id_str, name, current_user, session=session)
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
metadata = service.update_metadata_name(
|
||||
ref, str(metadata_id), req_data.name, actor_id=request_context.account_id
|
||||
)
|
||||
return dump_response(DatasetMetadataResponse, metadata), 200
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(204, "Metadata deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
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_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
MetadataService.delete_metadata(dataset, metadata_id_str, session)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
def delete(self, request_context: RequestContext, dataset_id: UUID, metadata_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
service.delete_metadata(ref, str(metadata_id))
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/datasets/metadata/built-in")
|
||||
class DatasetMetadataBuiltInFieldApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Built-in fields retrieved successfully",
|
||||
console_ns.models[DatasetMetadataBuiltInFieldsResponse.__name__],
|
||||
)
|
||||
def get(self):
|
||||
built_in_fields = MetadataService.get_built_in_fields()
|
||||
return dump_response(DatasetMetadataBuiltInFieldsResponse, {"fields": built_in_fields}), 200
|
||||
@console_account_admission()
|
||||
@metadata_errors
|
||||
def get(self, request_context: RequestContext):
|
||||
fields = application_services().knowledge.metadata.get_built_in_fields()
|
||||
return dump_response(DatasetMetadataBuiltInFieldsResponse, {"fields": fields}), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/metadata/built-in/<string:action>")
|
||||
class DatasetMetadataBuiltInFieldActionApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.response(204, "Action completed successfully")
|
||||
@with_current_user
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
def post(self, session: Session, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable"]):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
MetadataService.enable_built_in_field(dataset, session)
|
||||
case "disable":
|
||||
MetadataService.disable_built_in_field(dataset, session)
|
||||
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
def post(self, request_context: RequestContext, dataset_id: UUID, action: str):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
if action == "enable":
|
||||
service.enable_built_in_field(ref)
|
||||
elif action == "disable":
|
||||
service.disable_built_in_field(ref)
|
||||
else:
|
||||
raise ValueError("Invalid action.")
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/documents/metadata")
|
||||
class DocumentMetadataEditApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@console_ns.expect(console_ns.models[MetadataOperationData.__name__])
|
||||
@console_ns.response(
|
||||
204,
|
||||
"Documents metadata updated successfully",
|
||||
)
|
||||
@console_ns.response(404, "Dataset, document, or metadata not found")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),),
|
||||
)
|
||||
@metadata_errors
|
||||
@model_validate(MetadataOperationData)
|
||||
def post(
|
||||
self,
|
||||
req_data: MetadataOperationData,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
dataset_id: UUID,
|
||||
):
|
||||
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), current_tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
try:
|
||||
MetadataService.update_documents_metadata(dataset, req_data, current_user, session=session)
|
||||
except MetadataResourceNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
|
||||
# Frontend callers only await success and invalidate caches; no response body is consumed.
|
||||
def post(self, req_data: MetadataOperationData, request_context: RequestContext, dataset_id: UUID):
|
||||
service = application_services().knowledge.metadata
|
||||
ref = service.require_dataset(request_context, str(dataset_id))
|
||||
service.update_documents_metadata(ref, req_data, actor_id=request_context.account_id)
|
||||
return "", 204
|
||||
|
||||
@@ -24,6 +24,7 @@ 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_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -32,7 +33,6 @@ from libs.login import login_required
|
||||
from models import Account
|
||||
from models.enums import PermissionEnum
|
||||
from models.provider_ids import DatasourceProviderID
|
||||
from services.datasource_provider_service import DatasourceProviderService
|
||||
from services.plugin.oauth_service import OAuthProxyService
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
provider_name = datasource_provider_id.provider_name
|
||||
plugin_id = datasource_provider_id.plugin_id
|
||||
oauth_config = DatasourceProviderService().get_oauth_client(
|
||||
oauth_config = application_services().data_sources.providers.get_oauth_client(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
)
|
||||
@@ -246,7 +246,7 @@ class DatasourceOAuthCallback(Resource):
|
||||
tenant_id: str = context["tenant_id"]
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
plugin_id = datasource_provider_id.plugin_id
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
oauth_client_params = datasource_provider_service.get_oauth_client(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -315,7 +315,7 @@ class DatasourceAuth(Resource):
|
||||
@model_validate(DatasourceCredentialPayload)
|
||||
def post(self, req_data: DatasourceCredentialPayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
|
||||
try:
|
||||
datasource_provider_service.add_datasource_api_key_provider(
|
||||
@@ -342,14 +342,14 @@ class DatasourceAuth(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
|
||||
datasources = datasource_provider_service.list_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
provider=datasource_provider_id.provider_name,
|
||||
plugin_id=datasource_provider_id.plugin_id,
|
||||
user=user,
|
||||
session=db.session(),
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -370,7 +370,7 @@ class DatasourceAuthDeleteApi(Resource):
|
||||
plugin_id = datasource_provider_id.plugin_id
|
||||
provider_name = datasource_provider_id.provider_name
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.remove_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
auth_id=req_data.credential_id,
|
||||
@@ -397,7 +397,7 @@ class DatasourceAuthUpdateApi(Resource):
|
||||
def post(self, req_data: DatasourceCredentialUpdatePayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.update_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
auth_id=req_data.credential_id,
|
||||
@@ -422,9 +422,12 @@ class DatasourceAuthListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasources = datasource_provider_service.get_all_datasource_credentials(
|
||||
tenant_id=current_tenant_id, session=db.session(), user=user
|
||||
tenant_id=current_tenant_id,
|
||||
session=db.session(),
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -442,9 +445,12 @@ class DatasourceHardCodeAuthListApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasources = datasource_provider_service.get_hard_code_datasource_credentials(
|
||||
tenant_id=current_tenant_id, session=db.session(), user=user
|
||||
tenant_id=current_tenant_id,
|
||||
session=db.session(),
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
@@ -464,7 +470,7 @@ class DatasourceAuthOauthCustomClient(Resource):
|
||||
@model_validate(DatasourceCustomClientPayload)
|
||||
def post(self, req_data: DatasourceCustomClientPayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.setup_oauth_custom_client_params(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -482,7 +488,7 @@ class DatasourceAuthOauthCustomClient(Resource):
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.remove_oauth_custom_client_params(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -503,7 +509,7 @@ class DatasourceAuthDefaultApi(Resource):
|
||||
@model_validate(DatasourceDefaultPayload)
|
||||
def post(self, req_data: DatasourceDefaultPayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.set_default_datasource_provider(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
@@ -525,7 +531,7 @@ class DatasourceUpdateProviderNameApi(Resource):
|
||||
@model_validate(DatasourceUpdateNamePayload)
|
||||
def post(self, req_data: DatasourceUpdateNamePayload, current_tenant_id: str, provider_id: str):
|
||||
datasource_provider_id = DatasourceProviderID(provider_id)
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasource_provider_service = application_services().data_sources.providers
|
||||
datasource_provider_service.update_datasource_provider_name(
|
||||
tenant_id=current_tenant_id,
|
||||
datasource_provider_id=datasource_provider_id,
|
||||
|
||||
@@ -9,6 +9,7 @@ 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, model_validate, setup_required, with_current_user
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
@@ -51,5 +52,6 @@ class DataSourceContentPreviewApi(Resource):
|
||||
datasource_type=datasource_type,
|
||||
is_published=True,
|
||||
credential_id=req_data.credential_id,
|
||||
datasource_providers=application_services().data_sources.providers,
|
||||
)
|
||||
return preview_content, 200
|
||||
|
||||
@@ -35,10 +35,11 @@ from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
from models.dataset import Pipeline
|
||||
from services.dataset_service import DatasetService
|
||||
from repositories.knowledge.dataset_read_repository import get_pipeline_dataset
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
|
||||
from services.knowledge.dataset_service import DatasetService
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -232,7 +233,7 @@ class PublishCustomizedPipelineTemplateApi(Resource):
|
||||
pipeline: Pipeline,
|
||||
) -> tuple[str, int]:
|
||||
session = db.session()
|
||||
dataset = pipeline.retrieve_dataset(session=session)
|
||||
dataset = get_pipeline_dataset(pipeline, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found")
|
||||
|
||||
|
||||
@@ -17,14 +17,17 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
|
||||
from fields.dataset_fields import (
|
||||
DatasetDetailResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.dataset import DatasetPermissionEnum
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity
|
||||
from services.knowledge.dataset_read_service import load_dataset_detail
|
||||
from services.knowledge.dataset_service import DatasetPermissionService, DatasetService
|
||||
from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineDslService
|
||||
|
||||
|
||||
@@ -136,4 +139,4 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
|
||||
),
|
||||
session=session,
|
||||
)
|
||||
return dump_response(DatasetDetailResponse, dataset_detail_response_source(dataset, session=session)), 201
|
||||
return dump_response(DatasetDetailResponse, load_dataset_detail(dataset, session=session)), 201
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, RootModel, ValidationError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
import services.errors.base
|
||||
from configs import dify_config
|
||||
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
@@ -46,6 +46,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
from fields.base import ResponseModel
|
||||
@@ -66,10 +67,10 @@ from models.dataset import Pipeline
|
||||
from models.model import EndUser
|
||||
from models.workflow import Workflow
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.dataset_service import DatasetService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
|
||||
from services.knowledge.dataset_service import DatasetService
|
||||
from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
from services.rag_pipeline.rag_pipeline_manage_service import RagPipelineManageService
|
||||
@@ -303,7 +304,13 @@ class RagPipelineDraftRunIterationNodeApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate_single_iteration(
|
||||
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
node_id=node_id,
|
||||
args=args,
|
||||
session=db.session(),
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
@@ -338,7 +345,13 @@ class RagPipelineDraftRunLoopNodeApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate_single_loop(
|
||||
pipeline=pipeline, user=current_user, node_id=node_id, args=args, session=db.session(), streaming=True
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
node_id=node_id,
|
||||
args=args,
|
||||
session=db.session(),
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
@@ -374,6 +387,7 @@ class DraftRagPipelineRunApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate(
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
@@ -409,6 +423,7 @@ class PublishedRagPipelineRunApi(Resource):
|
||||
|
||||
try:
|
||||
response = PipelineGenerateService.generate(
|
||||
generator=application_services().knowledge.pipeline_generator,
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
user=current_user,
|
||||
@@ -450,6 +465,7 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource):
|
||||
datasource_type=req_data.datasource_type,
|
||||
is_published=False,
|
||||
credential_id=req_data.credential_id,
|
||||
datasource_providers=application_services().data_sources.providers,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -483,6 +499,7 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource):
|
||||
datasource_type=req_data.datasource_type,
|
||||
is_published=False,
|
||||
credential_id=req_data.credential_id,
|
||||
datasource_providers=application_services().data_sources.providers,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1016,7 +1033,9 @@ class DatasourceListApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
providers = RagPipelineManageService.list_rag_pipeline_datasources(current_tenant_id)
|
||||
providers = RagPipelineManageService.list_rag_pipeline_datasources(
|
||||
current_tenant_id, datasource_providers=application_services().data_sources.providers
|
||||
)
|
||||
return RagPipelineDatasourceListResponse.model_validate(providers, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -1069,7 +1088,7 @@ class RagPipelineTransformApi(Resource):
|
||||
raise Forbidden()
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as exc:
|
||||
except services.errors.base.NoPermissionError as exc:
|
||||
raise Forbidden(str(exc)) from exc
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,8 +7,9 @@ from controllers.common.schema import query_params_from_model, register_response
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.datasets.error import WebsiteCrawlError
|
||||
from controllers.console.wraps import account_initialization_required, model_validate, setup_required
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.login import login_required
|
||||
from services.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
|
||||
from services.data_source.website_service import WebsiteCrawlApiRequest, WebsiteCrawlStatusApiRequest, WebsiteService
|
||||
|
||||
|
||||
class WebsiteCrawlPayload(BaseModel):
|
||||
@@ -49,7 +50,7 @@ class WebsiteCrawlApi(Resource):
|
||||
|
||||
# Crawl URL using typed request
|
||||
try:
|
||||
result = WebsiteService.crawl_url(api_request)
|
||||
result = WebsiteService(providers=application_services().data_sources.providers).crawl_url(api_request)
|
||||
except Exception as e:
|
||||
raise WebsiteCrawlError(str(e))
|
||||
return result, 200
|
||||
@@ -77,7 +78,9 @@ class WebsiteCrawlStatusApi(Resource):
|
||||
|
||||
# Get crawl status using typed request
|
||||
try:
|
||||
result = WebsiteService.get_crawl_status_typed(api_request)
|
||||
result = WebsiteService(providers=application_services().data_sources.providers).get_crawl_status_typed(
|
||||
api_request
|
||||
)
|
||||
except Exception as e:
|
||||
raise WebsiteCrawlError(str(e))
|
||||
return result, 200
|
||||
|
||||
@@ -7,10 +7,10 @@ from http import HTTPStatus
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError
|
||||
|
||||
from controllers.common.audio_response import audio_binary_response
|
||||
from controllers.common.controller_schemas import TextToAudioPayload
|
||||
from controllers.common.errors import InternalServerError
|
||||
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model
|
||||
from controllers.console import console_ns
|
||||
@@ -27,14 +27,17 @@ from controllers.console.app.error import (
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.explore.error import AgentVersionNotFoundHTTPError
|
||||
from controllers.console.explore.installed_app_admission import get_installed_app
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_application_services import application_services
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from services.agent.errors import AgentVersionNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.audio_types import AudioAppRef, AudioUpload
|
||||
from services.errors.app_model_config import AppModelConfigBrokenError
|
||||
@@ -84,7 +87,9 @@ def _audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise ProviderModelCurrentlyNotSupportError() from error
|
||||
except InvokeError as error:
|
||||
raise CompletionRequestError(error.description) from error
|
||||
except (HTTPException, ValueError):
|
||||
except AgentVersionNotFoundError as error:
|
||||
raise AgentVersionNotFoundHTTPError() from error
|
||||
except (BaseHTTPException, ValueError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception("Installed-app audio operation failed")
|
||||
@@ -101,10 +106,15 @@ class ChatAudioApi(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_installed_app
|
||||
@_audio_errors
|
||||
def post(self, request_context: RequestContext, installed_app: InstalledAppRef) -> dict[str, object]:
|
||||
# Let Flask handle multipart parsing errors before mapping audio operation failures.
|
||||
file = request.files.get("file")
|
||||
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
|
||||
return self._transcribe(installed_app, audio)
|
||||
|
||||
@_audio_errors
|
||||
def _transcribe(self, installed_app: InstalledAppRef, audio: AudioUpload | None) -> dict[str, object]:
|
||||
"""Transcribe an upload; None lets the audio service report a missing file."""
|
||||
transcript = application_services().app_audio.transcript_asr(
|
||||
app=AudioAppRef(
|
||||
app_id=installed_app.app_id,
|
||||
|
||||
@@ -6,9 +6,9 @@ from uuid import UUID
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import InternalServerError, NotFound, Unauthorized
|
||||
|
||||
import services
|
||||
from controllers.common.errors import InternalServerError, NotFoundError, UnauthorizedError
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console.app.error import (
|
||||
@@ -112,11 +112,11 @@ class CompletionApi(Resource):
|
||||
except InstalledAppNotCompletionError:
|
||||
raise NotCompletionAppError() from None
|
||||
except InstalledAppNotFoundError:
|
||||
raise NotFound("Installed app not found") from None
|
||||
raise NotFoundError("Installed app not found") from None
|
||||
except AccountNotFoundError:
|
||||
raise Unauthorized("Account no longer exists.") from None
|
||||
raise UnauthorizedError("Account no longer exists.") from None
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
@@ -192,11 +192,11 @@ class ChatApi(Resource):
|
||||
except InstalledAppNotChatError:
|
||||
raise NotChatAppError() from None
|
||||
except InstalledAppNotFoundError:
|
||||
raise NotFound("Installed app not found") from None
|
||||
raise NotFoundError("Installed app not found") from None
|
||||
except AccountNotFoundError:
|
||||
raise Unauthorized("Account no longer exists.") from None
|
||||
raise UnauthorizedError("Account no longer exists.") from None
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
|
||||
@@ -4,6 +4,12 @@ from core.logging.context import get_request_id
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
class AgentVersionNotFoundHTTPError(BaseHTTPException):
|
||||
error_code = "agent_version_not_found_error"
|
||||
description = "Agent config version not found."
|
||||
code = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class InstalledAppHTTPError(BaseHTTPException):
|
||||
"""A Console error with safe details and a request ID for troubleshooting."""
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ from uuid import UUID
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError, Unauthorized
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
|
||||
from controllers.common.errors import InternalServerError, UnauthorizedError
|
||||
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.app.error import (
|
||||
@@ -42,6 +42,7 @@ from fields.conversation_fields import ResultResponse
|
||||
from fields.message_fields import ExploreMessageInfiniteScrollPagination, SuggestedQuestionsResponse
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from libs.exception import BaseHTTPException
|
||||
from machinery.context import RequestContext
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
@@ -84,7 +85,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except (AccountNotFoundError, SuggestedQuestionsActorNotFoundError) as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except MessageNotChatAppError as error:
|
||||
raise NotChatAppError() from error
|
||||
except InstalledAppNotCompletionError as error:
|
||||
@@ -109,7 +110,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise ProviderModelCurrentlyNotSupportError() from error
|
||||
except InvokeError as error:
|
||||
raise CompletionRequestError(error.description) from error
|
||||
except (HTTPException, ValueError):
|
||||
except (BaseHTTPException, ValueError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception("Installed-app message operation failed")
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import make_response, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, computed_field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@@ -11,7 +12,9 @@ from controllers.console.explore.error import RecommendedAppNotFoundError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.app_export_fields import AppExportResponse
|
||||
from fields.base import ResponseModel
|
||||
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
|
||||
from libs.helper import build_icon_url, dump_response
|
||||
from machinery.context import RequestContext
|
||||
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
|
||||
@@ -21,6 +24,13 @@ class RecommendedAppsQuery(BaseModel):
|
||||
language: str = Field(default="en-US", description="Language code for recommended app localization")
|
||||
|
||||
|
||||
class RecommendedAgentExportQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: UUID = Field(description="Current published template snapshot ID")
|
||||
format: Literal["yaml", "ifpkg"] = Field(default="ifpkg", description="Export format; defaults to ifpkg")
|
||||
|
||||
|
||||
class RecommendedAppInfoResponse(ResponseModel):
|
||||
id: str
|
||||
name: str | None = None
|
||||
@@ -78,11 +88,14 @@ class RecommendedAppDetailResponse(ResponseModel):
|
||||
mode: str
|
||||
export_data: str
|
||||
can_trial: bool
|
||||
package_url: str | None = Field(default=None, description="Download URL for a New Agent .ifpkg template")
|
||||
version_id: str | None = Field(default=None, description="Published version for direct local template creation")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
RecommendedAppsQuery,
|
||||
RecommendedAgentExportQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
@@ -91,6 +104,7 @@ register_response_schema_models(
|
||||
RecommendedAppListResponse,
|
||||
LearnDifyAppListResponse,
|
||||
RecommendedAppDetailResponse,
|
||||
AppExportResponse,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,3 +149,37 @@ class RecommendedAppApi(Resource):
|
||||
except RecommendedAppQueryNotFoundError:
|
||||
raise RecommendedAppNotFoundError() from None
|
||||
return dump_response(RecommendedAppDetailResponse, result)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/export")
|
||||
class RecommendedAgentExportApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAgentExportQuery))
|
||||
@console_ns.doc(
|
||||
produces=["application/json", "application/zip"],
|
||||
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
|
||||
)
|
||||
@console_ns.response(200, "Published Agent template export", console_ns.models[AppExportResponse.__name__])
|
||||
@console_ns.response(404, "Published template unavailable")
|
||||
@model_validate(RecommendedAgentExportQuery)
|
||||
def get(self, query: RecommendedAgentExportQuery, app_id: UUID):
|
||||
# Both formats require public catalog membership and the current published version.
|
||||
try:
|
||||
exported = application_services().recommended_app_packages.export(
|
||||
app_id=str(app_id), version_id=query.version_id, format=query.format
|
||||
)
|
||||
except RecommendedAppQueryNotFoundError:
|
||||
raise RecommendedAppNotFoundError() from None
|
||||
if isinstance(exported, str):
|
||||
response = make_response(dump_response(AppExportResponse, {"data": exported}))
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
try:
|
||||
response = send_file(
|
||||
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
|
||||
)
|
||||
except Exception:
|
||||
exported.close()
|
||||
raise
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.call_on_close(exported.close)
|
||||
return response
|
||||
|
||||
@@ -2,9 +2,9 @@ from http import HTTPStatus
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery
|
||||
from controllers.common.errors import NotFoundError
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.explore.error import NotCompletionAppError
|
||||
@@ -72,7 +72,7 @@ class SavedMessageListApi(Resource):
|
||||
message_id=str(req_data.message_id),
|
||||
)
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message Not Exists.")
|
||||
raise NotFoundError("Message Not Exists.")
|
||||
|
||||
return ResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ from uuid import UUID
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.audio_response import audio_binary_response
|
||||
from controllers.common.errors import InternalServerError, NotFoundError, UnauthorizedError
|
||||
from controllers.common.fields import (
|
||||
AudioBinaryResponse,
|
||||
AudioTranscriptResponse,
|
||||
@@ -46,23 +46,26 @@ from controllers.console.app.error import (
|
||||
)
|
||||
from controllers.console.app.preview_admission import get_preview_app
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.error import (
|
||||
AgentVersionNotFoundHTTPError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
NotWorkflowAppError,
|
||||
)
|
||||
from controllers.console.explore.trial_app_admission import get_trial_app
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.error import (
|
||||
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
|
||||
)
|
||||
from controllers.console.explore.trial_app_admission import get_trial_app, get_trial_app_for_stop
|
||||
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request_context
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file
|
||||
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.errors.error import (
|
||||
AppInvokeQuotaExceededError,
|
||||
ModelCurrentlyNotSupportError,
|
||||
@@ -72,6 +75,7 @@ from core.errors.error import (
|
||||
from core.helper import encrypter
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.agent_fields import AgentAppComposerResponse
|
||||
from fields.base import ResponseModel
|
||||
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
|
||||
from fields.file_fields import FileResponse, FileWithSignedUrl
|
||||
@@ -79,12 +83,15 @@ from fields.message_fields import SuggestedQuestionsResponse
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from graphon.variables import SecretVariable, VariableBase
|
||||
from libs import helper
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.helper import dump_response, to_timestamp, uuid_value
|
||||
from libs.stream import close_stream
|
||||
from libs.url_utils import normalize_api_base_url
|
||||
from machinery.context import RequestContext
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.agent.errors import AgentVersionNotFoundError
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
from services.app_preview_query_service import (
|
||||
AppPreviewOwnerUnavailableError,
|
||||
@@ -153,7 +160,7 @@ class TrialDatasetListQuery(BaseModel):
|
||||
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
|
||||
|
||||
|
||||
type TrialAppMode = Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||
type TrialAppMode = Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
|
||||
type TrialIconType = Literal["emoji", "image", "link"]
|
||||
type JsonObject = dict[str, Any]
|
||||
|
||||
@@ -448,6 +455,7 @@ register_response_schema_models(
|
||||
SiteResponse,
|
||||
SuggestedQuestionsResponse,
|
||||
TrialAppDetailResponse,
|
||||
AgentAppComposerResponse,
|
||||
TrialDatasetListResponse,
|
||||
TrialWorkflowResponse,
|
||||
)
|
||||
@@ -526,7 +534,7 @@ class TrialAppWorkflowRunApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@@ -595,14 +603,16 @@ class TrialChatApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except (AgentAppGeneratorError, AgentAppNotPublishedError):
|
||||
raise AppUnavailableError() from None
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@@ -620,6 +630,27 @@ class TrialChatApi(Resource):
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",
|
||||
endpoint="trial_app_stop_chat_completion",
|
||||
)
|
||||
class TrialChatTaskStopApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app_for_stop
|
||||
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str):
|
||||
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
|
||||
raise NotChatAppError()
|
||||
|
||||
application_services().app_tasks.stop_task(
|
||||
task_id=task_id,
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
user_id=request_context.account_id,
|
||||
app_mode=AppMode.value_of(trial_app.app_mode),
|
||||
)
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
endpoint="trial_app_suggested_question",
|
||||
@@ -629,7 +660,7 @@ class TrialMessageSuggestedQuestionApi(Resource):
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
def get(self, request_context: RequestContext, trial_app: TrialAppRef, message_id: UUID) -> dict[str, object]:
|
||||
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
|
||||
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
|
||||
raise NotChatAppError()
|
||||
|
||||
try:
|
||||
@@ -643,11 +674,11 @@ class TrialMessageSuggestedQuestionApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except SuggestedQuestionsActorNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
raise NotFoundError("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
raise NotFound("Conversation not found")
|
||||
raise NotFoundError("Conversation not found")
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
@@ -690,7 +721,9 @@ def _trial_audio_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
raise ProviderModelCurrentlyNotSupportError() from error
|
||||
except InvokeError as error:
|
||||
raise CompletionRequestError(error.description) from error
|
||||
except (HTTPException, ValueError):
|
||||
except AgentVersionNotFoundError as error:
|
||||
raise AgentVersionNotFoundHTTPError() from error
|
||||
except (BaseHTTPException, ValueError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception("Trial audio operation failed")
|
||||
@@ -707,10 +740,17 @@ class TrialChatAudioApi(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AudioTranscriptResponse.__name__])
|
||||
@console_account_admission()
|
||||
@get_trial_app
|
||||
@_trial_audio_errors
|
||||
def post(self, request_context: RequestContext, trial_app: TrialAppRef) -> dict[str, object]:
|
||||
# Let Flask handle multipart parsing errors before mapping audio operation failures.
|
||||
file = request.files.get("file")
|
||||
audio = AudioUpload(stream=file.stream, mime_type=file.mimetype) if file is not None else None
|
||||
return self._transcript_asr(request_context=request_context, trial_app=trial_app, audio=audio)
|
||||
|
||||
@_trial_audio_errors
|
||||
def _transcript_asr(
|
||||
self, *, request_context: RequestContext, trial_app: TrialAppRef, audio: AudioUpload | None
|
||||
) -> dict[str, object]:
|
||||
"""Transcribe an upload; None lets the audio service report a missing file."""
|
||||
transcript = application_services().app_audio.transcript_asr(
|
||||
app=AudioAppRef(app_id=trial_app.app_id, tenant_id=trial_app.tenant_id, app_mode=trial_app.app_mode),
|
||||
audio=audio,
|
||||
@@ -779,9 +819,9 @@ class TrialCompletionApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
raise NotFoundError("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
@@ -837,7 +877,7 @@ class TrialAppParameterApi(Resource):
|
||||
"""Retrieve app parameters."""
|
||||
|
||||
try:
|
||||
parameters = application_services().app_definitions.get_parameters(app.app_id)
|
||||
parameters = application_services().app_definitions.get_public_parameters(app.app_id)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
|
||||
@@ -862,7 +902,7 @@ class AppApi(Resource):
|
||||
except AppDefinitionUnavailableError as error:
|
||||
raise AppUnavailableError() from error
|
||||
except AccountNotFoundError as error:
|
||||
raise Unauthorized("Account no longer exists.") from error
|
||||
raise UnauthorizedError("Account no longer exists.") from error
|
||||
except AppPreviewSiteUnavailableError as error:
|
||||
raise AppPreviewSiteUnavailableHttpError(str(error)) from error
|
||||
|
||||
@@ -875,6 +915,18 @@ class AppApi(Resource):
|
||||
return dump_response(TrialAppDetailResponse, source)
|
||||
|
||||
|
||||
@console_ns.route("/trial-apps/<uuid:app_id>/agent-composer")
|
||||
class TrialAgentComposerApi(Resource):
|
||||
@console_ns.response(200, "Published Agent configuration", console_ns.models[AgentAppComposerResponse.__name__])
|
||||
@get_preview_app
|
||||
def get(self, app: AppPreviewRef):
|
||||
try:
|
||||
preview = application_services().app_previews.get_agent_composer(app=app)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise AppUnavailableError() from None
|
||||
return dump_response(AgentAppComposerResponse, preview)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/trial-apps/<uuid:app_id>/workflows",
|
||||
endpoint="trial_app_workflow",
|
||||
|
||||
@@ -28,8 +28,10 @@ def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
|
||||
def get_trial_app[T, **P, R](
|
||||
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
|
||||
*,
|
||||
check_usage: bool = True,
|
||||
) -> Callable[Concatenate[T, RequestContext, P], R]:
|
||||
"""Check trial availability and inject an admitted app after Console admission."""
|
||||
"""Admit the trial app, optionally skipping generation quota for task cancellation."""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
@@ -38,7 +40,7 @@ def get_trial_app[T, **P, R](
|
||||
raise RuntimeError("The trial app admission route must provide app_id")
|
||||
try:
|
||||
trial_app = application_services().trial_apps.access.get_access(
|
||||
app_id=str(app_id), account_id=request_context.account_id
|
||||
app_id=str(app_id), account_id=request_context.account_id, check_usage=check_usage
|
||||
)
|
||||
except TrialAppUnavailableError as error:
|
||||
raise TrialAppNotAllowed() from error
|
||||
@@ -47,3 +49,10 @@ def get_trial_app[T, **P, R](
|
||||
return view(self, request_context, trial_app, *args, **kwargs)
|
||||
|
||||
return trial_feature_enable(decorated)
|
||||
|
||||
|
||||
def get_trial_app_for_stop[T, **P, R](
|
||||
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
|
||||
) -> Callable[Concatenate[T, RequestContext, P], R]:
|
||||
"""Keep trial access checks without requiring remaining generation quota."""
|
||||
return get_trial_app(view, check_usage=False)
|
||||
|
||||
@@ -3,9 +3,9 @@ from http import HTTPStatus
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import InternalServerError, Unauthorized
|
||||
|
||||
from controllers.common.controller_schemas import WorkflowRunPayload
|
||||
from controllers.common.errors import InternalServerError, UnauthorizedError
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model
|
||||
from controllers.console.app.error import (
|
||||
@@ -70,7 +70,7 @@ class InstalledAppWorkflowRunApi(Resource):
|
||||
except (AppDefinitionUnavailableError, InstalledAppNotWorkflowError):
|
||||
raise NotWorkflowAppError() from None
|
||||
except AccountNotFoundError:
|
||||
raise Unauthorized("Account no longer exists.") from None
|
||||
raise UnauthorizedError("Account no longer exists.") from None
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@@ -6,17 +6,23 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, field_validator
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
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 machinery.context import RequestContext
|
||||
from services.api_based_extension_application_service import (
|
||||
APIBasedExtensionError,
|
||||
APIBasedExtensionInput,
|
||||
APIBasedExtensionRecord,
|
||||
APIBasedExtensionUpdate,
|
||||
)
|
||||
from services.code_based_extension_service import CodeBasedExtensionService
|
||||
|
||||
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, model_validate, setup_required, with_current_tenant_id
|
||||
from .wraps import account_initialization_required, model_validate, setup_required
|
||||
|
||||
|
||||
class CodeBasedExtensionQuery(BaseModel):
|
||||
@@ -77,6 +83,10 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _extension_response(extension: APIBasedExtensionRecord) -> dict[str, Any]:
|
||||
return dump_response(APIBasedExtensionResponse, extension)
|
||||
|
||||
|
||||
@console_ns.route("/code-based-extension")
|
||||
class CodeBasedExtensionAPI(Resource):
|
||||
@console_ns.doc("get_code_based_extension")
|
||||
@@ -103,41 +113,32 @@ 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.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
return dump_response(
|
||||
APIBasedExtensionListResponse,
|
||||
APIBasedExtensionService.get_all_by_tenant_id(current_tenant_id, session=db.session()),
|
||||
)
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
extensions = application_services().api_based_extensions.list_extensions(request_context)
|
||||
return dump_response(APIBasedExtensionListResponse, extensions)
|
||||
|
||||
@console_ns.doc("create_api_based_extension")
|
||||
@console_ns.doc(description="Create a new API-based extension")
|
||||
@console_ns.expect(console_ns.models[APIBasedExtensionPayload.__name__])
|
||||
@console_ns.response(201, "Extension created successfully", console_ns.models[APIBasedExtensionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(APIBasedExtensionPayload)
|
||||
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str):
|
||||
extension_data = APIBasedExtension(
|
||||
tenant_id=current_tenant_id,
|
||||
name=req_data.name,
|
||||
api_endpoint=req_data.api_endpoint,
|
||||
api_key=req_data.api_key,
|
||||
)
|
||||
def post(self, req_data: APIBasedExtensionPayload, request_context: RequestContext):
|
||||
try:
|
||||
extension = application_services().api_based_extensions.create_extension(
|
||||
request_context,
|
||||
APIBasedExtensionInput(
|
||||
name=req_data.name,
|
||||
api_endpoint=req_data.api_endpoint,
|
||||
api_key=req_data.api_key,
|
||||
),
|
||||
)
|
||||
except APIBasedExtensionError as error:
|
||||
# Legacy clients receive the 400 `invalid_param` envelope for every extension failure.
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
extension = APIBasedExtensionService.save(extension_data, session=db.session())
|
||||
return APIBasedExtensionResponse(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
api_endpoint=extension.api_endpoint,
|
||||
api_key=req_data.api_key,
|
||||
created_at=to_timestamp(extension.created_at),
|
||||
).model_dump(mode="json"), 201
|
||||
return _extension_response(extension), 201
|
||||
|
||||
|
||||
@console_ns.route("/api-based-extension/<uuid:id>")
|
||||
@@ -146,70 +147,48 @@ class APIBasedExtensionDetailAPI(Resource):
|
||||
@console_ns.doc(description="Get API-based extension by ID")
|
||||
@console_ns.doc(params={"id": "Extension ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[APIBasedExtensionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, id: UUID):
|
||||
api_based_extension_id = str(id)
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext, id: UUID):
|
||||
try:
|
||||
extension = application_services().api_based_extensions.get_extension(request_context, str(id))
|
||||
except APIBasedExtensionError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
return dump_response(
|
||||
APIBasedExtensionResponse,
|
||||
APIBasedExtensionService.get_with_tenant_id(
|
||||
current_tenant_id, api_based_extension_id, session=db.session()
|
||||
),
|
||||
)
|
||||
return _extension_response(extension)
|
||||
|
||||
@console_ns.doc("update_api_based_extension")
|
||||
@console_ns.doc(description="Update API-based extension")
|
||||
@console_ns.doc(params={"id": "Extension ID"})
|
||||
@console_ns.expect(console_ns.models[APIBasedExtensionPayload.__name__])
|
||||
@console_ns.response(200, "Extension updated successfully", console_ns.models[APIBasedExtensionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(APIBasedExtensionPayload)
|
||||
def post(self, req_data: APIBasedExtensionPayload, current_tenant_id: str, id: UUID):
|
||||
api_based_extension_id = str(id)
|
||||
def post(self, req_data: APIBasedExtensionPayload, request_context: RequestContext, id: UUID):
|
||||
try:
|
||||
extension = application_services().api_based_extensions.update_extension(
|
||||
request_context,
|
||||
str(id),
|
||||
APIBasedExtensionUpdate(
|
||||
name=req_data.name,
|
||||
api_endpoint=req_data.api_endpoint,
|
||||
# The console echoes the masked key back; keep the stored key when it is sent unchanged.
|
||||
api_key=None if req_data.api_key == HIDDEN_VALUE else req_data.api_key,
|
||||
),
|
||||
)
|
||||
except APIBasedExtensionError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(
|
||||
current_tenant_id, api_based_extension_id, session=db.session()
|
||||
)
|
||||
|
||||
api_key_for_response = extension_data_from_db.api_key
|
||||
|
||||
extension_data_from_db.name = req_data.name
|
||||
extension_data_from_db.api_endpoint = req_data.api_endpoint
|
||||
|
||||
if req_data.api_key != HIDDEN_VALUE:
|
||||
extension_data_from_db.api_key = req_data.api_key
|
||||
api_key_for_response = req_data.api_key
|
||||
|
||||
APIBasedExtensionService.save(extension_data_from_db, session=db.session())
|
||||
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")
|
||||
return _extension_response(extension)
|
||||
|
||||
@console_ns.doc("delete_api_based_extension")
|
||||
@console_ns.doc(description="Delete API-based extension")
|
||||
@console_ns.doc(params={"id": "Extension ID"})
|
||||
@console_ns.response(204, "Extension deleted successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, id: UUID):
|
||||
api_based_extension_id = str(id)
|
||||
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(
|
||||
current_tenant_id, api_based_extension_id, session=db.session()
|
||||
)
|
||||
|
||||
APIBasedExtensionService.delete(extension_data_from_db, session=db.session())
|
||||
@console_account_admission()
|
||||
def delete(self, request_context: RequestContext, id: UUID):
|
||||
try:
|
||||
application_services().api_based_extensions.delete_extension(request_context, str(id))
|
||||
except APIBasedExtensionError as error:
|
||||
raise ValueError(str(error)) from None
|
||||
|
||||
return "", 204
|
||||
|
||||
@@ -10,22 +10,32 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.rbac import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
|
||||
from controllers.console.admin import admin_required
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_resource_check,
|
||||
enable_change_email,
|
||||
enterprise_license_required,
|
||||
is_allow_transfer_owner,
|
||||
setup_required,
|
||||
)
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from enums import DeploymentEdition
|
||||
from enums.account import TenantAccountRole
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from libs.oauth_bearer import bearer_feature_required
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
|
||||
|
||||
def console_admin_admission[T, **P, R](
|
||||
view: Callable[Concatenate[T, P], R],
|
||||
) -> Callable[Concatenate[T, P], R]:
|
||||
"""Admit a configured admin API key without requiring a Console account."""
|
||||
|
||||
return setup_required(admin_required(view))
|
||||
|
||||
|
||||
def console_email_registration_admission[T, **P, R](
|
||||
view: Callable[Concatenate[T, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
@@ -49,29 +59,26 @@ def console_account_admission[T, **P, R](
|
||||
require_change_email_enabled: bool = False,
|
||||
require_initialized: bool = True,
|
||||
require_valid_enterprise_license: bool = False,
|
||||
require_owner_transfer_enabled: bool = False,
|
||||
billing_resource: str | None = None,
|
||||
require_oauth_bearer_enabled: bool = False,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
rbac_checks: Sequence[RBACCheck] | None = None,
|
||||
billing_resource: str | None = None,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate[T, RequestContext, P], R]],
|
||||
Callable[Concatenate[T, P], R | Response],
|
||||
]:
|
||||
"""Declare Console account admission and inject a stable RequestContext.
|
||||
|
||||
All combinations use this decorator factory. Requirements are data, while
|
||||
the execution order stays fixed: edition, setup, login/CSRF, optional
|
||||
account initialization, optional enterprise license and feature checks,
|
||||
role/RBAC checks, then context construction and optional billing-resource
|
||||
admission.
|
||||
Checks run in a fixed order: setup, login/CSRF, optional account
|
||||
initialization, edition, license, feature and billing checks, role/RBAC
|
||||
checks, then context construction.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
view: Callable[Concatenate[T, RequestContext, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
admitted_view = cloud_edition_billing_resource_check(billing_resource)(view) if billing_resource else view
|
||||
|
||||
@wraps(view, updated=())
|
||||
@wraps(view)
|
||||
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
account_with_tenant = current_account_with_tenant()
|
||||
account = account_with_tenant.account
|
||||
@@ -91,32 +98,40 @@ def console_account_admission[T, **P, R](
|
||||
request_id=get_request_id(),
|
||||
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
|
||||
)
|
||||
return admitted_view(self, request_context, *args, **kwargs)
|
||||
return view(self, request_context, *args, **kwargs)
|
||||
|
||||
if rbac_checks is not None:
|
||||
setattr(inject_request_context, RBAC_CHECKS_ATTR, rbac_checks)
|
||||
|
||||
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
|
||||
if billing_resource is not None:
|
||||
admitted = cloud_edition_billing_resource_check(billing_resource)(admitted)
|
||||
if require_change_email_enabled:
|
||||
admitted = enable_change_email(admitted)
|
||||
if require_owner_transfer_enabled:
|
||||
admitted = is_allow_transfer_owner(admitted)
|
||||
if require_oauth_bearer_enabled:
|
||||
admitted = bearer_feature_required(admitted)
|
||||
if require_valid_enterprise_license:
|
||||
admitted = enterprise_license_required(admitted)
|
||||
if editions is not None:
|
||||
admitted = enforce_edition(admitted)
|
||||
if require_initialized:
|
||||
admitted = account_initialization_required(admitted)
|
||||
admitted = login_required(admitted)
|
||||
admitted = setup_required(admitted)
|
||||
|
||||
if editions is None:
|
||||
return admitted
|
||||
return admitted
|
||||
|
||||
@wraps(view)
|
||||
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
if dify_config.DEPLOYMENT_EDITION not in editions:
|
||||
def enforce_edition(
|
||||
admitted: Callable[Concatenate[T, P], R | Response],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
@wraps(admitted)
|
||||
def checked(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
if editions is not None and dify_config.DEPLOYMENT_EDITION not in editions:
|
||||
abort(HTTPStatus.NOT_FOUND)
|
||||
return admitted(self, *args, **kwargs)
|
||||
|
||||
return enforce_edition
|
||||
return checked
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -7,11 +7,8 @@ from controllers.fastopenapi import console_router
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.password import valid_password
|
||||
from services.setup_service import (
|
||||
InitializationValidationRequiredError,
|
||||
SetupAlreadyCompletedError,
|
||||
SetupInput,
|
||||
)
|
||||
from services.account.contracts import SetupInput
|
||||
from services.setup_service import InitializationValidationRequiredError, SetupAlreadyCompletedError
|
||||
|
||||
from .error import AlreadySetupError, NotInitValidateError
|
||||
from .init_validate import is_init_validated
|
||||
|
||||
@@ -5,11 +5,11 @@ from typing import cast
|
||||
from flask import Request as FlaskRequest
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_socketio import sio
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_access_token
|
||||
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
|
||||
from services.account_service import AccountService
|
||||
from services.workflow_collaboration_service import WorkflowCollaborationService
|
||||
|
||||
repository = WorkflowCollaborationRepository()
|
||||
@@ -43,8 +43,8 @@ def socket_connect(sid, environ, auth):
|
||||
logging.warning("Socket connect rejected: missing user_id (sid=%s)", sid)
|
||||
return False
|
||||
|
||||
with sio.app.app_context(), session_factory.create_session() as session:
|
||||
user = AccountService.load_logged_in_account(account_id=user_id, session=session)
|
||||
with sio.app.app_context():
|
||||
user = application_services().accounts.identity.load_user(user_id)
|
||||
if not user:
|
||||
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
|
||||
return False
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated, Literal, NoReturn
|
||||
from urllib import parse
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
@@ -22,30 +20,25 @@ from controllers.console.auth.error import (
|
||||
MemberNotInTenantError,
|
||||
NotOwnerError,
|
||||
OwnerTransferLimitError,
|
||||
OwnerTransferRateLimitExceededError,
|
||||
)
|
||||
from controllers.console.error import EmailSendIpLimitError, SeatsLimitExceeded, WorkspaceMembersLimitExceeded
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.workspace.error import InvalidMemberRoleError
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
is_allow_transfer_owner,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from enums.account import TenantAccountRole
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse
|
||||
from libs.helper import dump_response, extract_remote_ip
|
||||
from libs.login import login_required
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.errors.account import AccountAlreadyInTenantError
|
||||
from services.feature_service import FeatureService
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.errors import workspace as workspace_errors
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.errors.workspace import (
|
||||
InvalidWorkspaceMemberRoleError,
|
||||
WorkspaceApplicationError,
|
||||
WorkspaceInvitationQuotaError,
|
||||
)
|
||||
|
||||
|
||||
class MemberInvitePayload(BaseModel):
|
||||
@@ -142,62 +135,6 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _is_role_enabled(role: TenantAccountRole | str) -> bool:
|
||||
if role != TenantAccountRole.DATASET_OPERATOR:
|
||||
return True
|
||||
return dify_config.DATASET_OPERATOR_ENABLED
|
||||
|
||||
|
||||
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> tuple[int, int]:
|
||||
new_member_count = 0
|
||||
new_account_count = 0
|
||||
for email in emails:
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())
|
||||
if not account:
|
||||
new_member_count += 1
|
||||
new_account_count += 1
|
||||
continue
|
||||
|
||||
exists = db.session.scalar(
|
||||
select(TenantAccountJoin.id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not exists:
|
||||
new_member_count += 1
|
||||
|
||||
return new_member_count, new_account_count
|
||||
|
||||
|
||||
def _count_current_members(tenant_id: str) -> int:
|
||||
return (
|
||||
db.session.scalar(select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == tenant_id)) or 0
|
||||
)
|
||||
|
||||
|
||||
def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_account_count: int) -> None:
|
||||
if new_member_count <= 0:
|
||||
return
|
||||
|
||||
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
||||
workspace_members = features.workspace_members
|
||||
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
|
||||
raise WorkspaceMembersLimitExceeded()
|
||||
if new_account_count > 0:
|
||||
seats = SystemFeatureService.get_license().seats
|
||||
if not seats.is_available(new_account_count):
|
||||
raise SeatsLimitExceeded()
|
||||
return
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
members = features.members
|
||||
current_member_count = _count_current_members(tenant_id)
|
||||
if 0 < members.limit < current_member_count + new_member_count:
|
||||
raise WorkspaceMembersLimitExceeded()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members")
|
||||
class MemberListApi(Resource):
|
||||
"""List all members of current tenant."""
|
||||
@@ -205,7 +142,7 @@ class MemberListApi(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
members = application_services().workspace_member_queries.list_current(request_context)
|
||||
members = application_services().workspaces.member_queries.list_current(request_context)
|
||||
serialized_members = [
|
||||
{
|
||||
"id": member.id,
|
||||
@@ -235,123 +172,68 @@ class MemberInviteEmailApi(Resource):
|
||||
"Invalid role or workspace member limit exceeded",
|
||||
console_ns.models[MemberInviteErrorResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = MemberInvitePayload.model_validate(payload)
|
||||
|
||||
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):
|
||||
raise InvalidMemberRoleError()
|
||||
if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)):
|
||||
raise InvalidMemberRoleError()
|
||||
inviter = current_user
|
||||
if not inviter.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(invitee_role):
|
||||
raise InvalidMemberRoleError()
|
||||
|
||||
# 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: list[MemberInviteResultResponse] = []
|
||||
console_web_url = dify_config.CONSOLE_WEB_URL
|
||||
|
||||
tenant_id = inviter.current_tenant.id
|
||||
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
|
||||
if dify_config.DEPLOYMENT_EDITION in {
|
||||
DeploymentEdition.CLOUD,
|
||||
DeploymentEdition.ENTERPRISE,
|
||||
}:
|
||||
new_member_count, new_account_count = _count_new_member_invites(tenant_id, invitee_emails)
|
||||
_check_member_invite_limits(tenant_id, new_member_count, new_account_count)
|
||||
|
||||
for invitee_email in invitee_emails:
|
||||
try:
|
||||
if not inviter.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
token = RegisterService.invite_new_member(
|
||||
tenant=inviter.current_tenant,
|
||||
email=invitee_email,
|
||||
language=interface_language,
|
||||
role=invitee_role,
|
||||
inviter=inviter,
|
||||
session=db.session(),
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
args = MemberInvitePayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
results = application_services().workspaces.invitations.invite_many(
|
||||
request_context,
|
||||
emails=args.emails,
|
||||
language=args.language,
|
||||
role=args.role,
|
||||
)
|
||||
except InvalidWorkspaceMemberRoleError:
|
||||
raise InvalidMemberRoleError() from None
|
||||
except NoPermissionError as error:
|
||||
raise Forbidden(str(error)) from error
|
||||
except WorkspaceInvitationQuotaError as error:
|
||||
if error.seats:
|
||||
raise SeatsLimitExceeded() from error
|
||||
raise WorkspaceMembersLimitExceeded() from error
|
||||
serialized: list[MemberInviteResultResponse] = []
|
||||
for result in results:
|
||||
if result.status == "success":
|
||||
email = parse.quote(result.email)
|
||||
serialized.append(
|
||||
MemberInviteSuccessResponse(
|
||||
status="success",
|
||||
email=result.email,
|
||||
url=f"{dify_config.CONSOLE_WEB_URL}/activate?email={email}&token={result.token}",
|
||||
)
|
||||
encoded_invitee_email = parse.quote(invitee_email)
|
||||
invitation_results.append(
|
||||
MemberInviteSuccessResponse(
|
||||
status="success",
|
||||
email=invitee_email,
|
||||
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
|
||||
)
|
||||
)
|
||||
elif result.status == "already_member":
|
||||
serialized.append(
|
||||
MemberInviteAlreadyMemberResponse(
|
||||
status="already_member", email=result.email, message=result.message or ""
|
||||
)
|
||||
except AccountAlreadyInTenantError:
|
||||
invitation_results.append(
|
||||
MemberInviteAlreadyMemberResponse(
|
||||
status="already_member",
|
||||
email=invitee_email,
|
||||
message="Account already in workspace.",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
invitation_results.append(
|
||||
MemberInviteFailedResponse(status="failed", email=invitee_email, message=str(e))
|
||||
)
|
||||
|
||||
return (
|
||||
dump_response(
|
||||
MemberInviteResponse,
|
||||
{
|
||||
"result": "success",
|
||||
"invitation_results": invitation_results,
|
||||
"tenant_id": inviter.current_tenant.id if inviter.current_tenant else "",
|
||||
},
|
||||
),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
)
|
||||
else:
|
||||
serialized.append(
|
||||
MemberInviteFailedResponse(status="failed", email=result.email, message=result.message or "")
|
||||
)
|
||||
return dump_response(
|
||||
MemberInviteResponse,
|
||||
{"result": "success", "invitation_results": serialized, "tenant_id": request_context.active_workspace_id},
|
||||
), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members/<uuid:member_id>")
|
||||
class MemberCancelInviteApi(Resource):
|
||||
"""Cancel an invitation by member id."""
|
||||
|
||||
@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(HTTPStatus.NOT_FOUND)
|
||||
else:
|
||||
try:
|
||||
TenantService.remove_member_from_tenant(
|
||||
current_user.current_tenant, member, current_user, session=db.session()
|
||||
)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
|
||||
except services.errors.account.MemberNotInTenantError as e:
|
||||
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
@console_account_admission()
|
||||
def delete(self, request_context: RequestContext, member_id: UUID):
|
||||
try:
|
||||
application_services().workspaces.members.remove(
|
||||
request_context.active_workspace_id, str(member_id), request_context.account_id
|
||||
)
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
return _member_mutation_error_response(error)
|
||||
|
||||
return MemberActionResponse(
|
||||
result="success",
|
||||
tenant_id=current_user.current_tenant.id if current_user.current_tenant else "",
|
||||
tenant_id=request_context.active_workspace_id,
|
||||
).model_dump(mode="json"), HTTPStatus.OK
|
||||
|
||||
|
||||
@@ -361,40 +243,16 @@ class MemberUpdateRoleApi(Resource):
|
||||
|
||||
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def put(self, current_user: Account, member_id: UUID):
|
||||
@console_account_admission()
|
||||
def put(self, request_context: RequestContext, member_id: UUID):
|
||||
payload = console_ns.payload or {}
|
||||
args = MemberRoleUpdatePayload.model_validate(payload)
|
||||
new_role = args.role
|
||||
|
||||
if not TenantAccountRole.is_valid_role(new_role):
|
||||
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):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if not member:
|
||||
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()
|
||||
application_services().workspaces.members.update_role(
|
||||
request_context.active_workspace_id, str(member_id), args.role, request_context.account_id
|
||||
)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
|
||||
except services.errors.account.MemberNotInTenantError as e:
|
||||
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)}, HTTPStatus.BAD_REQUEST
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
return _member_mutation_error_response(error)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
@@ -403,15 +261,12 @@ class MemberUpdateRoleApi(Resource):
|
||||
class DatasetOperatorMemberListApi(Resource):
|
||||
"""List all members of current tenant."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@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())
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
members = application_services().workspaces.member_queries.list_members(
|
||||
request_context.active_workspace_id, dataset_operators_only=True
|
||||
)
|
||||
return dump_response(AccountWithRoleListResponse, {"accounts": members}), HTTPStatus.OK
|
||||
|
||||
|
||||
@@ -421,37 +276,17 @@ class SendOwnerTransferEmailApi(Resource):
|
||||
|
||||
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_allow_transfer_owner
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = OwnerTransferEmailPayload.model_validate(payload)
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
# 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()):
|
||||
raise NotOwnerError()
|
||||
|
||||
if args.language is not None and args.language == "zh-Hans":
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
language = "en-US"
|
||||
|
||||
email = current_user.email
|
||||
|
||||
token = AccountService.send_owner_transfer_email(
|
||||
account=current_user,
|
||||
email=email,
|
||||
language=language,
|
||||
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
|
||||
)
|
||||
|
||||
@console_account_admission(require_owner_transfer_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
args = OwnerTransferEmailPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
token = application_services().workspaces.owner_transfer.send_code(
|
||||
request_context,
|
||||
ip_address=extract_remote_ip(request),
|
||||
language=args.language,
|
||||
)
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
_raise_owner_transfer_error(error)
|
||||
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -459,107 +294,73 @@ class SendOwnerTransferEmailApi(Resource):
|
||||
class OwnerTransferCheckApi(Resource):
|
||||
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_allow_transfer_owner
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = OwnerTransferCheckPayload.model_validate(payload)
|
||||
# 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()):
|
||||
raise NotOwnerError()
|
||||
|
||||
user_email = current_user.email
|
||||
|
||||
is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(user_email)
|
||||
if is_owner_transfer_error_rate_limit:
|
||||
raise OwnerTransferLimitError()
|
||||
|
||||
token_data = AccountService.get_owner_transfer_data(args.token)
|
||||
if token_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
if user_email != token_data.get("email"):
|
||||
raise InvalidEmailError()
|
||||
|
||||
if args.code != token_data.get("code"):
|
||||
AccountService.add_owner_transfer_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_owner_transfer_token(args.token)
|
||||
|
||||
# Refresh token data by generating a new token
|
||||
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={})
|
||||
|
||||
AccountService.reset_owner_transfer_error_rate_limit(user_email)
|
||||
return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json")
|
||||
@console_account_admission(require_owner_transfer_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
args = OwnerTransferCheckPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
email, token = application_services().workspaces.owner_transfer.verify_code(
|
||||
request_context, token=args.token, code=args.code
|
||||
)
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
_raise_owner_transfer_error(error)
|
||||
return VerificationTokenResponse(is_valid=True, email=email, token=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(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_allow_transfer_owner
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, member_id: UUID):
|
||||
payload = console_ns.payload or {}
|
||||
args = OwnerTransferPayload.model_validate(payload)
|
||||
|
||||
# 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()):
|
||||
raise NotOwnerError()
|
||||
|
||||
if current_user.id == str(member_id):
|
||||
raise CannotTransferOwnerToSelfError()
|
||||
|
||||
transfer_token_data = AccountService.get_owner_transfer_data(args.token)
|
||||
if not transfer_token_data:
|
||||
raise InvalidTokenError()
|
||||
|
||||
if transfer_token_data.get("email") != current_user.email:
|
||||
raise InvalidEmailError()
|
||||
|
||||
AccountService.revoke_owner_transfer_token(args.token)
|
||||
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if not member:
|
||||
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()):
|
||||
raise MemberNotInTenantError()
|
||||
|
||||
@console_account_admission(require_owner_transfer_enabled=True)
|
||||
def post(self, request_context: RequestContext, member_id: UUID):
|
||||
args = OwnerTransferPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(
|
||||
current_user.current_tenant, member, "owner", current_user, session=db.session()
|
||||
application_services().workspaces.owner_transfer.transfer(
|
||||
request_context, member_id=str(member_id), token=args.token
|
||||
)
|
||||
|
||||
AccountService.send_new_owner_transfer_notify_email(
|
||||
account=member,
|
||||
email=member.email,
|
||||
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
|
||||
)
|
||||
|
||||
AccountService.send_old_owner_transfer_notify_email(
|
||||
account=current_user,
|
||||
email=current_user.email,
|
||||
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
|
||||
new_owner_email=member.email,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
|
||||
except (WorkspaceApplicationError, AccountNotFoundError, NoPermissionError) as error:
|
||||
_raise_owner_transfer_error(error)
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
|
||||
|
||||
def _member_mutation_error_response(
|
||||
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
|
||||
) -> tuple[dict[str, str], HTTPStatus]:
|
||||
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
|
||||
raise NotFound() from error
|
||||
if isinstance(error, InvalidWorkspaceMemberRoleError):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
|
||||
if isinstance(error, workspace_errors.CannotOperateSelfError):
|
||||
return {"code": "cannot-operate-self", "message": str(error)}, HTTPStatus.BAD_REQUEST
|
||||
if isinstance(error, NoPermissionError):
|
||||
return {"code": "forbidden", "message": str(error)}, HTTPStatus.FORBIDDEN
|
||||
if isinstance(error, workspace_errors.MemberNotInTenantError):
|
||||
return {"code": "member-not-found", "message": str(error)}, HTTPStatus.NOT_FOUND
|
||||
if isinstance(error, workspace_errors.RoleAlreadyAssignedError):
|
||||
return {"code": "role-already-assigned", "message": str(error)}, HTTPStatus.BAD_REQUEST
|
||||
raise error
|
||||
|
||||
|
||||
def _raise_owner_transfer_error(
|
||||
error: WorkspaceApplicationError | AccountNotFoundError | NoPermissionError,
|
||||
) -> NoReturn:
|
||||
if isinstance(error, NoPermissionError):
|
||||
raise NotOwnerError() from error
|
||||
if isinstance(error, (AccountNotFoundError, workspace_errors.WorkspaceNotFoundError)):
|
||||
raise NotFound() from error
|
||||
if isinstance(error, workspace_errors.OwnerTransferSendIPLimitedError):
|
||||
raise EmailSendIpLimitError() from error
|
||||
if isinstance(error, workspace_errors.OwnerTransferSendRateLimitError):
|
||||
raise OwnerTransferRateLimitExceededError(error.retry_after_minutes) from error
|
||||
if isinstance(error, workspace_errors.OwnerTransferVerificationLimitError):
|
||||
raise OwnerTransferLimitError() from error
|
||||
if isinstance(error, workspace_errors.InvalidOwnerTransferTokenError):
|
||||
raise InvalidTokenError() from error
|
||||
if isinstance(error, workspace_errors.InvalidOwnerTransferEmailError):
|
||||
raise InvalidEmailError() from error
|
||||
if isinstance(error, workspace_errors.InvalidOwnerTransferCodeError):
|
||||
raise EmailCodeError() from error
|
||||
if isinstance(error, workspace_errors.CannotOperateSelfError):
|
||||
raise CannotTransferOwnerToSelfError() from error
|
||||
if isinstance(error, workspace_errors.MemberNotInTenantError):
|
||||
raise MemberNotInTenantError() from error
|
||||
raise error
|
||||
|
||||
@@ -4,12 +4,10 @@ from typing import Any, Literal
|
||||
from flask import send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse, ValidationResultResponse
|
||||
from controllers.common.rbac import RBACCheck, Workspace
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -21,6 +19,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -33,8 +32,7 @@ from services.entities.model_provider_entities import (
|
||||
ModelProviderSummaryResponse,
|
||||
ProviderResponse,
|
||||
)
|
||||
from services.model_provider_service import ModelProviderService
|
||||
from services.workspace_service import WorkspaceService
|
||||
from services.model_provider.service import ModelProviderService
|
||||
|
||||
|
||||
class ParserModelList(BaseModel):
|
||||
@@ -191,9 +189,8 @@ class ModelProviderCreditsApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
credit_pool = WorkspaceService.get_effective_credit_pool(tenant_id, session=session)
|
||||
def get(self, tenant_id: str):
|
||||
credit_pool = application_services().workspaces.management.get_effective_credit_pool(tenant_id)
|
||||
return dump_response(ModelProviderCreditsResponse, credit_pool)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from core.entities.model_entities import DefaultModelSetting
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
|
||||
@@ -38,7 +39,7 @@ from services.entities.model_provider_entities import (
|
||||
ProviderWithModelsResponse,
|
||||
)
|
||||
from services.model_load_balancing_service import ModelLoadBalancingService
|
||||
from services.model_provider_service import ModelProviderService
|
||||
from services.model_provider.service import ModelProviderService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -376,6 +377,7 @@ class ModelProviderModelCredentialApi(Resource):
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
)
|
||||
else:
|
||||
available_credentials = model_provider_service.get_provider_model_available_credentials(
|
||||
|
||||
@@ -62,6 +62,7 @@ from core.tools.entities.tool_entities import (
|
||||
WorkflowToolParameterConfiguration,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import alphanumeric, dump_response, uuid_value
|
||||
@@ -664,9 +665,9 @@ class ToolBuiltinProviderGetCredentialsApi(Resource):
|
||||
BuiltinToolManageService.get_builtin_tool_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
provider_name=provider,
|
||||
session=db.session(),
|
||||
user=user,
|
||||
include_credential_ids=query.include_credential_ids or None,
|
||||
credential_query=application_services().credential_queries,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1363,9 +1364,9 @@ class ToolBuiltinProviderGetCredentialInfoApi(Resource):
|
||||
BuiltinToolManageService.get_builtin_tool_provider_credential_info(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
session=db.session(),
|
||||
user=user,
|
||||
include_credential_ids=query.include_credential_ids or None,
|
||||
credential_query=application_services().credential_queries,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from core.trigger.entities.api_entities import (
|
||||
)
|
||||
from core.trigger.entities.entities import RequestLog, SubscriptionBuilderUpdater
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
@@ -213,6 +214,7 @@ class TriggerSubscriptionListApi(Resource):
|
||||
tenant_id=tenant_id,
|
||||
provider_id=TriggerProviderID(provider),
|
||||
user=user,
|
||||
credential_query=application_services().credential_queries,
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.errors import (
|
||||
FilenameNotExistsError,
|
||||
FileTooLargeError,
|
||||
@@ -24,35 +19,19 @@ from controllers.common.schema import (
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.admin import admin_required
|
||||
from controllers.console.error import AccountNotLinkTenantError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.flask_admission import console_account_admission, console_admin_admission
|
||||
from controllers.console.workspace.error import CurrentWorkspaceArchivedError
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_resource_check,
|
||||
only_edition_enterprise,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from enums import CloudPlan
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from enums.account import TenantAccountRole
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from libs.pagination import paginate_query
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, Tenant, TenantAccountRole, TenantCustomConfigDict, TenantStatus
|
||||
from services.account_service import TenantService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.file_service import FileService
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from services.errors import file as file_errors
|
||||
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError, WorkspaceNotLinkedError
|
||||
from services.workspace.contracts import WorkspaceCustomConfigChanges
|
||||
|
||||
|
||||
class WorkspaceListQuery(BaseModel):
|
||||
@@ -231,7 +210,7 @@ class TenantListApi(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantListResponse.__name__])
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
workspaces = application_services().workspace_queries.list_for_account(request_context)
|
||||
workspaces = application_services().workspaces.queries.list_for_account(request_context)
|
||||
return dump_response(TenantListResponse, {"workspaces": workspaces}), HTTPStatus.OK
|
||||
|
||||
|
||||
@@ -239,207 +218,108 @@ class TenantListApi(Resource):
|
||||
class WorkspaceListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(WorkspaceListQuery))
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePaginationResponse.__name__])
|
||||
@setup_required
|
||||
@admin_required
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session):
|
||||
@console_admin_admission
|
||||
def get(self):
|
||||
args = query_params_from_request(WorkspaceListQuery)
|
||||
|
||||
stmt = select(Tenant).order_by(Tenant.created_at.desc())
|
||||
tenants = paginate_query(stmt, session=session, page=args.page, per_page=args.limit)
|
||||
has_more = False
|
||||
|
||||
if tenants.has_next:
|
||||
has_more = True
|
||||
|
||||
return WorkspacePaginationResponse(
|
||||
data=tenants.items, has_more=has_more, limit=args.limit, page=args.page, total=tenants.total or 0
|
||||
).model_dump(mode="json"), HTTPStatus.OK
|
||||
result = application_services().workspaces.management.list_all(page=args.page, limit=args.limit)
|
||||
return dump_response(WorkspacePaginationResponse, result), HTTPStatus.OK
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/summary")
|
||||
class CurrentWorkspaceSummaryApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Success",
|
||||
console_ns.models[CurrentWorkspaceSummaryResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[CurrentWorkspaceSummaryResponse.__name__])
|
||||
@console_ns.response(HTTPStatus.CONFLICT, "Current workspace is archived")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_user: Account):
|
||||
tenant = current_user.current_tenant
|
||||
if not tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if tenant.status == TenantStatus.ARCHIVE:
|
||||
raise CurrentWorkspaceArchivedError()
|
||||
|
||||
return (
|
||||
dump_response(
|
||||
CurrentWorkspaceSummaryResponse,
|
||||
WorkspaceService.get_current_workspace_summary(tenant, current_user.id, session=session),
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
@console_account_admission()
|
||||
def get(self, context: RequestContext):
|
||||
try:
|
||||
summary = application_services().workspaces.management.current_summary(context)
|
||||
except WorkspaceArchivedError as exc:
|
||||
raise CurrentWorkspaceArchivedError() from exc
|
||||
except WorkspaceNotFoundError as exc:
|
||||
raise NotFound() from exc
|
||||
return dump_response(CurrentWorkspaceSummaryResponse, summary), HTTPStatus.OK
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/switch")
|
||||
class SwitchWorkspaceApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SwitchWorkspacePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
def post(self, session: Session, current_user: Account):
|
||||
payload = console_ns.payload or {}
|
||||
args = SwitchWorkspacePayload.model_validate(payload)
|
||||
|
||||
# Check whether the tenant_id belongs to the current account.
|
||||
@console_account_admission()
|
||||
def post(self, context: RequestContext):
|
||||
args = SwitchWorkspacePayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
TenantService.switch_tenant(current_user, args.tenant_id, session=session)
|
||||
except Exception as e:
|
||||
raise AccountNotLinkTenantError("Account not link tenant") from e
|
||||
|
||||
new_tenant = TenantService.get_tenant_by_id(args.tenant_id, session=session)
|
||||
if new_tenant is None:
|
||||
raise ValueError("Tenant not found")
|
||||
|
||||
return SwitchWorkspaceResponse(
|
||||
result="success", new_tenant=WorkspaceService.get_tenant_info(new_tenant, session=session)
|
||||
).model_dump(mode="json")
|
||||
tenant = application_services().workspaces.management.switch(context, args.tenant_id)
|
||||
except WorkspaceNotLinkedError as exc:
|
||||
raise AccountNotLinkTenantError("Account not link tenant") from exc
|
||||
except WorkspaceNotFoundError as exc:
|
||||
raise NotFound() from exc
|
||||
return dump_response(SwitchWorkspaceResponse, {"result": "success", "new_tenant": tenant})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/custom-config")
|
||||
class CustomConfigWorkspaceApi(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceCustomConfigResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str):
|
||||
tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session)
|
||||
if tenant is None:
|
||||
raise NotFound()
|
||||
|
||||
custom_config = tenant.custom_config_dict
|
||||
replace_webapp_logo = (
|
||||
f"{dify_config.FILES_URL}/files/workspaces/{tenant.id}/webapp-logo"
|
||||
if custom_config.get("replace_webapp_logo")
|
||||
else None
|
||||
)
|
||||
return dump_response(
|
||||
WorkspaceCustomConfigResponse,
|
||||
{
|
||||
"remove_webapp_brand": custom_config.get("remove_webapp_brand", False),
|
||||
"replace_webapp_logo": replace_webapp_logo,
|
||||
},
|
||||
)
|
||||
@console_account_admission()
|
||||
def get(self, context: RequestContext):
|
||||
try:
|
||||
config = application_services().workspaces.management.custom_config(context)
|
||||
except WorkspaceNotFoundError as exc:
|
||||
raise NotFound() from exc
|
||||
return dump_response(WorkspaceCustomConfigResponse, config)
|
||||
|
||||
@console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("workspace_custom")
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str):
|
||||
payload = console_ns.payload or {}
|
||||
args = WorkspaceCustomConfigPayload.model_validate(payload)
|
||||
tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session)
|
||||
if tenant is None:
|
||||
raise NotFound()
|
||||
|
||||
custom_config_dict: TenantCustomConfigDict = {
|
||||
"remove_webapp_brand": args.remove_webapp_brand
|
||||
if args.remove_webapp_brand is not None
|
||||
else tenant.custom_config_dict.get("remove_webapp_brand", False),
|
||||
"replace_webapp_logo": args.replace_webapp_logo
|
||||
if args.replace_webapp_logo is not None
|
||||
else tenant.custom_config_dict.get("replace_webapp_logo"),
|
||||
}
|
||||
|
||||
tenant.custom_config_dict = custom_config_dict
|
||||
session.commit()
|
||||
|
||||
return WorkspaceTenantResultResponse(
|
||||
result="success", tenant=WorkspaceService.get_tenant_info(tenant, session=session)
|
||||
).model_dump(mode="json")
|
||||
@console_account_admission(billing_resource="workspace_custom")
|
||||
def post(self, context: RequestContext):
|
||||
args = WorkspaceCustomConfigPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
tenant = application_services().workspaces.management.update_custom_config(
|
||||
context,
|
||||
WorkspaceCustomConfigChanges(
|
||||
remove_webapp_brand=args.remove_webapp_brand, replace_webapp_logo=args.replace_webapp_logo
|
||||
),
|
||||
)
|
||||
except WorkspaceNotFoundError as exc:
|
||||
raise NotFound() from exc
|
||||
return dump_response(WorkspaceTenantResultResponse, {"result": "success", "tenant": tenant})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/custom-config/webapp-logo/upload")
|
||||
class WebappLogoWorkspaceApi(Resource):
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params=WORKSPACE_LOGO_UPLOAD_PARAMS)
|
||||
@console_ns.response(HTTPStatus.CREATED, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("workspace_custom")
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
# check file
|
||||
@console_account_admission(billing_resource="workspace_custom")
|
||||
def post(self, context: RequestContext):
|
||||
if "file" not in request.files:
|
||||
raise NoFileUploadedError()
|
||||
|
||||
if len(request.files) > 1:
|
||||
raise TooManyFilesError()
|
||||
|
||||
# get file from request
|
||||
file = request.files["file"]
|
||||
if not file.filename:
|
||||
raise FilenameNotExistsError
|
||||
|
||||
extension = file.filename.split(".")[-1]
|
||||
if extension.lower() not in {"svg", "png"}:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
raise FilenameNotExistsError()
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
content=file.stream.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
file_id = application_services().workspaces.management.upload_logo(
|
||||
context, filename=file.filename, content=file.stream.read(), mimetype=file.mimetype
|
||||
)
|
||||
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
return WorkspaceLogoUploadResponse(id=upload_file.id).model_dump(mode="json"), HTTPStatus.CREATED
|
||||
except file_errors.FileTooLargeError as exc:
|
||||
raise FileTooLargeError(exc.description) from exc
|
||||
except file_errors.UnsupportedFileTypeError as exc:
|
||||
raise UnsupportedFileTypeError() from exc
|
||||
return dump_response(WorkspaceLogoUploadResponse, {"id": file_id}), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/info")
|
||||
class WorkspaceInfoApi(Resource):
|
||||
@console_ns.expect(console_ns.models[WorkspaceInfoPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
# Change workspace name
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str):
|
||||
payload = console_ns.payload or {}
|
||||
args = WorkspaceInfoPayload.model_validate(payload)
|
||||
|
||||
if not current_tenant_id:
|
||||
raise ValueError("No current tenant")
|
||||
tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session)
|
||||
if tenant is None:
|
||||
raise NotFound()
|
||||
tenant.name = args.name
|
||||
session.commit()
|
||||
|
||||
return WorkspaceTenantResultResponse(
|
||||
result="success", tenant=WorkspaceService.get_tenant_info(tenant, session=session)
|
||||
).model_dump(mode="json")
|
||||
@console_account_admission()
|
||||
def post(self, context: RequestContext):
|
||||
args = WorkspaceInfoPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
tenant = application_services().workspaces.management.rename(context, args.name)
|
||||
except WorkspaceNotFoundError as exc:
|
||||
raise NotFound() from exc
|
||||
return dump_response(WorkspaceTenantResultResponse, {"result": "success", "tenant": tenant})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/permission")
|
||||
@@ -447,24 +327,7 @@ class WorkspacePermissionApi(Resource):
|
||||
"""Get workspace permissions for the current workspace."""
|
||||
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_enterprise
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
"""
|
||||
Get workspace permission settings.
|
||||
Returns permission flags that control workspace features like member invitations and owner transfer.
|
||||
"""
|
||||
if not current_tenant_id:
|
||||
raise ValueError("No current tenant")
|
||||
|
||||
# Get workspace permissions from enterprise service
|
||||
permission = EnterpriseService.WorkspacePermissionService.get_permission(current_tenant_id)
|
||||
|
||||
return WorkspacePermissionResponse(
|
||||
workspace_id=permission.workspace_id,
|
||||
allow_member_invite=permission.allow_member_invite,
|
||||
allow_owner_transfer=permission.allow_owner_transfer,
|
||||
).model_dump(mode="json"), HTTPStatus.OK
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.ENTERPRISE}))
|
||||
def get(self, context: RequestContext):
|
||||
permission = application_services().workspaces.management.permission(context)
|
||||
return dump_response(WorkspacePermissionResponse, permission), HTTPStatus.OK
|
||||
|
||||
@@ -17,9 +17,9 @@ from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from core.plugin.entities.request import RequestDownloadFileMapping, RequestRequestUploadFile
|
||||
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.account_service import TenantService
|
||||
from services.file_request_service import FileRequestService
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class AgentFileUploadRequestApi(Resource):
|
||||
status_code=400,
|
||||
) from exc
|
||||
|
||||
tenant = TenantService.get_tenant_by_id(payload.tenant_id, session=session)
|
||||
tenant = application_services().workspaces.management.get(payload.tenant_id)
|
||||
if tenant is None:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="tenant_not_found",
|
||||
@@ -116,7 +116,7 @@ class AgentFileUploadRequestApi(Resource):
|
||||
)
|
||||
try:
|
||||
if payload.user_from == "account":
|
||||
if not TenantService.account_belongs_to_tenant(payload.user_id, tenant.id, session=session):
|
||||
if not application_services().workspaces.members.get_role(tenant.id, payload.user_id):
|
||||
raise ValueError("account not found")
|
||||
owner_id = payload.user_id
|
||||
else:
|
||||
@@ -164,7 +164,7 @@ class AgentFileDownloadRequestApi(Resource):
|
||||
status_code=400,
|
||||
) from exc
|
||||
|
||||
if TenantService.get_tenant_by_id(payload.tenant_id, session=session) is None:
|
||||
if application_services().workspaces.management.get(payload.tenant_id) is None:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="tenant_not_found",
|
||||
description="tenant not found",
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import json
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console.wraps import model_validate, setup_required
|
||||
from controllers.console.wraps import setup_required, validate_request
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from events.tenant_event import tenant_was_created
|
||||
from extensions.ext_database import db
|
||||
from models import Account
|
||||
from models.account import TenantAccountRole
|
||||
from services.account_service import (
|
||||
EnterpriseWorkspaceMemberAccountNotFoundError,
|
||||
EnterpriseWorkspaceMemberWorkspaceNotFoundError,
|
||||
TenantService,
|
||||
from controllers.inner_api.wraps import inner_api_only
|
||||
from enums.account import TenantAccountRole
|
||||
from extensions.ext_application_services import application_services
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.errors.workspace import (
|
||||
InvalidWorkspaceMemberRoleError,
|
||||
WorkspaceNotFoundError,
|
||||
WorkspaceOwnerNotFoundError,
|
||||
WorkspacesLimitExceededError,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +41,7 @@ register_schema_models(inner_api_ns, WorkspaceCreatePayload, WorkspaceOwnerlessP
|
||||
@inner_api_ns.route("/enterprise/workspace")
|
||||
class EnterpriseWorkspace(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_only
|
||||
@inner_api_ns.doc("create_enterprise_workspace")
|
||||
@inner_api_ns.doc(description="Create a new enterprise workspace with owner assignment")
|
||||
@inner_api_ns.expect(inner_api_ns.models[WorkspaceCreatePayload.__name__])
|
||||
@@ -54,38 +52,31 @@ class EnterpriseWorkspace(Resource):
|
||||
404: "Owner account not found or service not available",
|
||||
}
|
||||
)
|
||||
@model_validate(WorkspaceCreatePayload)
|
||||
def post(self, args: WorkspaceCreatePayload):
|
||||
account = db.session.scalar(select(Account).where(Account.email == args.owner_email).limit(1))
|
||||
if account is None:
|
||||
def post(self):
|
||||
args = validate_request(WorkspaceCreatePayload)
|
||||
try:
|
||||
tenant = application_services().workspaces.provisioning.create(name=args.name, owner_email=args.owner_email)
|
||||
except WorkspaceOwnerNotFoundError:
|
||||
return {"message": "owner account not found."}, 404
|
||||
|
||||
tenant = TenantService.create_owner_tenant(
|
||||
account,
|
||||
name=args.name,
|
||||
is_from_dashboard=True,
|
||||
session=db.session(),
|
||||
)
|
||||
|
||||
resp = {
|
||||
"id": tenant.id,
|
||||
"name": tenant.name,
|
||||
"plan": tenant.plan,
|
||||
"status": tenant.status,
|
||||
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
|
||||
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
|
||||
}
|
||||
|
||||
except WorkspacesLimitExceededError as exc:
|
||||
raise InvalidArgumentError(str(exc)) from exc
|
||||
return {
|
||||
"message": "enterprise workspace created.",
|
||||
"tenant": resp,
|
||||
"tenant": {
|
||||
"id": tenant.id,
|
||||
"name": tenant.name,
|
||||
"plan": tenant.plan,
|
||||
"status": tenant.status,
|
||||
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
|
||||
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/ownerless")
|
||||
class EnterpriseWorkspaceNoOwnerEmail(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_only
|
||||
@inner_api_ns.doc("create_enterprise_workspace_ownerless")
|
||||
@inner_api_ns.doc(description="Create a new enterprise workspace without initial owner assignment")
|
||||
@inner_api_ns.expect(inner_api_ns.models[WorkspaceOwnerlessPayload.__name__])
|
||||
@@ -96,33 +87,28 @@ class EnterpriseWorkspaceNoOwnerEmail(Resource):
|
||||
404: "Service not available",
|
||||
}
|
||||
)
|
||||
@model_validate(WorkspaceOwnerlessPayload)
|
||||
def post(self, args: WorkspaceOwnerlessPayload):
|
||||
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True, session=db.session())
|
||||
|
||||
tenant_was_created.send(tenant)
|
||||
|
||||
resp = {
|
||||
"id": tenant.id,
|
||||
"name": tenant.name,
|
||||
"encrypt_public_key": tenant.encrypt_public_key,
|
||||
"plan": tenant.plan,
|
||||
"status": tenant.status,
|
||||
"custom_config": json.loads(tenant.custom_config) if tenant.custom_config else {},
|
||||
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
|
||||
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
|
||||
}
|
||||
|
||||
def post(self):
|
||||
args = validate_request(WorkspaceOwnerlessPayload)
|
||||
tenant = application_services().workspaces.provisioning.create(name=args.name)
|
||||
return {
|
||||
"message": "enterprise workspace created.",
|
||||
"tenant": resp,
|
||||
"tenant": {
|
||||
"id": tenant.id,
|
||||
"name": tenant.name,
|
||||
"encrypt_public_key": tenant.encrypt_public_key,
|
||||
"plan": tenant.plan,
|
||||
"status": tenant.status,
|
||||
"custom_config": tenant.custom_config,
|
||||
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
|
||||
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/member")
|
||||
class EnterpriseWorkspaceMember(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_only
|
||||
@inner_api_ns.doc("join_enterprise_workspace_member")
|
||||
@inner_api_ns.doc(description="Add an existing account to an enterprise workspace")
|
||||
@inner_api_ns.expect(inner_api_ns.models[WorkspaceMemberPayload.__name__])
|
||||
@@ -134,33 +120,27 @@ class EnterpriseWorkspaceMember(Resource):
|
||||
404: "Workspace or account not found",
|
||||
}
|
||||
)
|
||||
@model_validate(WorkspaceMemberPayload)
|
||||
def post(self, args: WorkspaceMemberPayload):
|
||||
def post(self):
|
||||
args = validate_request(WorkspaceMemberPayload)
|
||||
try:
|
||||
role = TenantAccountRole(args.role)
|
||||
except ValueError:
|
||||
return {"message": "invalid workspace member role."}, 400
|
||||
if role == TenantAccountRole.OWNER:
|
||||
return {"message": "cannot join workspace as owner."}, 400
|
||||
|
||||
try:
|
||||
membership = TenantService.join_enterprise_workspace_member(
|
||||
member = application_services().workspaces.provisioning.join_member(
|
||||
workspace_id=args.workspace_id,
|
||||
account_id=args.account_id,
|
||||
email=args.email,
|
||||
role=role,
|
||||
role=args.role,
|
||||
operator_account_id=args.operator_account_id,
|
||||
)
|
||||
except EnterpriseWorkspaceMemberWorkspaceNotFoundError:
|
||||
except InvalidWorkspaceMemberRoleError as exc:
|
||||
return {"message": str(exc)}, 400
|
||||
except WorkspaceNotFoundError:
|
||||
return {"message": "workspace not found."}, 404
|
||||
except EnterpriseWorkspaceMemberAccountNotFoundError:
|
||||
except AccountNotFoundError:
|
||||
return {"message": "account not found."}, 404
|
||||
|
||||
return {
|
||||
"message": "enterprise workspace member joined.",
|
||||
"member": {
|
||||
"workspace_id": membership.tenant_id,
|
||||
"account_id": membership.account_id,
|
||||
"role": membership.role.value,
|
||||
"workspace_id": member.workspace_id,
|
||||
"account_id": member.account_id,
|
||||
"role": member.role,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model
|
||||
from controllers.mcp import mcp_ns
|
||||
@@ -13,8 +13,9 @@ from core.mcp.server.streamable_http import handle_mcp_request, negotiate_protoc
|
||||
from extensions.ext_database import db
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from libs import helper
|
||||
from models.enums import AppMCPServerStatus, EndUserType
|
||||
from models.enums import EndUserType
|
||||
from models.model import App, AppMCPServer, AppMode, EndUser
|
||||
from services.app.mcp_server_service import AppMCPServerStatus
|
||||
|
||||
|
||||
class MCPRequestError(Exception):
|
||||
@@ -82,7 +83,7 @@ class MCPAppApi(Resource):
|
||||
else:
|
||||
return self._protocol_version_error_response(request_id, header_value)
|
||||
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
# Get MCP server and app
|
||||
mcp_server, app = self._get_mcp_server_and_app(server_code, session)
|
||||
self._validate_server_status(mcp_server)
|
||||
@@ -91,9 +92,11 @@ class MCPAppApi(Resource):
|
||||
user_input_form = self._get_user_input_form(app, session)
|
||||
|
||||
# Handle notification vs request differently
|
||||
return self._process_mcp_message(
|
||||
response = self._process_mcp_message(
|
||||
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
def _protocol_version_error_response(
|
||||
self, request_id: Union[int, str] | None, header_value: str | None
|
||||
@@ -128,7 +131,7 @@ class MCPAppApi(Resource):
|
||||
|
||||
def _validate_server_status(self, mcp_server: AppMCPServer):
|
||||
"""Validate MCP server status"""
|
||||
if mcp_server.status != AppMCPServerStatus.ACTIVE:
|
||||
if mcp_server.status != AppMCPServerStatus.ACTIVE.value:
|
||||
raise MCPRequestError(mcp_types.INVALID_REQUEST, "Server is not active")
|
||||
|
||||
def _process_mcp_message(
|
||||
@@ -192,8 +195,7 @@ class MCPAppApi(Resource):
|
||||
app_model_config = app.app_model_config_with_session(session=session)
|
||||
if not app_model_config:
|
||||
raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable")
|
||||
features_dict = app_model_config.to_dict()
|
||||
raw_user_input_form = features_dict.get("user_input_form", [])
|
||||
raw_user_input_form = app_model_config.user_input_form_list
|
||||
|
||||
# Convert to VariableEntity objects
|
||||
try:
|
||||
@@ -237,16 +239,15 @@ class MCPAppApi(Resource):
|
||||
except ValidationError as e:
|
||||
raise MCPRequestError(mcp_types.INVALID_PARAMS, f"Invalid MCP request: {str(e)}")
|
||||
|
||||
def _retrieve_end_user(self, tenant_id: str, mcp_server_id: str) -> EndUser | None:
|
||||
"""Get end user - manages its own database session"""
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
return session.scalar(
|
||||
select(EndUser)
|
||||
.where(
|
||||
EndUser.tenant_id == tenant_id, EndUser.session_id == mcp_server_id, EndUser.type == EndUserType.MCP
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
def _retrieve_end_user(self, tenant_id: str, mcp_server_id: str, session: Session) -> EndUser | None:
|
||||
"""Get end user in the caller session"""
|
||||
return session.scalar(
|
||||
select(EndUser)
|
||||
.where(EndUser.tenant_id == tenant_id)
|
||||
.where(EndUser.session_id == mcp_server_id)
|
||||
.where(EndUser.type == EndUserType.MCP)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
def _create_end_user(
|
||||
self, client_name: str, tenant_id: str, app_id: str, mcp_server_id: str, session: Session
|
||||
@@ -275,7 +276,7 @@ class MCPAppApi(Resource):
|
||||
protocol_version: str,
|
||||
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError | None:
|
||||
"""Handle MCP request and return response"""
|
||||
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id)
|
||||
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id, session)
|
||||
|
||||
if not end_user and isinstance(mcp_request.root, mcp_types.InitializeRequest):
|
||||
client_info = mcp_request.root.params.clientInfo
|
||||
|
||||
@@ -67,7 +67,7 @@ def _mapping(part: FileStorage, caller: Any) -> FileMapping:
|
||||
uploaded = upload(part, caller)
|
||||
return {
|
||||
"transfer_method": FileTransferMethod.LOCAL_FILE,
|
||||
"upload_file_id": str(uploaded.id),
|
||||
"upload_file_id": uploaded.id,
|
||||
"type": standardize_file_type(extension="." + uploaded.extension, mime_type=uploaded.mime_type),
|
||||
}
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ class RunPayloadBase(BaseModel):
|
||||
inputs: dict[str, Any] = Field(
|
||||
description=(
|
||||
"Variables declared by the app. The exact shape is per app: read `input_schema` from "
|
||||
"console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, "
|
||||
"describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, "
|
||||
"or a local file in `files`, not both."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ from services.entities.account_entities import AccountSnapshot
|
||||
@openapi_ns.route("/account")
|
||||
class AccountApi(Resource):
|
||||
@endpoint(
|
||||
op="account.get",
|
||||
op="describe.account",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Current account",
|
||||
examples=(Example(title="Show the logged-in account and its workspaces", input={}),),
|
||||
@@ -61,7 +61,7 @@ class AccountApi(Resource):
|
||||
@openapi_ns.route("/account/sessions/self")
|
||||
class AccountSessionsSelfApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.revoke_current",
|
||||
op="delete.account.current_session",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Revoke the session behind this token",
|
||||
examples=(Example(title="Log out the session behind this token", input={}),),
|
||||
@@ -76,7 +76,7 @@ class AccountSessionsSelfApi(Resource):
|
||||
@openapi_ns.route("/account/sessions")
|
||||
class AccountSessionsApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.list",
|
||||
op="get.account.session",
|
||||
kind=Kind.LIST,
|
||||
summary="List login sessions of the current account",
|
||||
examples=(Example(title="List login sessions, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -102,7 +102,7 @@ class AccountSessionsApi(Resource):
|
||||
@openapi_ns.route("/account/sessions/<string:session_id>")
|
||||
class AccountSessionByIdApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.revoke",
|
||||
op="delete.account.session",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Revoke one login session by id",
|
||||
examples=(Example(title="Log out one device by session id", input={"session_id": "<session_id>"}),),
|
||||
|
||||
@@ -33,8 +33,8 @@ from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.errors.base import NoPermissionError
|
||||
|
||||
|
||||
def _import_response(result: Import, *, workspace_id: str) -> AppDslImportResponse:
|
||||
@@ -66,7 +66,7 @@ class AppDslImportApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dsl.import",
|
||||
op="import.console_app.dsl",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Import an app from DSL text or URL",
|
||||
examples=(
|
||||
@@ -129,7 +129,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dsl.import_confirm",
|
||||
op="confirm.console_app.dsl_import",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Confirm a pending DSL import",
|
||||
examples=(Example(title="Confirm an import that is pending confirmation", input={"import_id": "<import_id>"}),),
|
||||
@@ -167,7 +167,7 @@ class AppDslExportApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="console_app.dsl.export",
|
||||
op="export.console_app.dsl",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Export app DSL as YAML text inside a JSON object",
|
||||
examples=(
|
||||
@@ -213,7 +213,7 @@ class AppDslCheckDependenciesApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dependencies.check",
|
||||
op="check.console_app.dependency",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Check plugin dependencies of an app",
|
||||
examples=(Example(title="Check which plugins an app needs", input={"app_id": "<app_id>"}),),
|
||||
|
||||
@@ -237,7 +237,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="WorkflowRunApi",
|
||||
segment="workflow",
|
||||
op="console_app.workflow.run",
|
||||
op="run.console_app.workflow",
|
||||
summary="Run a workflow app; streams workflow events",
|
||||
payload=WorkflowRunPayload,
|
||||
modes=(AppMode.WORKFLOW,),
|
||||
@@ -260,7 +260,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="ChatRunApi",
|
||||
segment="chat",
|
||||
op="console_app.chat.run",
|
||||
op="run.console_app.chat",
|
||||
summary="Run a chat or agent app; streams message events",
|
||||
payload=ChatRunPayload,
|
||||
modes=(AppMode.CHAT, AppMode.AGENT_CHAT),
|
||||
@@ -293,7 +293,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="AdvancedChatRunApi",
|
||||
segment="advanced-chat",
|
||||
op="console_app.advanced_chat.run",
|
||||
op="run.console_app.advanced_chat",
|
||||
summary="Run an advanced-chat (chatflow) app; streams message and workflow events",
|
||||
payload=AdvancedChatRunPayload,
|
||||
modes=(AppMode.ADVANCED_CHAT,),
|
||||
@@ -317,7 +317,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="CompletionRunApi",
|
||||
segment="completion",
|
||||
op="console_app.completion.run",
|
||||
op="run.console_app.completion",
|
||||
summary="Run a completion app; streams message events",
|
||||
payload=CompletionRunPayload,
|
||||
modes=(AppMode.COMPLETION,),
|
||||
@@ -362,7 +362,7 @@ WorkflowRunApi, ChatRunApi, AdvancedChatRunApi, CompletionRunApi = (_run_api(rou
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>:stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@endpoint(
|
||||
op="run.stop",
|
||||
op="stop.run",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Stop a running task",
|
||||
examples=(Example(title="Stop a running task", input={"app_id": "<app_id>", "task_id": "<task_id>"}),),
|
||||
|
||||
@@ -40,7 +40,6 @@ from extensions.ext_application_services import application_services
|
||||
from models import App
|
||||
from models.enums import AppStatus
|
||||
from models.model import AppMode
|
||||
from services.account_service import TenantService
|
||||
from services.app.access import AppAccessFilter, resolve_app_access_filter
|
||||
from services.entities.app_entities import AppListParams, AppSummary
|
||||
|
||||
@@ -105,7 +104,7 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S
|
||||
@openapi_ns.route("/apps/<string:app_id>")
|
||||
class AppDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.describe",
|
||||
op="describe.console_app",
|
||||
kind=Kind.OBJECT,
|
||||
summary="App detail, parameters and runtime input_schema",
|
||||
examples=(
|
||||
@@ -133,7 +132,7 @@ class AppDescribeApi(Resource):
|
||||
@openapi_ns.route("/apps")
|
||||
class AppListApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.list",
|
||||
op="get.console_app",
|
||||
kind=Kind.LIST,
|
||||
summary="List apps in a workspace",
|
||||
examples=(
|
||||
@@ -184,7 +183,8 @@ class AppListApi(Resource):
|
||||
str(app.id), str(app.maintainer) if app.maintainer else None, account_id
|
||||
):
|
||||
return empty
|
||||
tenant_name = TenantService.get_tenant_name(workspace_id, session=ctx.session)
|
||||
workspace = application_services().workspaces.management.get(workspace_id)
|
||||
tenant_name = workspace.name if workspace else None
|
||||
item = AppListRow(
|
||||
id=str(app.id),
|
||||
name=app.name,
|
||||
@@ -216,7 +216,8 @@ class AppListApi(Resource):
|
||||
|
||||
tenant_name = None
|
||||
if pagination.items:
|
||||
tenant_name = TenantService.get_tenant_name(workspace_id, session=ctx.session)
|
||||
workspace = application_services().workspaces.management.get(workspace_id)
|
||||
tenant_name = workspace.name if workspace else None
|
||||
|
||||
items = [
|
||||
AppListRow(
|
||||
|
||||
@@ -33,7 +33,6 @@ from controllers.openapi.auth.subjects import ExternalSsoSubject
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from models.enums import AppStatus
|
||||
from services.account_service import TenantService
|
||||
from services.enterprise.app_permitted_service import list_permitted_apps
|
||||
from services.entities.app_entities import AppSummary
|
||||
|
||||
@@ -43,7 +42,7 @@ _ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE})
|
||||
@openapi_ns.route("/permitted-external-apps")
|
||||
class PermittedExternalAppsListApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.external.list",
|
||||
op="get.console_app.external",
|
||||
kind=Kind.LIST,
|
||||
summary="List apps an external SSO subject may run",
|
||||
examples=(Example(title="List the apps this SSO subject may run, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -73,7 +72,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
str(a.id): a for a in application_services().apps.queries.find_visible_apps_by_ids(page_result.app_ids)
|
||||
}
|
||||
tenant_ids = list({str(a.tenant_id) for a in apps_by_id.values()})
|
||||
tenants_by_id = {str(t.id): t for t in TenantService.get_tenants_by_ids(tenant_ids, session=ctx.session)}
|
||||
tenants_by_id = {str(t.id): t for t in application_services().workspaces.management.get_many(tenant_ids)}
|
||||
|
||||
items: list[AppListRow] = []
|
||||
for app_id in page_result.app_ids:
|
||||
@@ -101,7 +100,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>")
|
||||
class PermittedExternalAppDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.external.describe",
|
||||
op="describe.console_app.external",
|
||||
kind=Kind.OBJECT,
|
||||
summary="External-subject app detail",
|
||||
examples=(Example(title="Describe a permitted app with its input_schema", input={"app_id": "<app_id>"}),),
|
||||
|
||||
@@ -17,6 +17,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, Tenant, TenantAccountRole
|
||||
from models.model import App, EndUser
|
||||
|
||||
@@ -39,11 +41,23 @@ class Context:
|
||||
subject: Subject
|
||||
session: Session
|
||||
view_args: Mapping[str, str]
|
||||
request_id: str = field(default_factory=get_request_id)
|
||||
trace_id: str | None = field(default_factory=get_trace_id)
|
||||
_app: App | None = field(default=None, init=False)
|
||||
_workspace: Tenant | None = field(default=None, init=False)
|
||||
_workspace_role: TenantAccountRole | None = field(default=None, init=False)
|
||||
_caller: Caller | None = field(default=None, init=False)
|
||||
|
||||
@property
|
||||
def request_context(self) -> RequestContext:
|
||||
"""Pass only stable identity values across the application boundary."""
|
||||
return RequestContext(
|
||||
request_id=self.request_id,
|
||||
trace_id=self.trace_id,
|
||||
account_id=str(self.subject.account_id),
|
||||
active_workspace_id=str(self.workspace.id),
|
||||
)
|
||||
|
||||
@property
|
||||
def app(self) -> App:
|
||||
return _loaded(self._app, "app")
|
||||
|
||||
@@ -7,10 +7,10 @@ from flask import request
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.openapi.auth.context import Caller, Context, RouteContractError
|
||||
from extensions.ext_application_services import application_services
|
||||
from models.account import Account, AccountStatus, Tenant, TenantAccountRole, TenantStatus
|
||||
from models.enums import AppStatus
|
||||
from models.model import App, EndUser
|
||||
from services.account_service import TenantService
|
||||
from services.app_service import AppService
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ def _fetch_workspace(ctx: Context) -> Tenant:
|
||||
|
||||
def _workspace_from_app(ctx: Context) -> Tenant:
|
||||
app = load_app(ctx)
|
||||
tenant = TenantService.get_tenant_by_id(app.tenant_id, session=ctx.session)
|
||||
tenant = application_services().workspaces.identity.get_workspace(str(app.tenant_id))
|
||||
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden("workspace unavailable")
|
||||
return tenant
|
||||
@@ -120,7 +120,7 @@ def _workspace_from_request(ctx: Context) -> Tenant:
|
||||
uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise NotFound("workspace not found")
|
||||
tenant = TenantService.get_tenant_by_id(workspace_id, session=ctx.session)
|
||||
tenant = application_services().workspaces.identity.get_workspace(workspace_id)
|
||||
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
|
||||
raise NotFound("workspace not found")
|
||||
return tenant
|
||||
@@ -131,7 +131,7 @@ def _fetch_workspace_role(ctx: Context) -> TenantAccountRole:
|
||||
caller = load_caller(ctx)
|
||||
if not isinstance(caller, Account) or caller.status != AccountStatus.ACTIVE:
|
||||
raise NotFound("workspace not found")
|
||||
role = TenantService.get_account_role_in_tenant(str(ctx.subject.account_id), workspace.id, session=ctx.session)
|
||||
role = application_services().workspaces.members.get_role(str(workspace.id), str(ctx.subject.account_id))
|
||||
if role is None:
|
||||
raise NotFound("workspace not found")
|
||||
return role
|
||||
|
||||
@@ -21,14 +21,17 @@ from configs import dify_config
|
||||
from constants.oauth_bearer import Scope
|
||||
from controllers.common.rbac import RBACCheck, enforce_rbac_checks
|
||||
from controllers.openapi._audit import emit_wrong_surface
|
||||
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from models.account import TenantAccountRole
|
||||
from models.enums import CreatorUserRole
|
||||
from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode
|
||||
from services.entities.feature_entities import LicenseStatus
|
||||
from services.errors.workspace import WorkspaceInvitationQuotaError, WorkspaceMemberLicenseQuotaError
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
|
||||
_DEAD_LICENSE_STATUSES = frozenset({LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST})
|
||||
@@ -116,6 +119,19 @@ class CheckScope(Requirement):
|
||||
raise Forbidden("insufficient_scope")
|
||||
|
||||
|
||||
class CheckWorkspaceInvitationQuota(Requirement):
|
||||
"""Run after workspace membership and permission checks, before the handler."""
|
||||
|
||||
@override
|
||||
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
|
||||
try:
|
||||
application_services().workspaces.invitations.check_invitation_quota(ctx.request_context)
|
||||
except WorkspaceMemberLicenseQuotaError as error:
|
||||
raise MemberLicenseExceeded() from error
|
||||
except WorkspaceInvitationQuotaError as error:
|
||||
raise MemberLimitExceeded() from error
|
||||
|
||||
|
||||
class CheckRBACPermission(Requirement):
|
||||
"""The same check bundles the console's `rbac_permission_required` takes.
|
||||
Inert wherever RBAC is off; a route that needs a check there declares a
|
||||
|
||||
@@ -14,6 +14,7 @@ from controllers.openapi.auth.requirements import assert_license_valid
|
||||
from controllers.openapi.auth.spec import EndpointSpec
|
||||
from controllers.openapi.auth.subjects import subject_from_auth
|
||||
from core.db.session_factory import session_factory
|
||||
from core.logging.context import get_trace_id
|
||||
from enums import DeploymentEdition
|
||||
from libs.oauth_bearer import InvalidBearerError, assert_bearer_feature_enabled, extract_bearer, get_authenticator
|
||||
|
||||
@@ -61,7 +62,12 @@ class AuthRouter:
|
||||
# Account-context endpoints materialize identity and release it in the
|
||||
# pipeline, before calling services that own their transactions.
|
||||
with session_factory.create_session() as session:
|
||||
ctx = Context(subject, session, dict(request.view_args or {}))
|
||||
ctx = Context(
|
||||
subject,
|
||||
session,
|
||||
dict(request.view_args or {}),
|
||||
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
|
||||
)
|
||||
try:
|
||||
result = pipeline.run(
|
||||
subject=subject,
|
||||
|
||||
@@ -21,7 +21,6 @@ from libs.oauth_bearer import AuthContext
|
||||
from models.account import Account
|
||||
from models.enums import CreatorUserRole, EndUserType
|
||||
from models.model import EndUser
|
||||
from services.account_service import AccountService
|
||||
from services.enterprise.enterprise_service import WebAppAccessMode
|
||||
|
||||
_SUBJECT_CLASSES: dict[SubjectType, type[Subject]] = {}
|
||||
@@ -87,7 +86,7 @@ class AccountSubject(Subject):
|
||||
|
||||
@override
|
||||
def resolve_caller(self, ctx: Context, session: Session) -> Account:
|
||||
account = AccountService.get_account_by_id(str(self.account_id), session=session)
|
||||
account = application_services().accounts.identity.get_account_by_id(str(self.account_id))
|
||||
if account is None:
|
||||
raise Unauthorized("account not found")
|
||||
if ctx._workspace is not None:
|
||||
@@ -143,7 +142,7 @@ class ExternalSsoSubject(Subject):
|
||||
identity = self.external_identity
|
||||
if identity is None:
|
||||
return None
|
||||
account = AccountService.get_account_by_email(identity.email, session=session)
|
||||
account = application_services().accounts.identity.get_account_by_email(identity.email)
|
||||
return account.id if account is not None else None
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class AppFileUploadApi(Resource):
|
||||
}
|
||||
)
|
||||
@endpoint(
|
||||
op="console_app.file.upload",
|
||||
op="upload.console_app.file",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Upload a file and get a file id for later runs",
|
||||
examples=(
|
||||
|
||||
@@ -92,7 +92,7 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None:
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>")
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@endpoint(
|
||||
op="run.form.get",
|
||||
op="describe.run.form",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Read a human-input form",
|
||||
examples=(
|
||||
@@ -126,7 +126,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>:submit")
|
||||
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
|
||||
@endpoint(
|
||||
op="run.form.submit",
|
||||
op="submit.run.form",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Submit a human-input form",
|
||||
examples=(
|
||||
|
||||
@@ -53,7 +53,7 @@ class WorkflowEventsQuery(BaseModel):
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/events")
|
||||
class OpenApiWorkflowEventsApi(Resource):
|
||||
@endpoint(
|
||||
op="run.events",
|
||||
op="get.run.event",
|
||||
kind=Kind.SSE,
|
||||
summary="Stream the events of a workflow run",
|
||||
examples=(
|
||||
|
||||
@@ -12,8 +12,8 @@ account bearer and lets the view's own membership-scoped lookup answer 404.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import starmap
|
||||
from urllib import parse
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
@@ -23,7 +23,6 @@ from constants.oauth_bearer import Scope
|
||||
from controllers.common.rbac import RBACCheck, RBACPermission, Workspace
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import Example, Kind, endpoint
|
||||
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
|
||||
from controllers.openapi._models import (
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
@@ -42,54 +41,41 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckRBACPermission,
|
||||
CheckScope,
|
||||
CheckSubject,
|
||||
CheckWorkspaceInvitationQuota,
|
||||
CheckWorkspaceMember,
|
||||
CheckWorkspaceRole,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject
|
||||
from enums import DeploymentEdition
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.account import TenantAccountRole
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.errors.account import (
|
||||
from enums.account import TenantAccountRole
|
||||
from extensions.ext_application_services import application_services
|
||||
from services.account_errors import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
|
||||
from services.errors.base import NoPermissionError
|
||||
from services.errors.workspace import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountNotLinkTenantError,
|
||||
AccountRegisterError,
|
||||
CannotOperateSelfError,
|
||||
InvalidWorkspaceMemberRoleError,
|
||||
MemberNotInTenantError,
|
||||
NoPermissionError,
|
||||
RoleAlreadyAssignedError,
|
||||
SeatsLimitExceededError,
|
||||
WorkspaceNotLinkedError,
|
||||
)
|
||||
from services.feature_service import FeatureService
|
||||
from services.workspace.contracts import WorkspaceInvitation, WorkspaceMemberRecord, WorkspaceSnapshot
|
||||
|
||||
|
||||
def _member_response(account: Account) -> MemberResponse:
|
||||
def _member_response(account: WorkspaceMemberRecord) -> MemberResponse:
|
||||
return MemberResponse(
|
||||
id=account.id,
|
||||
name=account.name,
|
||||
email=account.email,
|
||||
role=account.role.value if account.role else "",
|
||||
status=account.status.value if account.status else "",
|
||||
role=account.legacy_role,
|
||||
status=account.status,
|
||||
avatar=account.avatar,
|
||||
)
|
||||
|
||||
|
||||
def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
features = FeatureService.get_features(tenant_id)
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
members = features.members
|
||||
if 0 < members.limit <= members.size:
|
||||
raise MemberLimitExceeded()
|
||||
|
||||
if features.workspace_members.enabled and not features.workspace_members.is_available(1):
|
||||
raise MemberLicenseExceeded()
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces")
|
||||
class WorkspacesApi(Resource):
|
||||
@endpoint(
|
||||
op="workspace.list",
|
||||
op="get.workspace",
|
||||
kind=Kind.LIST,
|
||||
summary="List workspaces of the current account",
|
||||
examples=(Example(title="List my workspaces, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -98,14 +84,14 @@ class WorkspacesApi(Resource):
|
||||
returns=(200, WorkspaceListResponse, "Workspace list"),
|
||||
)
|
||||
def get(self, ctx: Context, *, query: WorkspaceListQuery):
|
||||
rows = TenantService.get_workspaces_for_account(str(ctx.subject.account_id), session=ctx.session)
|
||||
return WorkspaceListResponse.page_of(list(starmap(_workspace_summary, rows)), query=query)
|
||||
rows = application_services().workspaces.management.list_memberships(str(ctx.subject.account_id))
|
||||
return WorkspaceListResponse.page_of([_workspace_summary(row) for row in rows], query=query)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>")
|
||||
class WorkspaceByIdApi(Resource):
|
||||
@endpoint(
|
||||
op="workspace.get",
|
||||
op="describe.workspace",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Workspace detail",
|
||||
examples=(
|
||||
@@ -116,12 +102,11 @@ class WorkspaceByIdApi(Resource):
|
||||
returns=(200, WorkspaceDetailResponse, "Workspace detail"),
|
||||
)
|
||||
def get(self, ctx: Context, workspace_id: str):
|
||||
row = TenantService.find_workspace_for_account(str(ctx.subject.account_id), workspace_id, session=ctx.session)
|
||||
row = application_services().workspaces.management.find_membership(str(ctx.subject.account_id), workspace_id)
|
||||
if row is None:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
tenant, membership = row
|
||||
return _workspace_detail(tenant, membership)
|
||||
return _workspace_detail(row)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>:switch")
|
||||
@@ -134,7 +119,7 @@ class WorkspaceSwitchApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.switch",
|
||||
op="switch.workspace",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Server-side current workspace switch (shared with the web console)",
|
||||
examples=(Example(title="Make a workspace current on the server", input={"workspace_id": "<workspace_id>"}),),
|
||||
@@ -148,15 +133,12 @@ class WorkspaceSwitchApi(Resource):
|
||||
)
|
||||
def post(self, ctx: Context, workspace_id: str):
|
||||
try:
|
||||
TenantService.switch_tenant(ctx.account, workspace_id, session=ctx.session)
|
||||
except AccountNotLinkTenantError:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
row = TenantService.find_workspace_for_account(str(ctx.subject.account_id), workspace_id, session=ctx.session)
|
||||
if row is None:
|
||||
raise NotFound("workspace not found")
|
||||
tenant, membership = row
|
||||
return _workspace_detail(tenant, membership)
|
||||
workspace = application_services().workspaces.management.switch_membership(
|
||||
str(ctx.subject.account_id), workspace_id
|
||||
)
|
||||
except WorkspaceNotLinkedError:
|
||||
raise NotFound("workspace not found") from None
|
||||
return _workspace_detail(workspace)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
|
||||
@@ -168,7 +150,7 @@ class WorkspaceMembersApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.list",
|
||||
op="get.workspace.member",
|
||||
kind=Kind.LIST,
|
||||
summary="List workspace members",
|
||||
examples=(Example(title="List members of the pinned workspace, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -181,11 +163,18 @@ class WorkspaceMembersApi(Resource):
|
||||
returns=(200, MemberListResponse, "Member list"),
|
||||
)
|
||||
def get(self, ctx: Context, workspace_id: str, *, query: MemberListQuery):
|
||||
members = TenantService.get_tenant_members(ctx.workspace, session=ctx.session)
|
||||
return MemberListResponse.page_of([_member_response(m) for m in members], query=query)
|
||||
result = application_services().workspaces.member_queries.list_page(
|
||||
ctx.request_context, page=query.page, limit=query.limit
|
||||
)
|
||||
return MemberListResponse.build(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
total=result.total,
|
||||
items=[_member_response(m) for m in result.members],
|
||||
)
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.invite",
|
||||
op="invite.workspace.member",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Invite a member by email",
|
||||
examples=(
|
||||
@@ -198,26 +187,23 @@ class WorkspaceMembersApi(Resource):
|
||||
CheckWorkspaceMember(),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})),
|
||||
CheckWorkspaceInvitationQuota(),
|
||||
),
|
||||
body=MemberInvitePayload,
|
||||
returns=(201, MemberInviteResponse, "Member invited"),
|
||||
)
|
||||
def post(self, ctx: Context, workspace_id: str, *, body: MemberInvitePayload):
|
||||
tenant = ctx.workspace
|
||||
|
||||
_check_member_invite_quota(tenant.id)
|
||||
|
||||
try:
|
||||
token = RegisterService.invite_new_member(
|
||||
tenant=tenant,
|
||||
invitation = application_services().workspaces.invitations.invite(
|
||||
ctx.request_context,
|
||||
email=body.email,
|
||||
language=None,
|
||||
role=body.role,
|
||||
inviter=ctx.account,
|
||||
session=ctx.session,
|
||||
)
|
||||
except AccountAlreadyInTenantError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except InvalidWorkspaceMemberRoleError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
except NoPermissionError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except SeatsLimitExceededError:
|
||||
@@ -225,20 +211,7 @@ class WorkspaceMembersApi(Resource):
|
||||
except AccountRegisterError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
normalized_email = body.email.lower()
|
||||
member = AccountService.get_account_by_email_with_case_fallback(normalized_email, session=ctx.session)
|
||||
if member is None:
|
||||
raise RuntimeError("invited member missing from DB after invite")
|
||||
|
||||
encoded_email = parse.quote(normalized_email)
|
||||
invite_url = f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={token}"
|
||||
return MemberInviteResponse(
|
||||
email=normalized_email,
|
||||
role=body.role,
|
||||
member_id=member.id,
|
||||
invite_url=invite_url,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
return _invitation_response(invitation)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
@@ -252,7 +225,7 @@ class WorkspaceMemberApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.remove",
|
||||
op="delete.workspace.member",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Remove a member",
|
||||
examples=(Example(title="Remove a member by account id", input={"member_id": "<member_id>"}),),
|
||||
@@ -266,12 +239,11 @@ class WorkspaceMemberApi(Resource):
|
||||
returns=(200, MemberActionResponse, "Member removed"),
|
||||
)
|
||||
def delete(self, ctx: Context, workspace_id: str, member_id: str):
|
||||
member = AccountService.get_account_by_id(member_id, session=ctx.session)
|
||||
if member is None:
|
||||
raise NotFound("member not found")
|
||||
|
||||
member_id = _parse_member_id(member_id)
|
||||
try:
|
||||
TenantService.remove_member_from_tenant(ctx.workspace, member, ctx.account, session=ctx.session)
|
||||
application_services().workspaces.members.remove(workspace_id, member_id, str(ctx.subject.account_id))
|
||||
except AccountNotFoundError:
|
||||
raise NotFound("member not found") from None
|
||||
except CannotOperateSelfError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
@@ -282,7 +254,7 @@ class WorkspaceMemberApi(Resource):
|
||||
return MemberActionResponse()
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.set_role",
|
||||
op="set.workspace.member_role",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Change a member's role",
|
||||
examples=(
|
||||
@@ -300,12 +272,13 @@ class WorkspaceMemberApi(Resource):
|
||||
returns=(200, MemberActionResponse, "Role updated"),
|
||||
)
|
||||
def patch(self, ctx: Context, workspace_id: str, member_id: str, *, body: MemberRoleUpdatePayload):
|
||||
member = AccountService.get_account_by_id(member_id, session=ctx.session)
|
||||
if member is None:
|
||||
raise NotFound("member not found")
|
||||
|
||||
member_id = _parse_member_id(member_id)
|
||||
try:
|
||||
TenantService.update_member_role(ctx.workspace, member, body.role, ctx.account, session=ctx.session)
|
||||
application_services().workspaces.members.update_role(
|
||||
workspace_id, member_id, body.role, str(ctx.subject.account_id)
|
||||
)
|
||||
except AccountNotFoundError:
|
||||
raise NotFound("member not found") from None
|
||||
except CannotOperateSelfError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
@@ -314,26 +287,47 @@ class WorkspaceMemberApi(Resource):
|
||||
raise NotFound(str(exc))
|
||||
except RoleAlreadyAssignedError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except InvalidWorkspaceMemberRoleError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
|
||||
return MemberActionResponse()
|
||||
|
||||
|
||||
def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceSummaryResponse:
|
||||
def _parse_member_id(member_id: str) -> str:
|
||||
# Permission comparisons must use the same identity as UUID database lookups.
|
||||
try:
|
||||
return str(UUID(member_id))
|
||||
except ValueError:
|
||||
raise NotFound("member not found") from None
|
||||
|
||||
|
||||
def _invitation_response(invitation: WorkspaceInvitation) -> MemberInviteResponse:
|
||||
encoded_email = parse.quote(invitation.email)
|
||||
return MemberInviteResponse(
|
||||
email=invitation.email,
|
||||
role=invitation.role,
|
||||
member_id=invitation.account_id,
|
||||
invite_url=f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={invitation.token}",
|
||||
tenant_id=invitation.workspace_id,
|
||||
)
|
||||
|
||||
|
||||
def _workspace_summary(tenant: WorkspaceSnapshot) -> WorkspaceSummaryResponse:
|
||||
return WorkspaceSummaryResponse(
|
||||
id=tenant.id,
|
||||
name=tenant.name,
|
||||
role=getattr(membership, "role", ""),
|
||||
role=tenant.role or "",
|
||||
status=tenant.status,
|
||||
current=getattr(membership, "current", False),
|
||||
current=tenant.current,
|
||||
)
|
||||
|
||||
|
||||
def _workspace_detail(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceDetailResponse:
|
||||
def _workspace_detail(tenant: WorkspaceSnapshot) -> WorkspaceDetailResponse:
|
||||
return WorkspaceDetailResponse(
|
||||
id=tenant.id,
|
||||
name=tenant.name,
|
||||
role=getattr(membership, "role", ""),
|
||||
role=tenant.role or "",
|
||||
status=tenant.status,
|
||||
current=getattr(membership, "current", False),
|
||||
current=tenant.current,
|
||||
created_at=tenant.created_at.isoformat() if tenant.created_at else None,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user