fix: fix conflict

This commit is contained in:
fatelei 2026-09-02 09:09:32 +08:00
commit 5fe4ceb651
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
361 changed files with 9483 additions and 5308 deletions

View File

@ -61,7 +61,7 @@ Flag effects that:
- Transform props/state for rendering.
- Copy one state value into another representing the same concept.
- Handle user actions that belong in event handlers.
- Reset state from props when a keyed reset, stable ID, or render-time derivation would work.
- Reset local state from props or visibility when derivation, a stable semantic identity, or the intended mounted owner already expresses the lifecycle.
- Fetch data that belongs in framework APIs or TanStack Query.
If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration.
@ -71,11 +71,24 @@ If an effect remains, it must synchronize with a named external system: browser
Flag:
- Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state.
- Per-session state held by a longer-lived visibility coordinator and cleared through an open-state Effect or a generated key when the primitive's mounted-content lifecycle already matches the intended state lifetime.
- A DOM field mirrored into competing prop, default, and React state sources when editing does not require those sources to synchronize.
- Local state used to fake server data or generated contract fields.
- UI state persisted to localStorage when it is live app state.
- Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed.
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source.
Review state lifetime before its storage mechanism. For a hidden surface, distinguish the
visibility coordinator from mounted content. State private to one mounted session belongs in that
content owner; promote it only when the draft must survive that content owner's unmount or another
owner coordinates it. A stable semantic identity key may create a new snapshot when the represented
identity changes; a generated key is not a routine reset command.
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled
popups, and feature UI state that has no server source. Submit-only DOM fields may remain
uncontrolled; use local controlled state when React must own the current value to drive rendering
or coordination. Observing change events or tracking a derived fact such as dirty state does not
require mirroring the field value. Do not flag controlled state by itself without a concrete
competing-source, stale-state, or ownership defect.
## Navigation

View File

@ -9,6 +9,7 @@ Flag missing coverage when a change alters a reachable contract such as:
- User interaction, navigation, form submission, validation, or permissions.
- Query or mutation behavior, URL state, persistence, or one-shot signals.
- Loading, error, empty, and recovery states that users can encounter.
- A hidden surface whose close-and-reopen behavior changes whether in-progress state resets or persists.
- Accessibility-critical labels, keyboard flow, focus, disabled state, or overlay behavior.
- A regression-prone business rule or bug fix that can be reproduced through a public boundary.

View File

@ -9,10 +9,11 @@ Use this skill to route component architecture decisions to its bundled referenc
## First Decisions
| Question | Default | Promote only when |
| Question | Default | Choose differently when |
| --- | --- | --- |
| Where should code live? | In the product workflow, route, or feature owner. | Several verticals need the same stable contract. |
| Who owns state and handlers? | The lowest visual owner that consumes them. | A parent coordinates one workflow or consistent snapshot. |
| Who owns state and handlers? | The lowest owner that consumes them and whose lifetime matches the state. | Another owner coordinates the value or it must survive the local owner's unmount. |
| Should React control a value? | Leave submit-only DOM fields uncontrolled. | The workflow must own the current value to drive rendering or coordination. |
| Should state enter Jotai? | Keep component and form state local. | Siblings need one source of truth or scoped workflow persistence. |
| Who owns URL state? | Next.js route APIs and `nuqs`. | Atoms require a read-only route-identity bridge. |
| Who owns remote state? | TanStack Query at the lowest consumer. | Atom state drives the query or shared derivations consume it. |
@ -24,12 +25,12 @@ Use this skill to route component architecture decisions to its bundled referenc
- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership].
- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state].
- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data].
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable.
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state.
- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime].
## Workflow
1. Identify the behavior owner and the public contract being changed.
1. Identify the behavior owner, the required state lifetime, and the public contract being changed.
2. Read the nearby implementation, tests, and only the routed skill references.
3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them.
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.

View File

@ -23,8 +23,10 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers.
- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening.
- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior.
- Keep overlay open-state ownership separate from content-session ownership. A controlled root does not require controlled fields or root-owned drafts.
- Match transient state to the primitive's content mount lifecycle. State below an unmounting content boundary gets a fresh instance after unmount; intentionally kept-mounted content needs an explicit persistence or reset policy.
- Keep a controlled overlay root at its coordination owner so the primitive can complete exit transitions, focus restoration, and detached-handle behavior. Do not conditionally remove the root to reset content state, and use keys only for stable semantic identity.
- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition.
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md

View File

@ -12,8 +12,8 @@ Read this document when adding, moving, splitting, or refactoring React componen
## Component Ownership
- Put state, data access, loading, empty, error, and handlers in the lowest visual owner that uses them.
- Keep coordination in a parent only when it needs one consistent snapshot or coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
- Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests.
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
- One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state.
@ -23,7 +23,8 @@ Read this document when adding, moving, splitting, or refactoring React componen
## Boundaries
- State-heavy wizards, drawers, modals, and secondary workflows can form a small vertical surface with an entrypoint, optional feature-local state, and shallow owners matching real visual regions.
- The entrypoint owns route integration, provider wiring, close behavior, and mounting. Composition owners handle workflow branches; the closest visual owner handles section branches.
- The entrypoint owns route integration, provider wiring, placement, and open-state coordination. A content or session owner keeps state scoped to that mounted surface.
- Judge hook lifetime by the component that declares the hook and the primitive's mount contract, not only by where its rendered controls appear in JSX.
- Separate hidden dialogs, dropdowns, and popovers into small local owners when their content obscures the parent flow.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they have their own state, reuse, or semantic boundary.
- Avoid wrapper components and wrapper DOM that only rename props, pass children through, or hide the real primitive. A wrapper must own behavior, validation, state, accessibility, layout, or library integration.

View File

@ -7,7 +7,7 @@ Read this document when a change introduces Effects, navigation side effects, me
- Keep render pure: do not read or write `ref.current` during render except for predictable null-guarded lazy initialization. Update interaction-owned refs in event handlers, synchronize external-system refs after commit, and use state or derivation for rendered values.
- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API.
- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query.
- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects.
- Initialize query-backed form sessions after their defaults are available instead of copying data through Effects. Use a stable semantic identity key when the represented identity changes; use the intended surface lifecycle for per-session reset.
## Navigation

View File

@ -9,10 +9,12 @@ Read this document when a change involves Jotai, form drafts, route identity, sh
- Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels.
- Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage.
## Forms
## Forms And Sessions
- Prefer uncontrolled Dify UI form and field controls when values are only read at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form values to atoms only when another owner reacts to in-progress values, the draft must survive scoped unmounting, or several workflow steps edit the same draft.
- Keep form state in the narrowest owner whose lifetime matches the draft. A draft scoped to one mounted surface belongs to that content or session owner; a draft that must survive its current owner's unmount belongs to an explicit longer-lived feature owner.
- Prefer uncontrolled fields when values are only read at submit time. Use local controlled state only when React must own the current value to drive dependent UI or linked fields; track derived facts such as dirty state without mirroring the field value. Controlledness does not decide whether a draft is local or persisted.
- For query-backed defaults, establish the form session after the required defaults are available. `defaultValue` initializes the current mount; a stable semantic identity key may create a fresh snapshot when the represented identity changes. Do not use a generated key as a routine reset command.
- Promote drafts beyond the session only when another owner reacts to in-progress values, several workflow steps share one draft, or the draft must intentionally survive unmounting. Start with the lowest shared React owner; use feature-scoped atoms only when their coordination or persistence contract is needed.
- Keep validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission.
## Route And URL State

View File

@ -12,7 +12,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "lts"
"version": "24.20.0"
},
"ghcr.io/devcontainers-extra/features/npm-package:1": {
"package": "typescript",
@ -46,4 +46,4 @@
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
}
}

View File

@ -11,6 +11,6 @@ runs:
- name: Setup Vite+
uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
with:
node-version-file: .nvmrc
node-version-file: package.json
cache: true
run-install: true

1
.github/labeler.yml vendored
View File

@ -6,7 +6,6 @@ web:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
e2e:
- changed-files:

View File

@ -82,7 +82,6 @@ jobs:
- 'pnpm-workspace.yaml'
- 'lint.config.ts'
- '.npmrc'
- '.nvmrc'
- '.github/workflows/cli-tests.yml'
- '.github/actions/setup-web/**'
web:
@ -91,7 +90,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
- '.github/workflows/main-ci.yml'
- '.github/workflows/web-tests.yml'
- '.github/actions/setup-web/**'
@ -105,7 +103,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
- 'docker/docker-compose.middleware.yaml'
- 'docker/envs/middleware.env.example'
- '.github/workflows/web-e2e.yml'

View File

@ -39,7 +39,6 @@ jobs:
- 'e2e/tsx-register.js'
- 'package.json'
- 'pnpm-lock.yaml'
- '.nvmrc'
- '.github/workflows/post-merge.yml'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'

View File

@ -114,7 +114,6 @@ jobs:
pnpm-workspace.yaml
knip.config.ts
scripts/check-web-production-unused-after-knip-fix.mjs
.nvmrc
.github/workflows/style.yml
.github/actions/setup-web/**
@ -170,7 +169,6 @@ jobs:
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
vite.config.ts
lint.config.ts
eslint.config.mjs

View File

@ -31,7 +31,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
node-version-file: package.json
cache: ''
cache-dependency-path: 'pnpm-lock.yaml'

View File

@ -162,7 +162,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210
uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1.0.211
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

1
.nvmrc
View File

@ -1 +0,0 @@
22

View File

@ -7,3 +7,9 @@ Dify is an open-source platform for building LLM applications, agentic workflows
- Run backend commands through `uv run --project api <command>`.
- Backend integration tests are CI-only and are not expected to run locally.
- Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files.
## Frontend Workflow
- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure].
[Truncated Text Disclosure]: web/docs/truncated-text-disclosure.md

View File

@ -65,7 +65,7 @@ ignore_imports =
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.feature_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
@ -99,9 +99,9 @@ ignore_imports =
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
libs.device_flow_security -> controllers.openapi._models
libs.device_flow_security -> services.entities.feature_entities
libs.device_flow_security -> services.feature_service
libs.device_flow_security -> services.system_feature_service
libs.email_i18n -> services.entities.feature_entities
libs.email_i18n -> services.feature_service
libs.email_i18n -> services.system_feature_service
libs.external_api -> core
libs.external_api -> core.errors.error
libs.external_api -> extensions.ext_logging
@ -117,7 +117,6 @@ ignore_imports =
libs.oauth_bearer -> models
libs.rsa -> extensions.ext_storage
libs.workspace_permission -> services.enterprise.enterprise_service
libs.workspace_permission -> services.feature_service
services.account_service -> controllers
services.account_service -> controllers.console.error
services.app_generate_service -> controllers.console.app.workflow
@ -387,6 +386,37 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:inner-mail-service-boundary]
name = Inner mail application service is framework and implementation neutral
type = forbidden
source_modules =
services.inner_mail_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
tasks
werkzeug
[importlinter:contract:web-passport-service-boundary]
name = Web passport application service is framework and persistence neutral
type = forbidden
source_modules =
services.web_passport_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:account-activation-service-boundary]
name = Account activation application service is framework and persistence neutral
type = forbidden

View File

@ -53,15 +53,14 @@ WORKDIR /app/api
# Create non-root user
ARG dify_uid=1001
ARG NODE_MAJOR=22
ARG NODE_PACKAGE_VERSION=22.21.0-1nodesource1
ARG NODE_PACKAGE_VERSION=24.20.0-1nodesource1
ARG NODESOURCE_KEY_FPR=6F71F525282841EEDAF851B42F59B5F99B1BE0B4
RUN groupadd -r -g ${dify_uid} dify && \
useradd -r -u ${dify_uid} -g ${dify_uid} -s /bin/bash dify && \
chown -R dify:dify /app
RUN \
apt-get update \
RUN NODE_MAJOR="${NODE_PACKAGE_VERSION%%.*}" \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \

View File

@ -79,10 +79,10 @@ from services.agent.observability_service import (
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
AgentPublicationStatus = Literal["published", "drafts"]
@ -390,7 +390,7 @@ def _serialize_agent_app_detail(
"""
app_model = AppService().get_app(app_model, session=session)
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]

View File

@ -77,7 +77,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
WeightVectorSetting,
)
from services.errors.account import NoPermissionError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
@ -516,7 +516,7 @@ class AppImportResponse(ResponseModel):
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_ids = [str(app.id) for app in apps]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
@ -877,7 +877,7 @@ class AppApi(Resource):
app_model = app_service.get_app(app_model, session=session)
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode
@ -1002,7 +1002,7 @@ class AppCopyApi(Resource):
session.commit()
# Inherit web app permission from original app
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
try:
# Get the original app's access mode
original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_model.id)

View File

@ -31,7 +31,7 @@ from services.app_dsl_service import (
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.account import NoPermissionError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from .. import console_ns
from .permission_keys import get_app_permission_keys
@ -127,7 +127,7 @@ class AppImportApi(Resource):
result.app_id,
)
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
# Return appropriate status code based on result

View File

@ -25,7 +25,7 @@ from services.entities.auth_entities import (
ForgotPasswordResetPayload,
ForgotPasswordSendPayload,
)
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class ForgotPasswordEmailResponse(BaseModel):
@ -87,7 +87,7 @@ class ForgotPasswordSendEmailApi(Resource):
account=account,
email=normalized_email,
language=language,
is_allow_register=FeatureService.get_system_features().is_allow_register,
is_allow_register=SystemFeatureService.is_registration_allowed(),
)
return {"result": "success", "data": token}
@ -198,6 +198,6 @@ class ForgotPasswordResetApi(Resource):
# Create workspace if needed
if (
not TenantService.get_join_tenants(account, session=db.session())
and FeatureService.is_workspace_creation_allowed()
and SystemFeatureService.is_workspace_creation_allowed()
):
TenantService.create_owner_tenant(account, session=db.session())

View File

@ -81,7 +81,7 @@ from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from services.turnstile_service import (
EMAIL_CODE_VERIFY_ACTION,
TurnstileChallengeRejectedError,
@ -201,8 +201,8 @@ class LoginApi(Resource):
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
if (
FeatureService.is_workspace_creation_allowed()
and not FeatureService.get_license().workspaces.is_available()
SystemFeatureService.is_workspace_creation_allowed()
and not SystemFeatureService.get_license().workspaces.is_available()
):
raise WorkspacesLimitExceeded()
else:
@ -272,7 +272,7 @@ class ResetPasswordSendEmailApi(Resource):
email=normalized_email,
account=account,
language=language,
is_allow_register=FeatureService.get_system_features().is_allow_register,
is_allow_register=SystemFeatureService.is_registration_allowed(),
)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@ -313,7 +313,7 @@ class EmailCodeLoginSendEmailApi(Resource):
raise AccountInFreezeError() from exc
if account is None:
if FeatureService.get_system_features().is_allow_register:
if SystemFeatureService.is_registration_allowed():
token = AccountService.send_email_code_login_email(email=normalized_email, language=language)
else:
raise AccountNotFound()
@ -398,10 +398,10 @@ class EmailCodeLoginApi(Resource):
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
workspaces = FeatureService.get_license().workspaces
workspaces = SystemFeatureService.get_license().workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceeded()
if not FeatureService.is_workspace_creation_allowed():
if not SystemFeatureService.is_workspace_creation_allowed():
raise NotAllowedCreateWorkspace()
else:
TenantService.create_owner_tenant(account, session=db.session())

View File

@ -36,7 +36,7 @@ from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from .. import console_ns
@ -310,7 +310,7 @@ def _generate_account(
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
if not FeatureService.is_workspace_creation_allowed():
if not SystemFeatureService.is_workspace_creation_allowed():
raise WorkSpaceNotAllowedCreateError()
else:
TenantService.create_owner_tenant(account, session=db.session())
@ -318,7 +318,7 @@ def _generate_account(
if not account:
normalized_email = user_info.email.lower()
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if not SystemFeatureService.is_registration_allowed():
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:

View File

@ -17,7 +17,7 @@ from controllers.console.wraps import (
with_current_user,
)
from extensions.ext_database import db
from fields.dataset_fields import DatasetDetailResponse
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
from libs.helper import dump_response
from libs.login import login_required
from models import Account
@ -116,6 +116,7 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
# 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()
session = db.session()
dataset = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=current_tenant_id,
rag_pipeline_dataset_create_entity=RagPipelineDatasetCreateEntity(
@ -129,6 +130,6 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
permission=DatasetPermissionEnum.ONLY_ME,
partial_member_list=None,
),
session=db.session(),
session=session,
)
return dump_response(DatasetDetailResponse, dataset), 201
return dump_response(DatasetDetailResponse, dataset_detail_response_source(dataset, session=session)), 201

View File

@ -18,7 +18,7 @@ from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
@ -55,8 +55,7 @@ def user_allowed_to_access_app[**P, R](view: Callable[Concatenate[InstalledApp,
@wraps(view)
def decorated(installed_app: InstalledApp, *args: P.args, **kwargs: P.kwargs):
current_user, _ = current_account_with_tenant()
feature = FeatureService.get_system_features()
if feature.webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_id = installed_app.app_id
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
user_id=str(current_user.id),

View File

@ -125,7 +125,7 @@ class SystemFeatureApi(Resource):
Authentication configuration must be available before the authentication flow can be selected.
Authenticated license detail is served separately by SystemFeatureLicenseApi.
"""
return dump_response(SystemFeatureModel, application_services().feature_queries.get_system_features())
return dump_response(SystemFeatureModel, application_services().feature_queries.get_public_system_features())
@console_ns.route("/system-features/license")

View File

@ -22,7 +22,7 @@ from libs.login import current_account_with_tenant, login_required
from machinery.context import RequestContext
from machinery.errors import AdmissionConfigurationError
from models.account import TenantAccountRole
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
def console_email_registration_admission[T, **P, R](
@ -32,8 +32,10 @@ def console_email_registration_admission[T, **P, R](
@wraps(view)
def check_registration_features(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
features = FeatureService.get_system_features()
if not features.enable_email_password_login or not features.is_allow_register:
if (
not SystemFeatureService.is_email_password_login_enabled()
or not SystemFeatureService.is_registration_allowed()
):
abort(403)
return view(self, *args, **kwargs)

View File

@ -45,6 +45,7 @@ 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
class MemberInvitePayload(BaseModel):
@ -185,7 +186,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
if new_account_count > 0:
seats = FeatureService.get_license().seats
seats = SystemFeatureService.get_license().seats
if not seats.is_available(new_account_count):
raise SeatsLimitExceeded()
return

View File

@ -1,7 +1,7 @@
import io
from typing import Any, Literal
from flask import request, send_file
from flask import send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
@ -15,6 +15,7 @@ from controllers.console.wraps import (
RBACResourceScope,
account_initialization_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
@ -154,10 +155,8 @@ class ModelProviderListApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
payload = request.args.to_dict(flat=True)
args = ParserModelList.model_validate(payload)
@model_validate(ParserModelList)
def get(self, args: ParserModelList, tenant_id: str):
model_provider_service = ModelProviderService()
provider_list = model_provider_service.get_provider_list(tenant_id=tenant_id, model_type=args.model_type)
@ -212,12 +211,10 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, provider: str):
# if credential_id is not provided, return current used credential
payload = request.args.to_dict(flat=True)
args = ParserCredentialId.model_validate(payload)
@model_validate(ParserCredentialId)
def get(self, args: ParserCredentialId, tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
# if credential_id is not provided, return current used credential
credentials = model_provider_service.get_provider_credential(
tenant_id=tenant_id, provider=provider, credential_id=args.credential_id
)
@ -232,10 +229,8 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialCreate.model_validate(payload)
@model_validate(ParserCredentialCreate)
def post(self, args: ParserCredentialCreate, current_tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
try:
@ -258,10 +253,8 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def put(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialUpdate.model_validate(payload)
@model_validate(ParserCredentialUpdate)
def put(self, args: ParserCredentialUpdate, current_tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
try:
@ -285,10 +278,8 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialDelete.model_validate(payload)
@model_validate(ParserCredentialDelete)
def delete(self, args: ParserCredentialDelete, current_tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
model_provider_service.remove_provider_credential(
tenant_id=current_tenant_id, provider=provider, credential_id=args.credential_id
@ -307,10 +298,8 @@ class ModelProviderCredentialSwitchApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialSwitch.model_validate(payload)
@model_validate(ParserCredentialSwitch)
def post(self, args: ParserCredentialSwitch, current_tenant_id: str, provider: str):
service = ModelProviderService()
service.switch_active_provider_credential(
tenant_id=current_tenant_id,
@ -332,10 +321,8 @@ class ModelProviderValidateApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialValidate.model_validate(payload)
@model_validate(ParserCredentialValidate)
def post(self, args: ParserCredentialValidate, current_tenant_id: str, provider: str):
tenant_id = current_tenant_id
model_provider_service = ModelProviderService()
@ -388,10 +375,8 @@ class PreferredProviderTypeUpdateApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserPreferredProviderType.model_validate(payload)
@model_validate(ParserPreferredProviderType)
def post(self, args: ParserPreferredProviderType, tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
model_provider_service.switch_preferred_provider(
tenant_id=tenant_id, provider=provider, preferred_provider_type=args.preferred_provider_type

View File

@ -19,6 +19,7 @@ from controllers.common.wraps import (
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
from controllers.console.workspace.error import AccountNotInitializedError
from enums import CloudPlan, DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.encryption import FieldEncryption
@ -31,6 +32,7 @@ from services.billing_service import BillingService
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.operation_service import OperationService, UtmInfo
from services.system_feature_service import SystemFeatureService
from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout
@ -183,7 +185,7 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return view(*args, **kwargs)
vector_space = FeatureService.get_vector_space(current_tenant_id)
vector_space = application_services().feature_queries.get_workspace_vector_space(current_tenant_id)
if 0 < vector_space.limit <= vector_space.size:
abort(
403,
@ -330,8 +332,11 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
def enterprise_license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
settings = FeatureService.get_system_features()
if settings.license.status in [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST]:
if SystemFeatureService.get_license_status() in [
LicenseStatus.INACTIVE,
LicenseStatus.EXPIRED,
LicenseStatus.LOST,
]:
raise UnauthorizedAndForceLogout("Your license is invalid. Please contact your administrator.")
return view(*args, **kwargs)
@ -342,8 +347,7 @@ def enterprise_license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
features = FeatureService.get_system_features()
if features.enable_email_password_login:
if SystemFeatureService.is_email_password_login_enabled():
return view(*args, **kwargs)
# otherwise, return 403
@ -355,8 +359,7 @@ def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]
def enable_change_email[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
features = FeatureService.get_system_features()
if features.enable_change_email:
if SystemFeatureService.is_change_email_enabled():
return view(*args, **kwargs)
# otherwise, return 403
@ -372,7 +375,11 @@ def is_allow_transfer_owner[**P, R](view: Callable[P, R]) -> Callable[P, R]:
_, current_tenant_id = current_account_with_tenant()
# Check both billing/plan level and workspace policy level permissions
check_workspace_owner_transfer_permission(current_tenant_id)
features = application_services().feature_queries.get_workspace_features(current_tenant_id)
check_workspace_owner_transfer_permission(
current_tenant_id,
owner_transfer_allowed=features.is_allow_transfer_workspace,
)
return view(*args, **kwargs)
return decorated

View File

@ -6,8 +6,9 @@ from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import billing_inner_api_only, enterprise_inner_api_only
from tasks.mail_inner_task import send_inner_email_task
from controllers.inner_api.wraps import inner_api_only
from extensions.ext_application_services import application_services
from services.entities.mail_entities import InnerMailMessage
class InnerMailPayload(BaseModel):
@ -28,25 +29,27 @@ class BaseMail(Resource):
@inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__])
def post(self):
args = InnerMailPayload.model_validate(inner_api_ns.payload or {})
send_inner_email_task.delay(
to=args.to,
subject=args.subject,
body=args.body,
substitutions=args.substitutions, # type: ignore
application_services().inner_mail.send(
InnerMailMessage(
recipients=tuple(args.to),
subject=args.subject,
body=args.body,
substitutions=args.substitutions,
)
)
return {"message": "success"}, 200
@inner_api_ns.route("/enterprise/mail")
class EnterpriseMail(BaseMail):
method_decorators = [setup_required, enterprise_inner_api_only]
@inner_api_ns.doc("send_enterprise_mail")
@inner_api_ns.doc(description="Send internal email for enterprise features")
@inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__])
@inner_api_ns.doc(
responses={200: "Email sent successfully", 401: "Unauthorized - invalid API key", 404: "Service not available"}
)
@inner_api_only
@setup_required
def post(self):
"""Send internal email for enterprise features.
@ -61,14 +64,14 @@ class EnterpriseMail(BaseMail):
@inner_api_ns.route("/billing/mail")
class BillingMail(BaseMail):
method_decorators = [setup_required, billing_inner_api_only]
@inner_api_ns.doc("send_billing_mail")
@inner_api_ns.doc(description="Send internal email for billing notifications")
@inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__])
@inner_api_ns.doc(
responses={200: "Email sent successfully", 401: "Unauthorized - invalid API key", 404: "Service not available"}
)
@inner_api_only
@setup_required
def post(self):
"""Send internal email for billing notifications.

View File

@ -35,10 +35,6 @@ def inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return decorated
def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return inner_api_only(view)
def enterprise_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return inner_api_only(view)

View File

@ -7,7 +7,7 @@ from controllers.openapi.auth.data import AuthData, RequestContext
from enums import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from services.enterprise.enterprise_service import WebAppAccessMode
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
CondFn = Callable[[RequestContext, AuthData | None], bool]
@ -50,7 +50,7 @@ EDITION_COMMUNITY = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == Deploy
EDITION_ENTERPRISE = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE)
EDITION_CLOUD = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD)
WEBAPP_AUTH_ENABLED = config_cond(lambda: FeatureService.get_system_features().webapp_auth.enabled)
WEBAPP_AUTH_ENABLED = config_cond(lambda: SystemFeatureService.is_webapp_auth_enabled())
WEBAPP_RUN_SCOPED = request_cond(lambda ctx: ctx.scope == Scope.APPS_RUN)

View File

@ -37,7 +37,7 @@ from libs.oauth_bearer import (
)
from models.account import TenantAccountRole
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class AuthPipeline:
@ -265,8 +265,11 @@ def _subject_type_str(identity: Any) -> str | None:
def _check_license() -> None:
settings = FeatureService.get_system_features()
if settings.license.status in {LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST}:
if SystemFeatureService.get_license_status() in {
LicenseStatus.INACTIVE,
LicenseStatus.EXPIRED,
LicenseStatus.LOST,
}:
raise Forbidden("license_invalid")

View File

@ -1,7 +1,6 @@
from typing import Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
from flask_restx.api import HTTPStatus
from pydantic import BaseModel, Field, TypeAdapter
@ -207,9 +206,9 @@ class AnnotationListApi(Resource):
)
@validate_app_token
@with_session(write=False)
def get(self, session: Session, app_model: App):
@model_validate(AnnotationListQuery)
def get(self, query: AnnotationListQuery, session: Session, app_model: App):
"""List annotations for the application."""
query = AnnotationListQuery.model_validate(request.args.to_dict(flat=True))
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(
app_model.id, query.page, query.limit, query.keyword, session

View File

@ -2,7 +2,6 @@ from datetime import datetime
from typing import Annotated, Any, Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema, field_validator
from sqlalchemy.orm import sessionmaker
@ -185,7 +184,8 @@ class ConversationApi(Resource):
service_api_ns.models[ConversationInfiniteScrollPagination.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser):
@model_validate(ConversationListQuery)
def get(self, query_args: ConversationListQuery, app_model: App, end_user: EndUser):
"""List all conversations for the current user.
Supports pagination using last_id and limit parameters.
@ -194,7 +194,6 @@ class ConversationApi(Resource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
query_args = ConversationListQuery.model_validate(request.args.to_dict())
last_id = query_args.last_id or None
try:
@ -343,7 +342,8 @@ class ConversationVariablesApi(Resource):
service_api_ns.models[ConversationVariableInfiniteScrollPaginationResponse.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser, conversation_id: UUID):
@model_validate(ConversationVariablesQuery)
def get(self, query_args: ConversationVariablesQuery, app_model: App, end_user: EndUser, conversation_id: UUID):
"""List all variables for a conversation.
Conversational variables are only available for chat applications.
@ -355,7 +355,6 @@ class ConversationVariablesApi(Resource):
conversation_id_str = str(conversation_id)
query_args = ConversationVariablesQuery.model_validate(request.args.to_dict())
last_id = query_args.last_id or None
try:

View File

@ -2,7 +2,7 @@ import logging
from urllib.parse import quote
from uuid import UUID
from flask import Response, request
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import select
@ -10,6 +10,7 @@ from sqlalchemy import select
from controllers.common.fields import BinaryFileResponse
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_model
from controllers.console.wraps import model_validate
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
FileAccessDeniedError,
@ -86,7 +87,8 @@ class FilePreviewApi(Resource):
)
@service_api_ns.response(200, "File retrieved successfully")
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser, file_id: UUID):
@model_validate(FilePreviewQuery)
def get(self, args: FilePreviewQuery, app_model: App, end_user: EndUser, file_id: UUID):
"""
Preview/Download a file that was uploaded via Service API.
@ -95,9 +97,6 @@ class FilePreviewApi(Resource):
"""
file_id_str = str(file_id)
# Parse query parameters
args = FilePreviewQuery.model_validate(request.args.to_dict())
# Validate file ownership and get file objects
_, upload_file = self._validate_file_ownership(file_id_str, app_model.id)

View File

@ -2,7 +2,6 @@ import logging
from typing import Annotated
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
@ -102,7 +101,8 @@ class MessageListApi(Resource):
service_api_ns.models[MessageInfiniteScrollPagination.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser):
@model_validate(MessageListQuery)
def get(self, query_args: MessageListQuery, app_model: App, end_user: EndUser):
"""List messages in a conversation.
Retrieves messages with pagination support using first_id.
@ -111,7 +111,6 @@ class MessageListApi(Resource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
query_args = MessageListQuery.model_validate(request.args.to_dict())
conversation_id = query_args.conversation_id
first_id = query_args.first_id or None
@ -212,12 +211,12 @@ class AppGetFeedbacksApi(Resource):
service_api_ns.models[AppFeedbackListResponse.__name__],
)
@validate_app_token
def get(self, app_model: App):
@model_validate(FeedbackListQuery)
def get(self, query_args: FeedbackListQuery, app_model: App):
"""Get all feedbacks for the application.
Returns paginated list of all feedback submitted for messages in this app.
"""
query_args = FeedbackListQuery.model_validate(request.args.to_dict())
feedbacks = MessageService.get_all_messages_feedbacks(
app_model, page=query_args.page, limit=query_args.limit, session=db.session()
)

View File

@ -23,6 +23,7 @@ from controllers.service_api.schema import (
USER_REQUIRED_ATTR,
)
from enums import CloudPlan, DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.login import current_user
@ -196,7 +197,7 @@ def cloud_edition_billing_resource_check[**P, R](
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return view(*args, **kwargs)
vector_space = FeatureService.get_vector_space(api_token.tenant_id)
vector_space = application_services().feature_queries.get_workspace_vector_space(api_token.tenant_id)
if vector_space.usage_unknown:
features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True)
if features.billing.enabled and features.billing.subscription.plan == CloudPlan.SANDBOX:

View File

@ -32,5 +32,5 @@ class SystemFeatureApi(Resource):
"""
return dump_response(
SystemFeatureModel,
application_services().feature_queries.get_system_features(),
application_services().feature_queries.get_public_system_features(),
)

View File

@ -1,27 +1,22 @@
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from werkzeug.exceptions import NotFound, Unauthorized
from configs import dify_config
from constants import HEADER_NAME_APP_CODE
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import WebAppAuthRequiredError
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_access_token
from models.enums import EndUserType
from models.model import App, EndUser, Site
from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
from services.entities.passport_entities import WebPassportRequest
from services.web_passport_service import (
WebPassportAuthenticationRequiredError,
WebPassportNotFoundError,
WebPassportUnauthorizedError,
)
class PassportQuery(BaseModel):
@ -40,7 +35,7 @@ register_response_schema_models(web_ns, PassportAccessTokenResponse)
@web_ns.route("/passport")
class PassportResource(Resource):
"""Base resource for passport."""
"""Issue an authentication passport for a deployed web application."""
@web_ns.doc("get_passport")
@web_ns.doc(description="Get authentication passport for web application access")
@ -54,207 +49,23 @@ class PassportResource(Resource):
)
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[PassportAccessTokenResponse.__name__])
def get(self):
system_features = FeatureService.get_system_features()
app_code = request.headers.get(HEADER_NAME_APP_CODE)
user_id = request.args.get("user_id")
access_token = extract_webapp_access_token(request)
if app_code is None:
raise Unauthorized("X-App-Code header is missing.")
if system_features.webapp_auth.enabled:
enterprise_user_decoded = decode_enterprise_webapp_user_id(access_token)
app_auth_type = WebAppAuthService.get_app_auth_type(app_code=app_code, session=db.session())
if app_auth_type != WebAppAuthType.PUBLIC:
if not enterprise_user_decoded:
raise WebAppAuthRequiredError()
return dump_response(
PassportAccessTokenResponse,
exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
),
)
# get site from db and check if it is normal
site = db.session.scalar(select(Site).where(Site.code == app_code, Site.status == "normal"))
if not site:
raise NotFound()
# get app from db and check if it is normal and enable_site
app_model = db.session.scalar(select(App).where(App.id == site.app_id))
if not app_model or app_model.status != "normal" or not app_model.enable_site:
raise NotFound()
if user_id:
end_user = db.session.scalar(
select(EndUser).where(EndUser.app_id == app_model.id, EndUser.session_id == user_id)
)
if end_user:
pass
else:
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=user_id,
)
db.session.add(end_user)
db.session.commit()
else:
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=generate_session_id(),
)
db.session.add(end_user)
db.session.commit()
payload = {
"iss": site.app_id,
"sub": "Web API Passport",
"app_id": site.app_id,
"app_code": app_code,
"end_user_id": end_user.id,
}
tk = PassportService().issue(payload)
return dump_response(PassportAccessTokenResponse, {"access_token": tk})
def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None:
"""
Decode the enterprise user session from the Authorization header.
"""
if not jwt_token:
return None
decoded: dict[str, Any] = PassportService().verify(jwt_token)
source = decoded.get("token_source")
if not source or source != "webapp_login_token":
raise Unauthorized("Invalid token source. Expected 'webapp_login_token'.")
return decoded
def exchange_token_for_existing_web_user(
app_code: str, enterprise_user_decoded: dict[str, Any], auth_type: WebAppAuthType
):
"""
Exchange a token for an existing web user session.
"""
user_id = enterprise_user_decoded.get("user_id")
end_user_id = enterprise_user_decoded.get("end_user_id")
session_id = enterprise_user_decoded.get("session_id")
user_auth_type = enterprise_user_decoded.get("auth_type")
exchanged_token_expires_unix = enterprise_user_decoded.get("exp")
if not user_auth_type:
raise Unauthorized("Missing auth_type in the token.")
site = db.session.scalar(select(Site).where(Site.code == app_code, Site.status == "normal"))
if not site:
raise NotFound()
app_model = db.session.scalar(select(App).where(App.id == site.app_id))
if not app_model or app_model.status != "normal" or not app_model.enable_site:
raise NotFound()
match auth_type:
case WebAppAuthType.PUBLIC:
return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded)
case WebAppAuthType.EXTERNAL:
if user_auth_type != "external":
raise WebAppAuthRequiredError("Please login as external user.")
case WebAppAuthType.INTERNAL:
if user_auth_type != "internal":
raise WebAppAuthRequiredError("Please login as internal user.")
end_user = None
if end_user_id:
end_user = db.session.scalar(select(EndUser).where(EndUser.id == end_user_id))
if session_id:
end_user = db.session.scalar(
select(EndUser).where(
EndUser.session_id == session_id,
EndUser.tenant_id == app_model.tenant_id,
EndUser.app_id == app_model.id,
)
query = PassportQuery.model_validate(request.args.to_dict(flat=True))
passport_request = WebPassportRequest(
app_code=app_code,
user_session_id=query.user_id,
access_token=extract_webapp_access_token(request),
)
if not end_user:
if not session_id:
raise NotFound("Missing session_id for existing web user.")
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=session_id,
)
db.session.add(end_user)
db.session.commit()
try:
result = application_services().web_passport.issue(passport_request)
except WebPassportAuthenticationRequiredError as exc:
raise WebAppAuthRequiredError(str(exc)) from exc
except WebPassportUnauthorizedError as exc:
raise Unauthorized(str(exc)) from exc
except WebPassportNotFoundError as exc:
raise NotFound(str(exc) or None) from exc
exp = int((datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)).timestamp())
if exchanged_token_expires_unix:
exp = int(exchanged_token_expires_unix)
payload = {
"iss": site.id,
"sub": "Web API Passport",
"app_id": site.app_id,
"app_code": site.code,
"user_id": user_id,
"end_user_id": end_user.id,
"auth_type": user_auth_type,
"granted_at": int(datetime.now(UTC).timestamp()),
"token_source": "webapp",
"exp": exp,
}
token: str = PassportService().issue(payload)
return {"access_token": token}
def _exchange_for_public_app_token(app_model, site, token_decoded):
user_id = token_decoded.get("user_id")
end_user = None
if user_id:
end_user = db.session.scalar(
select(EndUser).where(EndUser.app_id == app_model.id, EndUser.session_id == user_id)
)
if not end_user:
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=generate_session_id(),
)
db.session.add(end_user)
db.session.commit()
payload = {
"iss": site.app_id,
"sub": "Web API Passport",
"app_id": site.app_id,
"app_code": site.code,
"end_user_id": end_user.id,
}
tk = PassportService().issue(payload)
return {"access_token": tk}
def generate_session_id():
"""
Generate a unique session ID.
"""
while True:
session_id = str(uuid.uuid4())
existing_count = db.session.scalar(
select(func.count()).select_from(EndUser).where(EndUser.session_id == session_id)
)
if existing_count == 0:
return session_id
return dump_response(PassportAccessTokenResponse, {"access_token": result.access_token})

View File

@ -18,7 +18,7 @@ from libs.token import extract_webapp_passport
from models.model import App, EndUser, Site
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode, WebAppSettings
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from services.webapp_auth_service import WebAppAuthService
@ -44,7 +44,7 @@ def validate_jwt_token[**P, R](
def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) -> tuple[App, EndUser]:
system_features = FeatureService.get_system_features()
webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled()
if not app_code:
app_code = str(request.headers.get(HEADER_NAME_APP_CODE))
try:
@ -75,21 +75,19 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) ->
# for enterprise webapp auth
app_web_auth_enabled = False
webapp_settings = None
if system_features.webapp_auth.enabled:
if webapp_auth_enabled:
app_id = AppService.get_app_id_by_code(app_code, session=db.session())
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
if not webapp_settings:
raise NotFound("Web app settings not found.")
app_web_auth_enabled = webapp_settings.access_mode != WebAppAccessMode.PUBLIC
_validate_webapp_token(decoded, app_web_auth_enabled, system_features.webapp_auth.enabled)
_validate_user_accessibility(
decoded, app_code, app_web_auth_enabled, system_features.webapp_auth.enabled, webapp_settings
)
_validate_webapp_token(decoded, app_web_auth_enabled, webapp_auth_enabled)
_validate_user_accessibility(decoded, app_code, app_web_auth_enabled, webapp_auth_enabled, webapp_settings)
return app_model, end_user
except Unauthorized as e:
if system_features.webapp_auth.enabled:
if webapp_auth_enabled:
if not app_code:
raise Unauthorized("Please re-login to access the web app.")
app_id = AppService.get_app_id_by_code(app_code, session=db.session())

View File

@ -69,9 +69,9 @@ def check_credential_policy_compliance(
CheckCredentialPolicyComplianceRequest,
PluginManagerService,
)
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
if not FeatureService.is_plugin_manager_enabled() or not credential_id:
if not SystemFeatureService.is_plugin_manager_enabled() or not credential_id:
return
# Check if credential exists in database first (if requested)

View File

@ -69,7 +69,7 @@ from services.enterprise.plugin_manager_service import (
)
from services.entities.feature_entities import PluginInstallationPermissionModel, PluginInstallationScope
from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
logger = logging.getLogger(__name__)
_provider_entities_adapter: TypeAdapter[list[PluginModelProviderDeclaration]] = TypeAdapter(
@ -667,7 +667,7 @@ class PluginService:
@staticmethod
def _get_plugin_installation_permission() -> PluginInstallationPermissionModel:
"""Resolve the validated policy and reject deny-all before any installation side effect."""
permission = FeatureService.get_plugin_installation_permission()
permission = SystemFeatureService.get_plugin_installation_permission()
if permission.plugin_installation_scope == PluginInstallationScope.NONE:
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
return permission

View File

@ -3,7 +3,9 @@
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import cast
from uuid import uuid4
import httpx
from flask import Flask, current_app
@ -19,6 +21,7 @@ from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import RedisClientWrapper, redis_client
from libs.datetime_utils import naive_utc_now
from libs.helper import RateLimiter
from libs.passport import PassportService
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.account_repository import SQLAlchemyAccountRepository
@ -35,6 +38,7 @@ from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourSt
from repositories.tag_repository import TagRepository
from repositories.trial_app_query_repository import TrialAppQueryRepository
from repositories.trial_app_usage_repository import TrialAppUsageRepository
from repositories.web_passport_repository import WebPassportRepository
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
from repositories.workflow_run_archive_repository import WorkflowRunArchiveBundleQueryRepository
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
@ -101,10 +105,10 @@ from services.enterprise.enterprise_service import EnterpriseService
from services.errors.enterprise import EnterpriseServiceError
from services.explore_banner_query_service import ExploreBannerQueryService
from services.feature_query_service import FeatureQueryService
from services.feature_service import FeatureService
from services.feature_service_gateway import FeatureServiceGateway
from services.file_service import FileService
from services.init_validation_service import InitValidationService
from services.inner_mail_service import InnerMailService
from services.notification_gateway import BillingNotificationGateway
from services.notification_service import NotificationService
from services.notion_data_source_gateway import NotionDataSourceGateway
@ -126,9 +130,15 @@ from services.schema_definition_service import SchemaDefinitionService
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
from services.setup_service import SetupService
from services.step_by_step_tour_service import StepByStepTourService
from services.system_feature_service import SystemFeatureService
from services.tag_application_service import TagApplicationService
from services.trial_app_usage import TrialAppUsageRecorder
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
from services.web_passport_gateways import (
DeploymentWebPassportAuthGateway,
PassportTokenGateway,
)
from services.web_passport_service import WebPassportService
from services.webapp_access_query_service import (
WebAppAccessQueryService,
WebAppAccessUnavailableError,
@ -138,6 +148,7 @@ from services.workspace_member_query_service import WorkspaceMemberQueryService
from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver
from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway
from services.workspace_query_service import WorkspaceQueryService
from tasks.mail_inner_task import enqueue_inner_mail
_EXTENSION_KEY = "application_services"
@ -200,6 +211,8 @@ class ApplicationServices:
workflow_run_archives: WorkflowRunArchiveService
workspace_queries: WorkspaceQueryService
workspace_member_queries: WorkspaceMemberQueryService
inner_mail: InnerMailService
web_passport: WebPassportService
tags: TagApplicationService
workflow_statistics: WorkflowStatisticQueryService
@ -257,7 +270,7 @@ def build_application_services(
feature_gateway = FeatureServiceGateway()
accounts = SQLAlchemyAccountRepository(session_factory=database_client)
integrations = SQLAlchemyAccountIntegrationRepository(session_factory=database_client)
trial_app_enabled = FeatureService.is_trial_app_enabled()
trial_app_enabled = SystemFeatureService.is_trial_app_enabled()
database_catalog = DatabaseRecommendedAppCatalogRepository(session_factory=database_client, redis=redis)
builtin_catalog = BuiltinRecommendedAppCatalogGateway()
remote_catalog = RemoteRecommendedAppCatalogGateway()
@ -409,7 +422,7 @@ def build_application_services(
data_source_oauth=_build_data_source_oauth_services(database_client=database_client),
webapp_access=WebAppAccessQueryService(
access=WebAppAccessQueryRepository(session_factory=database_client),
webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(),
webapp_auth_enabled=SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition),
access_mode_for_app=_get_enterprise_webapp_access_mode,
is_user_allowed_for_app=_is_user_allowed_to_access_webapp,
),
@ -421,7 +434,7 @@ def build_application_services(
),
explore_banner_queries=ExploreBannerQueryService(
banners=ExploreBannerQueryRepository(session_factory=database_client),
enabled=FeatureService.is_explore_banner_enabled(),
enabled=SystemFeatureService.is_explore_banner_enabled(),
),
schema_definitions=SchemaDefinitionService(source_factory=SchemaManager),
setup=SetupService(
@ -475,6 +488,20 @@ def build_application_services(
),
roles=DeploymentWorkspaceMemberRoleResolver(),
),
inner_mail=InnerMailService(dispatch=enqueue_inner_mail),
web_passport=WebPassportService(
passports=WebPassportRepository(
session_factory=database_client,
generate_session_id=lambda: str(uuid4()),
),
auth=DeploymentWebPassportAuthGateway(
webapp_auth_enabled=SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition),
get_app_access_mode=EnterpriseService.WebAppAuth.get_app_access_mode_by_id,
),
tokens=PassportTokenGateway(passport=PassportService()),
now=lambda: datetime.now(UTC),
access_token_expire_minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES,
),
tags=TagApplicationService(
tags=TagRepository(session_factory=database_client),
),

View File

@ -18,7 +18,7 @@ from werkzeug.exceptions import NotFound
from libs import jws
from libs.token import is_secure
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
logger = logging.getLogger(__name__)
@ -40,8 +40,7 @@ def enterprise_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
settings = FeatureService.get_system_features()
if settings.license.status not in _EE_ENABLED_STATUSES:
if SystemFeatureService.get_license_status() not in _EE_ENABLED_STATUSES:
raise NotFound()
return view(*args, **kwargs)

View File

@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
from extensions.ext_mail import mail
from services.entities.feature_entities import BrandingModel
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class EmailType(StrEnum):
@ -136,7 +136,7 @@ class FeatureBrandingService:
def get_branding_config(self) -> BrandingModel:
"""Get branding configuration from feature service."""
return FeatureService.get_system_features().branding
return SystemFeatureService.get_branding()
class EmailSender(Protocol):

View File

@ -3,7 +3,7 @@ Workspace permission helper functions.
These helpers check both billing/plan level and workspace-specific policy level permissions.
Checks are performed at two levels:
1. Billing/plan level - via FeatureService (e.g., SANDBOX plan restrictions)
1. Billing/plan level - via an injected owner-transfer policy value
2. Workspace policy level - via EnterpriseService (admin-configured per workspace)
"""
@ -14,7 +14,6 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from enums import DeploymentEdition
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
@ -45,7 +44,11 @@ def check_workspace_member_invite_permission(workspace_id: str) -> None:
logger.exception("Failed to check workspace invite permission for %s", workspace_id)
def check_workspace_owner_transfer_permission(workspace_id: str) -> None:
def check_workspace_owner_transfer_permission(
workspace_id: str,
*,
owner_transfer_allowed: bool,
) -> None:
"""
Check if workspace allows owner transfer at both billing and policy levels.
@ -55,12 +58,12 @@ def check_workspace_owner_transfer_permission(workspace_id: str) -> None:
Args:
workspace_id: The workspace ID to check permissions for
owner_transfer_allowed: Whether the workspace plan permits ownership transfer
Raises:
Forbidden: If either billing plan or workspace policy prohibits ownership transfer
"""
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
if not features.is_allow_transfer_workspace:
if not owner_transfer_allowed:
raise Forbidden("Your current plan does not allow workspace ownership transfer")
# Check the enterprise workspace policy only in the Enterprise edition.

View File

@ -212,17 +212,9 @@ class Dataset(Base):
enable_api = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
is_multimodal = mapped_column(sa.Boolean, default=False, nullable=False, server_default=sa.text("false"))
@property
def total_documents(self) -> int:
return self.get_total_documents(session=db.session())
def get_total_documents(self, *, session: Session) -> int:
return self.get_document_count(session=session)
@property
def total_available_documents(self) -> int:
return self.get_total_available_documents(session=db.session())
def get_total_available_documents(self, *, session: Session) -> int:
return (
session.scalar(
@ -258,20 +250,12 @@ class Dataset(Base):
def get_created_by_account(self, *, session: Session) -> Account | None:
return session.get(Account, self.created_by)
@property
def author_name(self) -> str | None:
return self.get_author_name(session=db.session())
def get_author_name(self, *, session: Session) -> str | None:
account = self.get_created_by_account(session=session)
if account:
return account.name
return None
@property
def latest_process_rule(self):
return self.get_latest_process_rule(session=db.session())
def get_latest_process_rule(self, *, session: Session) -> "DatasetProcessRule | None":
return session.scalar(
select(DatasetProcessRule)
@ -391,10 +375,6 @@ class Dataset(Base):
return tags or []
@property
def external_knowledge_info(self) -> dict[str, Any] | None:
return self.get_external_knowledge_info(session=db.session())
def get_external_knowledge_info(self, *, session: Session) -> dict[str, Any] | None:
if self.provider != "external":
return None
@ -974,17 +954,15 @@ class DocumentSegment(TypeBase):
"""Load the owning document with the caller-owned database session."""
return session.get(Document, self.document_id)
@property
def previous_segment(self):
return db.session.scalar(
def previous_segment(self, session: Session) -> "DocumentSegment | None":
return session.scalar(
select(DocumentSegment).where(
DocumentSegment.document_id == self.document_id, DocumentSegment.position == self.position - 1
)
)
@property
def next_segment(self):
return db.session.scalar(
def next_segment(self, session: Session) -> "DocumentSegment | None":
return session.scalar(
select(DocumentSegment).where(
DocumentSegment.document_id == self.document_id, DocumentSegment.position == self.position + 1
)
@ -1204,9 +1182,8 @@ class AppDatasetJoin(TypeBase):
DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False
)
@property
def app(self):
return db.session.get(App, self.app_id)
def app(self, session: Session) -> App | None:
return session.get(App, self.app_id)
class DatasetQuery(TypeBase):

View File

@ -0,0 +1,150 @@
"""SQLAlchemy persistence adapter for web passport issuance."""
from collections.abc import Callable
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
from models.enums import AppStatus, EndUserType
from models.model import App, EndUser, Site
from services.entities.passport_entities import EndUserRecord, WebAppRecord, WebPassportEndUserResolution
class WebPassportRepository:
def __init__(
self,
*,
session_factory: sessionmaker[Session],
generate_session_id: Callable[[], str],
) -> None:
self._session_factory = session_factory
self._generate_session_id = generate_session_id
def get_active_web_app(self, app_code: str) -> WebAppRecord | None:
stmt = self._active_web_app_stmt(app_code).limit(1)
with self._session_factory() as session:
row = session.execute(stmt).one_or_none()
if row is None:
return None
site_id, app_id, tenant_id, persisted_app_code = row
return WebAppRecord(
site_id=str(site_id),
app_id=str(app_id),
tenant_id=str(tenant_id),
app_code=str(persisted_app_code),
)
def is_web_app_active(self, app: WebAppRecord) -> bool:
with self._session_factory() as session:
return self._is_web_app_active(session, app)
def resolve_standard_end_user(
self,
app: WebAppRecord,
session_id: str | None,
) -> WebPassportEndUserResolution:
with self._session_factory.begin() as session:
if not self._is_web_app_active(session, app):
return WebPassportEndUserResolution(app_active=False, end_user=None)
if session_id:
end_user = self._find_end_user_by_session_id(session, app, session_id)
if end_user is not None:
return WebPassportEndUserResolution(app_active=True, end_user=end_user)
else:
session_id = self._generate_unique_session_id(session)
end_user = self._create_anonymous_end_user(session, app, session_id)
return WebPassportEndUserResolution(app_active=True, end_user=end_user)
def resolve_authenticated_end_user(
self,
app: WebAppRecord,
*,
end_user_id: str | None,
session_id: str | None,
) -> WebPassportEndUserResolution:
with self._session_factory.begin() as session:
if not self._is_web_app_active(session, app):
return WebPassportEndUserResolution(app_active=False, end_user=None)
end_user = None
if session_id:
end_user = self._find_end_user_by_session_id(session, app, session_id)
if end_user is None:
end_user = self._create_anonymous_end_user(session, app, session_id)
elif end_user_id:
end_user = self._find_end_user_by_id(session, app, end_user_id)
return WebPassportEndUserResolution(app_active=True, end_user=end_user)
@staticmethod
def _active_web_app_stmt(app_code: str):
return (
select(Site.id, App.id, App.tenant_id, Site.code)
.join(App, App.id == Site.app_id)
.where(
Site.code == app_code,
Site.status == AppStatus.NORMAL,
App.status == AppStatus.NORMAL,
App.enable_site.is_(True),
)
)
def _is_web_app_active(self, session: Session, app: WebAppRecord) -> bool:
stmt = self._active_web_app_stmt(app.app_code).where(
Site.id == app.site_id,
App.id == app.app_id,
App.tenant_id == app.tenant_id,
)
return session.execute(stmt.limit(1)).one_or_none() is not None
@staticmethod
def _find_end_user_by_id(session: Session, app: WebAppRecord, end_user_id: str) -> EndUserRecord | None:
persisted_id = session.scalar(
select(EndUser.id).where(
EndUser.id == end_user_id,
EndUser.tenant_id == app.tenant_id,
EndUser.app_id == app.app_id,
)
)
return EndUserRecord(id=persisted_id) if persisted_id is not None else None
@staticmethod
def _find_end_user_by_session_id(
session: Session,
app: WebAppRecord,
session_id: str,
) -> EndUserRecord | None:
end_user_id = session.scalar(
select(EndUser.id).where(
EndUser.session_id == session_id,
EndUser.tenant_id == app.tenant_id,
EndUser.app_id == app.app_id,
)
)
return EndUserRecord(id=end_user_id) if end_user_id is not None else None
@staticmethod
def _create_anonymous_end_user(
session: Session,
app: WebAppRecord,
session_id: str,
) -> EndUserRecord:
end_user = EndUser(
tenant_id=app.tenant_id,
app_id=app.app_id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=session_id,
)
session.add(end_user)
session.flush()
return EndUserRecord(id=end_user.id)
def _generate_unique_session_id(self, session: Session) -> str:
while True:
session_id = self._generate_session_id()
stmt = select(func.count()).select_from(EndUser).where(EndUser.session_id == session_id)
if not session.scalar(stmt):
return session_id

View File

@ -79,8 +79,8 @@ from services.errors.account import (
SeatsLimitExceededError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.system_feature_service import SystemFeatureService
from services.telemetry_service import CommunityTelemetryService
from tasks.mail_change_mail_task import (
send_change_mail_completed_notification_task,
@ -446,7 +446,7 @@ class AccountService:
session: Session,
) -> Account:
"""Create an account, preferring explicit user timezone over language-derived defaults."""
if not FeatureService.get_system_features().is_allow_register and not is_setup:
if not SystemFeatureService.is_registration_allowed() and not is_setup:
from controllers.console.error import AccountNotFound
raise AccountNotFound()
@ -458,7 +458,7 @@ class AccountService:
# account into another workspace does not pass through here and costs no seat.
# get_license() carries the full license payload that server-side enforcement needs;
# the public system-features endpoint exposes only license status.
if not FeatureService.get_license().seats.is_available():
if not SystemFeatureService.get_license().seats.is_available():
raise SeatsLimitExceededError("licensed seats limit exceeded")
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
@ -1139,7 +1139,7 @@ class TenantService:
session: Session,
) -> Tenant:
"""Create tenant"""
if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
if not SystemFeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
from controllers.console.error import NotAllowedCreateWorkspace
raise NotAllowedCreateWorkspace()
@ -1202,10 +1202,10 @@ class TenantService:
owner. It persists the legacy membership before creating the matching
RBAC role binding, then makes the workspace current for the account.
"""
if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
if not SystemFeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
raise WorkSpaceNotAllowedCreateError()
workspaces = FeatureService.get_license().workspaces
workspaces = SystemFeatureService.get_license().workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceededError()
@ -1938,9 +1938,9 @@ class RegisterService:
AccountService.link_account_integrate(provider, open_id, account, session=session)
if (
FeatureService.is_workspace_creation_allowed()
SystemFeatureService.is_workspace_creation_allowed()
and create_workspace_required
and FeatureService.get_license().workspaces.is_available()
and SystemFeatureService.get_license().workspaces.is_available()
):
try:
TenantService.create_owner_tenant(account, session=session)

View File

@ -44,7 +44,7 @@ from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentW
from services.app_service import AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
@ -1120,7 +1120,7 @@ class AgentRosterService:
source_include_draft=not source_agent.active_config_is_published,
)
self._session.commit()
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
try:
original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id)
access_mode = original_settings.access_mode

View File

@ -49,8 +49,8 @@ from services.agent.workspace_service import AgentWorkspaceService
from services.billing_service import BillingService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
from services.system_feature_service import SystemFeatureService
from services.tag_service import TagService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
@ -703,7 +703,7 @@ class AppService:
app.id,
)
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private")
@ -1155,7 +1155,7 @@ class AppService:
)
# clean up web app settings
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:

View File

@ -488,7 +488,6 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
_LEGACY_APP_OWNER_KEYS: list[str] = [
"app.acl.preview",
"app.acl.access_point_manage",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",
@ -499,12 +498,13 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
"app.acl.access_config",
"app.acl.tracing_config",
"app.acl.log_and_annotation",
"app.acl.access_point_manage",
"app.acl.access_point_view",
]
_LEGACY_APP_ADMIN_KEYS: list[str] = [
"app.acl.preview",
"app.acl.view_layout",
"app.acl.access_point_manage",
"app.acl.test_and_run",
"app.acl.edit",
"app.acl.import_export_dsl",
@ -515,11 +515,12 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
"app.acl.access_config",
"app.acl.tracing_config",
"app.acl.log_and_annotation",
"app.acl.access_point_manage",
"app.acl.access_point_view",
]
_LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.preview",
"app.acl.access_point_manage",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",
@ -529,10 +530,13 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.monitor",
"app.acl.log_and_annotation",
"app.acl.access_config",
"app.acl.access_point_manage",
"app.acl.access_point_view",
]
_LEGACY_APP_NORMAL_KEYS: list[str] = [
"app.acl.monitor",
"app.acl.access_point_view",
]
_LEGACY_DATASET_OWNER_KEYS: list[str] = [

View File

@ -0,0 +1,12 @@
"""Framework-neutral data contracts for internal mail delivery."""
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class InnerMailMessage:
recipients: tuple[str, ...]
subject: str
body: str
substitutions: dict[str, Any] | None = None

View File

@ -0,0 +1,47 @@
"""Framework-neutral data contracts for web passport issuance."""
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict
@dataclass(frozen=True, slots=True)
class WebAppRecord:
site_id: str
app_id: str
tenant_id: str
app_code: str
@dataclass(frozen=True, slots=True)
class EndUserRecord:
id: str
@dataclass(frozen=True, slots=True)
class WebPassportEndUserResolution:
app_active: bool
end_user: EndUserRecord | None
@dataclass(frozen=True, slots=True)
class WebPassportRequest:
app_code: str
user_session_id: str | None
access_token: str | None
@dataclass(frozen=True, slots=True)
class WebPassportResult:
access_token: str
class WebAppLoginClaims(BaseModel):
token_source: str | None = None
user_id: str | None = None
end_user_id: str | None = None
session_id: str | None = None
auth_type: str | None = None
exp: int | None = None
model_config = ConfigDict(extra="ignore")

View File

@ -36,10 +36,16 @@ class FeatureQueryService:
self._app_dsl_version = app_dsl_version
def get_features(self, context: RequestContext) -> FeatureModel:
return self._features.get_workspace_features(self._require_active_workspace(context))
return self.get_workspace_features(self._require_active_workspace(context))
def get_workspace_features(self, workspace_id: str) -> FeatureModel:
return self._features.get_workspace_features(workspace_id)
def get_vector_space(self, context: RequestContext) -> VectorSpaceLimitationModel:
return self._features.get_vector_space(self._require_active_workspace(context))
return self.get_workspace_vector_space(self._require_active_workspace(context))
def get_workspace_vector_space(self, workspace_id: str) -> VectorSpaceLimitationModel:
return self._features.get_vector_space(workspace_id)
def get_trial_models(self, context: RequestContext) -> list[str]:
return self._features.get_trial_models(self._require_active_workspace(context))
@ -47,7 +53,7 @@ class FeatureQueryService:
def get_app_dsl_version(self) -> str:
return self._app_dsl_version
def get_system_features(self) -> SystemFeatureModel:
def get_public_system_features(self) -> SystemFeatureModel:
return self._features.get_public_system_features()
def get_license(self) -> LicenseModel:

View File

@ -1,23 +1,9 @@
import logging
from collections.abc import Mapping
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from configs import dify_config
from enums import CloudPlan, DeploymentEdition, HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
from services.entities import feature_entities
logger = logging.getLogger(__name__)
class _EnterprisePluginInstallationPermission(BaseModel):
model_config = ConfigDict(extra="ignore")
plugin_installation_scope: feature_entities.PluginInstallationScope = Field(alias="pluginInstallationScope")
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
class FeatureService:
@classmethod
@ -102,90 +88,6 @@ class FeatureService:
return False
return features.billing.enabled and features.billing.subscription.plan.is_paid
@classmethod
def get_system_features(cls) -> feature_entities.SystemFeatureModel:
system_features = feature_entities.SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
system_features.webapp_auth.enabled = cls.is_webapp_auth_enabled()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
system_features.branding.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED:
system_features.enable_marketplace = True
if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
system_features.enable_creators_platform = True
return system_features
@classmethod
def is_workspace_creation_allowed(cls) -> bool:
"""Resolve the backend workspace-creation policy, including the Enterprise override."""
is_allowed = dify_config.ALLOW_CREATE_WORKSPACE
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_allowed
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("IsAllowCreateWorkspace", is_allowed))
@classmethod
def is_plugin_manager_enabled(cls) -> bool:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@classmethod
def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@classmethod
def get_license(cls) -> feature_entities.LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
Non-enterprise deployments have no license, so an unconstrained default
(unlimited seats/workspaces) is returned.
"""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.LicenseModel()
license_model = cls._build_license(EnterpriseService.get_info())
license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE
return license_model
@staticmethod
def is_explore_banner_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER
@staticmethod
def is_webapp_auth_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@staticmethod
def is_trial_app_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel):
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL
system_features.enable_explore_banner = cls.is_explore_banner_enabled()
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
system_features.knowledge_fs_enabled = dify_config.KNOWLEDGE_FS_ENABLED
@classmethod
def _fulfill_trial_models_from_env(cls, quota_types: tuple[str, ...] | None = None) -> list[str]:
allowed_quota_types = quota_types or ("PAID", "TRIAL")
@ -311,125 +213,3 @@ class FeatureService:
# but feature API keeps LimitationModel.size as int for compatibility.
vector_space.size = int(billing_info["vector_space"]["size"])
vector_space.limit = billing_info["vector_space"]["limit"]
@classmethod
def _build_license(cls, enterprise_info: dict) -> feature_entities.LicenseModel:
license_model = feature_entities.LicenseModel()
if license_info := enterprise_info.get("License"):
license_model.status = feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
license_model.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
license_model.workspaces = feature_entities.LicenseLimitationModel(
enabled=workspaces_info.get("enabled", False),
limit=workspaces_info.get("limit", 0),
size=workspaces_info.get("used", 0),
)
if seats_info := license_info.get("licensedSeats"):
license_model.seats = feature_entities.LicenseLimitationModel(
enabled=seats_info.get("enabled", False),
limit=seats_info.get("limit", 0),
size=seats_info.get("used", 0),
)
return license_model
@classmethod
def _resolve_plugin_installation_permission(
cls, enterprise_info: Mapping[str, object]
) -> feature_entities.PluginInstallationPermissionModel:
if "PluginInstallationPermission" not in enterprise_info:
return feature_entities.PluginInstallationPermissionModel()
try:
permission = _EnterprisePluginInstallationPermission.model_validate(
enterprise_info["PluginInstallationPermission"]
)
except ValidationError as exc:
# Do not attach the exception because it may contain raw Enterprise configuration values.
logger.error( # noqa: TRY400
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
exc.errors(include_input=False),
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=feature_entities.PluginInstallationScope.NONE,
restrict_to_marketplace_only=True,
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=permission.plugin_installation_scope,
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
)
@staticmethod
def _resolve_sso_protocol(value: object, *, field_name: str) -> feature_entities.SSOProtocol | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
if not isinstance(value, str):
logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name)
return None
try:
return feature_entities.SSOProtocol(value)
except ValueError:
logger.error( # noqa: TRY400
"Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name
)
return None
@classmethod
def _fulfill_params_from_enterprise(cls, features: feature_entities.SystemFeatureModel):
enterprise_info = EnterpriseService.get_info()
if "SSOEnforcedForSignin" in enterprise_info:
features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"]
features.sso_enforced_for_signin_protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForSigninProtocol"),
field_name="SSOEnforcedForSigninProtocol",
)
if "EnableEmailCodeLogin" in enterprise_info:
features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"]
if "EnableEmailPasswordLogin" in enterprise_info:
features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"]
if "IsAllowRegister" in enterprise_info:
features.is_allow_register = enterprise_info["IsAllowRegister"]
if "EnableAppDeploy" in enterprise_info:
features.enable_app_deploy = enterprise_info["EnableAppDeploy"]
if "Branding" in enterprise_info:
features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "")
features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "")
features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "")
features.branding.favicon = enterprise_info["Branding"].get("favicon", "")
if "WebAppAuth" in enterprise_info:
features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False)
features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get(
"allowEmailCodeLogin", False
)
features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get(
"allowEmailPasswordLogin", False
)
features.webapp_auth.sso_config.protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForWebProtocol"),
field_name="SSOEnforcedForWebProtocol",
)
# SECURITY NOTE: system-features is unauthenticated, so it exposes only license
# *status* — enough for the login page to detect an expired/inactive license after
# force-logout. Full license detail (expiry, workspace/seat usage) is served
# separately by get_license() behind an authenticated endpoint.
if license_info := enterprise_info.get("License"):
features.license = feature_entities.LicenseStatusModel(
status=feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
)
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)

View File

@ -1,4 +1,4 @@
"""Feature-query gateway backed by the existing FeatureService."""
"""Feature-query gateway combining workspace and deployment feature providers."""
from typing import override
@ -10,10 +10,11 @@ from services.entities.feature_entities import (
)
from services.feature_query_service import FeatureQueryGateway
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class FeatureServiceGateway(FeatureQueryGateway):
"""Read dynamic feature resources through FeatureService."""
"""Read workspace features from FeatureService and deployment features from SystemFeatureService."""
@override
def get_workspace_features(self, workspace_id: str) -> FeatureModel:
@ -29,8 +30,8 @@ class FeatureServiceGateway(FeatureQueryGateway):
@override
def get_public_system_features(self) -> SystemFeatureModel:
return FeatureService.get_system_features()
return SystemFeatureService.get_public_system_features()
@override
def get_license(self) -> LicenseModel:
return FeatureService.get_license()
return SystemFeatureService.get_license()

View File

@ -0,0 +1,17 @@
"""Application service for mail received through the inner API."""
from typing import Protocol
from services.entities.mail_entities import InnerMailMessage
class InnerMailDispatcher(Protocol):
def __call__(self, message: InnerMailMessage) -> None: ...
class InnerMailService:
def __init__(self, *, dispatch: InnerMailDispatcher) -> None:
self._dispatch = dispatch
def send(self, message: InnerMailMessage) -> None:
self._dispatch(message)

View File

@ -8,7 +8,7 @@ from libs.helper import escape_like_pattern
from models import App, AppModelConfig, InstalledApp, Workflow
from models.model import AppMode
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class InstalledAppCursor(BaseModel):
@ -118,7 +118,7 @@ class InstalledAppService:
escaped_name = escape_like_pattern(normalized_name)
stmt = stmt.where(App.name.ilike(f"%{escaped_name}%", escape="\\"))
webapp_auth_enabled = FeatureService.get_system_features().webapp_auth.enabled
webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled()
scan_size = limit * 2 if webapp_auth_enabled else limit + 1
visible_rows: list[tuple[InstalledApp, App]] = []
scan_cursor = cursor

View File

@ -5,8 +5,7 @@ the EE blueprint chain is what gives CE deploys no callers on this surface
in practice, but the explicit short-circuit avoids any test/fixture that
flips the surface on without flipping the license.
Reuses ``FeatureService.get_system_features()`` so the license status
travels the same path as the console reads.
Uses the narrow system license policy shared with Console admission.
Companion to ``controllers.console.wraps.enterprise_license_required``
that one is for console (cookie-authed, force-logout 401). This one is
@ -24,7 +23,7 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from enums import DeploymentEdition
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
logger = logging.getLogger(__name__)
@ -47,8 +46,8 @@ def license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def _is_license_valid() -> bool:
try:
features = FeatureService.get_system_features()
license_status = SystemFeatureService.get_license_status()
except Exception:
logger.exception("license_gate: FeatureService.get_system_features failed")
logger.exception("license_gate: SystemFeatureService.get_license_status failed")
return False
return features.license.status in _VALID_LICENSE_STATUSES
return license_status in _VALID_LICENSE_STATUSES

View File

@ -0,0 +1,287 @@
"""Deployment-wide feature policies and the public system-features snapshot."""
import logging
from collections.abc import Mapping
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from configs import dify_config
from enums import DeploymentEdition
from services.enterprise.enterprise_service import EnterpriseService
from services.entities import feature_entities
logger = logging.getLogger(__name__)
class _EnterprisePluginInstallationPermission(BaseModel):
model_config = ConfigDict(extra="ignore")
plugin_installation_scope: feature_entities.PluginInstallationScope = Field(alias="pluginInstallationScope")
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
class SystemFeatureService:
"""Resolve deployment-wide policies without exposing the public response DTO internally."""
@classmethod
def get_public_system_features(cls) -> feature_entities.SystemFeatureModel:
"""Build the non-sensitive bootstrap snapshot shared by Console and Web."""
system_features = feature_entities.SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
if cls.is_webapp_auth_enabled():
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED:
system_features.enable_marketplace = True
if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
system_features.enable_creators_platform = True
return system_features
@classmethod
def is_registration_allowed(cls) -> bool:
"""Return the effective registration policy, including the Enterprise override."""
is_allowed = dify_config.ALLOW_REGISTER
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_allowed
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("IsAllowRegister", is_allowed))
@classmethod
def is_email_password_login_enabled(cls) -> bool:
"""Return the effective password-login policy, including the Enterprise override."""
is_enabled = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_enabled
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("EnableEmailPasswordLogin", is_enabled))
@staticmethod
def is_change_email_enabled() -> bool:
"""Return whether Console accounts may change their email address."""
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
return False
return dify_config.ENABLE_CHANGE_EMAIL
@staticmethod
def is_webapp_auth_enabled(*, deployment_edition: DeploymentEdition | None = None) -> bool:
"""Return whether deployment-level WebApp authentication integration is enabled."""
edition = deployment_edition if deployment_edition is not None else dify_config.DEPLOYMENT_EDITION
return edition == DeploymentEdition.ENTERPRISE
@classmethod
def get_license_status(cls) -> feature_entities.LicenseStatus:
"""Return the deployment license status used by internal admission policies."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.LicenseStatus.NONE
return cls._resolve_license_status(EnterpriseService.get_info())
@classmethod
def get_branding(cls) -> feature_entities.BrandingModel:
"""Return the deployment branding used by server-rendered email."""
branding = feature_entities.BrandingModel(enabled=cls.is_webapp_auth_enabled())
if not branding.enabled:
return branding
enterprise_info = EnterpriseService.get_info()
if branding_info := enterprise_info.get("Branding"):
branding.application_title = branding_info.get("applicationTitle", "")
branding.login_page_logo = branding_info.get("loginPageLogo", "")
branding.workspace_logo = branding_info.get("workspaceLogo", "")
branding.favicon = branding_info.get("favicon", "")
return branding
@classmethod
def is_workspace_creation_allowed(cls) -> bool:
"""Resolve the backend workspace-creation policy, including the Enterprise override."""
is_allowed = dify_config.ALLOW_CREATE_WORKSPACE
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_allowed
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("IsAllowCreateWorkspace", is_allowed))
@staticmethod
def is_plugin_manager_enabled() -> bool:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@classmethod
def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@classmethod
def get_license(cls) -> feature_entities.LicenseModel:
"""Return full license detail for authenticated server-side consumers."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.LicenseModel()
license_model = cls._build_license(EnterpriseService.get_info())
license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE
return license_model
@staticmethod
def is_explore_banner_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER
@staticmethod
def is_trial_app_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel) -> None:
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL
system_features.enable_explore_banner = cls.is_explore_banner_enabled()
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
system_features.knowledge_fs_enabled = dify_config.KNOWLEDGE_FS_ENABLED
@classmethod
def _fulfill_params_from_enterprise(cls, features: feature_entities.SystemFeatureModel) -> None:
enterprise_info = EnterpriseService.get_info()
if "SSOEnforcedForSignin" in enterprise_info:
features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"]
features.sso_enforced_for_signin_protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForSigninProtocol"),
field_name="SSOEnforcedForSigninProtocol",
)
if "EnableEmailCodeLogin" in enterprise_info:
features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"]
if "EnableEmailPasswordLogin" in enterprise_info:
features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"]
if "IsAllowRegister" in enterprise_info:
features.is_allow_register = enterprise_info["IsAllowRegister"]
if "EnableAppDeploy" in enterprise_info:
features.enable_app_deploy = enterprise_info["EnableAppDeploy"]
if "Branding" in enterprise_info:
features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "")
features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "")
features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "")
features.branding.favicon = enterprise_info["Branding"].get("favicon", "")
if "WebAppAuth" in enterprise_info:
features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False)
features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get(
"allowEmailCodeLogin", False
)
features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get(
"allowEmailPasswordLogin", False
)
features.webapp_auth.sso_config.protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForWebProtocol"),
field_name="SSOEnforcedForWebProtocol",
)
# The unauthenticated endpoint exposes status only. Full license detail is
# served by the authenticated license endpoint.
license_status = cls._resolve_license_status(enterprise_info)
if license_status != feature_entities.LicenseStatus.NONE:
features.license = feature_entities.LicenseStatusModel(
status=license_status,
)
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
@staticmethod
def _resolve_license_status(enterprise_info: Mapping[str, object]) -> feature_entities.LicenseStatus:
license_info = enterprise_info.get("License")
if not license_info:
return feature_entities.LicenseStatus.NONE
if not isinstance(license_info, Mapping):
return feature_entities.LicenseStatus.INACTIVE
status = license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
if isinstance(status, feature_entities.LicenseStatus):
return status
if isinstance(status, str):
return feature_entities.LicenseStatus(status)
return feature_entities.LicenseStatus.INACTIVE
@classmethod
def _build_license(cls, enterprise_info: dict) -> feature_entities.LicenseModel:
license_model = feature_entities.LicenseModel()
if license_info := enterprise_info.get("License"):
license_model.status = feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
license_model.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
license_model.workspaces = feature_entities.LicenseLimitationModel(
enabled=workspaces_info.get("enabled", False),
limit=workspaces_info.get("limit", 0),
size=workspaces_info.get("used", 0),
)
if seats_info := license_info.get("licensedSeats"):
license_model.seats = feature_entities.LicenseLimitationModel(
enabled=seats_info.get("enabled", False),
limit=seats_info.get("limit", 0),
size=seats_info.get("used", 0),
)
return license_model
@classmethod
def _resolve_plugin_installation_permission(
cls, enterprise_info: Mapping[str, object]
) -> feature_entities.PluginInstallationPermissionModel:
if "PluginInstallationPermission" not in enterprise_info:
return feature_entities.PluginInstallationPermissionModel()
try:
permission = _EnterprisePluginInstallationPermission.model_validate(
enterprise_info["PluginInstallationPermission"]
)
except ValidationError as exc:
logger.error( # noqa: TRY400
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
exc.errors(include_input=False),
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=feature_entities.PluginInstallationScope.NONE,
restrict_to_marketplace_only=True,
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=permission.plugin_installation_scope,
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
)
@staticmethod
def _resolve_sso_protocol(value: object, *, field_name: str) -> feature_entities.SSOProtocol | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
if not isinstance(value, str):
logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name)
return None
try:
return feature_entities.SSOProtocol(value)
except ValueError:
logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name) # noqa: TRY400
return None

View File

@ -0,0 +1,49 @@
"""Outer gateways used by the web passport application service."""
from collections.abc import Callable, Mapping
from typing import Any
from werkzeug.exceptions import Unauthorized
from libs.passport import PassportService
from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, WebAppAccessMode, WebAppSettings
from services.web_passport_service import WebAppAuthType, WebPassportUnauthorizedError
class DeploymentWebPassportAuthGateway:
def __init__(
self,
*,
webapp_auth_enabled: bool,
get_app_access_mode: Callable[[str], WebAppSettings],
) -> None:
self._webapp_auth_enabled = webapp_auth_enabled
self._get_app_access_mode = get_app_access_mode
def is_webapp_auth_enabled(self) -> bool:
return self._webapp_auth_enabled
def get_app_auth_type(self, app_id: str) -> WebAppAuthType:
access_mode = self._get_app_access_mode(app_id).access_mode
if access_mode == WebAppAccessMode.PUBLIC:
return WebAppAuthType.PUBLIC
if access_mode in PERMISSION_CHECK_MODES:
return WebAppAuthType.INTERNAL
if access_mode == WebAppAccessMode.SSO_VERIFIED:
return WebAppAuthType.EXTERNAL
raise ValueError(f"Unsupported web app access mode: {access_mode}")
class PassportTokenGateway:
def __init__(self, *, passport: PassportService) -> None:
self._passport = passport
def verify(self, token: str) -> Mapping[str, Any]:
try:
return self._passport.verify(token)
except Unauthorized as exc:
description = exc.description or "Invalid token."
raise WebPassportUnauthorizedError(description) from exc
def issue(self, payload: Mapping[str, Any]) -> str:
return self._passport.issue(dict(payload))

View File

@ -0,0 +1,185 @@
"""Application service for issuing passports used by deployed web applications."""
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from enum import StrEnum
from typing import Any, Protocol
from pydantic import ValidationError
from services.entities.passport_entities import (
EndUserRecord,
WebAppLoginClaims,
WebAppRecord,
WebPassportEndUserResolution,
WebPassportRequest,
WebPassportResult,
)
class WebAppAuthType(StrEnum):
PUBLIC = "public"
INTERNAL = "internal"
EXTERNAL = "external"
class WebPassportNotFoundError(Exception):
pass
class WebPassportUnauthorizedError(Exception):
pass
class WebPassportAuthenticationRequiredError(Exception):
pass
class WebPassportRepository(Protocol):
def get_active_web_app(self, app_code: str) -> WebAppRecord | None: ...
def is_web_app_active(self, app: WebAppRecord) -> bool: ...
def resolve_standard_end_user(self, app: WebAppRecord, session_id: str | None) -> WebPassportEndUserResolution: ...
def resolve_authenticated_end_user(
self,
app: WebAppRecord,
*,
end_user_id: str | None,
session_id: str | None,
) -> WebPassportEndUserResolution: ...
class WebPassportAuthGateway(Protocol):
def is_webapp_auth_enabled(self) -> bool: ...
def get_app_auth_type(self, app_id: str) -> WebAppAuthType: ...
class WebPassportTokenGateway(Protocol):
def verify(self, token: str) -> Mapping[str, Any]: ...
def issue(self, payload: Mapping[str, Any]) -> str: ...
class WebPassportService:
def __init__(
self,
*,
passports: WebPassportRepository,
auth: WebPassportAuthGateway,
tokens: WebPassportTokenGateway,
now: Callable[[], datetime],
access_token_expire_minutes: int,
) -> None:
self._passports = passports
self._auth = auth
self._tokens = tokens
self._now = now
self._access_token_expire_minutes = access_token_expire_minutes
def issue(self, request: WebPassportRequest) -> WebPassportResult:
app = self._passports.get_active_web_app(request.app_code)
if app is None:
raise WebPassportNotFoundError()
login_claims: WebAppLoginClaims | None = None
if self._auth.is_webapp_auth_enabled():
login_claims = self._decode_login_token(request.access_token)
auth_type = self._auth.get_app_auth_type(app.app_id)
if auth_type != WebAppAuthType.PUBLIC:
if login_claims is None:
raise WebPassportAuthenticationRequiredError("Web app authentication required.")
self._require_active_web_app(app)
return self._exchange_enterprise_token(app, login_claims, auth_type)
end_user = self._resolve_standard_user(app, request.user_session_id)
token = self._tokens.issue(
{
"iss": app.app_id,
"sub": "Web API Passport",
"app_id": app.app_id,
"app_code": app.app_code,
"end_user_id": end_user.id,
}
)
return WebPassportResult(access_token=token)
def _decode_login_token(self, token: str | None) -> WebAppLoginClaims | None:
if not token:
return None
decoded = self._tokens.verify(token)
try:
claims = WebAppLoginClaims.model_validate(decoded)
except ValidationError as exc:
raise WebPassportUnauthorizedError("Invalid web app login token.") from exc
if claims.token_source != "webapp_login_token":
raise WebPassportUnauthorizedError("Invalid token source. Expected 'webapp_login_token'.")
return claims
def _resolve_standard_user(self, app: WebAppRecord, session_id: str | None) -> EndUserRecord:
resolution = self._passports.resolve_standard_end_user(app, session_id)
self._require_active_resolution(resolution)
if resolution.end_user is None:
raise WebPassportNotFoundError()
return resolution.end_user
def _exchange_enterprise_token(
self,
app: WebAppRecord,
claims: WebAppLoginClaims,
auth_type: WebAppAuthType,
) -> WebPassportResult:
user_auth_type = claims.auth_type
if not user_auth_type:
raise WebPassportUnauthorizedError("Missing auth_type in the token.")
if auth_type == WebAppAuthType.EXTERNAL and user_auth_type != WebAppAuthType.EXTERNAL:
raise WebPassportAuthenticationRequiredError("Please login as external user.")
if auth_type == WebAppAuthType.INTERNAL and user_auth_type != WebAppAuthType.INTERNAL:
raise WebPassportAuthenticationRequiredError("Please login as internal user.")
resolution = self._passports.resolve_authenticated_end_user(
app,
end_user_id=claims.end_user_id,
session_id=claims.session_id,
)
self._require_active_resolution(resolution)
if resolution.end_user is None:
if not claims.session_id:
raise WebPassportNotFoundError("Missing session_id for existing web user.")
raise WebPassportNotFoundError()
end_user = resolution.end_user
now = self._now()
expires_at = int((now + timedelta(minutes=self._access_token_expire_minutes)).timestamp())
if claims.exp:
expires_at = int(claims.exp)
token = self._tokens.issue(
{
"iss": app.site_id,
"sub": "Web API Passport",
"app_id": app.app_id,
"app_code": app.app_code,
"user_id": claims.user_id,
"end_user_id": end_user.id,
"auth_type": user_auth_type,
"granted_at": int(now.timestamp()),
"token_source": "webapp",
"exp": expires_at,
}
)
return WebPassportResult(access_token=token)
def _require_active_web_app(self, app: WebAppRecord) -> None:
if not self._passports.is_web_app_active(app):
raise WebPassportNotFoundError()
@staticmethod
def _require_active_resolution(resolution: WebPassportEndUserResolution) -> None:
if not resolution.app_active:
raise WebPassportNotFoundError()

View File

@ -1,4 +1,3 @@
import enum
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
@ -16,19 +15,11 @@ from models.enums import EndUserType
from models.model import App, EndUser, Site
from services.account_service import AccountService
from services.app_service import AppService
from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, EnterpriseService, WebAppAccessMode
from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, EnterpriseService
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
from tasks.mail_email_code_login import send_email_code_login_mail_task
class WebAppAuthType(enum.StrEnum):
"""Enum for web app authentication types."""
PUBLIC = "public"
INTERNAL = "internal"
EXTERNAL = "external"
class WebAppAuthService:
"""Service for web app authentication."""
@ -156,28 +147,3 @@ class WebAppAuthService:
if webapp_settings and webapp_settings.access_mode in PERMISSION_CHECK_MODES:
return True
return False
@classmethod
def get_app_auth_type(
cls, app_code: str | None = None, access_mode: str | None = None, *, session: Session
) -> WebAppAuthType:
"""
Get the authentication type for the app based on its access mode.
"""
if not app_code and not access_mode:
raise ValueError("Either app_code or access_mode must be provided.")
if access_mode:
if access_mode == WebAppAccessMode.PUBLIC:
return WebAppAuthType.PUBLIC
elif access_mode in PERMISSION_CHECK_MODES:
return WebAppAuthType.INTERNAL
elif access_mode == WebAppAccessMode.SSO_VERIFIED:
return WebAppAuthType.EXTERNAL
if app_code:
app_id = AppService.get_app_id_by_code(app_code, session=session)
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id)
return cls.get_app_auth_type(access_mode=webapp_settings.access_mode, session=session)
raise ValueError("Could not determine app authentication type.")

View File

@ -94,6 +94,7 @@ from services.errors.app import (
WorkflowHashNotEqualError,
WorkflowNotFoundError,
)
from services.system_feature_service import SystemFeatureService
from tasks.new_agent_beta_task import register_new_agent_beta_workflow_publish_after_commit
@ -700,9 +701,7 @@ class WorkflowService:
)
# Validate credentials before publishing, for credential policy check
from services.feature_service import FeatureService
if FeatureService.is_plugin_manager_enabled():
if SystemFeatureService.is_plugin_manager_enabled():
self._validate_workflow_credentials(draft_workflow, session=session)
# validate graph structure

View File

@ -13,6 +13,7 @@ from configs import dify_config
from configs.feature import TemplateMode
from extensions.ext_mail import mail
from libs.email_i18n import get_email_i18n_service
from services.entities.mail_entities import InnerMailMessage
logger = logging.getLogger(__name__)
@ -29,7 +30,7 @@ class SandboxedEnvironment(ImmutableSandboxedEnvironment):
return super().call(context, obj, *args, **kwargs)
def _render_template_with_strategy(body: str, substitutions: Mapping[str, str]) -> str:
def _render_template_with_strategy(body: str, substitutions: Mapping[str, Any]) -> str:
mode = dify_config.MAIL_TEMPLATING_MODE
timeout = dify_config.MAIL_TEMPLATING_TIMEOUT
if mode == TemplateMode.UNSAFE:
@ -43,7 +44,7 @@ def _render_template_with_strategy(body: str, substitutions: Mapping[str, str])
@shared_task(queue="mail")
def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, str]):
def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, Any]):
if not mail.is_inited():
return
@ -60,3 +61,12 @@ def send_inner_email_task(to: list[str], subject: str, body: str, substitutions:
logger.info(click.style(f"Send enterprise mail to {to} succeeded: latency: {end_at - start_at}", fg="green"))
except Exception:
logger.exception("Send enterprise mail to %s failed", to)
def enqueue_inner_mail(message: InnerMailMessage) -> None:
send_inner_email_task.delay(
to=list(message.recipients),
subject=message.subject,
body=message.body,
substitutions=message.substitutions or {},
)

View File

@ -21,11 +21,11 @@ from tests.test_containers_integration_tests.helpers import generate_valid_passw
@pytest.fixture
def setup_dependencies() -> Iterator[MagicMock]:
with (
patch("services.account_service.FeatureService") as feature_service,
patch("services.account_service.SystemFeatureService") as feature_service,
patch("services.account_service.BillingService") as billing_service,
patch("services.account_service.CommunityTelemetryService.report_install") as report_install,
):
feature_service.get_system_features.return_value.is_allow_register = True
feature_service.is_registration_allowed.return_value = True
feature_service.get_license.return_value.seats.is_available.return_value = True
feature_service.get_license.return_value.workspaces.is_available.return_value = True
feature_service.is_workspace_creation_allowed.return_value = True

View File

@ -38,8 +38,8 @@ def make_account(db_session_with_containers: Session) -> Callable[..., Account]:
def _make(*, with_owner_tenant: bool = True) -> Account:
fake = Faker()
with patch("services.account_service.FeatureService") as mock_feature_service:
mock_feature_service.get_system_features.return_value.is_allow_register = True
with patch("services.account_service.SystemFeatureService") as mock_feature_service:
mock_feature_service.is_registration_allowed.return_value = True
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
@ -60,7 +60,7 @@ def add_tenant_for_account(
account: Account, *, session: Session, role: str = "normal", name: str = "Second WS"
) -> Tenant:
"""Create an additional tenant and join ``account`` to it (real service calls)."""
with patch("services.account_service.FeatureService") as mock_feature_service:
with patch("services.account_service.SystemFeatureService") as mock_feature_service:
mock_feature_service.is_workspace_creation_allowed.return_value = True
tenant = TenantService.create_tenant(name=name, session=session)
TenantService.create_tenant_member(tenant, account, session, role=role)

View File

@ -51,7 +51,7 @@ def external_deps() -> Generator[dict[str, object], None, None]:
patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service,
patch("services.app_dsl_service.app_was_created") as mock_app_was_created,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
):
mock_workflow_service.return_value.get_draft_workflow.return_value = None
@ -65,7 +65,7 @@ def external_deps() -> Generator[dict[str, object], None, None]:
mock_model_instance.get_default_model_instance.return_value = None
mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo")
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
@ -74,8 +74,8 @@ def external_deps() -> Generator[dict[str, object], None, None]:
def _app_and_account(db_session: Session, *, mode: str = "chat") -> tuple[App, Account]:
fake = Faker()
with patch("services.account_service.FeatureService") as mock_account_feature_service:
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
with patch("services.account_service.SystemFeatureService") as mock_account_feature_service:
mock_account_feature_service.is_registration_allowed.return_value = True
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),

View File

@ -231,7 +231,7 @@ class TestDecodeJwtToken:
@patch("controllers.web.wraps._validate_webapp_token")
@patch("controllers.web.wraps.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
@patch("controllers.web.wraps.AppService.get_app_id_by_code")
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_happy_path(
@ -254,7 +254,7 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": end_user.id,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
result_app, result_user = decode_jwt_token()
@ -262,17 +262,17 @@ class TestDecodeJwtToken:
assert result_app.id == app_model.id
assert result_user.id == end_user.id
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_missing_token_raises_unauthorized(self, mock_extract: MagicMock, mock_features: MagicMock, app) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
mock_extract.return_value = None
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(Unauthorized):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_missing_app_raises_not_found(
@ -289,13 +289,13 @@ class TestDecodeJwtToken:
"app_id": non_existent_id,
"end_user_id": str(uuid4()),
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(NotFound):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_disabled_site_raises_bad_request(
@ -314,13 +314,13 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": end_user.id,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
with pytest.raises(BadRequest, match="Site is disabled"):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_missing_end_user_raises_not_found(
@ -340,13 +340,13 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": non_existent_eu,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
with pytest.raises(NotFound):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_user_id_mismatch_raises_unauthorized(
@ -365,7 +365,7 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": end_user.id,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
with pytest.raises(Unauthorized, match="expired"):

View File

@ -626,10 +626,10 @@ class TestKnowledgeRetrievalIntegration:
@pytest.fixture
def mock_external_service_dependencies():
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"account_feature_service": mock_account_feature_service,

View File

@ -49,7 +49,7 @@ class TestDatasetDocumentProperties:
db_session_with_containers.add(doc)
db_session_with_containers.flush()
assert dataset.total_documents == 3
assert dataset.get_total_documents(session=db_session_with_containers) == 3
def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None:
"""Test dataset can count available documents."""
@ -104,7 +104,7 @@ class TestDatasetDocumentProperties:
db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled])
db_session_with_containers.flush()
assert dataset.total_available_documents == 1
assert dataset.get_total_available_documents(session=db_session_with_containers) == 1
def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None:
"""Test dataset can aggregate word count from documents."""
@ -426,7 +426,7 @@ class TestDocumentSegmentNavigationProperties:
db_session_with_containers.flush()
# Act
prev_seg = segment.previous_segment
prev_seg = segment.previous_segment(session=db_session_with_containers)
# Assert
assert prev_seg is not None
@ -483,7 +483,7 @@ class TestDocumentSegmentNavigationProperties:
db_session_with_containers.flush()
# Act
next_seg = segment.next_segment
next_seg = segment.next_segment(session=db_session_with_containers)
# Assert
assert next_seg is not None

View File

@ -30,12 +30,12 @@ class TestAccountService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_feature_service,
patch("services.account_service.SystemFeatureService") as mock_feature_service,
patch("services.account_service.BillingService") as mock_billing_service,
patch("services.account_service.PassportService") as mock_passport_service,
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.is_registration_allowed.return_value = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
@ -57,7 +57,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
account = AccountService.create_account(
@ -84,7 +84,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
account = AccountService.create_account(
@ -108,7 +108,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Test with too short password (assuming minimum length validation)
@ -131,7 +131,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks to disable registration
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = False
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = False
with pytest.raises(AccountNotFound): # AccountNotFound exception
AccountService.create_account(
@ -153,7 +153,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True
dify_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
@ -189,7 +189,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account first
@ -219,7 +219,7 @@ class TestAccountService:
correct_password = generate_valid_password(fake)
wrong_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account first
@ -245,7 +245,7 @@ class TestAccountService:
name = fake.name()
new_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account without password
@ -280,7 +280,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account with pending status
@ -309,7 +309,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -342,7 +342,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
@ -366,7 +366,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -393,7 +393,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.seats.is_available.return_value = False
@ -418,7 +418,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -456,7 +456,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -498,7 +498,7 @@ class TestAccountService:
password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -529,7 +529,7 @@ class TestAccountService:
password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -568,7 +568,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -599,7 +599,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -634,7 +634,7 @@ class TestAccountService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "new_mock_access_token"
@ -683,7 +683,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -718,7 +718,7 @@ class TestAccountService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -759,7 +759,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -788,7 +788,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_jwt_token"
@ -824,7 +824,7 @@ class TestAccountService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -859,7 +859,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -900,7 +900,7 @@ class TestAccountService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -946,7 +946,7 @@ class TestTenantService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_feature_service,
patch("services.account_service.SystemFeatureService") as mock_feature_service,
patch("services.account_service.BillingService") as mock_billing_service,
):
# Setup default mock returns
@ -2038,12 +2038,12 @@ class TestRegisterService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_feature_service,
patch("services.account_service.SystemFeatureService") as mock_feature_service,
patch("services.account_service.BillingService") as mock_billing_service,
patch("services.account_service.PassportService") as mock_passport_service,
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.is_registration_allowed.return_value = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
@ -2066,7 +2066,7 @@ class TestRegisterService:
admin_password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
from models.model import DifySetup
@ -2115,7 +2115,7 @@ class TestRegisterService:
admin_password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Mock AccountService.create_account to raise exception
@ -2157,7 +2157,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2199,7 +2199,7 @@ class TestRegisterService:
provider = fake.random_element(elements=("google", "github", "microsoft"))
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2246,7 +2246,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2290,7 +2290,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
@ -2327,7 +2327,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2365,7 +2365,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration without workspace creation
@ -2404,7 +2404,7 @@ class TestRegisterService:
new_member_email = fake.email()
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2476,7 +2476,7 @@ class TestRegisterService:
existing_member_password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@ -2550,7 +2550,7 @@ class TestRegisterService:
existing_pending_member_password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@ -2611,7 +2611,7 @@ class TestRegisterService:
new_member_email = fake.email()
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant
@ -2644,7 +2644,7 @@ class TestRegisterService:
already_in_tenant_password = generate_valid_password(fake)
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@ -2696,7 +2696,7 @@ class TestRegisterService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2741,7 +2741,7 @@ class TestRegisterService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2789,7 +2789,7 @@ class TestRegisterService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2833,7 +2833,7 @@ class TestRegisterService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2877,7 +2877,7 @@ class TestRegisterService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2950,7 +2950,7 @@ class TestRegisterService:
invalid_tenant_id = fake.uuid4()
token = fake.uuid4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -3002,7 +3002,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
token = fake.uuid4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -3054,7 +3054,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
token = fake.uuid4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account

View File

@ -26,10 +26,10 @@ class TestAgentService:
patch("services.agent_service.ToolManager", autospec=True) as mock_tool_manager,
patch("services.agent_service.AgentConfigManager", autospec=True) as mock_agent_config_manager,
patch("services.agent_service.current_user", create_autospec(Account, instance=True)) as mock_current_user,
patch("services.app_service.FeatureService", autospec=True) as mock_feature_service,
patch("services.app_service.SystemFeatureService", autospec=True) as mock_feature_service,
patch("services.app_service.EnterpriseService", autospec=True) as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant", autospec=True) as mock_model_manager,
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
patch("services.account_service.SystemFeatureService", autospec=True) as mock_account_feature_service,
):
# Setup default mock returns for agent service
mock_plugin_agent_client_instance = mock_plugin_agent_client.return_value
@ -67,12 +67,12 @@ class TestAgentService:
mock_current_user.timezone = "UTC"
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -104,9 +104,7 @@ class TestAgentService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -21,7 +21,7 @@ class TestAnnotationService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.annotation_service.FeatureService") as mock_feature_service,
patch("services.annotation_service.add_annotation_to_index_task") as mock_add_task,
patch("services.annotation_service.update_annotation_to_index_task") as mock_update_task,
@ -70,9 +70,7 @@ class TestAnnotationService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant first
from services.account_service import AccountService, TenantService

View File

@ -17,7 +17,7 @@ class TestAPIBasedExtensionService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.api_based_extension_service.APIBasedExtensionRequestor") as mock_requestor,
):
# Setup default mock returns
@ -47,9 +47,7 @@ class TestAPIBasedExtensionService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -122,7 +122,7 @@ class TestAppDslService:
patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service,
patch("services.app_dsl_service.app_was_created") as mock_app_was_created,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
):
mock_workflow_service.return_value.get_draft_workflow.return_value = None
@ -139,7 +139,7 @@ class TestAppDslService:
"gpt-3.5-turbo",
)
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
yield {
@ -153,8 +153,8 @@ class TestAppDslService:
def _create_test_app_and_account(self, db_session_with_containers: Session, mock_external_service_dependencies):
fake = Faker()
with patch("services.account_service.FeatureService") as mock_account_feature_service:
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
with patch("services.account_service.SystemFeatureService") as mock_account_feature_service:
mock_account_feature_service.is_registration_allowed.return_value = True
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),

View File

@ -38,7 +38,7 @@ class TestAppGenerateService:
patch(
"services.app_generate_service.MessageBasedAppGenerator", autospec=True
) as mock_message_based_generator,
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
patch("services.account_service.SystemFeatureService", autospec=True) as mock_account_feature_service,
patch("services.app_generate_service.dify_config") as mock_dify_config,
patch("services.quota_service.dify_config") as mock_quota_dify_config,
patch("configs.dify_config") as mock_global_dify_config,
@ -104,7 +104,7 @@ class TestAppGenerateService:
mock_message_based_generator.retrieve_events.return_value = ["workflow_events"]
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Setup dify_config mock returns
mock_dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
@ -155,9 +155,7 @@ class TestAppGenerateService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
from services.account_service import AccountService, TenantService

View File

@ -25,18 +25,18 @@ class TestAppService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -1252,9 +1252,7 @@ class TestAppService:
app_id = app.id
# Mock webapp auth cleanup
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.webapp_auth.enabled = True
mock_external_service_dependencies["feature_service"].is_webapp_auth_enabled.return_value = True
# Mock the async deletion task
with patch("services.app_service.remove_app_and_related_data_task") as mock_delete_task:

View File

@ -14,6 +14,7 @@ from services.entities.feature_entities import (
SystemFeatureModel,
)
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class TestFeatureService:
@ -25,6 +26,7 @@ class TestFeatureService:
with (
patch("services.feature_service.BillingService") as mock_billing_service,
patch("services.feature_service.EnterpriseService") as mock_enterprise_service,
patch("services.system_feature_service.EnterpriseService", new=mock_enterprise_service),
):
# Setup default mock returns for BillingService
mock_billing_service.get_info.return_value = {
@ -273,7 +275,7 @@ class TestFeatureService:
# Arrange: Setup test data with proper config
tenant_id = self._create_test_tenant_id()
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@ -285,7 +287,7 @@ class TestFeatureService:
mock_config.MAIL_TYPE = "smtp"
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -345,7 +347,7 @@ class TestFeatureService:
- The response structure adheres to the public schema for unauthenticated clients.
"""
# Arrange: Setup test data with exact same config as success test
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@ -357,7 +359,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the public (unauthenticated) system-features call
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Basic structure
assert result is not None
@ -399,11 +401,11 @@ class TestFeatureService:
- Detail withheld from the public system-features model is present here.
"""
# Arrange
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
# Act
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
# Assert: full license detail is populated
assert isinstance(result, LicenseModel)
@ -418,10 +420,10 @@ class TestFeatureService:
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Non-enterprise deployments have no license, so limits are unconstrained."""
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
assert isinstance(result, LicenseModel)
assert result.status == LicenseStatus.NONE
@ -442,7 +444,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup basic config mock (no enterprise)
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@ -456,7 +458,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -640,7 +642,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Use the Community edition.
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -653,7 +655,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 50
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -863,7 +865,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup edge case webapp auth mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -879,7 +881,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -981,7 +983,7 @@ class TestFeatureService:
"""
# Test case 1: Official only scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -999,12 +1001,12 @@ class TestFeatureService:
}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "official_only"
assert result.plugin_installation_permission.restrict_to_marketplace_only is True
# Test case 2: All plugins scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1019,12 +1021,12 @@ class TestFeatureService:
"PluginInstallationPermission": {"pluginInstallationScope": "all", "restrictToMarketplaceOnly": False}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "all"
assert result.plugin_installation_permission.restrict_to_marketplace_only is False
# Test case 3: Specific partners scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1042,12 +1044,12 @@ class TestFeatureService:
}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "official_and_specific_partners"
assert result.plugin_installation_permission.restrict_to_marketplace_only is False
# Test case 4: None scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1062,7 +1064,7 @@ class TestFeatureService:
"PluginInstallationPermission": {"pluginInstallationScope": "none", "restrictToMarketplaceOnly": True}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "none"
assert result.plugin_installation_permission.restrict_to_marketplace_only is True
@ -1120,7 +1122,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup inactive license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1140,7 +1142,7 @@ class TestFeatureService:
}
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
# Assert: Verify the expected outcomes
assert result is not None
@ -1169,7 +1171,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup partial enterprise info mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1187,7 +1189,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -1297,7 +1299,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup edge case protocols mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1315,7 +1317,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -1448,7 +1450,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup expired license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1468,7 +1470,7 @@ class TestFeatureService:
}
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
# Assert: Verify the expected outcomes
assert result is not None
@ -1554,7 +1556,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup edge case branding mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1575,7 +1577,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -1740,7 +1742,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup lost license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1756,7 +1758,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None

View File

@ -24,7 +24,7 @@ class TestMessageService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.message_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.message_service.WorkflowService") as mock_workflow_service,
patch("services.message_service.AdvancedChatAppConfigManager") as mock_app_config_manager,
@ -86,9 +86,7 @@ class TestMessageService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant first
from services.account_service import AccountService, TenantService

View File

@ -20,15 +20,15 @@ class TestOpsService:
@pytest.fixture
def mock_external_service_dependencies(self):
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
mock_model_instance = mock_model_manager.return_value
mock_model_instance.get_default_model_instance.return_value = None
mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo")

View File

@ -20,12 +20,12 @@ class TestSavedMessageService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.saved_message_service.MessageService") as mock_message_service,
):
# Setup default mock returns
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for app creation
mock_model_instance = mock_model_manager.return_value
@ -56,9 +56,7 @@ class TestSavedMessageService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant first
from services.account_service import AccountService, TenantService

View File

@ -27,7 +27,7 @@ class TestTriggerProviderService:
patch("services.trigger.trigger_provider_service.TriggerManager") as mock_trigger_manager,
patch("services.trigger.trigger_provider_service.redis_client") as mock_redis_client,
patch("services.trigger.trigger_provider_service.delete_cache_for_subscription") as mock_delete_cache,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns
mock_provider_controller = MagicMock()
@ -42,7 +42,7 @@ class TestTriggerProviderService:
mock_redis_client.lock.return_value = mock_lock
# Setup account feature service mock
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"trigger_manager": mock_trigger_manager,
@ -71,9 +71,7 @@ class TestTriggerProviderService:
from services.account_service import AccountService, TenantService
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies[
"trigger_manager"
].get_trigger_provider.return_value = mock_external_service_dependencies["provider_controller"]

View File

@ -23,18 +23,18 @@ class TestWebConversationService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -62,9 +62,7 @@ class TestWebConversationService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -11,7 +11,7 @@ from libs.password import hash_password
from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
from models.model import App, Site
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
from services.webapp_auth_service import WebAppAuthService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@ -825,90 +825,3 @@ class TestWebAppAuthService:
WebAppAuthService.is_app_require_permission_check(session=db_session_with_containers)
assert "Either app_code or app_id must be provided." in str(exc_info.value)
def test_get_app_auth_type_with_access_mode_public(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type for public access mode.
This test verifies:
- Proper authentication type determination for public mode
- Correct return value
- Mock service integration
"""
# Arrange: Setup test with public access mode
# Act: Execute authentication type determination
result = WebAppAuthService.get_app_auth_type(access_mode="public", session=db_session_with_containers)
# Assert: Verify correct result
assert result == WebAppAuthType.PUBLIC
def test_get_app_auth_type_with_access_mode_private(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type for private access mode.
This test verifies:
- Proper authentication type determination for private mode
- Correct return value
- Mock service integration
"""
# Arrange: Setup test with private access mode
# Act: Execute authentication type determination
result = WebAppAuthService.get_app_auth_type(access_mode="private", session=db_session_with_containers)
# Assert: Verify correct result
assert result == WebAppAuthType.INTERNAL
def test_get_app_auth_type_with_app_code(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type using app code.
This test verifies:
- Proper authentication type determination using app code
- Correct return value
- Mock service integration
"""
# Arrange: Setup mock for enterprise service
mock_external_service_dependencies["app_service"].get_app_id_by_code.return_value = "mock_app_id"
setting = type("MockWebAppAuth", (), {"access_mode": "sso_verified"})()
mock_external_service_dependencies[
"enterprise_service"
].WebAppAuth.get_app_access_mode_by_id.return_value = setting
# Act: Execute authentication type determination
result: WebAppAuthType = WebAppAuthService.get_app_auth_type(
app_code="mock_app_code", session=db_session_with_containers
)
# Assert: Verify correct result
assert result == WebAppAuthType.EXTERNAL
# Verify mock service was called correctly
mock_external_service_dependencies[
"enterprise_service"
].WebAppAuth.get_app_access_mode_by_id.assert_called_once_with(app_id="mock_app_id")
def test_get_app_auth_type_no_parameters(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type with no parameters.
This test verifies:
- Proper error handling when no parameters provided
- Correct exception type and message
"""
# Arrange: No parameters provided
# Act & Assert: Verify proper error handling
with pytest.raises(ValueError) as exc_info:
WebAppAuthService.get_app_auth_type(session=db_session_with_containers)
assert "Either app_code or access_mode must be provided." in str(exc_info.value)

View File

@ -8,14 +8,12 @@ from faker import Faker
from flask import Flask
from sqlalchemy.orm import Session
from enums import DeploymentEdition
from models.account import Account, Tenant
from models.enums import AppTriggerStatus, AppTriggerType
from models.model import App
from models.trigger import AppTrigger, WorkflowWebhookTrigger
from models.workflow import Workflow
from services.account_service import AccountService, TenantService
from services.entities.feature_entities import SystemFeatureModel
from services.trigger.webhook_service import WebhookService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@ -38,16 +36,12 @@ def test_data(
"""Persist the webhook graph with account and workspace creation enabled."""
fake = Faker()
system_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
is_allow_register=True,
monkeypatch.setattr(
"services.account_service.SystemFeatureService.is_registration_allowed",
lambda: True,
)
monkeypatch.setattr(
"services.account_service.FeatureService.get_system_features",
lambda: system_features,
)
monkeypatch.setattr(
"services.account_service.FeatureService.is_workspace_creation_allowed",
"services.account_service.SystemFeatureService.is_workspace_creation_allowed",
lambda: True,
)
account = AccountService.create_account(

View File

@ -28,18 +28,18 @@ class TestWorkflowAppService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -67,9 +67,7 @@ class TestWorkflowAppService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(
@ -116,9 +114,7 @@ class TestWorkflowAppService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -25,18 +25,18 @@ class TestWorkflowRunService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -64,9 +64,7 @@ class TestWorkflowRunService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -23,10 +23,10 @@ class TestWorkflowToolManageService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch(
"services.tools.workflow_tools_manage_service.WorkflowToolProviderController"
) as mock_workflow_tool_provider_controller,
@ -34,12 +34,12 @@ class TestWorkflowToolManageService:
patch("services.tools.workflow_tools_manage_service.ToolTransformService") as mock_tool_transform_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -79,9 +79,7 @@ class TestWorkflowToolManageService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -37,10 +37,10 @@ class TestCleanNotionDocumentTask:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"account_feature_service": mock_account_feature_service,

View File

@ -29,10 +29,10 @@ class TestDealDatasetVectorIndexTask:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"account_feature_service": mock_account_feature_service,

View File

@ -41,7 +41,7 @@ from models.trigger import (
from models.workflow import Workflow
from schedule import workflow_schedule_task
from schedule.workflow_schedule_task import poll_workflow_schedules
from services import feature_service as feature_service_module
from services.system_feature_service import SystemFeatureService
from services.trigger import webhook_service
from services.trigger.schedule_service import ScheduleService
from services.workflow_service import WorkflowService
@ -112,7 +112,7 @@ def test_publish_blocks_start_and_trigger_coexistence(
workflow_service = WorkflowService()
monkeypatch.setattr(
feature_service_module.FeatureService,
SystemFeatureService,
"is_plugin_manager_enabled",
classmethod(lambda _cls: False),
)

View File

@ -1,4 +1,3 @@
from collections.abc import Callable
from datetime import datetime
from inspect import getclosurevars, getsource, unwrap
from types import SimpleNamespace
@ -489,9 +488,7 @@ def test_agent_app_list_and_create_use_agent_route(
lambda _self, **kwargs: {"agent-list": "debug-conversation-list"},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"count_agent_app_debug_conversation_messages",
lambda _self, **kwargs: 0,
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
)
monkeypatch.setattr(
roster_controller.enterprise_rbac_service.RBACService.AgentPermissions,
@ -523,9 +520,9 @@ def test_agent_app_list_and_create_use_agent_route(
get_or_create_debug_conversation,
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
roster_controller.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
with app.test_request_context(
"/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created"
@ -568,22 +565,12 @@ def test_agent_app_list_and_create_use_agent_route(
assert count_params.agent_is_published is True
with app.test_request_context(
"/console/api/agent",
json={
"name": "Iris",
"description": "Agent app",
"role": "Coordinator",
"icon_type": "emoji",
"icon": "robot",
},
json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"},
):
created, status = unwrap(AgentAppListApi.post)(
AgentAppListApi(),
AgentAppCreatePayload(
name="Iris",
description="Agent app",
role="Coordinator",
icon_type="emoji",
icon="robot",
name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot"
),
sqlite_session,
"tenant-1",
@ -611,6 +598,7 @@ def test_agent_app_list_and_create_use_agent_route(
"account_id": account_id,
"commit": False,
}
<<<<<<< HEAD
def test_agent_app_create_skips_rbac_access_initialization_when_rbac_is_disabled(
@ -681,6 +669,8 @@ def test_agent_app_create_skips_rbac_access_initialization_when_rbac_is_disabled
assert created["id"] == "agent-created"
replace_whitelist.assert_not_called()
initialize_access.assert_not_called()
=======
>>>>>>> feat/app-access-point-permission
def test_agent_app_create_payload_allows_optional_role() -> None:
@ -766,9 +756,9 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 2
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
roster_controller.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
monkeypatch.setattr(
roster_controller,
@ -1256,9 +1246,9 @@ def test_agent_app_update_allows_empty_role(
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
roster_controller.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
class FakeAppService:

View File

@ -13,14 +13,12 @@ from sqlalchemy import Engine, event
from sqlalchemy.orm import Session
from controllers.console.app import app_import as app_import_module
from enums import DeploymentEdition
from models.account import Account, Tenant
from models.base import TypeBase
from models.engine import db
from models.model import App, AppMode
from services.app_dsl_service import ImportStatus
from services.entities.dsl_entities import CheckDependenciesResult
from services.entities.feature_entities import SystemFeatureModel, WebAppAuthModel
from tests.unit_tests.config_override import apply_config_overrides
@ -49,11 +47,7 @@ class _Result:
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
webapp_auth=WebAppAuthModel(enabled=enabled),
)
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
monkeypatch.setattr(app_import_module.SystemFeatureService, "is_webapp_auth_enabled", lambda: enabled)
def _make_account(account_id: str = "u1") -> Account:

View File

@ -563,8 +563,8 @@ def test_app_list_uses_injected_session_for_draft_workflows(
)
monkeypatch.setattr(
app_module,
"FeatureService",
SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))),
"SystemFeatureService",
SimpleNamespace(is_webapp_auth_enabled=lambda: False),
)
get_permissions = MagicMock(
return_value=app_module.enterprise_rbac_service.MyPermissionsResponse(
@ -680,9 +680,9 @@ def test_app_list_api_attaches_permission_keys(
get_paginate_apps,
)
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
app_module.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.MyPermissions,
@ -865,9 +865,9 @@ def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permis
lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-shared", "app-not-permitted"]),
)
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
app_module.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session)
@ -922,9 +922,9 @@ def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(
lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-whitelist-only"]),
)
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
app_module.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session)
@ -960,9 +960,9 @@ def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permiss
lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-not-permitted"]),
)
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
app_module.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session)
@ -996,9 +996,9 @@ def test_app_detail_api_attaches_current_user_permission_keys(
get_app = MagicMock(return_value=app_obj)
monkeypatch.setattr(app_module, "AppService", lambda: SimpleNamespace(get_app=get_app))
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
app_module.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
get_permissions = MagicMock(
return_value=app_module.enterprise_rbac_service.MyPermissionsResponse(
@ -1081,9 +1081,9 @@ def test_app_copy_api_attaches_permission_keys(
),
)
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
app_module.SystemFeatureService,
"is_webapp_auth_enabled",
lambda: False,
)
monkeypatch.setattr(app_module, "db", SimpleNamespace(engine=sqlite_engine))
monkeypatch.setattr(

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